From 7dfab6830498d246c370e05eb07a718ad6ba3da4 Mon Sep 17 00:00:00 2001
From: Harivansh Rathi
Date: Wed, 25 Mar 2026 18:36:21 -0400
Subject: [PATCH 01/49] mv skills into own dir
---
SKILL.md => skills/SKILL.md | 0
1 file changed, 0 insertions(+), 0 deletions(-)
rename SKILL.md => skills/SKILL.md (100%)
diff --git a/SKILL.md b/skills/SKILL.md
similarity index 100%
rename from SKILL.md
rename to skills/SKILL.md
From 3819a85c478adc8e81c1e093aba0f097e73bd344 Mon Sep 17 00:00:00 2001
From: Hari <73809867+harivansh-afk@users.noreply.github.com>
Date: Wed, 25 Mar 2026 19:29:59 -0400
Subject: [PATCH 02/49] tests and tooling (#4)
* init openspec
* clean out src, move mod into lib, remove trash
* create tests
* pre-commit hook
* add tests to CI
* update website
* README, CONTRIBUTING and Makefile
* openspec
* archive task
* fix ci order
* fix integration test
* fix validation tests
---
.github/workflows/ci.yml | 67 +++++-
.pre-commit-config.yaml | 15 ++
CONTRIBUTING.md | 70 ++++++
Makefile | 33 +++
README.md | 32 +++
.../.openspec.yaml | 2 +
.../design.md | 121 ++++++++++
.../proposal.md | 28 +++
.../specs/repo-quality/spec.md | 49 ++++
.../tasks.md | 23 ++
openspec/specs/repo-quality/spec.md | 49 ++++
site/src/pages/architecture.mdx | 20 +-
site/src/pages/commands.mdx | 10 +-
site/src/pages/index.astro | 6 +-
src/cli/connection.rs | 17 +-
src/cli/mod.rs | 2 +-
src/core/doctor.rs | 48 +---
src/core/refs.rs | 13 +-
src/daemon/handler.rs | 65 +-----
src/lib.rs | 11 +
src/main.rs | 12 +-
src/test_support.rs | 150 ------------
tests/support/mod.rs | 220 ++++++++++++++++++
tests/x11_runtime.rs | 115 +++++++++
24 files changed, 892 insertions(+), 286 deletions(-)
create mode 100644 .pre-commit-config.yaml
create mode 100644 CONTRIBUTING.md
create mode 100644 Makefile
create mode 100644 openspec/changes/archive/2026-03-25-test-and-tooling-foundation/.openspec.yaml
create mode 100644 openspec/changes/archive/2026-03-25-test-and-tooling-foundation/design.md
create mode 100644 openspec/changes/archive/2026-03-25-test-and-tooling-foundation/proposal.md
create mode 100644 openspec/changes/archive/2026-03-25-test-and-tooling-foundation/specs/repo-quality/spec.md
create mode 100644 openspec/changes/archive/2026-03-25-test-and-tooling-foundation/tasks.md
create mode 100644 openspec/specs/repo-quality/spec.md
create mode 100644 src/lib.rs
delete mode 100644 src/test_support.rs
create mode 100644 tests/support/mod.rs
create mode 100644 tests/x11_runtime.rs
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 469d98c..1f0f458 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -5,6 +5,8 @@ name: CI
# runs-on: ${{ vars.UVA_RUNNER || 'ubuntu-latest' }}
on:
+ pull_request:
+ branches: [main]
push:
branches: [main]
workflow_dispatch:
@@ -14,8 +16,67 @@ permissions:
packages: write
jobs:
+ validate:
+ name: Validate
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: dtolnay/rust-toolchain@stable
+ with:
+ components: clippy
+
+ - uses: Swatinem/rust-cache@v2
+
+ - uses: pnpm/action-setup@v4
+ with:
+ version: 10
+ run_install: false
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ cache: pnpm
+ cache-dependency-path: site/pnpm-lock.yaml
+
+ - name: Install site dependencies
+ run: pnpm --dir site install --frozen-lockfile
+
+ - name: Install system dependencies
+ run: sudo apt-get update && sudo apt-get install -y libx11-dev libxtst-dev
+
+ - name: Format check
+ run: make fmt-check
+
+ - name: Clippy
+ run: make lint
+
+ - name: Unit tests
+ run: make test-unit
+
+ - name: Site format check
+ run: make site-format-check
+
+ integration:
+ name: Integration (Xvfb)
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: dtolnay/rust-toolchain@stable
+
+ - uses: Swatinem/rust-cache@v2
+
+ - name: Install system dependencies
+ run: sudo apt-get update && sudo apt-get install -y libx11-dev libxtst-dev xvfb
+
+ - name: Xvfb integration tests
+ run: make test-integration
+
changes:
name: Changes
+ needs: [validate, integration]
+ if: github.event_name != 'pull_request'
runs-on: ubuntu-latest
outputs:
rust: ${{ steps.check.outputs.rust }}
@@ -32,9 +93,11 @@ jobs:
filters: |
rust:
- 'src/**'
+ - 'tests/**'
- 'Cargo.toml'
- 'Cargo.lock'
- 'docker/**'
+ - 'Makefile'
- name: Set outputs
id: check
@@ -69,7 +132,7 @@ jobs:
build:
name: Build (${{ matrix.target }})
needs: changes
- if: needs.changes.outputs.rust == 'true'
+ if: github.event_name != 'pull_request' && needs.changes.outputs.rust == 'true'
runs-on: ubuntu-latest
strategy:
fail-fast: true
@@ -132,6 +195,7 @@ jobs:
update-manifests:
name: Update Manifests
needs: [changes, build]
+ if: github.event_name != 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@@ -165,6 +229,7 @@ jobs:
release:
name: Release
needs: [changes, build, update-manifests]
+ if: github.event_name != 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
new file mode 100644
index 0000000..34d4298
--- /dev/null
+++ b/.pre-commit-config.yaml
@@ -0,0 +1,15 @@
+repos:
+ - repo: local
+ hooks:
+ - id: rustfmt
+ name: rustfmt
+ entry: cargo fmt --
+ language: system
+ files: \.rs$
+ pass_filenames: true
+ - id: site-format-check
+ name: site format check
+ entry: make site-format-check
+ language: system
+ files: ^site/
+ pass_filenames: false
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..3c44332
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,70 @@
+# Contributing
+
+## Prerequisites
+
+- Rust toolchain
+- `make`
+- `pre-commit` for commit-time hooks
+- `pnpm` for site formatting checks
+- Linux with `xvfb-run` for integration tests
+
+Install site dependencies before running site checks:
+
+```bash
+pnpm --dir site install
+```
+
+## Repository Layout
+
+- `src/lib.rs` exposes the library target used by integration tests
+- `src/main.rs` is the thin CLI binary wrapper
+- `src/` holds production code and unit tests
+- `tests/` holds integration tests
+- `tests/support/` holds shared X11 and daemon helpers for integration coverage
+
+Keep integration-only helpers out of `src/`.
+
+## Local Validation
+
+The repo uses one local validation surface through `make`:
+
+```bash
+make fmt-check
+make lint
+make test-unit
+make test-integration
+make site-format-check
+make validate
+```
+
+`make validate` runs the full Phase 2 validation stack. It requires Linux, `xvfb-run`, and site dependencies to be installed.
+
+## Pre-commit Hooks
+
+Install the hook workflow once:
+
+```bash
+pre-commit install
+```
+
+Run hooks across the repo on demand:
+
+```bash
+pre-commit run --all-files
+```
+
+The hook config intentionally stays small:
+
+- Rust files use default `rustfmt`
+- Site files reuse the existing `site/` Prettier setup
+- Slower checks stay in CI or `make validate`
+
+## Integration Tests
+
+Integration coverage is Linux/X11-only in this phase. The supported local entrypoint is:
+
+```bash
+make test-integration
+```
+
+That command runs the top-level X11 integration tests under `xvfb-run` with one test thread so the shared display/session environment stays deterministic.
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..bb02037
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,33 @@
+.PHONY: fmt fmt-check lint test-unit test-integration site-format-check validate
+
+fmt:
+ cargo fmt --all
+
+fmt-check:
+ cargo fmt --all --check
+
+lint:
+ cargo clippy --all-targets -- -D warnings
+
+test-unit:
+ cargo test --lib
+
+test-integration:
+ @if [ "$$(uname -s)" != "Linux" ]; then \
+ echo "Integration tests require Linux and xvfb-run."; \
+ exit 1; \
+ fi
+ @if ! command -v xvfb-run >/dev/null 2>&1; then \
+ echo "xvfb-run is required to execute integration tests."; \
+ exit 1; \
+ fi
+ XDG_SESSION_TYPE=x11 xvfb-run -a cargo test --test x11_runtime -- --test-threads=1
+
+site-format-check:
+ @if ! command -v pnpm >/dev/null 2>&1; then \
+ echo "pnpm is required for site formatting checks."; \
+ exit 1; \
+ fi
+ pnpm --dir site format:check
+
+validate: fmt-check lint test-unit test-integration site-format-check
diff --git a/README.md b/README.md
index a1405d7..2438381 100644
--- a/README.md
+++ b/README.md
@@ -59,6 +59,14 @@ deskctl focus "firefox"
Client-daemon architecture over Unix sockets (NDJSON wire protocol).
The daemon starts automatically on first command and keeps the X11 connection alive for fast repeated calls.
+Source layout:
+
+- `src/lib.rs` exposes the shared library target
+- `src/main.rs` is the thin CLI wrapper
+- `src/` contains production code and unit tests
+- `tests/` contains Linux/X11 integration tests
+- `tests/support/` contains shared integration helpers
+
## Runtime Requirements
- Linux with X11 session
@@ -89,6 +97,30 @@ deskctl doctor
`deskctl` supports Linux X11 in this phase. Wayland and Hyprland are explicitly out of scope for the current runtime contract.
+## Workflow
+
+Local validation uses the root `Makefile`:
+
+```bash
+make fmt-check
+make lint
+make test-unit
+make test-integration
+make site-format-check
+make validate
+```
+
+`make validate` is the full repo-quality check and requires Linux with `xvfb-run` plus `pnpm --dir site install`.
+
+The repository standardizes on `pre-commit` for fast commit-time checks:
+
+```bash
+pre-commit install
+pre-commit run --all-files
+```
+
+See [CONTRIBUTING.md](CONTRIBUTING.md) for the full contributor guide.
+
## Acknowledgements
- [@barrettruth](github.com/barrettruth) - i stole the website from [vimdoc](https://github.com/barrettruth/vimdoc-language-server)
diff --git a/openspec/changes/archive/2026-03-25-test-and-tooling-foundation/.openspec.yaml b/openspec/changes/archive/2026-03-25-test-and-tooling-foundation/.openspec.yaml
new file mode 100644
index 0000000..40c5540
--- /dev/null
+++ b/openspec/changes/archive/2026-03-25-test-and-tooling-foundation/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-03-25
diff --git a/openspec/changes/archive/2026-03-25-test-and-tooling-foundation/design.md b/openspec/changes/archive/2026-03-25-test-and-tooling-foundation/design.md
new file mode 100644
index 0000000..116f480
--- /dev/null
+++ b/openspec/changes/archive/2026-03-25-test-and-tooling-foundation/design.md
@@ -0,0 +1,121 @@
+## Context
+
+Phase 1 stabilized the runtime contract, but the repo still lacks the structure and tooling needed to keep that contract stable as contributors add features. The current state is mixed:
+
+- Rust checks exist in CI, but there is no explicit `cargo test` lane and no Xvfb integration lane.
+- The repo has site-local Prettier config under `site/`, but no root-level contributor workflow for formatting or hooks.
+- Integration-style tests are starting to appear, but the crate is still binary-first and does not yet have a clean top-level `tests/` structure.
+- An empty `src/tests/` directory exists, which suggests the intended direction is not yet settled.
+
+This change should establish the repo-quality foundation before Phase 3 agent features and Phase 4 distribution work expand the maintenance surface further.
+
+## Goals / Non-Goals
+
+**Goals:**
+- Define a clean test architecture with a centralized top-level `tests/` directory for integration coverage.
+- Introduce the crate structure needed for integration tests to import the project cleanly.
+- Add a real Xvfb-backed CI lane and make CI validate the same commands contributors run locally.
+- Define one formatting and hook workflow for the repo instead of ad hoc tool usage.
+- Keep the site formatting story integrated without turning the entire repo into a Node-first project.
+
+**Non-Goals:**
+- New runtime capabilities such as `wait-for-window`, `version`, or broader read commands.
+- npm distribution, crates.io publishing, or release automation changes.
+- Introducing both Husky and pre-commit, or multiple competing hook systems.
+- Adding `rustfmt.toml` unless we have a concrete non-default formatting requirement.
+
+## Decisions
+
+### 1. Convert the crate to library + binary
+
+This change will introduce `src/lib.rs` and make `src/main.rs` a thin binary wrapper.
+
+Rationale:
+- Top-level Rust integration tests in `/tests` should import the crate cleanly.
+- Shared test support and internal modules become easier to organize without abusing `src/main.rs`.
+- This is the standard structure for a Rust project that needs both unit and integration coverage.
+
+Alternatives considered:
+- Keep the binary-only layout and continue placing all tests inside `src/`. Rejected because it makes integration coverage awkward and keeps test structure implicit.
+- Put integration helpers in `src/tests` without a library target. Rejected because it preserves the binary-first coupling and does not create a clean external-test boundary.
+
+### 2. Standardize on top-level `/tests` for integration coverage
+
+Integration tests will live under a centralized `/tests` directory, with shared helpers under `tests/support/`.
+
+Rationale:
+- The runtime-facing flows are integration problems, not unit problems.
+- A centralized `/tests` layout makes it clear which tests require Xvfb or daemon orchestration.
+- It keeps `src/` focused on application code.
+
+Alternatives considered:
+- Keep helpers in `src/test_support.rs`. Rejected because it mixes production and integration concerns.
+
+### 3. Standardize on `pre-commit`, not Husky
+
+This change will define one hook system: `pre-commit`.
+
+Rationale:
+- The repo is Rust-first, not root-Node-managed.
+- Husky would imply a root `package.json` and a Node-first workflow the repo does not currently have.
+- `pre-commit` can run Rust and site checks without forcing the whole repo through npm.
+
+Alternatives considered:
+- Husky. Rejected because it introduces a root Node workflow for a repo that is not otherwise Node-based.
+- Both Husky and pre-commit. Rejected because dual hook systems inevitably drift.
+- No hooks. Rejected because contributor ergonomics and CI parity are explicit goals of this phase.
+
+### 4. Keep formatting opinionated but minimal
+
+This phase will use default `rustfmt` behavior and site-local Prettier behavior. A root `rustfmt.toml` will only be added if the implementation reveals a real non-default formatting need.
+
+Rationale:
+- A config file with no meaningful opinion is noise.
+- What matters more is that CI and hooks actually run formatting checks.
+- The site already has a working Prettier configuration; we should integrate it rather than duplicate it prematurely.
+
+Alternatives considered:
+- Add `rustfmt.toml` immediately. Rejected because there is no concrete formatting policy to encode yet.
+- Add a root Prettier config for the whole repo. Rejected because it would broaden Node tooling scope before there is a clear need.
+
+### 5. CI should call stable local entrypoints
+
+This phase should define one local command surface for validation, and CI should call those same commands instead of hand-coded bespoke steps where practical.
+
+Candidate checks:
+- format check
+- clippy
+- unit tests
+- Xvfb integration tests
+- site formatting check
+
+Rationale:
+- Local/CI drift is one of the fastest ways to make an open source repo unpleasant to contribute to.
+- Contributors should be able to run the same validation shape locally before pushing.
+
+Alternatives considered:
+- Keep all validation logic encoded only in GitHub Actions. Rejected because local parity matters.
+
+## Risks / Trade-offs
+
+- [Introducing `src/lib.rs` creates some churn] → Keep `main.rs` thin and preserve module names to minimize callsite disruption.
+- [Xvfb CI can be flaky if test fixtures are underspecified] → Keep fixture windows simple and deterministic; avoid broad screenshot assertions early.
+- [Pre-commit adds a Python-based contributor dependency] → Document installation clearly and keep hooks fast so the value exceeds the setup cost.
+- [Formatting/tooling scope could sprawl into site build work] → Limit this phase to formatting and validation, not full site build architecture.
+
+## Migration Plan
+
+1. Introduce `src/lib.rs` and move reusable modules behind the library target.
+2. Move integration support into top-level `tests/support/` and create real `/tests` coverage for Xvfb-backed flows.
+3. Add local validation entrypoints for formatting, lint, and tests.
+4. Add a root hook configuration using `pre-commit`.
+5. Update CI to run unit tests, Xvfb integration tests, and relevant formatting checks on pull requests and main.
+6. Update contributor docs so local validation, hooks, and test structure are discoverable.
+
+Rollback strategy:
+- This phase is repo-internal and pre-1.0, so rollback is a normal revert rather than a compatibility shim.
+
+## Open Questions
+
+- Whether the local validation entrypoint should be a `Justfile`, `Makefile`, or another lightweight wrapper.
+- Whether site validation in this phase should be limited to Prettier checks or also include `astro check`.
diff --git a/openspec/changes/archive/2026-03-25-test-and-tooling-foundation/proposal.md b/openspec/changes/archive/2026-03-25-test-and-tooling-foundation/proposal.md
new file mode 100644
index 0000000..8c854b4
--- /dev/null
+++ b/openspec/changes/archive/2026-03-25-test-and-tooling-foundation/proposal.md
@@ -0,0 +1,28 @@
+## Why
+
+`deskctl` now has a decent Phase 1 runtime contract, but the repo still lacks the test architecture and local tooling discipline needed to keep that contract stable as the project grows. Before moving into broader agent primitives or distribution polish, the repo needs a real integration-test story, CI coverage that exercises it, and one coherent developer workflow for formatting and pre-commit checks.
+
+## What Changes
+
+- Introduce a proper repository quality foundation for testing, CI, and local developer tooling.
+- Establish a centralized top-level integration test layout and the crate structure needed to support it cleanly.
+- Add an explicit Xvfb-backed CI lane for runtime integration tests.
+- Define one local formatting and hook workflow for Rust and site content instead of ad hoc tool usage.
+- Add contributor-facing commands and docs for running the same checks locally that CI will enforce.
+
+## Capabilities
+
+### New Capabilities
+- `repo-quality`: Repository-level quality guarantees covering test architecture, CI validation, formatting, and local hook workflow.
+
+### Modified Capabilities
+- None.
+
+## Impact
+
+- Rust crate layout in `src/` and likely a new `src/lib.rs`
+- New top-level `tests/` structure and shared integration test support
+- GitHub Actions workflow(s) under `.github/workflows/`
+- Root-level contributor tooling files such as `.pre-commit-config.yaml` and related local task entrypoints
+- Site formatting integration for files under `site/`
+- README and contributor documentation describing local validation workflows
diff --git a/openspec/changes/archive/2026-03-25-test-and-tooling-foundation/specs/repo-quality/spec.md b/openspec/changes/archive/2026-03-25-test-and-tooling-foundation/specs/repo-quality/spec.md
new file mode 100644
index 0000000..6e83331
--- /dev/null
+++ b/openspec/changes/archive/2026-03-25-test-and-tooling-foundation/specs/repo-quality/spec.md
@@ -0,0 +1,49 @@
+## ADDED Requirements
+
+### Requirement: Repository exposes a clean integration test architecture
+The repository SHALL provide a top-level integration test architecture that allows runtime flows to be tested outside in-module unit tests.
+
+#### Scenario: Integration tests import the crate cleanly
+- **WHEN** an integration test under `/tests` needs to exercise repository code
+- **THEN** it imports the project through a library target rather than depending on binary-only wiring
+
+#### Scenario: Integration helpers are centralized
+- **WHEN** multiple integration tests need shared X11 or daemon helpers
+- **THEN** those helpers live in a shared test-support location under `/tests`
+- **AND** production code does not need to host integration-only support files
+
+### Requirement: CI validates real X11 integration behavior
+The repository SHALL run Xvfb-backed integration coverage in CI for the supported X11 runtime.
+
+#### Scenario: Pull requests run X11 integration tests
+- **WHEN** a pull request modifies runtime or test-relevant code
+- **THEN** CI runs the repository's Xvfb-backed integration test lane
+- **AND** fails the change if the integration lane does not pass
+
+#### Scenario: Integration lane covers core runtime flows
+- **WHEN** the Xvfb integration lane runs
+- **THEN** it exercises at least runtime diagnostics, window enumeration, and daemon startup/recovery behavior
+
+### Requirement: Repository defines one local validation workflow
+The repository SHALL define one coherent local validation workflow that contributors can run before pushing and that CI can mirror.
+
+#### Scenario: Local formatting and linting entrypoints are documented
+- **WHEN** a contributor wants to validate a change locally
+- **THEN** the repository provides documented commands for formatting, linting, unit tests, and integration tests
+
+#### Scenario: CI and local validation stay aligned
+- **WHEN** CI validates the repository
+- **THEN** it uses the same validation categories that contributors are expected to run locally
+- **AND** avoids introducing a separate undocumented CI-only workflow
+
+### Requirement: Repository uses a single hook system
+The repository SHALL standardize on one pre-commit hook workflow for contributor checks.
+
+#### Scenario: Hook workflow does not require root Node ownership
+- **WHEN** a contributor installs the hook workflow
+- **THEN** the repository can run Rust and site checks without requiring a root-level Node package workflow
+
+#### Scenario: Hook scope stays fast and focused
+- **WHEN** the pre-commit hook runs
+- **THEN** it executes only fast checks appropriate for commit-time feedback
+- **AND** slower validation remains in pre-push or CI lanes
diff --git a/openspec/changes/archive/2026-03-25-test-and-tooling-foundation/tasks.md b/openspec/changes/archive/2026-03-25-test-and-tooling-foundation/tasks.md
new file mode 100644
index 0000000..06e6d80
--- /dev/null
+++ b/openspec/changes/archive/2026-03-25-test-and-tooling-foundation/tasks.md
@@ -0,0 +1,23 @@
+## 1. Repository Structure
+
+- [x] 1.1 Introduce `src/lib.rs`, keep `src/main.rs` as a thin binary wrapper, and preserve existing module boundaries so integration tests can import the crate cleanly
+- [x] 1.2 Move integration-only helpers out of production modules into `tests/support/` and remove the unused `src/tests` direction so the test layout is unambiguous
+- [x] 1.3 Add top-level integration tests under `tests/` that exercise at least diagnostics, window enumeration, and daemon startup/recovery flows through the library target
+
+## 2. Local Validation Tooling
+
+- [x] 2.1 Add one documented local validation entrypoint for formatting, linting, unit tests, integration tests, and site formatting checks
+- [x] 2.2 Add a root `.pre-commit-config.yaml` that standardizes on `pre-commit` for fast commit-time checks without introducing a root Node workflow
+- [x] 2.3 Keep formatting configuration minimal by using default `rustfmt`, reusing the existing site-local Prettier setup, and only adding new config where implementation requires it
+
+## 3. CI Hardening
+
+- [x] 3.1 Update GitHub Actions to validate pull requests in addition to `main` pushes and to run the same validation categories contributors run locally
+- [x] 3.2 Add an explicit Xvfb-backed CI lane that runs the integration tests covering diagnostics, window enumeration, and daemon recovery behavior
+- [x] 3.3 Ensure CI also runs the repository's formatting, clippy, unit test, and site formatting checks through the shared local entrypoints where practical
+
+## 4. Documentation
+
+- [x] 4.1 Update contributor-facing docs to explain the new crate/test layout, including where integration tests and shared helpers live
+- [x] 4.2 Document the local validation workflow and `pre-commit` installation/use so contributors can reproduce CI expectations locally
+- [x] 4.3 Update the Phase 2 planning/docs references so the repo-quality foundation clearly lands before later packaging and distribution phases
diff --git a/openspec/specs/repo-quality/spec.md b/openspec/specs/repo-quality/spec.md
new file mode 100644
index 0000000..6e83331
--- /dev/null
+++ b/openspec/specs/repo-quality/spec.md
@@ -0,0 +1,49 @@
+## ADDED Requirements
+
+### Requirement: Repository exposes a clean integration test architecture
+The repository SHALL provide a top-level integration test architecture that allows runtime flows to be tested outside in-module unit tests.
+
+#### Scenario: Integration tests import the crate cleanly
+- **WHEN** an integration test under `/tests` needs to exercise repository code
+- **THEN** it imports the project through a library target rather than depending on binary-only wiring
+
+#### Scenario: Integration helpers are centralized
+- **WHEN** multiple integration tests need shared X11 or daemon helpers
+- **THEN** those helpers live in a shared test-support location under `/tests`
+- **AND** production code does not need to host integration-only support files
+
+### Requirement: CI validates real X11 integration behavior
+The repository SHALL run Xvfb-backed integration coverage in CI for the supported X11 runtime.
+
+#### Scenario: Pull requests run X11 integration tests
+- **WHEN** a pull request modifies runtime or test-relevant code
+- **THEN** CI runs the repository's Xvfb-backed integration test lane
+- **AND** fails the change if the integration lane does not pass
+
+#### Scenario: Integration lane covers core runtime flows
+- **WHEN** the Xvfb integration lane runs
+- **THEN** it exercises at least runtime diagnostics, window enumeration, and daemon startup/recovery behavior
+
+### Requirement: Repository defines one local validation workflow
+The repository SHALL define one coherent local validation workflow that contributors can run before pushing and that CI can mirror.
+
+#### Scenario: Local formatting and linting entrypoints are documented
+- **WHEN** a contributor wants to validate a change locally
+- **THEN** the repository provides documented commands for formatting, linting, unit tests, and integration tests
+
+#### Scenario: CI and local validation stay aligned
+- **WHEN** CI validates the repository
+- **THEN** it uses the same validation categories that contributors are expected to run locally
+- **AND** avoids introducing a separate undocumented CI-only workflow
+
+### Requirement: Repository uses a single hook system
+The repository SHALL standardize on one pre-commit hook workflow for contributor checks.
+
+#### Scenario: Hook workflow does not require root Node ownership
+- **WHEN** a contributor installs the hook workflow
+- **THEN** the repository can run Rust and site checks without requiring a root-level Node package workflow
+
+#### Scenario: Hook scope stays fast and focused
+- **WHEN** the pre-commit hook runs
+- **THEN** it executes only fast checks appropriate for commit-time feedback
+- **AND** slower validation remains in pre-push or CI lanes
diff --git a/site/src/pages/architecture.mdx b/site/src/pages/architecture.mdx
index ec874e2..87b2b4e 100644
--- a/site/src/pages/architecture.mdx
+++ b/site/src/pages/architecture.mdx
@@ -19,7 +19,7 @@ Requests and responses are newline-delimited JSON (NDJSON) over a Unix socket.
**Request:**
```json
-{"id": "r123456", "action": "snapshot", "annotate": true}
+{ "id": "r123456", "action": "snapshot", "annotate": true }
```
**Response:**
@@ -31,7 +31,7 @@ Requests and responses are newline-delimited JSON (NDJSON) over a Unix socket.
Error responses include an `error` field:
```json
-{"success": false, "error": "window not found: @w99"}
+{ "success": false, "error": "window not found: @w99" }
```
## Socket location
@@ -66,13 +66,13 @@ The trait-based design means adding Wayland support is a single trait implementa
Window detection uses EWMH properties:
-| Property | Purpose |
-|----------|---------|
-| `_NET_CLIENT_LIST_STACKING` | Window stacking order |
-| `_NET_ACTIVE_WINDOW` | Currently focused window |
-| `_NET_WM_NAME` | Window title (UTF-8) |
-| `_NET_WM_STATE_HIDDEN` | Minimized state |
-| `_NET_CLOSE_WINDOW` | Graceful close |
-| `WM_CLASS` | Application class/name |
+| Property | Purpose |
+| --------------------------- | ------------------------ |
+| `_NET_CLIENT_LIST_STACKING` | Window stacking order |
+| `_NET_ACTIVE_WINDOW` | Currently focused window |
+| `_NET_WM_NAME` | Window title (UTF-8) |
+| `_NET_WM_STATE_HIDDEN` | Minimized state |
+| `_NET_CLOSE_WINDOW` | Graceful close |
+| `WM_CLASS` | Application class/name |
Falls back to `XQueryTree` if `_NET_CLIENT_LIST_STACKING` is unavailable.
diff --git a/site/src/pages/commands.mdx b/site/src/pages/commands.mdx
index bd639c7..e1fc509 100644
--- a/site/src/pages/commands.mdx
+++ b/site/src/pages/commands.mdx
@@ -169,8 +169,8 @@ deskctl launch code --args /path/to/project
## Global options
-| Flag | Env | Description |
-|------|-----|-------------|
-| `--json` | | Output as JSON |
-| `--socket ` | `DESKCTL_SOCKET` | Path to daemon Unix socket |
-| `--session ` | | Session name for multiple daemons (default: `default`) |
+| Flag | Env | Description |
+| ------------------ | ---------------- | ------------------------------------------------------ |
+| `--json` | | Output as JSON |
+| `--socket ` | `DESKCTL_SOCKET` | Path to daemon Unix socket |
+| `--session ` | | Session name for multiple daemons (default: `default`) |
diff --git a/site/src/pages/index.astro b/site/src/pages/index.astro
index a33ab8c..9327dc5 100644
--- a/site/src/pages/index.astro
+++ b/site/src/pages/index.astro
@@ -9,9 +9,9 @@ import DocLayout from "../layouts/DocLayout.astro";
- Desktop control CLI for AI agents on Linux X11. Compact JSON output
- for agent loops. Screenshot, click, type, scroll, drag, and manage
- windows through a fast client-daemon architecture. 100% native Rust.
+ Desktop control CLI for AI agents on Linux X11. Compact JSON output for
+ agent loops. Screenshot, click, type, scroll, drag, and manage windows
+ through a fast client-daemon architecture. 100% native Rust.
Getting started
diff --git a/src/cli/connection.rs b/src/cli/connection.rs
index 1237a85..840e637 100644
--- a/src/cli/connection.rs
+++ b/src/cli/connection.rs
@@ -6,10 +6,10 @@ use std::process::{Command, Stdio};
use std::thread;
use std::time::Duration;
-use anyhow::{Context, Result, bail};
+use anyhow::{bail, Context, Result};
use crate::cli::GlobalOpts;
-use crate::core::doctor::{DoctorReport, run as run_doctor_report};
+use crate::core::doctor::{run as run_doctor_report, DoctorReport};
use crate::core::paths::{pid_path_for_session, socket_dir, socket_path_for_session};
use crate::core::protocol::{Request, Response};
@@ -95,7 +95,8 @@ fn send_request_over_stream(mut stream: UnixStream, request: &Request) -> Result
}
fn ping_daemon(opts: &GlobalOpts) -> Result<()> {
- let response = send_request_over_stream(connect_socket(&socket_path(opts))?, &Request::new("ping"))?;
+ let response =
+ send_request_over_stream(connect_socket(&socket_path(opts))?, &Request::new("ping"))?;
if response.success {
Ok(())
} else {
@@ -212,7 +213,9 @@ pub fn daemon_status(opts: &GlobalOpts) -> Result<()> {
let path = socket_path(opts);
match ping_daemon(opts) {
Ok(()) => println!("Daemon running ({})", path.display()),
- Err(_) if path.exists() => println!("Daemon socket exists but is unhealthy ({})", path.display()),
+ Err(_) if path.exists() => {
+ println!("Daemon socket exists but is unhealthy ({})", path.display())
+ }
Err(_) => println!("Daemon not running"),
}
Ok(())
@@ -226,7 +229,11 @@ fn print_doctor_report(report: &DoctorReport, json_output: bool) -> Result<()> {
println!(
"deskctl doctor: {}",
- if report.healthy { "healthy" } else { "issues found" }
+ if report.healthy {
+ "healthy"
+ } else {
+ "issues found"
+ }
);
for check in &report.checks {
let status = if check.ok { "OK" } else { "FAIL" };
diff --git a/src/cli/mod.rs b/src/cli/mod.rs
index 3500522..d4003ff 100644
--- a/src/cli/mod.rs
+++ b/src/cli/mod.rs
@@ -1,4 +1,4 @@
-mod connection;
+pub mod connection;
use anyhow::Result;
use clap::{Args, Parser, Subcommand};
diff --git a/src/core/doctor.rs b/src/core/doctor.rs
index 3d240c3..e9c4b99 100644
--- a/src/core/doctor.rs
+++ b/src/core/doctor.rs
@@ -84,13 +84,16 @@ pub fn run(socket_path: &Path) -> DoctorReport {
checks.push(match backend.capture_screenshot() {
Ok(image) => check_ok(
"screenshot",
- format!("Captured {}x{} desktop image", image.width(), image.height()),
+ format!(
+ "Captured {}x{} desktop image",
+ image.width(),
+ image.height()
+ ),
),
Err(error) => check_fail(
"screenshot",
error.to_string(),
- "Verify the X11 session permits desktop capture on the active display."
- .to_string(),
+ "Verify the X11 session permits desktop capture on the active display.".to_string(),
),
});
} else {
@@ -117,7 +120,10 @@ fn check_socket_dir(socket_path: &Path) -> DoctorCheck {
let Some(socket_dir) = socket_path.parent() else {
return check_fail(
"socket-dir",
- format!("Socket path {} has no parent directory", socket_path.display()),
+ format!(
+ "Socket path {} has no parent directory",
+ socket_path.display()
+ ),
"Use a socket path inside a writable directory.".to_string(),
);
};
@@ -203,37 +209,3 @@ fn check_fail(name: &str, details: String, fix: String) -> DoctorCheck {
fix: Some(fix),
}
}
-
-#[cfg(all(test, target_os = "linux"))]
-mod tests {
- use super::run;
- use crate::test_support::{X11TestEnv, env_lock};
-
- #[test]
- fn doctor_reports_healthy_x11_environment_under_xvfb() {
- let _guard = env_lock().lock().unwrap();
- let Some(env) = X11TestEnv::new().unwrap() else {
- eprintln!("Skipping Xvfb-dependent doctor test");
- return;
- };
- env.create_window("deskctl doctor test", "DeskctlDoctor").unwrap();
-
- let socket_path = std::env::temp_dir().join("deskctl-doctor-test.sock");
- let report = run(&socket_path);
-
- assert!(report.checks.iter().any(|check| check.name == "display" && check.ok));
- assert!(report.checks.iter().any(|check| check.name == "backend" && check.ok));
- assert!(
- report
- .checks
- .iter()
- .any(|check| check.name == "window-enumeration" && check.ok)
- );
- assert!(
- report
- .checks
- .iter()
- .any(|check| check.name == "screenshot" && check.ok)
- );
- }
-}
diff --git a/src/core/refs.rs b/src/core/refs.rs
index 101b00c..6185ebf 100644
--- a/src/core/refs.rs
+++ b/src/core/refs.rs
@@ -136,8 +136,12 @@ impl RefMap {
/// Resolve a selector to the center coordinates of the window.
pub fn resolve_to_center(&self, selector: &str) -> Option<(i32, i32)> {
- self.resolve(selector)
- .map(|entry| (entry.x + entry.width as i32 / 2, entry.y + entry.height as i32 / 2))
+ self.resolve(selector).map(|entry| {
+ (
+ entry.x + entry.width as i32 / 2,
+ entry.y + entry.height as i32 / 2,
+ )
+ })
}
pub fn entries(&self) -> impl Iterator- {
@@ -182,7 +186,10 @@ mod tests {
assert_eq!(refs.resolve("@w1").unwrap().window_id, window_id);
assert_eq!(refs.resolve(&window_id).unwrap().backend_window_id, 42);
- assert_eq!(refs.resolve(&format!("id={window_id}")).unwrap().title, "Editor");
+ assert_eq!(
+ refs.resolve(&format!("id={window_id}")).unwrap().title,
+ "Editor"
+ );
}
#[test]
diff --git a/src/daemon/handler.rs b/src/daemon/handler.rs
index d37b0f1..21f5e76 100644
--- a/src/daemon/handler.rs
+++ b/src/daemon/handler.rs
@@ -394,14 +394,13 @@ fn capture_snapshot(
) -> Result
{
let windows = refresh_windows(state)?;
let screenshot_path = path.unwrap_or_else(temp_screenshot_path);
- let screenshot = capture_and_save_screenshot(
- state,
- &screenshot_path,
- annotate,
- Some(&windows),
- )?;
+ let screenshot =
+ capture_and_save_screenshot(state, &screenshot_path, annotate, Some(&windows))?;
- Ok(Snapshot { screenshot, windows })
+ Ok(Snapshot {
+ screenshot,
+ windows,
+ })
}
fn capture_and_save_screenshot(
@@ -439,55 +438,3 @@ fn parse_coords(value: &str) -> Option<(i32, i32)> {
let y = parts[1].trim().parse().ok()?;
Some((x, y))
}
-
-#[cfg(all(test, target_os = "linux"))]
-mod tests {
- use std::sync::Arc;
-
- use tokio::runtime::Builder;
- use tokio::sync::Mutex;
-
- use super::handle_request;
- use crate::core::protocol::Request;
- use crate::daemon::state::DaemonState;
- use crate::test_support::{X11TestEnv, deskctl_tmp_screenshot_count, env_lock};
-
- #[test]
- fn list_windows_is_side_effect_free_under_xvfb() {
- let _guard = env_lock().lock().unwrap();
- let Some(env) = X11TestEnv::new().unwrap() else {
- eprintln!("Skipping Xvfb-dependent list-windows test");
- return;
- };
- env.create_window("deskctl list-windows test", "DeskctlList").unwrap();
-
- let before = deskctl_tmp_screenshot_count();
- let runtime = Builder::new_current_thread().enable_all().build().unwrap();
- let state = Arc::new(Mutex::new(
- DaemonState::new(
- "test".to_string(),
- std::env::temp_dir().join("deskctl-list-windows.sock"),
- )
- .unwrap(),
- ));
-
- let response = runtime.block_on(handle_request(&Request::new("list-windows"), &state));
- assert!(response.success);
-
- let data = response.data.unwrap();
- let windows = data
- .get("windows")
- .and_then(|value| value.as_array())
- .unwrap();
- assert!(windows.iter().any(|window| {
- window
- .get("title")
- .and_then(|value| value.as_str())
- .map(|title| title == "deskctl list-windows test")
- .unwrap_or(false)
- }));
-
- let after = deskctl_tmp_screenshot_count();
- assert_eq!(before, after, "list-windows should not create screenshot artifacts");
- }
-}
diff --git a/src/lib.rs b/src/lib.rs
new file mode 100644
index 0000000..408a4fc
--- /dev/null
+++ b/src/lib.rs
@@ -0,0 +1,11 @@
+pub mod backend;
+pub mod cli;
+pub mod core;
+pub mod daemon;
+
+pub fn run() -> anyhow::Result<()> {
+ if std::env::var("DESKCTL_DAEMON").is_ok() {
+ return daemon::run();
+ }
+ cli::run()
+}
diff --git a/src/main.rs b/src/main.rs
index 4bb6fab..ed77595 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,13 +1,3 @@
-mod backend;
-mod cli;
-mod core;
-mod daemon;
-#[cfg(test)]
-mod test_support;
-
fn main() -> anyhow::Result<()> {
- if std::env::var("DESKCTL_DAEMON").is_ok() {
- return daemon::run();
- }
- cli::run()
+ deskctl::run()
}
diff --git a/src/test_support.rs b/src/test_support.rs
deleted file mode 100644
index c21a61c..0000000
--- a/src/test_support.rs
+++ /dev/null
@@ -1,150 +0,0 @@
-#![cfg(all(test, target_os = "linux"))]
-
-use std::path::Path;
-use std::process::{Child, Command, Stdio};
-use std::sync::{Mutex, OnceLock};
-use std::thread;
-use std::time::Duration;
-
-use anyhow::{Context, Result};
-use x11rb::connection::Connection;
-use x11rb::protocol::xproto::{
- AtomEnum, ConnectionExt as XprotoConnectionExt, CreateWindowAux, EventMask, PropMode,
- WindowClass,
-};
-
-pub fn env_lock() -> &'static Mutex<()> {
- static LOCK: OnceLock> = OnceLock::new();
- LOCK.get_or_init(|| Mutex::new(()))
-}
-
-pub struct X11TestEnv {
- child: Child,
- old_display: Option,
- old_session_type: Option,
-}
-
-impl X11TestEnv {
- pub fn new() -> Result> {
- if Command::new("Xvfb")
- .arg("-help")
- .stdout(Stdio::null())
- .stderr(Stdio::null())
- .status()
- .is_err()
- {
- return Ok(None);
- }
-
- for display_num in 90..110 {
- let display = format!(":{display_num}");
- let lock_path = format!("/tmp/.X{display_num}-lock");
- let unix_socket = format!("/tmp/.X11-unix/X{display_num}");
- if Path::new(&lock_path).exists() || Path::new(&unix_socket).exists() {
- continue;
- }
-
- let child = Command::new("Xvfb")
- .arg(&display)
- .arg("-screen")
- .arg("0")
- .arg("1024x768x24")
- .arg("-nolisten")
- .arg("tcp")
- .stdout(Stdio::null())
- .stderr(Stdio::null())
- .spawn()
- .with_context(|| format!("Failed to launch Xvfb on {display}"))?;
-
- thread::sleep(Duration::from_millis(250));
-
- let old_display = std::env::var("DISPLAY").ok();
- let old_session_type = std::env::var("XDG_SESSION_TYPE").ok();
- std::env::set_var("DISPLAY", &display);
- std::env::set_var("XDG_SESSION_TYPE", "x11");
-
- return Ok(Some(Self {
- child,
- old_display,
- old_session_type,
- }));
- }
-
- anyhow::bail!("Failed to find a free Xvfb display")
- }
-
- pub fn create_window(&self, title: &str, app_class: &str) -> Result<()> {
- let (conn, screen_num) =
- x11rb::connect(None).context("Failed to connect to test Xvfb display")?;
- let screen = &conn.setup().roots[screen_num];
- let window = conn.generate_id()?;
-
- conn.create_window(
- x11rb::COPY_DEPTH_FROM_PARENT,
- window,
- screen.root,
- 10,
- 10,
- 320,
- 180,
- 0,
- WindowClass::INPUT_OUTPUT,
- 0,
- &CreateWindowAux::new()
- .background_pixel(screen.white_pixel)
- .event_mask(EventMask::EXPOSURE),
- )?;
- conn.change_property8(
- PropMode::REPLACE,
- window,
- AtomEnum::WM_NAME,
- AtomEnum::STRING,
- title.as_bytes(),
- )?;
- let class_bytes = format!("{app_class}\0{app_class}\0");
- conn.change_property8(
- PropMode::REPLACE,
- window,
- AtomEnum::WM_CLASS,
- AtomEnum::STRING,
- class_bytes.as_bytes(),
- )?;
- conn.map_window(window)?;
- conn.flush()?;
-
- thread::sleep(Duration::from_millis(150));
- Ok(())
- }
-}
-
-impl Drop for X11TestEnv {
- fn drop(&mut self) {
- let _ = self.child.kill();
- let _ = self.child.wait();
-
- match &self.old_display {
- Some(value) => std::env::set_var("DISPLAY", value),
- None => std::env::remove_var("DISPLAY"),
- }
-
- match &self.old_session_type {
- Some(value) => std::env::set_var("XDG_SESSION_TYPE", value),
- None => std::env::remove_var("XDG_SESSION_TYPE"),
- }
- }
-}
-
-pub fn deskctl_tmp_screenshot_count() -> usize {
- std::fs::read_dir("/tmp")
- .ok()
- .into_iter()
- .flat_map(|iter| iter.filter_map(Result::ok))
- .filter(|entry| {
- entry
- .file_name()
- .to_str()
- .map(|name| name.starts_with("deskctl-") && name.ends_with(".png"))
- .unwrap_or(false)
- })
- .count()
-}
diff --git a/tests/support/mod.rs b/tests/support/mod.rs
new file mode 100644
index 0000000..d8b93a1
--- /dev/null
+++ b/tests/support/mod.rs
@@ -0,0 +1,220 @@
+#![cfg(target_os = "linux")]
+
+use std::os::unix::net::UnixListener;
+use std::path::{Path, PathBuf};
+use std::process::{Command, Output};
+use std::sync::{Mutex, OnceLock};
+use std::time::{SystemTime, UNIX_EPOCH};
+
+use anyhow::{anyhow, bail, Context, Result};
+use deskctl::cli::{connection, GlobalOpts};
+use x11rb::connection::Connection;
+use x11rb::protocol::xproto::{
+ AtomEnum, ConnectionExt as XprotoConnectionExt, CreateWindowAux, EventMask, PropMode,
+ WindowClass,
+};
+use x11rb::rust_connection::RustConnection;
+use x11rb::wrapper::ConnectionExt as X11WrapperConnectionExt;
+
+pub fn env_lock() -> &'static Mutex<()> {
+ static LOCK: OnceLock> = OnceLock::new();
+ LOCK.get_or_init(|| Mutex::new(()))
+}
+
+pub struct SessionEnvGuard {
+ old_session_type: Option,
+}
+
+impl SessionEnvGuard {
+ pub fn prepare() -> Option {
+ let _display = std::env::var("DISPLAY")
+ .ok()
+ .filter(|value| !value.is_empty())?;
+
+ let old_session_type = std::env::var("XDG_SESSION_TYPE").ok();
+ std::env::set_var("XDG_SESSION_TYPE", "x11");
+ Some(Self { old_session_type })
+ }
+}
+
+impl Drop for SessionEnvGuard {
+ fn drop(&mut self) {
+ match &self.old_session_type {
+ Some(value) => std::env::set_var("XDG_SESSION_TYPE", value),
+ None => std::env::remove_var("XDG_SESSION_TYPE"),
+ }
+ }
+}
+
+pub struct FixtureWindow {
+ conn: RustConnection,
+ window: u32,
+}
+
+impl FixtureWindow {
+ pub fn create(title: &str, app_class: &str) -> Result {
+ let (conn, screen_num) =
+ x11rb::connect(None).context("Failed to connect to the integration test display")?;
+ let screen = &conn.setup().roots[screen_num];
+ let window = conn.generate_id()?;
+
+ conn.create_window(
+ x11rb::COPY_DEPTH_FROM_PARENT,
+ window,
+ screen.root,
+ 10,
+ 10,
+ 320,
+ 180,
+ 0,
+ WindowClass::INPUT_OUTPUT,
+ 0,
+ &CreateWindowAux::new()
+ .background_pixel(screen.white_pixel)
+ .event_mask(EventMask::EXPOSURE),
+ )?;
+ conn.change_property8(
+ PropMode::REPLACE,
+ window,
+ AtomEnum::WM_NAME,
+ AtomEnum::STRING,
+ title.as_bytes(),
+ )?;
+ let class_bytes = format!("{app_class}\0{app_class}\0");
+ conn.change_property8(
+ PropMode::REPLACE,
+ window,
+ AtomEnum::WM_CLASS,
+ AtomEnum::STRING,
+ class_bytes.as_bytes(),
+ )?;
+ conn.map_window(window)?;
+ conn.flush()?;
+
+ std::thread::sleep(std::time::Duration::from_millis(150));
+ Ok(Self { conn, window })
+ }
+}
+
+impl Drop for FixtureWindow {
+ fn drop(&mut self) {
+ let _ = self.conn.destroy_window(self.window);
+ let _ = self.conn.flush();
+ }
+}
+
+pub struct TestSession {
+ pub opts: GlobalOpts,
+ root: PathBuf,
+}
+
+impl TestSession {
+ pub fn new(label: &str) -> Result {
+ let suffix = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .context("System clock is before the Unix epoch")?
+ .as_nanos();
+ let root = std::env::temp_dir().join(format!("deskctl-{label}-{suffix}"));
+ std::fs::create_dir_all(&root)
+ .with_context(|| format!("Failed to create {}", root.display()))?;
+
+ Ok(Self {
+ opts: GlobalOpts {
+ socket: Some(root.join("deskctl.sock")),
+ session: format!("{label}-{suffix}"),
+ json: false,
+ },
+ root,
+ })
+ }
+
+ pub fn socket_path(&self) -> &Path {
+ self.opts
+ .socket
+ .as_deref()
+ .expect("TestSession always has an explicit socket path")
+ }
+
+ pub fn create_stale_socket(&self) -> Result<()> {
+ let listener = UnixListener::bind(self.socket_path())
+ .with_context(|| format!("Failed to bind {}", self.socket_path().display()))?;
+ drop(listener);
+ Ok(())
+ }
+
+ pub fn start_daemon_cli(&self) -> Result<()> {
+ let output = self.run_cli(["daemon", "start"])?;
+ if output.status.success() {
+ return Ok(());
+ }
+
+ bail!(
+ "deskctl daemon start failed\nstdout:\n{}\nstderr:\n{}",
+ String::from_utf8_lossy(&output.stdout),
+ String::from_utf8_lossy(&output.stderr)
+ );
+ }
+
+ pub fn run_cli(&self, args: I) -> Result
+ where
+ I: IntoIterator- ,
+ S: AsRef
,
+ {
+ let socket = self.socket_path();
+ let mut command = Command::new(env!("CARGO_BIN_EXE_deskctl"));
+ command
+ .arg("--socket")
+ .arg(socket)
+ .arg("--session")
+ .arg(&self.opts.session);
+
+ for arg in args {
+ command.arg(arg.as_ref());
+ }
+
+ command.output().with_context(|| {
+ format!(
+ "Failed to run {} against {}",
+ env!("CARGO_BIN_EXE_deskctl"),
+ socket.display()
+ )
+ })
+ }
+}
+
+impl Drop for TestSession {
+ fn drop(&mut self) {
+ let _ = connection::stop_daemon(&self.opts);
+ if self.socket_path().exists() {
+ let _ = std::fs::remove_file(self.socket_path());
+ }
+ let _ = std::fs::remove_dir_all(&self.root);
+ }
+}
+
+pub fn deskctl_tmp_screenshot_count() -> usize {
+ std::fs::read_dir("/tmp")
+ .ok()
+ .into_iter()
+ .flat_map(|iter| iter.filter_map(Result::ok))
+ .filter(|entry| {
+ entry
+ .file_name()
+ .to_str()
+ .map(|name| name.starts_with("deskctl-") && name.ends_with(".png"))
+ .unwrap_or(false)
+ })
+ .count()
+}
+
+pub fn successful_json_response(output: Output) -> Result {
+ if !output.status.success() {
+ return Err(anyhow!(
+ "deskctl command failed\nstdout:\n{}\nstderr:\n{}",
+ String::from_utf8_lossy(&output.stdout),
+ String::from_utf8_lossy(&output.stderr)
+ ));
+ }
+
+ serde_json::from_slice(&output.stdout).context("Failed to parse JSON output from deskctl")
+}
diff --git a/tests/x11_runtime.rs b/tests/x11_runtime.rs
new file mode 100644
index 0000000..ef09411
--- /dev/null
+++ b/tests/x11_runtime.rs
@@ -0,0 +1,115 @@
+#![cfg(target_os = "linux")]
+
+mod support;
+
+use anyhow::Result;
+use deskctl::cli::connection::send_command;
+use deskctl::core::doctor;
+use deskctl::core::protocol::Request;
+
+use self::support::{
+ deskctl_tmp_screenshot_count, env_lock, successful_json_response, FixtureWindow,
+ SessionEnvGuard, TestSession,
+};
+
+#[test]
+fn doctor_reports_healthy_x11_environment() -> Result<()> {
+ let _guard = env_lock().lock().unwrap();
+ let Some(_env) = SessionEnvGuard::prepare() else {
+ eprintln!("Skipping X11 integration test because DISPLAY is not set");
+ return Ok(());
+ };
+
+ let _window = FixtureWindow::create("deskctl doctor test", "DeskctlDoctor")?;
+ let session = TestSession::new("doctor")?;
+ let report = doctor::run(session.socket_path());
+
+ assert!(report
+ .checks
+ .iter()
+ .any(|check| check.name == "display" && check.ok));
+ assert!(report
+ .checks
+ .iter()
+ .any(|check| check.name == "backend" && check.ok));
+ assert!(report
+ .checks
+ .iter()
+ .any(|check| check.name == "window-enumeration" && check.ok));
+ assert!(report
+ .checks
+ .iter()
+ .any(|check| check.name == "screenshot" && check.ok));
+
+ Ok(())
+}
+
+#[test]
+fn list_windows_is_side_effect_free() -> Result<()> {
+ let _guard = env_lock().lock().unwrap();
+ let Some(_env) = SessionEnvGuard::prepare() else {
+ eprintln!("Skipping X11 integration test because DISPLAY is not set");
+ return Ok(());
+ };
+
+ let _window = FixtureWindow::create("deskctl list-windows test", "DeskctlList")?;
+ let session = TestSession::new("list-windows")?;
+ session.start_daemon_cli()?;
+
+ let before = deskctl_tmp_screenshot_count();
+ let response = send_command(&session.opts, &Request::new("list-windows"))?;
+ assert!(response.success);
+
+ let windows = response
+ .data
+ .and_then(|data| data.get("windows").cloned())
+ .and_then(|windows| windows.as_array().cloned())
+ .expect("list-windows response must include a windows array");
+ assert!(windows.iter().any(|window| {
+ window
+ .get("title")
+ .and_then(|value| value.as_str())
+ .map(|title| title == "deskctl list-windows test")
+ .unwrap_or(false)
+ }));
+
+ let after = deskctl_tmp_screenshot_count();
+ assert_eq!(
+ before, after,
+ "list-windows should not create screenshot artifacts"
+ );
+
+ Ok(())
+}
+
+#[test]
+fn daemon_start_recovers_from_stale_socket() -> Result<()> {
+ let _guard = env_lock().lock().unwrap();
+ let Some(_env) = SessionEnvGuard::prepare() else {
+ eprintln!("Skipping X11 integration test because DISPLAY is not set");
+ return Ok(());
+ };
+
+ let _window = FixtureWindow::create("deskctl daemon recovery test", "DeskctlDaemon")?;
+ let session = TestSession::new("daemon-recovery")?;
+ session.create_stale_socket()?;
+
+ session.start_daemon_cli()?;
+ let response = successful_json_response(session.run_cli(["--json", "list-windows"])?)
+ .expect("list-windows should return valid JSON");
+
+ let windows = response
+ .get("data")
+ .and_then(|data| data.get("windows"))
+ .and_then(|value| value.as_array())
+ .expect("CLI JSON response must include windows");
+ assert!(windows.iter().any(|window| {
+ window
+ .get("title")
+ .and_then(|value| value.as_str())
+ .map(|title| title == "deskctl daemon recovery test")
+ .unwrap_or(false)
+ }));
+
+ Ok(())
+}
From ec9bc1f137db77657840d2ae0ccda1ca5f805488 Mon Sep 17 00:00:00 2001
From: Harivansh Rathi
Date: Wed, 25 Mar 2026 19:30:22 -0400
Subject: [PATCH 03/49] rm ai stuff from git
---
.claude/commands/opsx/apply.md | 152 ---------
.claude/commands/opsx/archive.md | 157 ----------
.claude/commands/opsx/explore.md | 173 -----------
.claude/commands/opsx/propose.md | 106 -------
.claude/skills/openspec-apply-change/SKILL.md | 156 ----------
.../skills/openspec-archive-change/SKILL.md | 114 -------
.claude/skills/openspec-explore/SKILL.md | 288 ------------------
.claude/skills/openspec-propose/SKILL.md | 110 -------
.codex/skills/openspec-apply-change/SKILL.md | 156 ----------
.../skills/openspec-archive-change/SKILL.md | 114 -------
.codex/skills/openspec-explore/SKILL.md | 288 ------------------
.codex/skills/openspec-propose/SKILL.md | 110 -------
.gitignore | 3 +
.../design.md | 116 -------
.../proposal.md | 28 --
.../specs/desktop-runtime/spec.md | 54 ----
.../tasks.md | 17 --
openspec/config.yaml | 20 --
openspec/specs/desktop-runtime/spec.md | 54 ----
19 files changed, 3 insertions(+), 2213 deletions(-)
delete mode 100644 .claude/commands/opsx/apply.md
delete mode 100644 .claude/commands/opsx/archive.md
delete mode 100644 .claude/commands/opsx/explore.md
delete mode 100644 .claude/commands/opsx/propose.md
delete mode 100644 .claude/skills/openspec-apply-change/SKILL.md
delete mode 100644 .claude/skills/openspec-archive-change/SKILL.md
delete mode 100644 .claude/skills/openspec-explore/SKILL.md
delete mode 100644 .claude/skills/openspec-propose/SKILL.md
delete mode 100644 .codex/skills/openspec-apply-change/SKILL.md
delete mode 100644 .codex/skills/openspec-archive-change/SKILL.md
delete mode 100644 .codex/skills/openspec-explore/SKILL.md
delete mode 100644 .codex/skills/openspec-propose/SKILL.md
delete mode 100644 openspec/changes/archive/2026-03-25-stabilize-v0-2-foundation/design.md
delete mode 100644 openspec/changes/archive/2026-03-25-stabilize-v0-2-foundation/proposal.md
delete mode 100644 openspec/changes/archive/2026-03-25-stabilize-v0-2-foundation/specs/desktop-runtime/spec.md
delete mode 100644 openspec/changes/archive/2026-03-25-stabilize-v0-2-foundation/tasks.md
delete mode 100644 openspec/config.yaml
delete mode 100644 openspec/specs/desktop-runtime/spec.md
diff --git a/.claude/commands/opsx/apply.md b/.claude/commands/opsx/apply.md
deleted file mode 100644
index bf23721..0000000
--- a/.claude/commands/opsx/apply.md
+++ /dev/null
@@ -1,152 +0,0 @@
----
-name: "OPSX: Apply"
-description: Implement tasks from an OpenSpec change (Experimental)
-category: Workflow
-tags: [workflow, artifacts, experimental]
----
-
-Implement tasks from an OpenSpec change.
-
-**Input**: Optionally specify a change name (e.g., `/opsx:apply add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
-
-**Steps**
-
-1. **Select the change**
-
- If a name is provided, use it. Otherwise:
- - Infer from conversation context if the user mentioned a change
- - Auto-select if only one active change exists
- - If ambiguous, run `openspec list --json` to get available changes and use the **AskUserQuestion tool** to let the user select
-
- Always announce: "Using change: " and how to override (e.g., `/opsx:apply `).
-
-2. **Check status to understand the schema**
- ```bash
- openspec status --change "" --json
- ```
- Parse the JSON to understand:
- - `schemaName`: The workflow being used (e.g., "spec-driven")
- - Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others)
-
-3. **Get apply instructions**
-
- ```bash
- openspec instructions apply --change "" --json
- ```
-
- This returns:
- - Context file paths (varies by schema)
- - Progress (total, complete, remaining)
- - Task list with status
- - Dynamic instruction based on current state
-
- **Handle states:**
- - If `state: "blocked"` (missing artifacts): show message, suggest using `/opsx:continue`
- - If `state: "all_done"`: congratulate, suggest archive
- - Otherwise: proceed to implementation
-
-4. **Read context files**
-
- Read the files listed in `contextFiles` from the apply instructions output.
- The files depend on the schema being used:
- - **spec-driven**: proposal, specs, design, tasks
- - Other schemas: follow the contextFiles from CLI output
-
-5. **Show current progress**
-
- Display:
- - Schema being used
- - Progress: "N/M tasks complete"
- - Remaining tasks overview
- - Dynamic instruction from CLI
-
-6. **Implement tasks (loop until done or blocked)**
-
- For each pending task:
- - Show which task is being worked on
- - Make the code changes required
- - Keep changes minimal and focused
- - Mark task complete in the tasks file: `- [ ]` → `- [x]`
- - Continue to next task
-
- **Pause if:**
- - Task is unclear → ask for clarification
- - Implementation reveals a design issue → suggest updating artifacts
- - Error or blocker encountered → report and wait for guidance
- - User interrupts
-
-7. **On completion or pause, show status**
-
- Display:
- - Tasks completed this session
- - Overall progress: "N/M tasks complete"
- - If all done: suggest archive
- - If paused: explain why and wait for guidance
-
-**Output During Implementation**
-
-```
-## Implementing: (schema: )
-
-Working on task 3/7:
-[...implementation happening...]
-✓ Task complete
-
-Working on task 4/7:
-[...implementation happening...]
-✓ Task complete
-```
-
-**Output On Completion**
-
-```
-## Implementation Complete
-
-**Change:**
-**Schema:**
-**Progress:** 7/7 tasks complete ✓
-
-### Completed This Session
-- [x] Task 1
-- [x] Task 2
-...
-
-All tasks complete! You can archive this change with `/opsx:archive`.
-```
-
-**Output On Pause (Issue Encountered)**
-
-```
-## Implementation Paused
-
-**Change:**
-**Schema:**
-**Progress:** 4/7 tasks complete
-
-### Issue Encountered
-
-
-**Options:**
-1.
-2.
-3. Other approach
-
-What would you like to do?
-```
-
-**Guardrails**
-- Keep going through tasks until done or blocked
-- Always read context files before starting (from the apply instructions output)
-- If task is ambiguous, pause and ask before implementing
-- If implementation reveals issues, pause and suggest artifact updates
-- Keep code changes minimal and scoped to each task
-- Update task checkbox immediately after completing each task
-- Pause on errors, blockers, or unclear requirements - don't guess
-- Use contextFiles from CLI output, don't assume specific file names
-
-**Fluid Workflow Integration**
-
-This skill supports the "actions on a change" model:
-
-- **Can be invoked anytime**: Before all artifacts are done (if tasks exist), after partial implementation, interleaved with other actions
-- **Allows artifact updates**: If implementation reveals design issues, suggest updating artifacts - not phase-locked, work fluidly
diff --git a/.claude/commands/opsx/archive.md b/.claude/commands/opsx/archive.md
deleted file mode 100644
index 5e91608..0000000
--- a/.claude/commands/opsx/archive.md
+++ /dev/null
@@ -1,157 +0,0 @@
----
-name: "OPSX: Archive"
-description: Archive a completed change in the experimental workflow
-category: Workflow
-tags: [workflow, archive, experimental]
----
-
-Archive a completed change in the experimental workflow.
-
-**Input**: Optionally specify a change name after `/opsx:archive` (e.g., `/opsx:archive add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
-
-**Steps**
-
-1. **If no change name provided, prompt for selection**
-
- Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
-
- Show only active changes (not already archived).
- Include the schema used for each change if available.
-
- **IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
-
-2. **Check artifact completion status**
-
- Run `openspec status --change "" --json` to check artifact completion.
-
- Parse the JSON to understand:
- - `schemaName`: The workflow being used
- - `artifacts`: List of artifacts with their status (`done` or other)
-
- **If any artifacts are not `done`:**
- - Display warning listing incomplete artifacts
- - Prompt user for confirmation to continue
- - Proceed if user confirms
-
-3. **Check task completion status**
-
- Read the tasks file (typically `tasks.md`) to check for incomplete tasks.
-
- Count tasks marked with `- [ ]` (incomplete) vs `- [x]` (complete).
-
- **If incomplete tasks found:**
- - Display warning showing count of incomplete tasks
- - Prompt user for confirmation to continue
- - Proceed if user confirms
-
- **If no tasks file exists:** Proceed without task-related warning.
-
-4. **Assess delta spec sync state**
-
- Check for delta specs at `openspec/changes//specs/`. If none exist, proceed without sync prompt.
-
- **If delta specs exist:**
- - Compare each delta spec with its corresponding main spec at `openspec/specs//spec.md`
- - Determine what changes would be applied (adds, modifications, removals, renames)
- - Show a combined summary before prompting
-
- **Prompt options:**
- - If changes needed: "Sync now (recommended)", "Archive without syncing"
- - If already synced: "Archive now", "Sync anyway", "Cancel"
-
- If user chooses sync, use Task tool (subagent_type: "general-purpose", prompt: "Use Skill tool to invoke openspec-sync-specs for change ''. Delta spec analysis: "). Proceed to archive regardless of choice.
-
-5. **Perform the archive**
-
- Create the archive directory if it doesn't exist:
- ```bash
- mkdir -p openspec/changes/archive
- ```
-
- Generate target name using current date: `YYYY-MM-DD-`
-
- **Check if target already exists:**
- - If yes: Fail with error, suggest renaming existing archive or using different date
- - If no: Move the change directory to archive
-
- ```bash
- mv openspec/changes/ openspec/changes/archive/YYYY-MM-DD-
- ```
-
-6. **Display summary**
-
- Show archive completion summary including:
- - Change name
- - Schema that was used
- - Archive location
- - Spec sync status (synced / sync skipped / no delta specs)
- - Note about any warnings (incomplete artifacts/tasks)
-
-**Output On Success**
-
-```
-## Archive Complete
-
-**Change:**
-**Schema:**
-**Archived to:** openspec/changes/archive/YYYY-MM-DD-/
-**Specs:** ✓ Synced to main specs
-
-All artifacts complete. All tasks complete.
-```
-
-**Output On Success (No Delta Specs)**
-
-```
-## Archive Complete
-
-**Change:**
-**Schema:**
-**Archived to:** openspec/changes/archive/YYYY-MM-DD-/
-**Specs:** No delta specs
-
-All artifacts complete. All tasks complete.
-```
-
-**Output On Success With Warnings**
-
-```
-## Archive Complete (with warnings)
-
-**Change:**
-**Schema:**
-**Archived to:** openspec/changes/archive/YYYY-MM-DD-/
-**Specs:** Sync skipped (user chose to skip)
-
-**Warnings:**
-- Archived with 2 incomplete artifacts
-- Archived with 3 incomplete tasks
-- Delta spec sync was skipped (user chose to skip)
-
-Review the archive if this was not intentional.
-```
-
-**Output On Error (Archive Exists)**
-
-```
-## Archive Failed
-
-**Change:**
-**Target:** openspec/changes/archive/YYYY-MM-DD-/
-
-Target archive directory already exists.
-
-**Options:**
-1. Rename the existing archive
-2. Delete the existing archive if it's a duplicate
-3. Wait until a different date to archive
-```
-
-**Guardrails**
-- Always prompt for change selection if not provided
-- Use artifact graph (openspec status --json) for completion checking
-- Don't block archive on warnings - just inform and confirm
-- Preserve .openspec.yaml when moving to archive (it moves with the directory)
-- Show clear summary of what happened
-- If sync is requested, use the Skill tool to invoke `openspec-sync-specs` (agent-driven)
-- If delta specs exist, always run the sync assessment and show the combined summary before prompting
diff --git a/.claude/commands/opsx/explore.md b/.claude/commands/opsx/explore.md
deleted file mode 100644
index 30d9c57..0000000
--- a/.claude/commands/opsx/explore.md
+++ /dev/null
@@ -1,173 +0,0 @@
----
-name: "OPSX: Explore"
-description: "Enter explore mode - think through ideas, investigate problems, clarify requirements"
-category: Workflow
-tags: [workflow, explore, experimental, thinking]
----
-
-Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes.
-
-**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, and investigate the codebase, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first and create a change proposal. You MAY create OpenSpec artifacts (proposals, designs, specs) if the user asks—that's capturing thinking, not implementing.
-
-**This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore.
-
-**Input**: The argument after `/opsx:explore` is whatever the user wants to think about. Could be:
-- A vague idea: "real-time collaboration"
-- A specific problem: "the auth system is getting unwieldy"
-- A change name: "add-dark-mode" (to explore in context of that change)
-- A comparison: "postgres vs sqlite for this"
-- Nothing (just enter explore mode)
-
----
-
-## The Stance
-
-- **Curious, not prescriptive** - Ask questions that emerge naturally, don't follow a script
-- **Open threads, not interrogations** - Surface multiple interesting directions and let the user follow what resonates. Don't funnel them through a single path of questions.
-- **Visual** - Use ASCII diagrams liberally when they'd help clarify thinking
-- **Adaptive** - Follow interesting threads, pivot when new information emerges
-- **Patient** - Don't rush to conclusions, let the shape of the problem emerge
-- **Grounded** - Explore the actual codebase when relevant, don't just theorize
-
----
-
-## What You Might Do
-
-Depending on what the user brings, you might:
-
-**Explore the problem space**
-- Ask clarifying questions that emerge from what they said
-- Challenge assumptions
-- Reframe the problem
-- Find analogies
-
-**Investigate the codebase**
-- Map existing architecture relevant to the discussion
-- Find integration points
-- Identify patterns already in use
-- Surface hidden complexity
-
-**Compare options**
-- Brainstorm multiple approaches
-- Build comparison tables
-- Sketch tradeoffs
-- Recommend a path (if asked)
-
-**Visualize**
-```
-┌─────────────────────────────────────────┐
-│ Use ASCII diagrams liberally │
-├─────────────────────────────────────────┤
-│ │
-│ ┌────────┐ ┌────────┐ │
-│ │ State │────────▶│ State │ │
-│ │ A │ │ B │ │
-│ └────────┘ └────────┘ │
-│ │
-│ System diagrams, state machines, │
-│ data flows, architecture sketches, │
-│ dependency graphs, comparison tables │
-│ │
-└─────────────────────────────────────────┘
-```
-
-**Surface risks and unknowns**
-- Identify what could go wrong
-- Find gaps in understanding
-- Suggest spikes or investigations
-
----
-
-## OpenSpec Awareness
-
-You have full context of the OpenSpec system. Use it naturally, don't force it.
-
-### Check for context
-
-At the start, quickly check what exists:
-```bash
-openspec list --json
-```
-
-This tells you:
-- If there are active changes
-- Their names, schemas, and status
-- What the user might be working on
-
-If the user mentioned a specific change name, read its artifacts for context.
-
-### When no change exists
-
-Think freely. When insights crystallize, you might offer:
-
-- "This feels solid enough to start a change. Want me to create a proposal?"
-- Or keep exploring - no pressure to formalize
-
-### When a change exists
-
-If the user mentions a change or you detect one is relevant:
-
-1. **Read existing artifacts for context**
- - `openspec/changes//proposal.md`
- - `openspec/changes//design.md`
- - `openspec/changes//tasks.md`
- - etc.
-
-2. **Reference them naturally in conversation**
- - "Your design mentions using Redis, but we just realized SQLite fits better..."
- - "The proposal scopes this to premium users, but we're now thinking everyone..."
-
-3. **Offer to capture when decisions are made**
-
- | Insight Type | Where to Capture |
- |--------------|------------------|
- | New requirement discovered | `specs//spec.md` |
- | Requirement changed | `specs//spec.md` |
- | Design decision made | `design.md` |
- | Scope changed | `proposal.md` |
- | New work identified | `tasks.md` |
- | Assumption invalidated | Relevant artifact |
-
- Example offers:
- - "That's a design decision. Capture it in design.md?"
- - "This is a new requirement. Add it to specs?"
- - "This changes scope. Update the proposal?"
-
-4. **The user decides** - Offer and move on. Don't pressure. Don't auto-capture.
-
----
-
-## What You Don't Have To Do
-
-- Follow a script
-- Ask the same questions every time
-- Produce a specific artifact
-- Reach a conclusion
-- Stay on topic if a tangent is valuable
-- Be brief (this is thinking time)
-
----
-
-## Ending Discovery
-
-There's no required ending. Discovery might:
-
-- **Flow into a proposal**: "Ready to start? I can create a change proposal."
-- **Result in artifact updates**: "Updated design.md with these decisions"
-- **Just provide clarity**: User has what they need, moves on
-- **Continue later**: "We can pick this up anytime"
-
-When things crystallize, you might offer a summary - but it's optional. Sometimes the thinking IS the value.
-
----
-
-## Guardrails
-
-- **Don't implement** - Never write code or implement features. Creating OpenSpec artifacts is fine, writing application code is not.
-- **Don't fake understanding** - If something is unclear, dig deeper
-- **Don't rush** - Discovery is thinking time, not task time
-- **Don't force structure** - Let patterns emerge naturally
-- **Don't auto-capture** - Offer to save insights, don't just do it
-- **Do visualize** - A good diagram is worth many paragraphs
-- **Do explore the codebase** - Ground discussions in reality
-- **Do question assumptions** - Including the user's and your own
diff --git a/.claude/commands/opsx/propose.md b/.claude/commands/opsx/propose.md
deleted file mode 100644
index 05276f4..0000000
--- a/.claude/commands/opsx/propose.md
+++ /dev/null
@@ -1,106 +0,0 @@
----
-name: "OPSX: Propose"
-description: Propose a new change - create it and generate all artifacts in one step
-category: Workflow
-tags: [workflow, artifacts, experimental]
----
-
-Propose a new change - create the change and generate all artifacts in one step.
-
-I'll create a change with artifacts:
-- proposal.md (what & why)
-- design.md (how)
-- tasks.md (implementation steps)
-
-When ready to implement, run /opsx:apply
-
----
-
-**Input**: The argument after `/opsx:propose` is the change name (kebab-case), OR a description of what the user wants to build.
-
-**Steps**
-
-1. **If no input provided, ask what they want to build**
-
- Use the **AskUserQuestion tool** (open-ended, no preset options) to ask:
- > "What change do you want to work on? Describe what you want to build or fix."
-
- From their description, derive a kebab-case name (e.g., "add user authentication" → `add-user-auth`).
-
- **IMPORTANT**: Do NOT proceed without understanding what the user wants to build.
-
-2. **Create the change directory**
- ```bash
- openspec new change ""
- ```
- This creates a scaffolded change at `openspec/changes//` with `.openspec.yaml`.
-
-3. **Get the artifact build order**
- ```bash
- openspec status --change "" --json
- ```
- Parse the JSON to get:
- - `applyRequires`: array of artifact IDs needed before implementation (e.g., `["tasks"]`)
- - `artifacts`: list of all artifacts with their status and dependencies
-
-4. **Create artifacts in sequence until apply-ready**
-
- Use the **TodoWrite tool** to track progress through the artifacts.
-
- Loop through artifacts in dependency order (artifacts with no pending dependencies first):
-
- a. **For each artifact that is `ready` (dependencies satisfied)**:
- - Get instructions:
- ```bash
- openspec instructions --change "" --json
- ```
- - The instructions JSON includes:
- - `context`: Project background (constraints for you - do NOT include in output)
- - `rules`: Artifact-specific rules (constraints for you - do NOT include in output)
- - `template`: The structure to use for your output file
- - `instruction`: Schema-specific guidance for this artifact type
- - `outputPath`: Where to write the artifact
- - `dependencies`: Completed artifacts to read for context
- - Read any completed dependency files for context
- - Create the artifact file using `template` as the structure
- - Apply `context` and `rules` as constraints - but do NOT copy them into the file
- - Show brief progress: "Created "
-
- b. **Continue until all `applyRequires` artifacts are complete**
- - After creating each artifact, re-run `openspec status --change "" --json`
- - Check if every artifact ID in `applyRequires` has `status: "done"` in the artifacts array
- - Stop when all `applyRequires` artifacts are done
-
- c. **If an artifact requires user input** (unclear context):
- - Use **AskUserQuestion tool** to clarify
- - Then continue with creation
-
-5. **Show final status**
- ```bash
- openspec status --change ""
- ```
-
-**Output**
-
-After completing all artifacts, summarize:
-- Change name and location
-- List of artifacts created with brief descriptions
-- What's ready: "All artifacts created! Ready for implementation."
-- Prompt: "Run `/opsx:apply` to start implementing."
-
-**Artifact Creation Guidelines**
-
-- Follow the `instruction` field from `openspec instructions` for each artifact type
-- The schema defines what each artifact should contain - follow it
-- Read dependency artifacts for context before creating new ones
-- Use `template` as the structure for your output file - fill in its sections
-- **IMPORTANT**: `context` and `rules` are constraints for YOU, not content for the file
- - Do NOT copy ``, ``, `` blocks into the artifact
- - These guide what you write, but should never appear in the output
-
-**Guardrails**
-- Create ALL artifacts needed for implementation (as defined by schema's `apply.requires`)
-- Always read dependency artifacts before creating a new one
-- If context is critically unclear, ask the user - but prefer making reasonable decisions to keep momentum
-- If a change with that name already exists, ask if user wants to continue it or create a new one
-- Verify each artifact file exists after writing before proceeding to next
diff --git a/.claude/skills/openspec-apply-change/SKILL.md b/.claude/skills/openspec-apply-change/SKILL.md
deleted file mode 100644
index d474dc1..0000000
--- a/.claude/skills/openspec-apply-change/SKILL.md
+++ /dev/null
@@ -1,156 +0,0 @@
----
-name: openspec-apply-change
-description: Implement tasks from an OpenSpec change. Use when the user wants to start implementing, continue implementation, or work through tasks.
-license: MIT
-compatibility: Requires openspec CLI.
-metadata:
- author: openspec
- version: "1.0"
- generatedBy: "1.2.0"
----
-
-Implement tasks from an OpenSpec change.
-
-**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
-
-**Steps**
-
-1. **Select the change**
-
- If a name is provided, use it. Otherwise:
- - Infer from conversation context if the user mentioned a change
- - Auto-select if only one active change exists
- - If ambiguous, run `openspec list --json` to get available changes and use the **AskUserQuestion tool** to let the user select
-
- Always announce: "Using change: " and how to override (e.g., `/opsx:apply `).
-
-2. **Check status to understand the schema**
- ```bash
- openspec status --change "" --json
- ```
- Parse the JSON to understand:
- - `schemaName`: The workflow being used (e.g., "spec-driven")
- - Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others)
-
-3. **Get apply instructions**
-
- ```bash
- openspec instructions apply --change "" --json
- ```
-
- This returns:
- - Context file paths (varies by schema - could be proposal/specs/design/tasks or spec/tests/implementation/docs)
- - Progress (total, complete, remaining)
- - Task list with status
- - Dynamic instruction based on current state
-
- **Handle states:**
- - If `state: "blocked"` (missing artifacts): show message, suggest using openspec-continue-change
- - If `state: "all_done"`: congratulate, suggest archive
- - Otherwise: proceed to implementation
-
-4. **Read context files**
-
- Read the files listed in `contextFiles` from the apply instructions output.
- The files depend on the schema being used:
- - **spec-driven**: proposal, specs, design, tasks
- - Other schemas: follow the contextFiles from CLI output
-
-5. **Show current progress**
-
- Display:
- - Schema being used
- - Progress: "N/M tasks complete"
- - Remaining tasks overview
- - Dynamic instruction from CLI
-
-6. **Implement tasks (loop until done or blocked)**
-
- For each pending task:
- - Show which task is being worked on
- - Make the code changes required
- - Keep changes minimal and focused
- - Mark task complete in the tasks file: `- [ ]` → `- [x]`
- - Continue to next task
-
- **Pause if:**
- - Task is unclear → ask for clarification
- - Implementation reveals a design issue → suggest updating artifacts
- - Error or blocker encountered → report and wait for guidance
- - User interrupts
-
-7. **On completion or pause, show status**
-
- Display:
- - Tasks completed this session
- - Overall progress: "N/M tasks complete"
- - If all done: suggest archive
- - If paused: explain why and wait for guidance
-
-**Output During Implementation**
-
-```
-## Implementing: (schema: )
-
-Working on task 3/7:
-[...implementation happening...]
-✓ Task complete
-
-Working on task 4/7:
-[...implementation happening...]
-✓ Task complete
-```
-
-**Output On Completion**
-
-```
-## Implementation Complete
-
-**Change:**
-**Schema:**
-**Progress:** 7/7 tasks complete ✓
-
-### Completed This Session
-- [x] Task 1
-- [x] Task 2
-...
-
-All tasks complete! Ready to archive this change.
-```
-
-**Output On Pause (Issue Encountered)**
-
-```
-## Implementation Paused
-
-**Change:**
-**Schema:**
-**Progress:** 4/7 tasks complete
-
-### Issue Encountered
-
-
-**Options:**
-1.
-2.
-3. Other approach
-
-What would you like to do?
-```
-
-**Guardrails**
-- Keep going through tasks until done or blocked
-- Always read context files before starting (from the apply instructions output)
-- If task is ambiguous, pause and ask before implementing
-- If implementation reveals issues, pause and suggest artifact updates
-- Keep code changes minimal and scoped to each task
-- Update task checkbox immediately after completing each task
-- Pause on errors, blockers, or unclear requirements - don't guess
-- Use contextFiles from CLI output, don't assume specific file names
-
-**Fluid Workflow Integration**
-
-This skill supports the "actions on a change" model:
-
-- **Can be invoked anytime**: Before all artifacts are done (if tasks exist), after partial implementation, interleaved with other actions
-- **Allows artifact updates**: If implementation reveals design issues, suggest updating artifacts - not phase-locked, work fluidly
diff --git a/.claude/skills/openspec-archive-change/SKILL.md b/.claude/skills/openspec-archive-change/SKILL.md
deleted file mode 100644
index 9b1f851..0000000
--- a/.claude/skills/openspec-archive-change/SKILL.md
+++ /dev/null
@@ -1,114 +0,0 @@
----
-name: openspec-archive-change
-description: Archive a completed change in the experimental workflow. Use when the user wants to finalize and archive a change after implementation is complete.
-license: MIT
-compatibility: Requires openspec CLI.
-metadata:
- author: openspec
- version: "1.0"
- generatedBy: "1.2.0"
----
-
-Archive a completed change in the experimental workflow.
-
-**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
-
-**Steps**
-
-1. **If no change name provided, prompt for selection**
-
- Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
-
- Show only active changes (not already archived).
- Include the schema used for each change if available.
-
- **IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
-
-2. **Check artifact completion status**
-
- Run `openspec status --change "" --json` to check artifact completion.
-
- Parse the JSON to understand:
- - `schemaName`: The workflow being used
- - `artifacts`: List of artifacts with their status (`done` or other)
-
- **If any artifacts are not `done`:**
- - Display warning listing incomplete artifacts
- - Use **AskUserQuestion tool** to confirm user wants to proceed
- - Proceed if user confirms
-
-3. **Check task completion status**
-
- Read the tasks file (typically `tasks.md`) to check for incomplete tasks.
-
- Count tasks marked with `- [ ]` (incomplete) vs `- [x]` (complete).
-
- **If incomplete tasks found:**
- - Display warning showing count of incomplete tasks
- - Use **AskUserQuestion tool** to confirm user wants to proceed
- - Proceed if user confirms
-
- **If no tasks file exists:** Proceed without task-related warning.
-
-4. **Assess delta spec sync state**
-
- Check for delta specs at `openspec/changes//specs/`. If none exist, proceed without sync prompt.
-
- **If delta specs exist:**
- - Compare each delta spec with its corresponding main spec at `openspec/specs//spec.md`
- - Determine what changes would be applied (adds, modifications, removals, renames)
- - Show a combined summary before prompting
-
- **Prompt options:**
- - If changes needed: "Sync now (recommended)", "Archive without syncing"
- - If already synced: "Archive now", "Sync anyway", "Cancel"
-
- If user chooses sync, use Task tool (subagent_type: "general-purpose", prompt: "Use Skill tool to invoke openspec-sync-specs for change ''. Delta spec analysis: "). Proceed to archive regardless of choice.
-
-5. **Perform the archive**
-
- Create the archive directory if it doesn't exist:
- ```bash
- mkdir -p openspec/changes/archive
- ```
-
- Generate target name using current date: `YYYY-MM-DD-`
-
- **Check if target already exists:**
- - If yes: Fail with error, suggest renaming existing archive or using different date
- - If no: Move the change directory to archive
-
- ```bash
- mv openspec/changes/ openspec/changes/archive/YYYY-MM-DD-
- ```
-
-6. **Display summary**
-
- Show archive completion summary including:
- - Change name
- - Schema that was used
- - Archive location
- - Whether specs were synced (if applicable)
- - Note about any warnings (incomplete artifacts/tasks)
-
-**Output On Success**
-
-```
-## Archive Complete
-
-**Change:**
-**Schema:**
-**Archived to:** openspec/changes/archive/YYYY-MM-DD-/
-**Specs:** ✓ Synced to main specs (or "No delta specs" or "Sync skipped")
-
-All artifacts complete. All tasks complete.
-```
-
-**Guardrails**
-- Always prompt for change selection if not provided
-- Use artifact graph (openspec status --json) for completion checking
-- Don't block archive on warnings - just inform and confirm
-- Preserve .openspec.yaml when moving to archive (it moves with the directory)
-- Show clear summary of what happened
-- If sync is requested, use openspec-sync-specs approach (agent-driven)
-- If delta specs exist, always run the sync assessment and show the combined summary before prompting
diff --git a/.claude/skills/openspec-explore/SKILL.md b/.claude/skills/openspec-explore/SKILL.md
deleted file mode 100644
index ffa10ca..0000000
--- a/.claude/skills/openspec-explore/SKILL.md
+++ /dev/null
@@ -1,288 +0,0 @@
----
-name: openspec-explore
-description: Enter explore mode - a thinking partner for exploring ideas, investigating problems, and clarifying requirements. Use when the user wants to think through something before or during a change.
-license: MIT
-compatibility: Requires openspec CLI.
-metadata:
- author: openspec
- version: "1.0"
- generatedBy: "1.2.0"
----
-
-Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes.
-
-**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, and investigate the codebase, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first and create a change proposal. You MAY create OpenSpec artifacts (proposals, designs, specs) if the user asks—that's capturing thinking, not implementing.
-
-**This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore.
-
----
-
-## The Stance
-
-- **Curious, not prescriptive** - Ask questions that emerge naturally, don't follow a script
-- **Open threads, not interrogations** - Surface multiple interesting directions and let the user follow what resonates. Don't funnel them through a single path of questions.
-- **Visual** - Use ASCII diagrams liberally when they'd help clarify thinking
-- **Adaptive** - Follow interesting threads, pivot when new information emerges
-- **Patient** - Don't rush to conclusions, let the shape of the problem emerge
-- **Grounded** - Explore the actual codebase when relevant, don't just theorize
-
----
-
-## What You Might Do
-
-Depending on what the user brings, you might:
-
-**Explore the problem space**
-- Ask clarifying questions that emerge from what they said
-- Challenge assumptions
-- Reframe the problem
-- Find analogies
-
-**Investigate the codebase**
-- Map existing architecture relevant to the discussion
-- Find integration points
-- Identify patterns already in use
-- Surface hidden complexity
-
-**Compare options**
-- Brainstorm multiple approaches
-- Build comparison tables
-- Sketch tradeoffs
-- Recommend a path (if asked)
-
-**Visualize**
-```
-┌─────────────────────────────────────────┐
-│ Use ASCII diagrams liberally │
-├─────────────────────────────────────────┤
-│ │
-│ ┌────────┐ ┌────────┐ │
-│ │ State │────────▶│ State │ │
-│ │ A │ │ B │ │
-│ └────────┘ └────────┘ │
-│ │
-│ System diagrams, state machines, │
-│ data flows, architecture sketches, │
-│ dependency graphs, comparison tables │
-│ │
-└─────────────────────────────────────────┘
-```
-
-**Surface risks and unknowns**
-- Identify what could go wrong
-- Find gaps in understanding
-- Suggest spikes or investigations
-
----
-
-## OpenSpec Awareness
-
-You have full context of the OpenSpec system. Use it naturally, don't force it.
-
-### Check for context
-
-At the start, quickly check what exists:
-```bash
-openspec list --json
-```
-
-This tells you:
-- If there are active changes
-- Their names, schemas, and status
-- What the user might be working on
-
-### When no change exists
-
-Think freely. When insights crystallize, you might offer:
-
-- "This feels solid enough to start a change. Want me to create a proposal?"
-- Or keep exploring - no pressure to formalize
-
-### When a change exists
-
-If the user mentions a change or you detect one is relevant:
-
-1. **Read existing artifacts for context**
- - `openspec/changes//proposal.md`
- - `openspec/changes//design.md`
- - `openspec/changes//tasks.md`
- - etc.
-
-2. **Reference them naturally in conversation**
- - "Your design mentions using Redis, but we just realized SQLite fits better..."
- - "The proposal scopes this to premium users, but we're now thinking everyone..."
-
-3. **Offer to capture when decisions are made**
-
- | Insight Type | Where to Capture |
- |--------------|------------------|
- | New requirement discovered | `specs//spec.md` |
- | Requirement changed | `specs//spec.md` |
- | Design decision made | `design.md` |
- | Scope changed | `proposal.md` |
- | New work identified | `tasks.md` |
- | Assumption invalidated | Relevant artifact |
-
- Example offers:
- - "That's a design decision. Capture it in design.md?"
- - "This is a new requirement. Add it to specs?"
- - "This changes scope. Update the proposal?"
-
-4. **The user decides** - Offer and move on. Don't pressure. Don't auto-capture.
-
----
-
-## What You Don't Have To Do
-
-- Follow a script
-- Ask the same questions every time
-- Produce a specific artifact
-- Reach a conclusion
-- Stay on topic if a tangent is valuable
-- Be brief (this is thinking time)
-
----
-
-## Handling Different Entry Points
-
-**User brings a vague idea:**
-```
-User: I'm thinking about adding real-time collaboration
-
-You: Real-time collab is a big space. Let me think about this...
-
- COLLABORATION SPECTRUM
- ════════════════════════════════════════════
-
- Awareness Coordination Sync
- │ │ │
- ▼ ▼ ▼
- ┌────────┐ ┌────────┐ ┌────────┐
- │Presence│ │Cursors │ │ CRDT │
- │ "3 │ │ Multi │ │Conflict│
- │online" │ │ select │ │ free │
- └────────┘ └────────┘ └────────┘
- │ │ │
- trivial moderate complex
-
- Where's your head at?
-```
-
-**User brings a specific problem:**
-```
-User: The auth system is a mess
-
-You: [reads codebase]
-
- ┌─────────────────────────────────────────────┐
- │ CURRENT AUTH FLOW │
- └─────────────────────────────────────────────┘
- │
- ┌─────────────┼─────────────┐
- ▼ ▼ ▼
- ┌─────────┐ ┌─────────┐ ┌─────────┐
- │ Google │ │ GitHub │ │ Email │
- │ OAuth │ │ OAuth │ │ Magic │
- └────┬────┘ └────┬────┘ └────┬────┘
- │ │ │
- └─────────────┼─────────────┘
- ▼
- ┌───────────┐
- │ Session │
- └─────┬─────┘
- │
- ▼
- ┌───────────┐
- │ Perms │
- └───────────┘
-
- I see three tangles. Which one's burning?
-```
-
-**User is stuck mid-implementation:**
-```
-User: /opsx:explore add-auth-system
- The OAuth integration is more complex than expected
-
-You: [reads change artifacts]
-
- You're on task 4: "Implement OAuth flow"
-
- Let me trace what's involved...
-
- [draws diagram, explores options, suggests paths]
-
- Want to update the design to reflect this?
- Or add a spike task to investigate?
-```
-
-**User wants to compare options:**
-```
-User: Should we use Postgres or SQLite?
-
-You: Generic answer is boring. What's the context?
-
-User: A CLI tool that tracks local dev environments
-
-You: That changes everything.
-
- ┌─────────────────────────────────────────────────┐
- │ CLI TOOL DATA STORAGE │
- └─────────────────────────────────────────────────┘
-
- Key constraints:
- • No daemon running
- • Must work offline
- • Single user
-
- SQLite Postgres
- Deployment embedded ✓ needs server ✗
- Offline yes ✓ no ✗
- Single file yes ✓ no ✗
-
- SQLite. Not even close.
-
- Unless... is there a sync component?
-```
-
----
-
-## Ending Discovery
-
-There's no required ending. Discovery might:
-
-- **Flow into a proposal**: "Ready to start? I can create a change proposal."
-- **Result in artifact updates**: "Updated design.md with these decisions"
-- **Just provide clarity**: User has what they need, moves on
-- **Continue later**: "We can pick this up anytime"
-
-When it feels like things are crystallizing, you might summarize:
-
-```
-## What We Figured Out
-
-**The problem**: [crystallized understanding]
-
-**The approach**: [if one emerged]
-
-**Open questions**: [if any remain]
-
-**Next steps** (if ready):
-- Create a change proposal
-- Keep exploring: just keep talking
-```
-
-But this summary is optional. Sometimes the thinking IS the value.
-
----
-
-## Guardrails
-
-- **Don't implement** - Never write code or implement features. Creating OpenSpec artifacts is fine, writing application code is not.
-- **Don't fake understanding** - If something is unclear, dig deeper
-- **Don't rush** - Discovery is thinking time, not task time
-- **Don't force structure** - Let patterns emerge naturally
-- **Don't auto-capture** - Offer to save insights, don't just do it
-- **Do visualize** - A good diagram is worth many paragraphs
-- **Do explore the codebase** - Ground discussions in reality
-- **Do question assumptions** - Including the user's and your own
diff --git a/.claude/skills/openspec-propose/SKILL.md b/.claude/skills/openspec-propose/SKILL.md
deleted file mode 100644
index d27bc53..0000000
--- a/.claude/skills/openspec-propose/SKILL.md
+++ /dev/null
@@ -1,110 +0,0 @@
----
-name: openspec-propose
-description: Propose a new change with all artifacts generated in one step. Use when the user wants to quickly describe what they want to build and get a complete proposal with design, specs, and tasks ready for implementation.
-license: MIT
-compatibility: Requires openspec CLI.
-metadata:
- author: openspec
- version: "1.0"
- generatedBy: "1.2.0"
----
-
-Propose a new change - create the change and generate all artifacts in one step.
-
-I'll create a change with artifacts:
-- proposal.md (what & why)
-- design.md (how)
-- tasks.md (implementation steps)
-
-When ready to implement, run /opsx:apply
-
----
-
-**Input**: The user's request should include a change name (kebab-case) OR a description of what they want to build.
-
-**Steps**
-
-1. **If no clear input provided, ask what they want to build**
-
- Use the **AskUserQuestion tool** (open-ended, no preset options) to ask:
- > "What change do you want to work on? Describe what you want to build or fix."
-
- From their description, derive a kebab-case name (e.g., "add user authentication" → `add-user-auth`).
-
- **IMPORTANT**: Do NOT proceed without understanding what the user wants to build.
-
-2. **Create the change directory**
- ```bash
- openspec new change ""
- ```
- This creates a scaffolded change at `openspec/changes//` with `.openspec.yaml`.
-
-3. **Get the artifact build order**
- ```bash
- openspec status --change "" --json
- ```
- Parse the JSON to get:
- - `applyRequires`: array of artifact IDs needed before implementation (e.g., `["tasks"]`)
- - `artifacts`: list of all artifacts with their status and dependencies
-
-4. **Create artifacts in sequence until apply-ready**
-
- Use the **TodoWrite tool** to track progress through the artifacts.
-
- Loop through artifacts in dependency order (artifacts with no pending dependencies first):
-
- a. **For each artifact that is `ready` (dependencies satisfied)**:
- - Get instructions:
- ```bash
- openspec instructions --change "" --json
- ```
- - The instructions JSON includes:
- - `context`: Project background (constraints for you - do NOT include in output)
- - `rules`: Artifact-specific rules (constraints for you - do NOT include in output)
- - `template`: The structure to use for your output file
- - `instruction`: Schema-specific guidance for this artifact type
- - `outputPath`: Where to write the artifact
- - `dependencies`: Completed artifacts to read for context
- - Read any completed dependency files for context
- - Create the artifact file using `template` as the structure
- - Apply `context` and `rules` as constraints - but do NOT copy them into the file
- - Show brief progress: "Created "
-
- b. **Continue until all `applyRequires` artifacts are complete**
- - After creating each artifact, re-run `openspec status --change "" --json`
- - Check if every artifact ID in `applyRequires` has `status: "done"` in the artifacts array
- - Stop when all `applyRequires` artifacts are done
-
- c. **If an artifact requires user input** (unclear context):
- - Use **AskUserQuestion tool** to clarify
- - Then continue with creation
-
-5. **Show final status**
- ```bash
- openspec status --change ""
- ```
-
-**Output**
-
-After completing all artifacts, summarize:
-- Change name and location
-- List of artifacts created with brief descriptions
-- What's ready: "All artifacts created! Ready for implementation."
-- Prompt: "Run `/opsx:apply` or ask me to implement to start working on the tasks."
-
-**Artifact Creation Guidelines**
-
-- Follow the `instruction` field from `openspec instructions` for each artifact type
-- The schema defines what each artifact should contain - follow it
-- Read dependency artifacts for context before creating new ones
-- Use `template` as the structure for your output file - fill in its sections
-- **IMPORTANT**: `context` and `rules` are constraints for YOU, not content for the file
- - Do NOT copy ``, ``, `` blocks into the artifact
- - These guide what you write, but should never appear in the output
-
-**Guardrails**
-- Create ALL artifacts needed for implementation (as defined by schema's `apply.requires`)
-- Always read dependency artifacts before creating a new one
-- If context is critically unclear, ask the user - but prefer making reasonable decisions to keep momentum
-- If a change with that name already exists, ask if user wants to continue it or create a new one
-- Verify each artifact file exists after writing before proceeding to next
diff --git a/.codex/skills/openspec-apply-change/SKILL.md b/.codex/skills/openspec-apply-change/SKILL.md
deleted file mode 100644
index d474dc1..0000000
--- a/.codex/skills/openspec-apply-change/SKILL.md
+++ /dev/null
@@ -1,156 +0,0 @@
----
-name: openspec-apply-change
-description: Implement tasks from an OpenSpec change. Use when the user wants to start implementing, continue implementation, or work through tasks.
-license: MIT
-compatibility: Requires openspec CLI.
-metadata:
- author: openspec
- version: "1.0"
- generatedBy: "1.2.0"
----
-
-Implement tasks from an OpenSpec change.
-
-**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
-
-**Steps**
-
-1. **Select the change**
-
- If a name is provided, use it. Otherwise:
- - Infer from conversation context if the user mentioned a change
- - Auto-select if only one active change exists
- - If ambiguous, run `openspec list --json` to get available changes and use the **AskUserQuestion tool** to let the user select
-
- Always announce: "Using change: " and how to override (e.g., `/opsx:apply `).
-
-2. **Check status to understand the schema**
- ```bash
- openspec status --change "" --json
- ```
- Parse the JSON to understand:
- - `schemaName`: The workflow being used (e.g., "spec-driven")
- - Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others)
-
-3. **Get apply instructions**
-
- ```bash
- openspec instructions apply --change "" --json
- ```
-
- This returns:
- - Context file paths (varies by schema - could be proposal/specs/design/tasks or spec/tests/implementation/docs)
- - Progress (total, complete, remaining)
- - Task list with status
- - Dynamic instruction based on current state
-
- **Handle states:**
- - If `state: "blocked"` (missing artifacts): show message, suggest using openspec-continue-change
- - If `state: "all_done"`: congratulate, suggest archive
- - Otherwise: proceed to implementation
-
-4. **Read context files**
-
- Read the files listed in `contextFiles` from the apply instructions output.
- The files depend on the schema being used:
- - **spec-driven**: proposal, specs, design, tasks
- - Other schemas: follow the contextFiles from CLI output
-
-5. **Show current progress**
-
- Display:
- - Schema being used
- - Progress: "N/M tasks complete"
- - Remaining tasks overview
- - Dynamic instruction from CLI
-
-6. **Implement tasks (loop until done or blocked)**
-
- For each pending task:
- - Show which task is being worked on
- - Make the code changes required
- - Keep changes minimal and focused
- - Mark task complete in the tasks file: `- [ ]` → `- [x]`
- - Continue to next task
-
- **Pause if:**
- - Task is unclear → ask for clarification
- - Implementation reveals a design issue → suggest updating artifacts
- - Error or blocker encountered → report and wait for guidance
- - User interrupts
-
-7. **On completion or pause, show status**
-
- Display:
- - Tasks completed this session
- - Overall progress: "N/M tasks complete"
- - If all done: suggest archive
- - If paused: explain why and wait for guidance
-
-**Output During Implementation**
-
-```
-## Implementing: (schema: )
-
-Working on task 3/7:
-[...implementation happening...]
-✓ Task complete
-
-Working on task 4/7:
-[...implementation happening...]
-✓ Task complete
-```
-
-**Output On Completion**
-
-```
-## Implementation Complete
-
-**Change:**
-**Schema:**
-**Progress:** 7/7 tasks complete ✓
-
-### Completed This Session
-- [x] Task 1
-- [x] Task 2
-...
-
-All tasks complete! Ready to archive this change.
-```
-
-**Output On Pause (Issue Encountered)**
-
-```
-## Implementation Paused
-
-**Change:**
-**Schema:**
-**Progress:** 4/7 tasks complete
-
-### Issue Encountered
-
-
-**Options:**
-1.
-2.
-3. Other approach
-
-What would you like to do?
-```
-
-**Guardrails**
-- Keep going through tasks until done or blocked
-- Always read context files before starting (from the apply instructions output)
-- If task is ambiguous, pause and ask before implementing
-- If implementation reveals issues, pause and suggest artifact updates
-- Keep code changes minimal and scoped to each task
-- Update task checkbox immediately after completing each task
-- Pause on errors, blockers, or unclear requirements - don't guess
-- Use contextFiles from CLI output, don't assume specific file names
-
-**Fluid Workflow Integration**
-
-This skill supports the "actions on a change" model:
-
-- **Can be invoked anytime**: Before all artifacts are done (if tasks exist), after partial implementation, interleaved with other actions
-- **Allows artifact updates**: If implementation reveals design issues, suggest updating artifacts - not phase-locked, work fluidly
diff --git a/.codex/skills/openspec-archive-change/SKILL.md b/.codex/skills/openspec-archive-change/SKILL.md
deleted file mode 100644
index 9b1f851..0000000
--- a/.codex/skills/openspec-archive-change/SKILL.md
+++ /dev/null
@@ -1,114 +0,0 @@
----
-name: openspec-archive-change
-description: Archive a completed change in the experimental workflow. Use when the user wants to finalize and archive a change after implementation is complete.
-license: MIT
-compatibility: Requires openspec CLI.
-metadata:
- author: openspec
- version: "1.0"
- generatedBy: "1.2.0"
----
-
-Archive a completed change in the experimental workflow.
-
-**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
-
-**Steps**
-
-1. **If no change name provided, prompt for selection**
-
- Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
-
- Show only active changes (not already archived).
- Include the schema used for each change if available.
-
- **IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
-
-2. **Check artifact completion status**
-
- Run `openspec status --change "" --json` to check artifact completion.
-
- Parse the JSON to understand:
- - `schemaName`: The workflow being used
- - `artifacts`: List of artifacts with their status (`done` or other)
-
- **If any artifacts are not `done`:**
- - Display warning listing incomplete artifacts
- - Use **AskUserQuestion tool** to confirm user wants to proceed
- - Proceed if user confirms
-
-3. **Check task completion status**
-
- Read the tasks file (typically `tasks.md`) to check for incomplete tasks.
-
- Count tasks marked with `- [ ]` (incomplete) vs `- [x]` (complete).
-
- **If incomplete tasks found:**
- - Display warning showing count of incomplete tasks
- - Use **AskUserQuestion tool** to confirm user wants to proceed
- - Proceed if user confirms
-
- **If no tasks file exists:** Proceed without task-related warning.
-
-4. **Assess delta spec sync state**
-
- Check for delta specs at `openspec/changes//specs/`. If none exist, proceed without sync prompt.
-
- **If delta specs exist:**
- - Compare each delta spec with its corresponding main spec at `openspec/specs//spec.md`
- - Determine what changes would be applied (adds, modifications, removals, renames)
- - Show a combined summary before prompting
-
- **Prompt options:**
- - If changes needed: "Sync now (recommended)", "Archive without syncing"
- - If already synced: "Archive now", "Sync anyway", "Cancel"
-
- If user chooses sync, use Task tool (subagent_type: "general-purpose", prompt: "Use Skill tool to invoke openspec-sync-specs for change ''. Delta spec analysis: "). Proceed to archive regardless of choice.
-
-5. **Perform the archive**
-
- Create the archive directory if it doesn't exist:
- ```bash
- mkdir -p openspec/changes/archive
- ```
-
- Generate target name using current date: `YYYY-MM-DD-`
-
- **Check if target already exists:**
- - If yes: Fail with error, suggest renaming existing archive or using different date
- - If no: Move the change directory to archive
-
- ```bash
- mv openspec/changes/ openspec/changes/archive/YYYY-MM-DD-
- ```
-
-6. **Display summary**
-
- Show archive completion summary including:
- - Change name
- - Schema that was used
- - Archive location
- - Whether specs were synced (if applicable)
- - Note about any warnings (incomplete artifacts/tasks)
-
-**Output On Success**
-
-```
-## Archive Complete
-
-**Change:**
-**Schema:**
-**Archived to:** openspec/changes/archive/YYYY-MM-DD-/
-**Specs:** ✓ Synced to main specs (or "No delta specs" or "Sync skipped")
-
-All artifacts complete. All tasks complete.
-```
-
-**Guardrails**
-- Always prompt for change selection if not provided
-- Use artifact graph (openspec status --json) for completion checking
-- Don't block archive on warnings - just inform and confirm
-- Preserve .openspec.yaml when moving to archive (it moves with the directory)
-- Show clear summary of what happened
-- If sync is requested, use openspec-sync-specs approach (agent-driven)
-- If delta specs exist, always run the sync assessment and show the combined summary before prompting
diff --git a/.codex/skills/openspec-explore/SKILL.md b/.codex/skills/openspec-explore/SKILL.md
deleted file mode 100644
index ffa10ca..0000000
--- a/.codex/skills/openspec-explore/SKILL.md
+++ /dev/null
@@ -1,288 +0,0 @@
----
-name: openspec-explore
-description: Enter explore mode - a thinking partner for exploring ideas, investigating problems, and clarifying requirements. Use when the user wants to think through something before or during a change.
-license: MIT
-compatibility: Requires openspec CLI.
-metadata:
- author: openspec
- version: "1.0"
- generatedBy: "1.2.0"
----
-
-Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes.
-
-**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, and investigate the codebase, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first and create a change proposal. You MAY create OpenSpec artifacts (proposals, designs, specs) if the user asks—that's capturing thinking, not implementing.
-
-**This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore.
-
----
-
-## The Stance
-
-- **Curious, not prescriptive** - Ask questions that emerge naturally, don't follow a script
-- **Open threads, not interrogations** - Surface multiple interesting directions and let the user follow what resonates. Don't funnel them through a single path of questions.
-- **Visual** - Use ASCII diagrams liberally when they'd help clarify thinking
-- **Adaptive** - Follow interesting threads, pivot when new information emerges
-- **Patient** - Don't rush to conclusions, let the shape of the problem emerge
-- **Grounded** - Explore the actual codebase when relevant, don't just theorize
-
----
-
-## What You Might Do
-
-Depending on what the user brings, you might:
-
-**Explore the problem space**
-- Ask clarifying questions that emerge from what they said
-- Challenge assumptions
-- Reframe the problem
-- Find analogies
-
-**Investigate the codebase**
-- Map existing architecture relevant to the discussion
-- Find integration points
-- Identify patterns already in use
-- Surface hidden complexity
-
-**Compare options**
-- Brainstorm multiple approaches
-- Build comparison tables
-- Sketch tradeoffs
-- Recommend a path (if asked)
-
-**Visualize**
-```
-┌─────────────────────────────────────────┐
-│ Use ASCII diagrams liberally │
-├─────────────────────────────────────────┤
-│ │
-│ ┌────────┐ ┌────────┐ │
-│ │ State │────────▶│ State │ │
-│ │ A │ │ B │ │
-│ └────────┘ └────────┘ │
-│ │
-│ System diagrams, state machines, │
-│ data flows, architecture sketches, │
-│ dependency graphs, comparison tables │
-│ │
-└─────────────────────────────────────────┘
-```
-
-**Surface risks and unknowns**
-- Identify what could go wrong
-- Find gaps in understanding
-- Suggest spikes or investigations
-
----
-
-## OpenSpec Awareness
-
-You have full context of the OpenSpec system. Use it naturally, don't force it.
-
-### Check for context
-
-At the start, quickly check what exists:
-```bash
-openspec list --json
-```
-
-This tells you:
-- If there are active changes
-- Their names, schemas, and status
-- What the user might be working on
-
-### When no change exists
-
-Think freely. When insights crystallize, you might offer:
-
-- "This feels solid enough to start a change. Want me to create a proposal?"
-- Or keep exploring - no pressure to formalize
-
-### When a change exists
-
-If the user mentions a change or you detect one is relevant:
-
-1. **Read existing artifacts for context**
- - `openspec/changes//proposal.md`
- - `openspec/changes//design.md`
- - `openspec/changes//tasks.md`
- - etc.
-
-2. **Reference them naturally in conversation**
- - "Your design mentions using Redis, but we just realized SQLite fits better..."
- - "The proposal scopes this to premium users, but we're now thinking everyone..."
-
-3. **Offer to capture when decisions are made**
-
- | Insight Type | Where to Capture |
- |--------------|------------------|
- | New requirement discovered | `specs//spec.md` |
- | Requirement changed | `specs//spec.md` |
- | Design decision made | `design.md` |
- | Scope changed | `proposal.md` |
- | New work identified | `tasks.md` |
- | Assumption invalidated | Relevant artifact |
-
- Example offers:
- - "That's a design decision. Capture it in design.md?"
- - "This is a new requirement. Add it to specs?"
- - "This changes scope. Update the proposal?"
-
-4. **The user decides** - Offer and move on. Don't pressure. Don't auto-capture.
-
----
-
-## What You Don't Have To Do
-
-- Follow a script
-- Ask the same questions every time
-- Produce a specific artifact
-- Reach a conclusion
-- Stay on topic if a tangent is valuable
-- Be brief (this is thinking time)
-
----
-
-## Handling Different Entry Points
-
-**User brings a vague idea:**
-```
-User: I'm thinking about adding real-time collaboration
-
-You: Real-time collab is a big space. Let me think about this...
-
- COLLABORATION SPECTRUM
- ════════════════════════════════════════════
-
- Awareness Coordination Sync
- │ │ │
- ▼ ▼ ▼
- ┌────────┐ ┌────────┐ ┌────────┐
- │Presence│ │Cursors │ │ CRDT │
- │ "3 │ │ Multi │ │Conflict│
- │online" │ │ select │ │ free │
- └────────┘ └────────┘ └────────┘
- │ │ │
- trivial moderate complex
-
- Where's your head at?
-```
-
-**User brings a specific problem:**
-```
-User: The auth system is a mess
-
-You: [reads codebase]
-
- ┌─────────────────────────────────────────────┐
- │ CURRENT AUTH FLOW │
- └─────────────────────────────────────────────┘
- │
- ┌─────────────┼─────────────┐
- ▼ ▼ ▼
- ┌─────────┐ ┌─────────┐ ┌─────────┐
- │ Google │ │ GitHub │ │ Email │
- │ OAuth │ │ OAuth │ │ Magic │
- └────┬────┘ └────┬────┘ └────┬────┘
- │ │ │
- └─────────────┼─────────────┘
- ▼
- ┌───────────┐
- │ Session │
- └─────┬─────┘
- │
- ▼
- ┌───────────┐
- │ Perms │
- └───────────┘
-
- I see three tangles. Which one's burning?
-```
-
-**User is stuck mid-implementation:**
-```
-User: /opsx:explore add-auth-system
- The OAuth integration is more complex than expected
-
-You: [reads change artifacts]
-
- You're on task 4: "Implement OAuth flow"
-
- Let me trace what's involved...
-
- [draws diagram, explores options, suggests paths]
-
- Want to update the design to reflect this?
- Or add a spike task to investigate?
-```
-
-**User wants to compare options:**
-```
-User: Should we use Postgres or SQLite?
-
-You: Generic answer is boring. What's the context?
-
-User: A CLI tool that tracks local dev environments
-
-You: That changes everything.
-
- ┌─────────────────────────────────────────────────┐
- │ CLI TOOL DATA STORAGE │
- └─────────────────────────────────────────────────┘
-
- Key constraints:
- • No daemon running
- • Must work offline
- • Single user
-
- SQLite Postgres
- Deployment embedded ✓ needs server ✗
- Offline yes ✓ no ✗
- Single file yes ✓ no ✗
-
- SQLite. Not even close.
-
- Unless... is there a sync component?
-```
-
----
-
-## Ending Discovery
-
-There's no required ending. Discovery might:
-
-- **Flow into a proposal**: "Ready to start? I can create a change proposal."
-- **Result in artifact updates**: "Updated design.md with these decisions"
-- **Just provide clarity**: User has what they need, moves on
-- **Continue later**: "We can pick this up anytime"
-
-When it feels like things are crystallizing, you might summarize:
-
-```
-## What We Figured Out
-
-**The problem**: [crystallized understanding]
-
-**The approach**: [if one emerged]
-
-**Open questions**: [if any remain]
-
-**Next steps** (if ready):
-- Create a change proposal
-- Keep exploring: just keep talking
-```
-
-But this summary is optional. Sometimes the thinking IS the value.
-
----
-
-## Guardrails
-
-- **Don't implement** - Never write code or implement features. Creating OpenSpec artifacts is fine, writing application code is not.
-- **Don't fake understanding** - If something is unclear, dig deeper
-- **Don't rush** - Discovery is thinking time, not task time
-- **Don't force structure** - Let patterns emerge naturally
-- **Don't auto-capture** - Offer to save insights, don't just do it
-- **Do visualize** - A good diagram is worth many paragraphs
-- **Do explore the codebase** - Ground discussions in reality
-- **Do question assumptions** - Including the user's and your own
diff --git a/.codex/skills/openspec-propose/SKILL.md b/.codex/skills/openspec-propose/SKILL.md
deleted file mode 100644
index d27bc53..0000000
--- a/.codex/skills/openspec-propose/SKILL.md
+++ /dev/null
@@ -1,110 +0,0 @@
----
-name: openspec-propose
-description: Propose a new change with all artifacts generated in one step. Use when the user wants to quickly describe what they want to build and get a complete proposal with design, specs, and tasks ready for implementation.
-license: MIT
-compatibility: Requires openspec CLI.
-metadata:
- author: openspec
- version: "1.0"
- generatedBy: "1.2.0"
----
-
-Propose a new change - create the change and generate all artifacts in one step.
-
-I'll create a change with artifacts:
-- proposal.md (what & why)
-- design.md (how)
-- tasks.md (implementation steps)
-
-When ready to implement, run /opsx:apply
-
----
-
-**Input**: The user's request should include a change name (kebab-case) OR a description of what they want to build.
-
-**Steps**
-
-1. **If no clear input provided, ask what they want to build**
-
- Use the **AskUserQuestion tool** (open-ended, no preset options) to ask:
- > "What change do you want to work on? Describe what you want to build or fix."
-
- From their description, derive a kebab-case name (e.g., "add user authentication" → `add-user-auth`).
-
- **IMPORTANT**: Do NOT proceed without understanding what the user wants to build.
-
-2. **Create the change directory**
- ```bash
- openspec new change ""
- ```
- This creates a scaffolded change at `openspec/changes//` with `.openspec.yaml`.
-
-3. **Get the artifact build order**
- ```bash
- openspec status --change "" --json
- ```
- Parse the JSON to get:
- - `applyRequires`: array of artifact IDs needed before implementation (e.g., `["tasks"]`)
- - `artifacts`: list of all artifacts with their status and dependencies
-
-4. **Create artifacts in sequence until apply-ready**
-
- Use the **TodoWrite tool** to track progress through the artifacts.
-
- Loop through artifacts in dependency order (artifacts with no pending dependencies first):
-
- a. **For each artifact that is `ready` (dependencies satisfied)**:
- - Get instructions:
- ```bash
- openspec instructions --change "" --json
- ```
- - The instructions JSON includes:
- - `context`: Project background (constraints for you - do NOT include in output)
- - `rules`: Artifact-specific rules (constraints for you - do NOT include in output)
- - `template`: The structure to use for your output file
- - `instruction`: Schema-specific guidance for this artifact type
- - `outputPath`: Where to write the artifact
- - `dependencies`: Completed artifacts to read for context
- - Read any completed dependency files for context
- - Create the artifact file using `template` as the structure
- - Apply `context` and `rules` as constraints - but do NOT copy them into the file
- - Show brief progress: "Created "
-
- b. **Continue until all `applyRequires` artifacts are complete**
- - After creating each artifact, re-run `openspec status --change "" --json`
- - Check if every artifact ID in `applyRequires` has `status: "done"` in the artifacts array
- - Stop when all `applyRequires` artifacts are done
-
- c. **If an artifact requires user input** (unclear context):
- - Use **AskUserQuestion tool** to clarify
- - Then continue with creation
-
-5. **Show final status**
- ```bash
- openspec status --change ""
- ```
-
-**Output**
-
-After completing all artifacts, summarize:
-- Change name and location
-- List of artifacts created with brief descriptions
-- What's ready: "All artifacts created! Ready for implementation."
-- Prompt: "Run `/opsx:apply` or ask me to implement to start working on the tasks."
-
-**Artifact Creation Guidelines**
-
-- Follow the `instruction` field from `openspec instructions` for each artifact type
-- The schema defines what each artifact should contain - follow it
-- Read dependency artifacts for context before creating new ones
-- Use `template` as the structure for your output file - fill in its sections
-- **IMPORTANT**: `context` and `rules` are constraints for YOU, not content for the file
- - Do NOT copy ``, ``, `` blocks into the artifact
- - These guide what you write, but should never appear in the output
-
-**Guardrails**
-- Create ALL artifacts needed for implementation (as defined by schema's `apply.requires`)
-- Always read dependency artifacts before creating a new one
-- If context is critically unclear, ask the user - but prefer making reasonable decisions to keep momentum
-- If a change with that name already exists, ask if user wants to continue it or create a new one
-- Verify each artifact file exists after writing before proceeding to next
diff --git a/.gitignore b/.gitignore
index 25dec72..7406874 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,3 +2,6 @@ target/
tmp/
.vercel
secret/
+.claude/
+.codex/
+openspec/
diff --git a/openspec/changes/archive/2026-03-25-stabilize-v0-2-foundation/design.md b/openspec/changes/archive/2026-03-25-stabilize-v0-2-foundation/design.md
deleted file mode 100644
index c320144..0000000
--- a/openspec/changes/archive/2026-03-25-stabilize-v0-2-foundation/design.md
+++ /dev/null
@@ -1,116 +0,0 @@
-## Context
-
-`deskctl` already exposes a useful X11 runtime for screenshots, input, and window management, but the current implementation mixes together concerns that need to be separated before the repo can become a stable primitive. Public window data still exposes `xcb_id`, `list-windows` is routed through screenshot-producing code, setup failures are opaque, and daemon startup behavior is not yet explicit enough for reliable reuse by higher-level tooling.
-
-Phase 1 is the foundation tranche. It should make the runtime contract clean and cheap to consume without expanding into packaging, skills, or non-X11 backends.
-
-## Goals / Non-Goals
-
-**Goals:**
-- Define a backend-neutral public window identity and selector contract.
-- Make read-only window enumeration cheap and side-effect free.
-- Add a first-run diagnostics command that explains broken environments precisely.
-- Harden daemon startup and health behavior enough for predictable CLI use.
-- Keep the Phase 1 scope implementable in one focused change.
-
-**Non-Goals:**
-- Wayland support or any additional backend implementation.
-- npm distribution, crates.io publishing, or release automation changes.
-- Broad new read surface such as monitors, workspaces, clipboard, or batching.
-- Agent skills, config files, or policy/confirmation features.
-
-## Decisions
-
-### 1. Public window identity becomes `window_id`, not `xcb_id`
-
-The stable contract will expose an opaque `window_id` for programmatic targeting. Backend-specific handles such as X11 window IDs stay internal to daemon/backend state.
-
-Rationale:
-- This removes X11 leakage from the public interface.
-- It keeps the future backend boundary open without promising a Wayland implementation now.
-- It makes selector behavior explicit: users and agents target `@wN`, window names, or `window_id`, not backend handles.
-
-Alternatives considered:
-- Keep exposing `xcb_id` and add `window_id` alongside it. Rejected because it cements the wrong contract and encourages downstream dependence on X11 internals.
-- Hide programmatic identity entirely and rely only on refs. Rejected because refs are intentionally ephemeral and not sufficient for durable automation.
-
-### 2. Window enumeration and screenshot capture become separate backend operations
-
-The backend API will separate:
-- window listing / state collection
-- screenshot capture
-- composed snapshot behavior
-
-`list-windows` will call the cheap enumeration path directly. `snapshot` remains the convenience command that combines enumeration plus screenshot generation.
-
-Rationale:
-- Read-only commands must not capture screenshots or write `/tmp` files.
-- This reduces latency and unintended side effects.
-- It clarifies which operations are safe to call frequently in agent loops.
-
-Alternatives considered:
-- Keep the current `snapshot(false)` path and optimize it internally. Rejected because the coupling itself is the product problem; the API shape needs to reflect the intended behavior.
-
-### 3. `deskctl doctor` runs without requiring a healthy daemon
-
-`doctor` will be implemented as a CLI command that can run before daemon startup. It will probe environment prerequisites directly and optionally inspect daemon/socket state as one of the checks.
-
-Expected checks:
-- `DISPLAY` present
-- X11 session expectation (`XDG_SESSION_TYPE=x11` or explicit note if missing)
-- X server connectivity
-- required extensions or equivalent runtime capabilities used by the backend
-- socket directory existence and permissions
-- basic window enumeration
-- screenshot capture viability
-
-Rationale:
-- Diagnostics must work when daemon startup is the thing that is broken.
-- The command should return actionable failure messages, not “failed to connect to daemon.”
-
-Alternatives considered:
-- Implement `doctor` as a normal daemon request. Rejected because it hides the startup path and cannot diagnose stale socket or spawn failures well.
-
-### 4. Daemon hardening stays minimal and focused in this phase
-
-Phase 1 daemon work will cover:
-- stale socket detection and cleanup on startup/connect
-- clearer startup/connect failure messages
-- a lightweight health-check request or equivalent status probe used by the client path
-
-Rationale:
-- This is enough to make CLI behavior predictable without turning the spec into a full daemon observability project.
-
-Alternatives considered:
-- Include idle timeout, structured logging, and full lifecycle policy now. Rejected because those are useful but not necessary to stabilize the basic runtime contract.
-
-### 5. X11 remains the explicit support boundary for this change
-
-The spec and implementation will define expected behavior for X11 environments only. Unsupported-session diagnostics are in scope; a second backend is not.
-
-Rationale:
-- The repo needs a stable foundation more than a nominally portable abstraction.
-- Clear support boundaries are better than implying near-term Wayland support.
-
-## Risks / Trade-offs
-
-- [Breaking JSON contract for existing users] → Treat this as a deliberate pre-1.0 stabilization change, update docs/examples, and keep the new shape simple.
-- [More internal mapping state between public IDs and backend handles] → Keep one canonical mapping in daemon state and use it for selector resolution.
-- [Separate read and screenshot paths could drift] → Share the same window collection logic underneath both operations.
-- [`doctor` checks may be environment-specific] → Keep the checks narrow, tied to actual backend requirements, and report concrete pass/fail reasons.
-
-## Migration Plan
-
-1. Introduce the new public `window_id` contract and update selector resolution to accept it.
-2. Refactor backend and daemon code so `list-windows` uses a pure read path.
-3. Add `deskctl doctor` and wire it into the CLI as a daemon-independent command.
-4. Add unit and integration coverage for the new contract and cheap read behavior.
-5. Update user-facing docs and examples after implementation so the new output shape is canonical.
-
-Rollback strategy:
-- This is a pre-1.0 contract cleanup, so rollback is a normal code revert rather than a compatibility shim plan.
-
-## Open Questions
-
-- Whether the health check should be an explicit public `daemon ping` action or an internal client/daemon probe.
-- Whether `doctor` should expose machine-readable JSON on day one or land text output first and add JSON immediately afterward in the same tranche if implementation cost is low.
diff --git a/openspec/changes/archive/2026-03-25-stabilize-v0-2-foundation/proposal.md b/openspec/changes/archive/2026-03-25-stabilize-v0-2-foundation/proposal.md
deleted file mode 100644
index 5472981..0000000
--- a/openspec/changes/archive/2026-03-25-stabilize-v0-2-foundation/proposal.md
+++ /dev/null
@@ -1,28 +0,0 @@
-## Why
-
-`deskctl` already works as a useful X11 desktop control CLI, but the current contract is not stable enough to build packaging, skills, or broader agent workflows on top of it yet. Public output still leaks X11-specific identifiers, some read-only commands still perform screenshot capture and write files, setup failures are not self-diagnosing, and daemon lifecycle behavior needs to be more predictable before the repo is treated as a reliable primitive.
-
-## What Changes
-
-- Stabilize the public desktop runtime contract around backend-neutral window identity and explicit selector semantics.
-- Separate cheap read paths from screenshot-producing paths so read-only commands do not capture or write screenshots unless explicitly requested.
-- Add a first-run `deskctl doctor` command that verifies X11 runtime prerequisites and reports exact remediation steps.
-- Harden daemon startup and health behavior enough for reliable reuse from CLI commands and future higher-level tooling.
-- Document the Phase 1 support boundary: X11 is the supported runtime today; Wayland is out of scope for this change.
-
-## Capabilities
-
-### New Capabilities
-- `desktop-runtime`: Stable Phase 1 desktop runtime behavior covering public window identity, cheap read commands, runtime diagnostics, and foundational daemon health behavior.
-
-### Modified Capabilities
-- None.
-
-## Impact
-
-- Affected CLI surface in `src/cli/`
-- Affected daemon request handling and state in `src/daemon/`
-- Affected backend contract and X11 implementation in `src/backend/`
-- Affected shared protocol and types in `src/core/`
-- New tests for unit behavior and X11 integration coverage
-- Follow-on docs updates for usage and troubleshooting once implementation lands
diff --git a/openspec/changes/archive/2026-03-25-stabilize-v0-2-foundation/specs/desktop-runtime/spec.md b/openspec/changes/archive/2026-03-25-stabilize-v0-2-foundation/specs/desktop-runtime/spec.md
deleted file mode 100644
index 191180f..0000000
--- a/openspec/changes/archive/2026-03-25-stabilize-v0-2-foundation/specs/desktop-runtime/spec.md
+++ /dev/null
@@ -1,54 +0,0 @@
-## ADDED Requirements
-
-### Requirement: Backend-neutral window identity
-The desktop runtime SHALL expose a backend-neutral `window_id` for each enumerated window. Public runtime responses MUST NOT require backend-specific handles such as X11 window IDs for targeting or automation.
-
-#### Scenario: Listing windows returns stable public identity
-- **WHEN** a client lists windows through the runtime
-- **THEN** each window result includes a `window_id` that the client can use for later targeting
-- **AND** the result does not require the client to know the underlying X11 handle format
-
-#### Scenario: Selector resolution accepts public identity
-- **WHEN** a client targets a window by `window_id`
-- **THEN** the runtime resolves the request to the correct live window
-- **AND** performs the requested action without exposing backend handles in the public contract
-
-### Requirement: Read-only window enumeration is side-effect free
-The desktop runtime SHALL provide a read-only window enumeration path that does not capture screenshots and does not write screenshot artifacts unless a screenshot-producing command was explicitly requested.
-
-#### Scenario: Listing windows does not write a screenshot
-- **WHEN** a client runs a read-only window listing command
-- **THEN** the runtime returns current window data
-- **AND** does not capture a screenshot
-- **AND** does not create a screenshot file as a side effect
-
-#### Scenario: Snapshot remains an explicit screenshot-producing command
-- **WHEN** a client runs a snapshot command
-- **THEN** the runtime returns window data together with screenshot output
-- **AND** any screenshot artifact is created only because the snapshot command explicitly requested it
-
-### Requirement: Runtime diagnostics are first-class
-The CLI SHALL provide a `doctor` command that checks runtime prerequisites for the supported X11 environment and reports actionable remediation guidance for each failed check.
-
-#### Scenario: Doctor reports missing display configuration
-- **WHEN** `deskctl doctor` runs without a usable `DISPLAY`
-- **THEN** it reports that the X11 display is unavailable
-- **AND** includes a concrete remediation message describing what environment setup is required
-
-#### Scenario: Doctor verifies basic runtime operations
-- **WHEN** `deskctl doctor` runs in a healthy supported environment
-- **THEN** it verifies X11 connectivity, basic window enumeration, screenshot viability, and socket path health
-- **AND** reports a successful diagnostic result for each check
-
-### Requirement: Daemon startup failures are recoverable and diagnosable
-The runtime SHALL detect stale daemon socket state and surface actionable startup or connection errors instead of failing with ambiguous transport errors.
-
-#### Scenario: Client encounters a stale socket
-- **WHEN** the client finds a socket path whose daemon is no longer serving requests
-- **THEN** the runtime removes or replaces the stale socket state safely
-- **AND** proceeds with a healthy daemon startup or reports a specific failure if recovery does not succeed
-
-#### Scenario: Health probing distinguishes startup failure from runtime failure
-- **WHEN** a client attempts to use the runtime and the daemon cannot become healthy
-- **THEN** the returned error explains whether the failure occurred during spawn, health probing, or request handling
-- **AND** does not report the problem as a generic connection failure alone
diff --git a/openspec/changes/archive/2026-03-25-stabilize-v0-2-foundation/tasks.md b/openspec/changes/archive/2026-03-25-stabilize-v0-2-foundation/tasks.md
deleted file mode 100644
index 85220a2..0000000
--- a/openspec/changes/archive/2026-03-25-stabilize-v0-2-foundation/tasks.md
+++ /dev/null
@@ -1,17 +0,0 @@
-## 1. Contract and protocol stabilization
-
-- [x] 1.1 Define the public `window_id` contract in shared types/protocol code and remove backend-handle assumptions from public runtime responses
-- [x] 1.2 Update daemon state and selector resolution to map `window_id` and refs to internal backend handles without exposing X11-specific IDs publicly
-- [x] 1.3 Update CLI text and JSON response handling to use the new public identity consistently
-
-## 2. Cheap reads and diagnostics
-
-- [x] 2.1 Split backend window enumeration from screenshot capture and route `list-windows` through a read-only path with no screenshot side effects
-- [x] 2.2 Add a daemon-independent `deskctl doctor` command that probes X11 environment setup, socket health, window enumeration, and screenshot viability
-- [x] 2.3 Harden daemon startup and reconnect behavior with stale socket cleanup, health probing, and clearer failure messages
-
-## 3. Validation and follow-through
-
-- [x] 3.1 Add unit tests for selector parsing, public ID resolution, and read-only behavior
-- [x] 3.2 Add X11 integration coverage for `doctor`, `list-windows`, and daemon recovery behavior
-- [x] 3.3 Update user-facing docs and examples to reflect the new contract, `doctor`, and the explicit X11 support boundary
diff --git a/openspec/config.yaml b/openspec/config.yaml
deleted file mode 100644
index 392946c..0000000
--- a/openspec/config.yaml
+++ /dev/null
@@ -1,20 +0,0 @@
-schema: spec-driven
-
-# Project context (optional)
-# This is shown to AI when creating artifacts.
-# Add your tech stack, conventions, style guides, domain knowledge, etc.
-# Example:
-# context: |
-# Tech stack: TypeScript, React, Node.js
-# We use conventional commits
-# Domain: e-commerce platform
-
-# Per-artifact rules (optional)
-# Add custom rules for specific artifacts.
-# Example:
-# rules:
-# proposal:
-# - Keep proposals under 500 words
-# - Always include a "Non-goals" section
-# tasks:
-# - Break tasks into chunks of max 2 hours
diff --git a/openspec/specs/desktop-runtime/spec.md b/openspec/specs/desktop-runtime/spec.md
deleted file mode 100644
index 191180f..0000000
--- a/openspec/specs/desktop-runtime/spec.md
+++ /dev/null
@@ -1,54 +0,0 @@
-## ADDED Requirements
-
-### Requirement: Backend-neutral window identity
-The desktop runtime SHALL expose a backend-neutral `window_id` for each enumerated window. Public runtime responses MUST NOT require backend-specific handles such as X11 window IDs for targeting or automation.
-
-#### Scenario: Listing windows returns stable public identity
-- **WHEN** a client lists windows through the runtime
-- **THEN** each window result includes a `window_id` that the client can use for later targeting
-- **AND** the result does not require the client to know the underlying X11 handle format
-
-#### Scenario: Selector resolution accepts public identity
-- **WHEN** a client targets a window by `window_id`
-- **THEN** the runtime resolves the request to the correct live window
-- **AND** performs the requested action without exposing backend handles in the public contract
-
-### Requirement: Read-only window enumeration is side-effect free
-The desktop runtime SHALL provide a read-only window enumeration path that does not capture screenshots and does not write screenshot artifacts unless a screenshot-producing command was explicitly requested.
-
-#### Scenario: Listing windows does not write a screenshot
-- **WHEN** a client runs a read-only window listing command
-- **THEN** the runtime returns current window data
-- **AND** does not capture a screenshot
-- **AND** does not create a screenshot file as a side effect
-
-#### Scenario: Snapshot remains an explicit screenshot-producing command
-- **WHEN** a client runs a snapshot command
-- **THEN** the runtime returns window data together with screenshot output
-- **AND** any screenshot artifact is created only because the snapshot command explicitly requested it
-
-### Requirement: Runtime diagnostics are first-class
-The CLI SHALL provide a `doctor` command that checks runtime prerequisites for the supported X11 environment and reports actionable remediation guidance for each failed check.
-
-#### Scenario: Doctor reports missing display configuration
-- **WHEN** `deskctl doctor` runs without a usable `DISPLAY`
-- **THEN** it reports that the X11 display is unavailable
-- **AND** includes a concrete remediation message describing what environment setup is required
-
-#### Scenario: Doctor verifies basic runtime operations
-- **WHEN** `deskctl doctor` runs in a healthy supported environment
-- **THEN** it verifies X11 connectivity, basic window enumeration, screenshot viability, and socket path health
-- **AND** reports a successful diagnostic result for each check
-
-### Requirement: Daemon startup failures are recoverable and diagnosable
-The runtime SHALL detect stale daemon socket state and surface actionable startup or connection errors instead of failing with ambiguous transport errors.
-
-#### Scenario: Client encounters a stale socket
-- **WHEN** the client finds a socket path whose daemon is no longer serving requests
-- **THEN** the runtime removes or replaces the stale socket state safely
-- **AND** proceeds with a healthy daemon startup or reports a specific failure if recovery does not succeed
-
-#### Scenario: Health probing distinguishes startup failure from runtime failure
-- **WHEN** a client attempts to use the runtime and the daemon cannot become healthy
-- **THEN** the returned error explains whether the failure occurred during spawn, health probing, or request handling
-- **AND** does not report the problem as a generic connection failure alone
From 8944e82c1ffeb7f394e59b8e075bfe25295f06a1 Mon Sep 17 00:00:00 2001
From: Harivansh Rathi
Date: Wed, 25 Mar 2026 19:32:09 -0400
Subject: [PATCH 04/49] rm
---
.../.openspec.yaml | 2 -
.../design.md | 121 ------------------
.../proposal.md | 28 ----
.../specs/repo-quality/spec.md | 49 -------
.../tasks.md | 23 ----
openspec/specs/repo-quality/spec.md | 49 -------
6 files changed, 272 deletions(-)
delete mode 100644 openspec/changes/archive/2026-03-25-test-and-tooling-foundation/.openspec.yaml
delete mode 100644 openspec/changes/archive/2026-03-25-test-and-tooling-foundation/design.md
delete mode 100644 openspec/changes/archive/2026-03-25-test-and-tooling-foundation/proposal.md
delete mode 100644 openspec/changes/archive/2026-03-25-test-and-tooling-foundation/specs/repo-quality/spec.md
delete mode 100644 openspec/changes/archive/2026-03-25-test-and-tooling-foundation/tasks.md
delete mode 100644 openspec/specs/repo-quality/spec.md
diff --git a/openspec/changes/archive/2026-03-25-test-and-tooling-foundation/.openspec.yaml b/openspec/changes/archive/2026-03-25-test-and-tooling-foundation/.openspec.yaml
deleted file mode 100644
index 40c5540..0000000
--- a/openspec/changes/archive/2026-03-25-test-and-tooling-foundation/.openspec.yaml
+++ /dev/null
@@ -1,2 +0,0 @@
-schema: spec-driven
-created: 2026-03-25
diff --git a/openspec/changes/archive/2026-03-25-test-and-tooling-foundation/design.md b/openspec/changes/archive/2026-03-25-test-and-tooling-foundation/design.md
deleted file mode 100644
index 116f480..0000000
--- a/openspec/changes/archive/2026-03-25-test-and-tooling-foundation/design.md
+++ /dev/null
@@ -1,121 +0,0 @@
-## Context
-
-Phase 1 stabilized the runtime contract, but the repo still lacks the structure and tooling needed to keep that contract stable as contributors add features. The current state is mixed:
-
-- Rust checks exist in CI, but there is no explicit `cargo test` lane and no Xvfb integration lane.
-- The repo has site-local Prettier config under `site/`, but no root-level contributor workflow for formatting or hooks.
-- Integration-style tests are starting to appear, but the crate is still binary-first and does not yet have a clean top-level `tests/` structure.
-- An empty `src/tests/` directory exists, which suggests the intended direction is not yet settled.
-
-This change should establish the repo-quality foundation before Phase 3 agent features and Phase 4 distribution work expand the maintenance surface further.
-
-## Goals / Non-Goals
-
-**Goals:**
-- Define a clean test architecture with a centralized top-level `tests/` directory for integration coverage.
-- Introduce the crate structure needed for integration tests to import the project cleanly.
-- Add a real Xvfb-backed CI lane and make CI validate the same commands contributors run locally.
-- Define one formatting and hook workflow for the repo instead of ad hoc tool usage.
-- Keep the site formatting story integrated without turning the entire repo into a Node-first project.
-
-**Non-Goals:**
-- New runtime capabilities such as `wait-for-window`, `version`, or broader read commands.
-- npm distribution, crates.io publishing, or release automation changes.
-- Introducing both Husky and pre-commit, or multiple competing hook systems.
-- Adding `rustfmt.toml` unless we have a concrete non-default formatting requirement.
-
-## Decisions
-
-### 1. Convert the crate to library + binary
-
-This change will introduce `src/lib.rs` and make `src/main.rs` a thin binary wrapper.
-
-Rationale:
-- Top-level Rust integration tests in `/tests` should import the crate cleanly.
-- Shared test support and internal modules become easier to organize without abusing `src/main.rs`.
-- This is the standard structure for a Rust project that needs both unit and integration coverage.
-
-Alternatives considered:
-- Keep the binary-only layout and continue placing all tests inside `src/`. Rejected because it makes integration coverage awkward and keeps test structure implicit.
-- Put integration helpers in `src/tests` without a library target. Rejected because it preserves the binary-first coupling and does not create a clean external-test boundary.
-
-### 2. Standardize on top-level `/tests` for integration coverage
-
-Integration tests will live under a centralized `/tests` directory, with shared helpers under `tests/support/`.
-
-Rationale:
-- The runtime-facing flows are integration problems, not unit problems.
-- A centralized `/tests` layout makes it clear which tests require Xvfb or daemon orchestration.
-- It keeps `src/` focused on application code.
-
-Alternatives considered:
-- Keep helpers in `src/test_support.rs`. Rejected because it mixes production and integration concerns.
-
-### 3. Standardize on `pre-commit`, not Husky
-
-This change will define one hook system: `pre-commit`.
-
-Rationale:
-- The repo is Rust-first, not root-Node-managed.
-- Husky would imply a root `package.json` and a Node-first workflow the repo does not currently have.
-- `pre-commit` can run Rust and site checks without forcing the whole repo through npm.
-
-Alternatives considered:
-- Husky. Rejected because it introduces a root Node workflow for a repo that is not otherwise Node-based.
-- Both Husky and pre-commit. Rejected because dual hook systems inevitably drift.
-- No hooks. Rejected because contributor ergonomics and CI parity are explicit goals of this phase.
-
-### 4. Keep formatting opinionated but minimal
-
-This phase will use default `rustfmt` behavior and site-local Prettier behavior. A root `rustfmt.toml` will only be added if the implementation reveals a real non-default formatting need.
-
-Rationale:
-- A config file with no meaningful opinion is noise.
-- What matters more is that CI and hooks actually run formatting checks.
-- The site already has a working Prettier configuration; we should integrate it rather than duplicate it prematurely.
-
-Alternatives considered:
-- Add `rustfmt.toml` immediately. Rejected because there is no concrete formatting policy to encode yet.
-- Add a root Prettier config for the whole repo. Rejected because it would broaden Node tooling scope before there is a clear need.
-
-### 5. CI should call stable local entrypoints
-
-This phase should define one local command surface for validation, and CI should call those same commands instead of hand-coded bespoke steps where practical.
-
-Candidate checks:
-- format check
-- clippy
-- unit tests
-- Xvfb integration tests
-- site formatting check
-
-Rationale:
-- Local/CI drift is one of the fastest ways to make an open source repo unpleasant to contribute to.
-- Contributors should be able to run the same validation shape locally before pushing.
-
-Alternatives considered:
-- Keep all validation logic encoded only in GitHub Actions. Rejected because local parity matters.
-
-## Risks / Trade-offs
-
-- [Introducing `src/lib.rs` creates some churn] → Keep `main.rs` thin and preserve module names to minimize callsite disruption.
-- [Xvfb CI can be flaky if test fixtures are underspecified] → Keep fixture windows simple and deterministic; avoid broad screenshot assertions early.
-- [Pre-commit adds a Python-based contributor dependency] → Document installation clearly and keep hooks fast so the value exceeds the setup cost.
-- [Formatting/tooling scope could sprawl into site build work] → Limit this phase to formatting and validation, not full site build architecture.
-
-## Migration Plan
-
-1. Introduce `src/lib.rs` and move reusable modules behind the library target.
-2. Move integration support into top-level `tests/support/` and create real `/tests` coverage for Xvfb-backed flows.
-3. Add local validation entrypoints for formatting, lint, and tests.
-4. Add a root hook configuration using `pre-commit`.
-5. Update CI to run unit tests, Xvfb integration tests, and relevant formatting checks on pull requests and main.
-6. Update contributor docs so local validation, hooks, and test structure are discoverable.
-
-Rollback strategy:
-- This phase is repo-internal and pre-1.0, so rollback is a normal revert rather than a compatibility shim.
-
-## Open Questions
-
-- Whether the local validation entrypoint should be a `Justfile`, `Makefile`, or another lightweight wrapper.
-- Whether site validation in this phase should be limited to Prettier checks or also include `astro check`.
diff --git a/openspec/changes/archive/2026-03-25-test-and-tooling-foundation/proposal.md b/openspec/changes/archive/2026-03-25-test-and-tooling-foundation/proposal.md
deleted file mode 100644
index 8c854b4..0000000
--- a/openspec/changes/archive/2026-03-25-test-and-tooling-foundation/proposal.md
+++ /dev/null
@@ -1,28 +0,0 @@
-## Why
-
-`deskctl` now has a decent Phase 1 runtime contract, but the repo still lacks the test architecture and local tooling discipline needed to keep that contract stable as the project grows. Before moving into broader agent primitives or distribution polish, the repo needs a real integration-test story, CI coverage that exercises it, and one coherent developer workflow for formatting and pre-commit checks.
-
-## What Changes
-
-- Introduce a proper repository quality foundation for testing, CI, and local developer tooling.
-- Establish a centralized top-level integration test layout and the crate structure needed to support it cleanly.
-- Add an explicit Xvfb-backed CI lane for runtime integration tests.
-- Define one local formatting and hook workflow for Rust and site content instead of ad hoc tool usage.
-- Add contributor-facing commands and docs for running the same checks locally that CI will enforce.
-
-## Capabilities
-
-### New Capabilities
-- `repo-quality`: Repository-level quality guarantees covering test architecture, CI validation, formatting, and local hook workflow.
-
-### Modified Capabilities
-- None.
-
-## Impact
-
-- Rust crate layout in `src/` and likely a new `src/lib.rs`
-- New top-level `tests/` structure and shared integration test support
-- GitHub Actions workflow(s) under `.github/workflows/`
-- Root-level contributor tooling files such as `.pre-commit-config.yaml` and related local task entrypoints
-- Site formatting integration for files under `site/`
-- README and contributor documentation describing local validation workflows
diff --git a/openspec/changes/archive/2026-03-25-test-and-tooling-foundation/specs/repo-quality/spec.md b/openspec/changes/archive/2026-03-25-test-and-tooling-foundation/specs/repo-quality/spec.md
deleted file mode 100644
index 6e83331..0000000
--- a/openspec/changes/archive/2026-03-25-test-and-tooling-foundation/specs/repo-quality/spec.md
+++ /dev/null
@@ -1,49 +0,0 @@
-## ADDED Requirements
-
-### Requirement: Repository exposes a clean integration test architecture
-The repository SHALL provide a top-level integration test architecture that allows runtime flows to be tested outside in-module unit tests.
-
-#### Scenario: Integration tests import the crate cleanly
-- **WHEN** an integration test under `/tests` needs to exercise repository code
-- **THEN** it imports the project through a library target rather than depending on binary-only wiring
-
-#### Scenario: Integration helpers are centralized
-- **WHEN** multiple integration tests need shared X11 or daemon helpers
-- **THEN** those helpers live in a shared test-support location under `/tests`
-- **AND** production code does not need to host integration-only support files
-
-### Requirement: CI validates real X11 integration behavior
-The repository SHALL run Xvfb-backed integration coverage in CI for the supported X11 runtime.
-
-#### Scenario: Pull requests run X11 integration tests
-- **WHEN** a pull request modifies runtime or test-relevant code
-- **THEN** CI runs the repository's Xvfb-backed integration test lane
-- **AND** fails the change if the integration lane does not pass
-
-#### Scenario: Integration lane covers core runtime flows
-- **WHEN** the Xvfb integration lane runs
-- **THEN** it exercises at least runtime diagnostics, window enumeration, and daemon startup/recovery behavior
-
-### Requirement: Repository defines one local validation workflow
-The repository SHALL define one coherent local validation workflow that contributors can run before pushing and that CI can mirror.
-
-#### Scenario: Local formatting and linting entrypoints are documented
-- **WHEN** a contributor wants to validate a change locally
-- **THEN** the repository provides documented commands for formatting, linting, unit tests, and integration tests
-
-#### Scenario: CI and local validation stay aligned
-- **WHEN** CI validates the repository
-- **THEN** it uses the same validation categories that contributors are expected to run locally
-- **AND** avoids introducing a separate undocumented CI-only workflow
-
-### Requirement: Repository uses a single hook system
-The repository SHALL standardize on one pre-commit hook workflow for contributor checks.
-
-#### Scenario: Hook workflow does not require root Node ownership
-- **WHEN** a contributor installs the hook workflow
-- **THEN** the repository can run Rust and site checks without requiring a root-level Node package workflow
-
-#### Scenario: Hook scope stays fast and focused
-- **WHEN** the pre-commit hook runs
-- **THEN** it executes only fast checks appropriate for commit-time feedback
-- **AND** slower validation remains in pre-push or CI lanes
diff --git a/openspec/changes/archive/2026-03-25-test-and-tooling-foundation/tasks.md b/openspec/changes/archive/2026-03-25-test-and-tooling-foundation/tasks.md
deleted file mode 100644
index 06e6d80..0000000
--- a/openspec/changes/archive/2026-03-25-test-and-tooling-foundation/tasks.md
+++ /dev/null
@@ -1,23 +0,0 @@
-## 1. Repository Structure
-
-- [x] 1.1 Introduce `src/lib.rs`, keep `src/main.rs` as a thin binary wrapper, and preserve existing module boundaries so integration tests can import the crate cleanly
-- [x] 1.2 Move integration-only helpers out of production modules into `tests/support/` and remove the unused `src/tests` direction so the test layout is unambiguous
-- [x] 1.3 Add top-level integration tests under `tests/` that exercise at least diagnostics, window enumeration, and daemon startup/recovery flows through the library target
-
-## 2. Local Validation Tooling
-
-- [x] 2.1 Add one documented local validation entrypoint for formatting, linting, unit tests, integration tests, and site formatting checks
-- [x] 2.2 Add a root `.pre-commit-config.yaml` that standardizes on `pre-commit` for fast commit-time checks without introducing a root Node workflow
-- [x] 2.3 Keep formatting configuration minimal by using default `rustfmt`, reusing the existing site-local Prettier setup, and only adding new config where implementation requires it
-
-## 3. CI Hardening
-
-- [x] 3.1 Update GitHub Actions to validate pull requests in addition to `main` pushes and to run the same validation categories contributors run locally
-- [x] 3.2 Add an explicit Xvfb-backed CI lane that runs the integration tests covering diagnostics, window enumeration, and daemon recovery behavior
-- [x] 3.3 Ensure CI also runs the repository's formatting, clippy, unit test, and site formatting checks through the shared local entrypoints where practical
-
-## 4. Documentation
-
-- [x] 4.1 Update contributor-facing docs to explain the new crate/test layout, including where integration tests and shared helpers live
-- [x] 4.2 Document the local validation workflow and `pre-commit` installation/use so contributors can reproduce CI expectations locally
-- [x] 4.3 Update the Phase 2 planning/docs references so the repo-quality foundation clearly lands before later packaging and distribution phases
diff --git a/openspec/specs/repo-quality/spec.md b/openspec/specs/repo-quality/spec.md
deleted file mode 100644
index 6e83331..0000000
--- a/openspec/specs/repo-quality/spec.md
+++ /dev/null
@@ -1,49 +0,0 @@
-## ADDED Requirements
-
-### Requirement: Repository exposes a clean integration test architecture
-The repository SHALL provide a top-level integration test architecture that allows runtime flows to be tested outside in-module unit tests.
-
-#### Scenario: Integration tests import the crate cleanly
-- **WHEN** an integration test under `/tests` needs to exercise repository code
-- **THEN** it imports the project through a library target rather than depending on binary-only wiring
-
-#### Scenario: Integration helpers are centralized
-- **WHEN** multiple integration tests need shared X11 or daemon helpers
-- **THEN** those helpers live in a shared test-support location under `/tests`
-- **AND** production code does not need to host integration-only support files
-
-### Requirement: CI validates real X11 integration behavior
-The repository SHALL run Xvfb-backed integration coverage in CI for the supported X11 runtime.
-
-#### Scenario: Pull requests run X11 integration tests
-- **WHEN** a pull request modifies runtime or test-relevant code
-- **THEN** CI runs the repository's Xvfb-backed integration test lane
-- **AND** fails the change if the integration lane does not pass
-
-#### Scenario: Integration lane covers core runtime flows
-- **WHEN** the Xvfb integration lane runs
-- **THEN** it exercises at least runtime diagnostics, window enumeration, and daemon startup/recovery behavior
-
-### Requirement: Repository defines one local validation workflow
-The repository SHALL define one coherent local validation workflow that contributors can run before pushing and that CI can mirror.
-
-#### Scenario: Local formatting and linting entrypoints are documented
-- **WHEN** a contributor wants to validate a change locally
-- **THEN** the repository provides documented commands for formatting, linting, unit tests, and integration tests
-
-#### Scenario: CI and local validation stay aligned
-- **WHEN** CI validates the repository
-- **THEN** it uses the same validation categories that contributors are expected to run locally
-- **AND** avoids introducing a separate undocumented CI-only workflow
-
-### Requirement: Repository uses a single hook system
-The repository SHALL standardize on one pre-commit hook workflow for contributor checks.
-
-#### Scenario: Hook workflow does not require root Node ownership
-- **WHEN** a contributor installs the hook workflow
-- **THEN** the repository can run Rust and site checks without requiring a root-level Node package workflow
-
-#### Scenario: Hook scope stays fast and focused
-- **WHEN** the pre-commit hook runs
-- **THEN** it executes only fast checks appropriate for commit-time feedback
-- **AND** slower validation remains in pre-push or CI lanes
From ae5eb21563c9a91416cc1380c1d0ad685d4c5fa1 Mon Sep 17 00:00:00 2001
From: Harivansh Rathi
Date: Wed, 25 Mar 2026 19:34:36 -0400
Subject: [PATCH 05/49] licence
---
LICENCE | 21 +++++++++++++++++++++
1 file changed, 21 insertions(+)
create mode 100644 LICENCE
diff --git a/LICENCE b/LICENCE
new file mode 100644
index 0000000..2f23cfe
--- /dev/null
+++ b/LICENCE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Harivansh Rathi
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
From 08d8402afc9114fee5a8227b7e88d69d8653a74c Mon Sep 17 00:00:00 2001
From: Harivansh Rathi
Date: Wed, 25 Mar 2026 19:41:11 -0400
Subject: [PATCH 06/49] fix ci tag mismatch
---
.github/workflows/ci.yml | 10 +++++++++-
1 file changed, 9 insertions(+), 1 deletion(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 1f0f458..da0438d 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -126,6 +126,12 @@ jobs:
NEW="${MAJOR}.${MINOR}.${NEW_PATCH}"
fi
+ # Ensure the computed version does not already have a tag
+ while git rev-parse "v${NEW}" >/dev/null 2>&1; do
+ IFS='.' read -r MAJOR MINOR PATCH <<< "$NEW"
+ NEW="${MAJOR}.${MINOR}.$((PATCH + 1))"
+ done
+
echo "version=${NEW}" >> "$GITHUB_OUTPUT"
echo "tag=v${NEW}" >> "$GITHUB_OUTPUT"
@@ -223,7 +229,9 @@ jobs:
git commit -m "release: ${{ needs.changes.outputs.tag }} [skip ci]"
fi
- git tag "${{ needs.changes.outputs.tag }}"
+ if ! git rev-parse "${{ needs.changes.outputs.tag }}" >/dev/null 2>&1; then
+ git tag "${{ needs.changes.outputs.tag }}"
+ fi
git push origin main --tags
release:
From 11ea267feb6a54295aacdf7f400824a56ccf67cc Mon Sep 17 00:00:00 2001
From: Harivansh Rathi
Date: Wed, 25 Mar 2026 19:45:19 -0400
Subject: [PATCH 07/49] gate the validation CI behind changes
---
.github/workflows/ci.yml | 128 ++++++++++++++++++++-------------------
1 file changed, 65 insertions(+), 63 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index da0438d..18311e0 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -16,8 +16,70 @@ permissions:
packages: write
jobs:
+ changes:
+ name: Changes
+ runs-on: ubuntu-latest
+ outputs:
+ rust: ${{ steps.check.outputs.rust }}
+ version: ${{ steps.version.outputs.version }}
+ tag: ${{ steps.version.outputs.tag }}
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - uses: dorny/paths-filter@v3
+ id: filter
+ with:
+ filters: |
+ rust:
+ - 'src/**'
+ - 'tests/**'
+ - 'Cargo.toml'
+ - 'Cargo.lock'
+ - 'docker/**'
+ - 'Makefile'
+
+ - name: Set outputs
+ id: check
+ run: |
+ if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
+ echo "rust=true" >> "$GITHUB_OUTPUT"
+ else
+ echo "rust=${{ steps.filter.outputs.rust }}" >> "$GITHUB_OUTPUT"
+ fi
+
+ - name: Calculate next version
+ id: version
+ if: github.event_name != 'pull_request' && steps.check.outputs.rust == 'true'
+ run: |
+ BASE=$(grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/')
+ IFS='.' read -r MAJOR MINOR PATCH <<< "$BASE"
+
+ LATEST=$(git tag -l "v${MAJOR}.${MINOR}.*" | sort -V | tail -1)
+
+ if [ -z "$LATEST" ]; then
+ NEW="$BASE"
+ else
+ LATEST_VER="${LATEST#v}"
+ IFS='.' read -r _ _ LATEST_PATCH <<< "$LATEST_VER"
+ NEW_PATCH=$((LATEST_PATCH + 1))
+ NEW="${MAJOR}.${MINOR}.${NEW_PATCH}"
+ fi
+
+ # Ensure the computed version does not already have a tag
+ while git rev-parse "v${NEW}" >/dev/null 2>&1; do
+ IFS='.' read -r MAJOR MINOR PATCH <<< "$NEW"
+ NEW="${MAJOR}.${MINOR}.$((PATCH + 1))"
+ done
+
+ echo "version=${NEW}" >> "$GITHUB_OUTPUT"
+ echo "tag=v${NEW}" >> "$GITHUB_OUTPUT"
+
validate:
name: Validate
+ needs: changes
+ if: needs.changes.outputs.rust == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@@ -59,6 +121,8 @@ jobs:
integration:
name: Integration (Xvfb)
+ needs: changes
+ if: needs.changes.outputs.rust == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@@ -73,71 +137,9 @@ jobs:
- name: Xvfb integration tests
run: make test-integration
- changes:
- name: Changes
- needs: [validate, integration]
- if: github.event_name != 'pull_request'
- runs-on: ubuntu-latest
- outputs:
- rust: ${{ steps.check.outputs.rust }}
- version: ${{ steps.version.outputs.version }}
- tag: ${{ steps.version.outputs.tag }}
- steps:
- - uses: actions/checkout@v4
- with:
- fetch-depth: 0
-
- - uses: dorny/paths-filter@v3
- id: filter
- with:
- filters: |
- rust:
- - 'src/**'
- - 'tests/**'
- - 'Cargo.toml'
- - 'Cargo.lock'
- - 'docker/**'
- - 'Makefile'
-
- - name: Set outputs
- id: check
- run: |
- if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
- echo "rust=true" >> "$GITHUB_OUTPUT"
- else
- echo "rust=${{ steps.filter.outputs.rust }}" >> "$GITHUB_OUTPUT"
- fi
-
- - name: Calculate next version
- id: version
- if: steps.check.outputs.rust == 'true'
- run: |
- BASE=$(grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/')
- IFS='.' read -r MAJOR MINOR PATCH <<< "$BASE"
-
- LATEST=$(git tag -l "v${MAJOR}.${MINOR}.*" | sort -V | tail -1)
-
- if [ -z "$LATEST" ]; then
- NEW="$BASE"
- else
- LATEST_VER="${LATEST#v}"
- IFS='.' read -r _ _ LATEST_PATCH <<< "$LATEST_VER"
- NEW_PATCH=$((LATEST_PATCH + 1))
- NEW="${MAJOR}.${MINOR}.${NEW_PATCH}"
- fi
-
- # Ensure the computed version does not already have a tag
- while git rev-parse "v${NEW}" >/dev/null 2>&1; do
- IFS='.' read -r MAJOR MINOR PATCH <<< "$NEW"
- NEW="${MAJOR}.${MINOR}.$((PATCH + 1))"
- done
-
- echo "version=${NEW}" >> "$GITHUB_OUTPUT"
- echo "tag=v${NEW}" >> "$GITHUB_OUTPUT"
-
build:
name: Build (${{ matrix.target }})
- needs: changes
+ needs: [changes, validate, integration]
if: github.event_name != 'pull_request' && needs.changes.outputs.rust == 'true'
runs-on: ubuntu-latest
strategy:
From cc8f8e548a1cfa7b61739f53d34fa6b27c5dbdd6 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
Date: Wed, 25 Mar 2026 23:47:58 +0000
Subject: [PATCH 08/49] release: v0.1.3 [skip ci]
---
Cargo.lock | 2 +-
Cargo.toml | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
index 64706c9..0a64e12 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -400,7 +400,7 @@ checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "deskctl"
-version = "0.1.1"
+version = "0.1.3"
dependencies = [
"ab_glyph",
"anyhow",
diff --git a/Cargo.toml b/Cargo.toml
index c9570a2..a4fd4fe 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "deskctl"
-version = "0.1.1"
+version = "0.1.3"
edition = "2021"
description = "X11 desktop control CLI for agents"
license = "MIT"
From a4cf9e32dd227205b2bc312f107daf12f4d3b381 Mon Sep 17 00:00:00 2001
From: Hari <73809867+harivansh-afk@users.noreply.github.com>
Date: Wed, 25 Mar 2026 21:11:30 -0400
Subject: [PATCH 09/49] grouped runtime reads and waits selector modes (#5)
- grouped runtime reads and waits
selector modes
- Fix wait command client timeouts and test failures
---
README.md | 80 ++++++++-
skills/SKILL.md | 34 +++-
src/backend/mod.rs | 22 +++
src/backend/x11.rs | 107 +++++++++++-
src/cli/connection.rs | 17 +-
src/cli/mod.rs | 219 ++++++++++++++++++++++++-
src/core/protocol.rs | 8 +
src/core/refs.rs | 357 +++++++++++++++++++++++++++++++++++-----
src/core/types.rs | 43 ++++-
src/daemon/handler.rs | 372 +++++++++++++++++++++++++++++++++++++++---
tests/support/mod.rs | 11 ++
tests/x11_runtime.rs | 130 ++++++++++++++-
12 files changed, 1323 insertions(+), 77 deletions(-)
diff --git a/README.md b/README.md
index 2438381..387f3e9 100644
--- a/README.md
+++ b/README.md
@@ -44,14 +44,22 @@ deskctl doctor
# See the desktop
deskctl snapshot
+# Query focused runtime state
+deskctl get active-window
+deskctl get monitors
+
# Click a window
deskctl click @w1
# Type text
deskctl type "hello world"
-# Focus by name
-deskctl focus "firefox"
+# Wait for a window or focus transition
+deskctl wait window --selector 'title=Firefox' --timeout 10
+deskctl wait focus --selector 'class=firefox' --timeout 5
+
+# Focus by explicit selector
+deskctl focus 'title=Firefox'
```
## Architecture
@@ -93,6 +101,74 @@ deskctl doctor
- `--json` output includes a stable `window_id` for programmatic targeting within the current daemon session
- `list-windows` is a cheap read-only operation and does not capture or write a screenshot
+## Read and Wait Surface
+
+The grouped runtime reads are:
+
+```bash
+deskctl get active-window
+deskctl get monitors
+deskctl get version
+deskctl get systeminfo
+```
+
+The grouped runtime waits are:
+
+```bash
+deskctl wait window --selector 'title=Firefox' --timeout 10
+deskctl wait focus --selector 'id=win3' --timeout 5
+```
+
+Successful `get active-window`, `wait window`, and `wait focus` responses return a `window` payload with:
+- `ref_id`
+- `window_id`
+- `title`
+- `app_name`
+- geometry (`x`, `y`, `width`, `height`)
+- state flags (`focused`, `minimized`)
+
+`get monitors` returns:
+- `count`
+- `monitors[]` with geometry and primary/automatic flags
+
+`get version` returns:
+- `version`
+- `backend`
+
+`get systeminfo` stays runtime-scoped and returns:
+- `backend`
+- `display`
+- `session_type`
+- `session`
+- `socket_path`
+- `screen`
+- `monitor_count`
+- `monitors`
+
+Wait timeout and selector failures are structured in `--json` mode so agents can recover without string parsing.
+
+## Selector Contract
+
+Explicit selector modes:
+
+```bash
+ref=w1
+id=win1
+title=Firefox
+class=firefox
+focused
+```
+
+Legacy refs remain supported:
+
+```bash
+@w1
+w1
+win1
+```
+
+Bare selectors such as `firefox` are still supported as fuzzy substring matches, but they now fail on ambiguity and return candidate windows instead of silently picking the first match.
+
## Support Boundary
`deskctl` supports Linux X11 in this phase. Wayland and Hyprland are explicitly out of scope for the current runtime contract.
diff --git a/skills/SKILL.md b/skills/SKILL.md
index 50a8f00..3b1733d 100644
--- a/skills/SKILL.md
+++ b/skills/SKILL.md
@@ -11,8 +11,9 @@ Desktop control CLI for AI agents on Linux X11. Provides a unified interface for
## Core Workflow
1. **Snapshot** to see the desktop and get window refs
-2. **Act** using refs or coordinates (click, type, focus)
-3. **Repeat** as needed
+2. **Query / wait** using grouped `get` and `wait` commands
+3. **Act** using refs, explicit selectors, or coordinates
+4. **Repeat** as needed
## Quick Reference
@@ -24,6 +25,12 @@ deskctl snapshot --annotate # Screenshot with bounding boxes and labels
deskctl snapshot --json # Structured JSON output
deskctl list-windows # Window tree without screenshot
deskctl screenshot /tmp/s.png # Screenshot only (no window tree)
+deskctl get active-window # Currently focused window
+deskctl get monitors # Monitor geometry
+deskctl get version # deskctl version + backend
+deskctl get systeminfo # Runtime-scoped diagnostics
+deskctl wait window --selector 'title=Firefox' --timeout 10
+deskctl wait focus --selector 'class=firefox' --timeout 5
```
### Click and Type
@@ -51,7 +58,9 @@ deskctl mouse drag 100 100 500 500 # Drag from (100,100) to (500,500)
```bash
deskctl focus @w2 # Focus window by ref
-deskctl focus "firefox" # Focus window by name (substring match)
+deskctl focus 'title=Firefox' # Focus by explicit title selector
+deskctl focus 'class=firefox' # Focus by explicit class selector
+deskctl focus "firefox" # Fuzzy substring match (fails on ambiguity)
deskctl close @w3 # Close window gracefully
deskctl move-window @w1 100 200 # Move window to position
deskctl resize-window @w1 800 600 # Resize window
@@ -89,14 +98,29 @@ After `snapshot` or `list-windows`, windows are assigned short refs:
- Refs reset on each `snapshot` call
- Use `--json` to see stable `window_id` values for programmatic tracking within the current daemon session
+## Selector Contract
+
+Prefer explicit selectors when an agent needs deterministic targeting:
+
+```bash
+ref=w1
+id=win1
+title=Firefox
+class=firefox
+focused
+```
+
+Bare selectors such as `firefox` still work as fuzzy substring matches, but they now fail with candidate windows if multiple matches exist.
+
## Example Agent Workflow
```bash
# 1. See what's on screen
deskctl snapshot --annotate
-# 2. Focus the browser
-deskctl focus "firefox"
+# 2. Wait for the browser and focus it deterministically
+deskctl wait window --selector 'class=firefox' --timeout 10
+deskctl focus 'class=firefox'
# 3. Navigate to a URL
deskctl hotkey ctrl l
diff --git a/src/backend/mod.rs b/src/backend/mod.rs
index 53ea405..ea4df7f 100644
--- a/src/backend/mod.rs
+++ b/src/backend/mod.rs
@@ -17,11 +17,30 @@ pub struct BackendWindow {
pub minimized: bool,
}
+#[derive(Debug, Clone)]
+pub struct BackendMonitor {
+ pub name: String,
+ pub x: i32,
+ pub y: i32,
+ pub width: u32,
+ pub height: u32,
+ pub width_mm: u32,
+ pub height_mm: u32,
+ pub primary: bool,
+ pub automatic: bool,
+}
+
#[allow(dead_code)]
pub trait DesktopBackend: Send {
/// Collect z-ordered windows for read-only queries and targeting.
fn list_windows(&mut self) -> Result>;
+ /// Get the currently focused window, if one is known.
+ fn active_window(&mut self) -> Result>;
+
+ /// Collect monitor geometry and metadata.
+ fn list_monitors(&self) -> Result>;
+
/// Capture the current desktop image without writing it to disk.
fn capture_screenshot(&mut self) -> Result;
@@ -69,4 +88,7 @@ pub trait DesktopBackend: Send {
/// Launch an application.
fn launch(&self, command: &str, args: &[String]) -> Result;
+
+ /// Human-readable backend name for diagnostics and runtime queries.
+ fn backend_name(&self) -> &'static str;
}
diff --git a/src/backend/x11.rs b/src/backend/x11.rs
index 721b96d..7b1b396 100644
--- a/src/backend/x11.rs
+++ b/src/backend/x11.rs
@@ -2,6 +2,7 @@ use anyhow::{Context, Result};
use enigo::{Axis, Button, Coordinate, Direction, Enigo, Key, Keyboard, Mouse, Settings};
use image::RgbaImage;
use x11rb::connection::Connection;
+use x11rb::protocol::randr::ConnectionExt as RandrConnectionExt;
use x11rb::protocol::xproto::{
Atom, AtomEnum, ClientMessageData, ClientMessageEvent, ConfigureWindowAux,
ConnectionExt as XprotoConnectionExt, EventMask, GetPropertyReply, ImageFormat, ImageOrder,
@@ -9,7 +10,7 @@ use x11rb::protocol::xproto::{
};
use x11rb::rust_connection::RustConnection;
-use crate::backend::BackendWindow;
+use crate::backend::{BackendMonitor, BackendWindow};
struct Atoms {
client_list_stacking: Atom,
@@ -103,6 +104,74 @@ impl X11Backend {
Ok(window_infos)
}
+ fn active_window_info(&self) -> Result> {
+ let Some(active_window) = self.active_window()? else {
+ return Ok(None);
+ };
+
+ let title = self.window_title(active_window).unwrap_or_default();
+ let app_name = self.window_app_name(active_window).unwrap_or_default();
+ if title.is_empty() && app_name.is_empty() {
+ return Ok(None);
+ }
+
+ let (x, y, width, height) = self.window_geometry(active_window)?;
+ let minimized = self.window_is_minimized(active_window).unwrap_or(false);
+ Ok(Some(BackendWindow {
+ native_id: active_window,
+ title,
+ app_name,
+ x,
+ y,
+ width,
+ height,
+ focused: true,
+ minimized,
+ }))
+ }
+
+ fn collect_monitors(&self) -> Result> {
+ let reply = self
+ .conn
+ .randr_get_monitors(self.root, true)?
+ .reply()
+ .context("Failed to query RANDR monitors")?;
+
+ let mut monitors = Vec::with_capacity(reply.monitors.len());
+ for (index, monitor) in reply.monitors.into_iter().enumerate() {
+ monitors.push(BackendMonitor {
+ name: self
+ .atom_name(monitor.name)
+ .unwrap_or_else(|_| format!("monitor{}", index + 1)),
+ x: i32::from(monitor.x),
+ y: i32::from(monitor.y),
+ width: u32::from(monitor.width),
+ height: u32::from(monitor.height),
+ width_mm: monitor.width_in_millimeters,
+ height_mm: monitor.height_in_millimeters,
+ primary: monitor.primary,
+ automatic: monitor.automatic,
+ });
+ }
+
+ if monitors.is_empty() {
+ let (width, height) = self.root_geometry()?;
+ monitors.push(BackendMonitor {
+ name: "screen".to_string(),
+ x: 0,
+ y: 0,
+ width,
+ height,
+ width_mm: 0,
+ height_mm: 0,
+ primary: true,
+ automatic: true,
+ });
+ }
+
+ Ok(monitors)
+ }
+
fn capture_root_image(&self) -> Result {
let (width, height) = self.root_geometry()?;
let reply = self
@@ -224,6 +293,14 @@ impl X11Backend {
.reply()
.with_context(|| format!("Failed to read property {property} from window {window}"))
}
+
+ fn atom_name(&self, atom: Atom) -> Result {
+ self.conn
+ .get_atom_name(atom)?
+ .reply()
+ .map(|reply| String::from_utf8_lossy(&reply.name).to_string())
+ .with_context(|| format!("Failed to read atom name for {atom}"))
+ }
}
impl super::DesktopBackend for X11Backend {
@@ -231,6 +308,30 @@ impl super::DesktopBackend for X11Backend {
self.collect_window_infos()
}
+ fn active_window(&mut self) -> Result> {
+ self.active_window_info()
+ }
+
+ fn list_monitors(&self) -> Result> {
+ match self.collect_monitors() {
+ Ok(monitors) => Ok(monitors),
+ Err(_) => {
+ let (width, height) = self.root_geometry()?;
+ Ok(vec![BackendMonitor {
+ name: "screen".to_string(),
+ x: 0,
+ y: 0,
+ width,
+ height,
+ width_mm: 0,
+ height_mm: 0,
+ primary: true,
+ automatic: true,
+ }])
+ }
+ }
+ }
+
fn capture_screenshot(&mut self) -> Result {
self.capture_root_image()
}
@@ -452,6 +553,10 @@ impl super::DesktopBackend for X11Backend {
.with_context(|| format!("Failed to launch: {command}"))?;
Ok(child.id())
}
+
+ fn backend_name(&self) -> &'static str {
+ "x11"
+ }
}
fn parse_key(name: &str) -> Result {
diff --git a/src/cli/connection.rs b/src/cli/connection.rs
index 840e637..1b7b0b2 100644
--- a/src/cli/connection.rs
+++ b/src/cli/connection.rs
@@ -79,8 +79,23 @@ fn spawn_daemon(opts: &GlobalOpts) -> Result<()> {
Ok(())
}
+fn request_read_timeout(request: &Request) -> Duration {
+ let default_timeout = Duration::from_secs(30);
+ match request.action.as_str() {
+ "wait-window" | "wait-focus" => {
+ let wait_timeout = request
+ .extra
+ .get("timeout_ms")
+ .and_then(|value| value.as_u64())
+ .unwrap_or(10_000);
+ Duration::from_millis(wait_timeout.saturating_add(5_000))
+ }
+ _ => default_timeout,
+ }
+}
+
fn send_request_over_stream(mut stream: UnixStream, request: &Request) -> Result {
- stream.set_read_timeout(Some(Duration::from_secs(30)))?;
+ stream.set_read_timeout(Some(request_read_timeout(request)))?;
stream.set_write_timeout(Some(Duration::from_secs(5)))?;
let json = serde_json::to_string(request)?;
diff --git a/src/cli/mod.rs b/src/cli/mod.rs
index d4003ff..ccd5b28 100644
--- a/src/cli/mod.rs
+++ b/src/cli/mod.rs
@@ -102,6 +102,12 @@ pub enum Command {
GetMousePosition,
/// Diagnose X11 runtime, screenshot, and daemon health
Doctor,
+ /// Query runtime state
+ #[command(subcommand)]
+ Get(GetCmd),
+ /// Wait for runtime state transitions
+ #[command(subcommand)]
+ Wait(WaitCmd),
/// Take a screenshot without window tree
Screenshot {
/// Save path (default: /tmp/deskctl-{timestamp}.png)
@@ -169,6 +175,57 @@ pub enum DaemonAction {
Status,
}
+const GET_ACTIVE_WINDOW_EXAMPLES: &str =
+ "Examples:\n deskctl get active-window\n deskctl --json get active-window";
+const GET_MONITORS_EXAMPLES: &str =
+ "Examples:\n deskctl get monitors\n deskctl --json get monitors";
+const GET_VERSION_EXAMPLES: &str = "Examples:\n deskctl get version\n deskctl --json get version";
+const GET_SYSTEMINFO_EXAMPLES: &str =
+ "Examples:\n deskctl get systeminfo\n deskctl --json get systeminfo";
+const WAIT_WINDOW_EXAMPLES: &str = "Examples:\n deskctl wait window --selector 'title=Firefox' --timeout 10\n deskctl --json wait window --selector 'class=firefox' --poll-ms 100";
+const WAIT_FOCUS_EXAMPLES: &str = "Examples:\n deskctl wait focus --selector 'id=win3' --timeout 5\n deskctl wait focus --selector focused --poll-ms 200";
+
+#[derive(Subcommand)]
+pub enum GetCmd {
+ /// Show the currently focused window
+ #[command(after_help = GET_ACTIVE_WINDOW_EXAMPLES)]
+ ActiveWindow,
+ /// List current monitor geometry and metadata
+ #[command(after_help = GET_MONITORS_EXAMPLES)]
+ Monitors,
+ /// Show deskctl version and backend information
+ #[command(after_help = GET_VERSION_EXAMPLES)]
+ Version,
+ /// Show runtime-focused diagnostic information
+ #[command(after_help = GET_SYSTEMINFO_EXAMPLES)]
+ Systeminfo,
+}
+
+#[derive(Subcommand)]
+pub enum WaitCmd {
+ /// Wait until a window matching the selector exists
+ #[command(after_help = WAIT_WINDOW_EXAMPLES)]
+ Window(WaitSelectorOpts),
+ /// Wait until the selector resolves to a focused window
+ #[command(after_help = WAIT_FOCUS_EXAMPLES)]
+ Focus(WaitSelectorOpts),
+}
+
+#[derive(Args)]
+pub struct WaitSelectorOpts {
+ /// Selector: ref=w1, id=win1, title=Firefox, class=firefox, focused, or a fuzzy substring
+ #[arg(long)]
+ pub selector: String,
+
+ /// Timeout in seconds
+ #[arg(long, default_value_t = 10)]
+ pub timeout: u64,
+
+ /// Poll interval in milliseconds
+ #[arg(long = "poll-ms", default_value_t = 250)]
+ pub poll_ms: u64,
+}
+
pub fn run() -> Result<()> {
let app = App::parse();
@@ -188,9 +245,13 @@ pub fn run() -> Result<()> {
// All other commands need a daemon connection
let request = build_request(&app.command)?;
let response = connection::send_command(&app.global, &request)?;
+ let success = response.success;
if app.global.json {
println!("{}", serde_json::to_string_pretty(&response)?);
+ if !success {
+ std::process::exit(1);
+ }
} else {
print_response(&app.command, &response)?;
}
@@ -244,6 +305,22 @@ fn build_request(cmd: &Command) -> Result {
Command::GetScreenSize => Request::new("get-screen-size"),
Command::GetMousePosition => Request::new("get-mouse-position"),
Command::Doctor => unreachable!(),
+ Command::Get(sub) => match sub {
+ GetCmd::ActiveWindow => Request::new("get-active-window"),
+ GetCmd::Monitors => Request::new("get-monitors"),
+ GetCmd::Version => Request::new("get-version"),
+ GetCmd::Systeminfo => Request::new("get-systeminfo"),
+ },
+ Command::Wait(sub) => match sub {
+ WaitCmd::Window(opts) => Request::new("wait-window")
+ .with_extra("selector", json!(opts.selector))
+ .with_extra("timeout_ms", json!(opts.timeout * 1000))
+ .with_extra("poll_ms", json!(opts.poll_ms)),
+ WaitCmd::Focus(opts) => Request::new("wait-focus")
+ .with_extra("selector", json!(opts.selector))
+ .with_extra("timeout_ms", json!(opts.timeout * 1000))
+ .with_extra("poll_ms", json!(opts.poll_ms)),
+ },
Command::Screenshot { path, annotate } => {
let mut req = Request::new("screenshot").with_extra("annotate", json!(annotate));
if let Some(p) = path {
@@ -264,6 +341,32 @@ fn print_response(cmd: &Command, response: &Response) -> Result<()> {
if let Some(ref err) = response.error {
eprintln!("Error: {err}");
}
+ if let Some(ref data) = response.data {
+ if let Some(kind) = data.get("kind").and_then(|value| value.as_str()) {
+ match kind {
+ "selector_ambiguous" => {
+ if let Some(candidates) = data.get("candidates").and_then(|v| v.as_array())
+ {
+ eprintln!("Candidates:");
+ for candidate in candidates {
+ print_window_to_stderr(candidate);
+ }
+ }
+ }
+ "timeout" => {
+ if let Some(selector) = data.get("selector").and_then(|v| v.as_str()) {
+ let wait = data.get("wait").and_then(|v| v.as_str()).unwrap_or("wait");
+ let timeout_ms =
+ data.get("timeout_ms").and_then(|v| v.as_u64()).unwrap_or(0);
+ eprintln!(
+ "Timed out after {timeout_ms}ms waiting for {wait} selector {selector}"
+ );
+ }
+ }
+ _ => {}
+ }
+ }
+ }
std::process::exit(1);
}
if let Some(ref data) = response.data {
@@ -293,17 +396,61 @@ fn print_response(cmd: &Command, response: &Response) -> Result<()> {
} else {
"visible"
};
- let display_title = if title.len() > 30 {
- format!("{}...", &title[..27])
- } else {
- title.to_string()
- };
+ let display_title = truncate_display(title, 30);
println!(
"@{:<4} {:<30} ({:<7}) {},{} {}x{}",
ref_id, display_title, state, x, y, width, height
);
}
}
+ } else if matches!(
+ cmd,
+ Command::Get(GetCmd::ActiveWindow)
+ | Command::Wait(WaitCmd::Window(_))
+ | Command::Wait(WaitCmd::Focus(_))
+ ) {
+ if let Some(window) = data.get("window") {
+ print_window(window);
+ if let Some(elapsed_ms) = data.get("elapsed_ms").and_then(|v| v.as_u64()) {
+ println!("Elapsed: {elapsed_ms}ms");
+ }
+ } else {
+ println!("{}", serde_json::to_string_pretty(data)?);
+ }
+ } else if matches!(cmd, Command::Get(GetCmd::Monitors)) {
+ if let Some(monitors) = data.get("monitors").and_then(|v| v.as_array()) {
+ for monitor in monitors {
+ let name = monitor
+ .get("name")
+ .and_then(|v| v.as_str())
+ .unwrap_or("monitor");
+ let x = monitor.get("x").and_then(|v| v.as_i64()).unwrap_or(0);
+ let y = monitor.get("y").and_then(|v| v.as_i64()).unwrap_or(0);
+ let width = monitor.get("width").and_then(|v| v.as_u64()).unwrap_or(0);
+ let height = monitor.get("height").and_then(|v| v.as_u64()).unwrap_or(0);
+ let primary = monitor
+ .get("primary")
+ .and_then(|v| v.as_bool())
+ .unwrap_or(false);
+ let primary_suffix = if primary { " primary" } else { "" };
+ println!(
+ "{name:<16} {},{} {}x{}{primary_suffix}",
+ x, y, width, height
+ );
+ }
+ }
+ } else if matches!(cmd, Command::Get(GetCmd::Version)) {
+ let version = data
+ .get("version")
+ .and_then(|v| v.as_str())
+ .unwrap_or("unknown");
+ let backend = data
+ .get("backend")
+ .and_then(|v| v.as_str())
+ .unwrap_or("unknown");
+ println!("deskctl {version} ({backend})");
+ } else if matches!(cmd, Command::Get(GetCmd::Systeminfo)) {
+ println!("{}", serde_json::to_string_pretty(data)?);
} else {
// Generic: print JSON data
println!("{}", serde_json::to_string_pretty(data)?);
@@ -311,3 +458,65 @@ fn print_response(cmd: &Command, response: &Response) -> Result<()> {
}
Ok(())
}
+
+fn print_window(window: &serde_json::Value) {
+ print_window_line(window, false);
+}
+
+fn print_window_to_stderr(window: &serde_json::Value) {
+ print_window_line(window, true);
+}
+
+fn print_window_line(window: &serde_json::Value, stderr: bool) {
+ let ref_id = window.get("ref_id").and_then(|v| v.as_str()).unwrap_or("?");
+ let window_id = window
+ .get("window_id")
+ .and_then(|v| v.as_str())
+ .unwrap_or("unknown");
+ let title = window.get("title").and_then(|v| v.as_str()).unwrap_or("");
+ let focused = window
+ .get("focused")
+ .and_then(|v| v.as_bool())
+ .unwrap_or(false);
+ let minimized = window
+ .get("minimized")
+ .and_then(|v| v.as_bool())
+ .unwrap_or(false);
+ let x = window.get("x").and_then(|v| v.as_i64()).unwrap_or(0);
+ let y = window.get("y").and_then(|v| v.as_i64()).unwrap_or(0);
+ let width = window.get("width").and_then(|v| v.as_u64()).unwrap_or(0);
+ let height = window.get("height").and_then(|v| v.as_u64()).unwrap_or(0);
+ let state = if focused {
+ "focused"
+ } else if minimized {
+ "hidden"
+ } else {
+ "visible"
+ };
+ let line = format!(
+ "@{:<4} {:<30} ({:<7}) {},{} {}x{} [{}]",
+ ref_id,
+ truncate_display(title, 30),
+ state,
+ x,
+ y,
+ width,
+ height,
+ window_id
+ );
+ if stderr {
+ eprintln!("{line}");
+ } else {
+ println!("{line}");
+ }
+}
+
+fn truncate_display(value: &str, max_chars: usize) -> String {
+ let char_count = value.chars().count();
+ if char_count <= max_chars {
+ return value.to_string();
+ }
+
+ let truncated: String = value.chars().take(max_chars.saturating_sub(3)).collect();
+ format!("{truncated}...")
+}
diff --git a/src/core/protocol.rs b/src/core/protocol.rs
index c0ead03..8feb87e 100644
--- a/src/core/protocol.rs
+++ b/src/core/protocol.rs
@@ -58,4 +58,12 @@ impl Response {
error: Some(msg.into()),
}
}
+
+ pub fn err_with_data(msg: impl Into, data: Value) -> Self {
+ Self {
+ success: false,
+ data: Some(data),
+ error: Some(msg.into()),
+ }
+ }
}
diff --git a/src/core/refs.rs b/src/core/refs.rs
index 6185ebf..34e1ba7 100644
--- a/src/core/refs.rs
+++ b/src/core/refs.rs
@@ -7,6 +7,7 @@ use crate::core::types::WindowInfo;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[allow(dead_code)]
pub struct RefEntry {
+ pub ref_id: String,
pub window_id: String,
pub backend_window_id: u32,
pub app_class: String,
@@ -30,6 +31,35 @@ pub struct RefMap {
next_window: usize,
}
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum SelectorQuery {
+ Ref(String),
+ WindowId(String),
+ Title(String),
+ Class(String),
+ Focused,
+ Fuzzy(String),
+}
+
+#[derive(Debug, Clone)]
+pub enum ResolveResult {
+ Match(RefEntry),
+ NotFound {
+ selector: String,
+ mode: &'static str,
+ },
+ Ambiguous {
+ selector: String,
+ mode: &'static str,
+ candidates: Vec,
+ },
+ Invalid {
+ selector: String,
+ mode: &'static str,
+ message: String,
+ },
+}
+
#[allow(dead_code)]
impl RefMap {
pub fn new() -> Self {
@@ -65,6 +95,7 @@ impl RefMap {
let window_id = self.window_id_for_backend(window.native_id);
let entry = RefEntry {
+ ref_id: ref_id.clone(),
window_id: window_id.clone(),
backend_window_id: window.native_id,
app_class: window.app_name.clone(),
@@ -110,48 +141,205 @@ impl RefMap {
window_id
}
- /// Resolve a selector to a RefEntry.
- /// Accepts: "@w1", "w1", "ref=w1", "win1", "id=win1", or a substring match on app_class/title.
- pub fn resolve(&self, selector: &str) -> Option<&RefEntry> {
- let normalized = selector
- .strip_prefix('@')
- .or_else(|| selector.strip_prefix("ref="))
- .unwrap_or(selector);
-
- if let Some(entry) = self.refs.get(normalized) {
- return Some(entry);
- }
-
- let window_id = selector.strip_prefix("id=").unwrap_or(normalized);
- if let Some(ref_id) = self.window_id_to_ref.get(window_id) {
- return self.refs.get(ref_id);
- }
-
- let lower = selector.to_lowercase();
- self.refs.values().find(|entry| {
- entry.app_class.to_lowercase().contains(&lower)
- || entry.title.to_lowercase().contains(&lower)
- })
+ pub fn resolve(&self, selector: &str) -> ResolveResult {
+ self.resolve_query(SelectorQuery::parse(selector), selector)
}
/// Resolve a selector to the center coordinates of the window.
- pub fn resolve_to_center(&self, selector: &str) -> Option<(i32, i32)> {
- self.resolve(selector).map(|entry| {
- (
- entry.x + entry.width as i32 / 2,
- entry.y + entry.height as i32 / 2,
- )
- })
+ pub fn resolve_to_center(&self, selector: &str) -> ResolveResult {
+ self.resolve(selector)
}
pub fn entries(&self) -> impl Iterator- {
self.refs.iter()
}
+
+ fn resolve_query(&self, query: SelectorQuery, selector: &str) -> ResolveResult {
+ match query {
+ SelectorQuery::Ref(ref_id) => self
+ .refs
+ .get(&ref_id)
+ .cloned()
+ .map(ResolveResult::Match)
+ .unwrap_or_else(|| ResolveResult::NotFound {
+ selector: selector.to_string(),
+ mode: "ref",
+ }),
+ SelectorQuery::WindowId(window_id) => self
+ .window_id_to_ref
+ .get(&window_id)
+ .and_then(|ref_id| self.refs.get(ref_id))
+ .cloned()
+ .map(ResolveResult::Match)
+ .unwrap_or_else(|| ResolveResult::NotFound {
+ selector: selector.to_string(),
+ mode: "id",
+ }),
+ SelectorQuery::Focused => self.resolve_candidates(
+ selector,
+ "focused",
+ self.refs
+ .values()
+ .filter(|entry| entry.focused)
+ .cloned()
+ .collect(),
+ ),
+ SelectorQuery::Title(title) => {
+ if title.is_empty() {
+ return ResolveResult::Invalid {
+ selector: selector.to_string(),
+ mode: "title",
+ message: "title selectors must not be empty".to_string(),
+ };
+ }
+ self.resolve_candidates(
+ selector,
+ "title",
+ self.refs
+ .values()
+ .filter(|entry| entry.title.eq_ignore_ascii_case(&title))
+ .cloned()
+ .collect(),
+ )
+ }
+ SelectorQuery::Class(app_class) => {
+ if app_class.is_empty() {
+ return ResolveResult::Invalid {
+ selector: selector.to_string(),
+ mode: "class",
+ message: "class selectors must not be empty".to_string(),
+ };
+ }
+ self.resolve_candidates(
+ selector,
+ "class",
+ self.refs
+ .values()
+ .filter(|entry| entry.app_class.eq_ignore_ascii_case(&app_class))
+ .cloned()
+ .collect(),
+ )
+ }
+ SelectorQuery::Fuzzy(value) => {
+ if let Some(entry) = self.refs.get(&value).cloned() {
+ return ResolveResult::Match(entry);
+ }
+
+ if let Some(entry) = self
+ .window_id_to_ref
+ .get(&value)
+ .and_then(|ref_id| self.refs.get(ref_id))
+ .cloned()
+ {
+ return ResolveResult::Match(entry);
+ }
+
+ let lower = value.to_lowercase();
+ self.resolve_candidates(
+ selector,
+ "fuzzy",
+ self.refs
+ .values()
+ .filter(|entry| {
+ entry.app_class.to_lowercase().contains(&lower)
+ || entry.title.to_lowercase().contains(&lower)
+ })
+ .cloned()
+ .collect(),
+ )
+ }
+ }
+ }
+
+ fn resolve_candidates(
+ &self,
+ selector: &str,
+ mode: &'static str,
+ mut candidates: Vec
,
+ ) -> ResolveResult {
+ candidates.sort_by(|left, right| left.ref_id.cmp(&right.ref_id));
+ match candidates.len() {
+ 0 => ResolveResult::NotFound {
+ selector: selector.to_string(),
+ mode,
+ },
+ 1 => ResolveResult::Match(candidates.remove(0)),
+ _ => ResolveResult::Ambiguous {
+ selector: selector.to_string(),
+ mode,
+ candidates: candidates
+ .into_iter()
+ .map(|entry| entry.to_window_info())
+ .collect(),
+ },
+ }
+ }
+}
+
+impl SelectorQuery {
+ pub fn parse(selector: &str) -> Self {
+ if let Some(value) = selector.strip_prefix('@') {
+ return Self::Ref(value.to_string());
+ }
+ if let Some(value) = selector.strip_prefix("ref=") {
+ return Self::Ref(value.to_string());
+ }
+ if let Some(value) = selector.strip_prefix("id=") {
+ return Self::WindowId(value.to_string());
+ }
+ if let Some(value) = selector.strip_prefix("title=") {
+ return Self::Title(value.to_string());
+ }
+ if let Some(value) = selector.strip_prefix("class=") {
+ return Self::Class(value.to_string());
+ }
+ if selector == "focused" {
+ return Self::Focused;
+ }
+ Self::Fuzzy(selector.to_string())
+ }
+
+ pub fn needs_live_refresh(&self) -> bool {
+ !matches!(self, Self::Ref(_))
+ }
+}
+
+impl RefEntry {
+ pub fn center(&self) -> (i32, i32) {
+ (
+ self.x + self.width as i32 / 2,
+ self.y + self.height as i32 / 2,
+ )
+ }
+
+ pub fn to_window_info(&self) -> WindowInfo {
+ WindowInfo {
+ ref_id: self.ref_id.clone(),
+ window_id: self.window_id.clone(),
+ title: self.title.clone(),
+ app_name: self.app_class.clone(),
+ x: self.x,
+ y: self.y,
+ width: self.width,
+ height: self.height,
+ focused: self.focused,
+ minimized: self.minimized,
+ }
+ }
+}
+
+impl ResolveResult {
+ pub fn matched_entry(&self) -> Option<&RefEntry> {
+ match self {
+ Self::Match(entry) => Some(entry),
+ _ => None,
+ }
+ }
}
#[cfg(test)]
mod tests {
- use super::RefMap;
+ use super::{RefMap, ResolveResult, SelectorQuery};
use crate::backend::BackendWindow;
fn sample_window(native_id: u32, title: &str) -> BackendWindow {
@@ -184,12 +372,18 @@ mod tests {
let public = refs.rebuild(&[sample_window(42, "Editor")]);
let window_id = public[0].window_id.clone();
- assert_eq!(refs.resolve("@w1").unwrap().window_id, window_id);
- assert_eq!(refs.resolve(&window_id).unwrap().backend_window_id, 42);
- assert_eq!(
- refs.resolve(&format!("id={window_id}")).unwrap().title,
- "Editor"
- );
+ match refs.resolve("@w1") {
+ ResolveResult::Match(entry) => assert_eq!(entry.window_id, window_id),
+ other => panic!("unexpected resolve result: {other:?}"),
+ }
+ match refs.resolve(&window_id) {
+ ResolveResult::Match(entry) => assert_eq!(entry.backend_window_id, 42),
+ other => panic!("unexpected resolve result: {other:?}"),
+ }
+ match refs.resolve(&format!("id={window_id}")) {
+ ResolveResult::Match(entry) => assert_eq!(entry.title, "Editor"),
+ other => panic!("unexpected resolve result: {other:?}"),
+ }
}
#[test]
@@ -197,6 +391,95 @@ mod tests {
let mut refs = RefMap::new();
refs.rebuild(&[sample_window(7, "Browser")]);
- assert_eq!(refs.resolve_to_center("w1"), Some((160, 120)));
+ match refs.resolve_to_center("w1") {
+ ResolveResult::Match(entry) => assert_eq!(entry.center(), (160, 120)),
+ other => panic!("unexpected resolve result: {other:?}"),
+ }
+ }
+
+ #[test]
+ fn selector_query_parses_explicit_modes() {
+ assert_eq!(
+ SelectorQuery::parse("@w1"),
+ SelectorQuery::Ref("w1".to_string())
+ );
+ assert_eq!(
+ SelectorQuery::parse("ref=w2"),
+ SelectorQuery::Ref("w2".to_string())
+ );
+ assert_eq!(
+ SelectorQuery::parse("id=win4"),
+ SelectorQuery::WindowId("win4".to_string())
+ );
+ assert_eq!(
+ SelectorQuery::parse("title=Firefox"),
+ SelectorQuery::Title("Firefox".to_string())
+ );
+ assert_eq!(
+ SelectorQuery::parse("class=Navigator"),
+ SelectorQuery::Class("Navigator".to_string())
+ );
+ assert_eq!(SelectorQuery::parse("focused"), SelectorQuery::Focused);
+ }
+
+ #[test]
+ fn resolve_supports_exact_title_class_and_focused_modes() {
+ let mut refs = RefMap::new();
+ refs.rebuild(&[
+ sample_window(1, "Browser"),
+ BackendWindow {
+ native_id: 2,
+ title: "Editor".to_string(),
+ app_name: "Code".to_string(),
+ x: 0,
+ y: 0,
+ width: 10,
+ height: 10,
+ focused: false,
+ minimized: false,
+ },
+ ]);
+
+ match refs.resolve("focused") {
+ ResolveResult::Match(entry) => assert_eq!(entry.title, "Browser"),
+ other => panic!("unexpected resolve result: {other:?}"),
+ }
+ match refs.resolve("title=Editor") {
+ ResolveResult::Match(entry) => assert_eq!(entry.app_class, "Code"),
+ other => panic!("unexpected resolve result: {other:?}"),
+ }
+ match refs.resolve("class=code") {
+ ResolveResult::Match(entry) => assert_eq!(entry.title, "Editor"),
+ other => panic!("unexpected resolve result: {other:?}"),
+ }
+ }
+
+ #[test]
+ fn fuzzy_resolution_fails_with_candidates_when_ambiguous() {
+ let mut refs = RefMap::new();
+ refs.rebuild(&[
+ sample_window(1, "Firefox"),
+ BackendWindow {
+ native_id: 2,
+ title: "Firefox Settings".to_string(),
+ app_name: "Firefox".to_string(),
+ x: 0,
+ y: 0,
+ width: 10,
+ height: 10,
+ focused: false,
+ minimized: false,
+ },
+ ]);
+
+ match refs.resolve("firefox") {
+ ResolveResult::Ambiguous {
+ mode, candidates, ..
+ } => {
+ assert_eq!(mode, "fuzzy");
+ assert_eq!(candidates.len(), 2);
+ }
+ other => panic!("unexpected resolve result: {other:?}"),
+ }
}
}
diff --git a/src/core/types.rs b/src/core/types.rs
index 569fe37..845a4c0 100644
--- a/src/core/types.rs
+++ b/src/core/types.rs
@@ -8,7 +8,7 @@ pub struct Snapshot {
}
#[allow(dead_code)]
-#[derive(Debug, Serialize, Deserialize)]
+#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WindowInfo {
pub ref_id: String,
pub window_id: String,
@@ -22,6 +22,47 @@ pub struct WindowInfo {
pub minimized: bool,
}
+#[allow(dead_code)]
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct MonitorInfo {
+ pub name: String,
+ pub x: i32,
+ pub y: i32,
+ pub width: u32,
+ pub height: u32,
+ pub width_mm: u32,
+ pub height_mm: u32,
+ pub primary: bool,
+ pub automatic: bool,
+}
+
+#[allow(dead_code)]
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct ScreenSize {
+ pub width: u32,
+ pub height: u32,
+}
+
+#[allow(dead_code)]
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct VersionInfo {
+ pub version: String,
+ pub backend: String,
+}
+
+#[allow(dead_code)]
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct SystemInfo {
+ pub backend: String,
+ pub display: Option,
+ pub session_type: Option,
+ pub session: String,
+ pub socket_path: String,
+ pub screen: ScreenSize,
+ pub monitor_count: usize,
+ pub monitors: Vec,
+}
+
impl std::fmt::Display for WindowInfo {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let state = if self.focused {
diff --git a/src/daemon/handler.rs b/src/daemon/handler.rs
index 21f5e76..c0f4f1d 100644
--- a/src/daemon/handler.rs
+++ b/src/daemon/handler.rs
@@ -2,11 +2,13 @@ use std::sync::Arc;
use anyhow::{Context, Result};
use tokio::sync::Mutex;
+use tokio::time::{sleep, Duration, Instant};
use super::state::DaemonState;
use crate::backend::annotate::annotate_screenshot;
use crate::core::protocol::{Request, Response};
-use crate::core::types::{Snapshot, WindowInfo};
+use crate::core::refs::{ResolveResult, SelectorQuery};
+use crate::core::types::{MonitorInfo, ScreenSize, Snapshot, SystemInfo, VersionInfo, WindowInfo};
pub async fn handle_request(request: &Request, state: &Arc>) -> Response {
match request.action.as_str() {
@@ -27,6 +29,12 @@ pub async fn handle_request(request: &Request, state: &Arc>)
"list-windows" => handle_list_windows(state).await,
"get-screen-size" => handle_get_screen_size(state).await,
"get-mouse-position" => handle_get_mouse_position(state).await,
+ "get-active-window" => handle_get_active_window(state).await,
+ "get-monitors" => handle_get_monitors(state).await,
+ "get-version" => handle_get_version(state).await,
+ "get-systeminfo" => handle_get_systeminfo(state).await,
+ "wait-window" => handle_wait(request, state, WaitKind::Window).await,
+ "wait-focus" => handle_wait(request, state, WaitKind::Focus).await,
"screenshot" => handle_screenshot(request, state).await,
"launch" => handle_launch(request, state).await,
action => Response::err(format!("Unknown action: {action}")),
@@ -54,6 +62,7 @@ async fn handle_click(request: &Request, state: &Arc>) -> Res
};
let mut state = state.lock().await;
+ let selector_query = SelectorQuery::parse(&selector);
if let Some((x, y)) = parse_coords(&selector) {
return match state.backend.click(x, y) {
@@ -62,14 +71,23 @@ async fn handle_click(request: &Request, state: &Arc>) -> Res
};
}
+ if selector_query.needs_live_refresh() {
+ if let Err(error) = refresh_windows(&mut state) {
+ return Response::err(format!("Click failed: {error}"));
+ }
+ }
+
match state.ref_map.resolve_to_center(&selector) {
- Some((x, y)) => match state.backend.click(x, y) {
- Ok(()) => {
- Response::ok(serde_json::json!({"clicked": {"x": x, "y": y, "ref": selector}}))
+ ResolveResult::Match(entry) => {
+ let (x, y) = entry.center();
+ match state.backend.click(x, y) {
+ Ok(()) => Response::ok(
+ serde_json::json!({"clicked": {"x": x, "y": y, "selector": selector, "window_id": entry.window_id}}),
+ ),
+ Err(error) => Response::err(format!("Click failed: {error}")),
}
- Err(error) => Response::err(format!("Click failed: {error}")),
- },
- None => Response::err(format!("Could not resolve selector: {selector}")),
+ }
+ outcome => selector_failure_response(outcome),
}
}
@@ -80,6 +98,7 @@ async fn handle_dblclick(request: &Request, state: &Arc>) ->
};
let mut state = state.lock().await;
+ let selector_query = SelectorQuery::parse(&selector);
if let Some((x, y)) = parse_coords(&selector) {
return match state.backend.dblclick(x, y) {
@@ -88,14 +107,23 @@ async fn handle_dblclick(request: &Request, state: &Arc>) ->
};
}
+ if selector_query.needs_live_refresh() {
+ if let Err(error) = refresh_windows(&mut state) {
+ return Response::err(format!("Double-click failed: {error}"));
+ }
+ }
+
match state.ref_map.resolve_to_center(&selector) {
- Some((x, y)) => match state.backend.dblclick(x, y) {
- Ok(()) => Response::ok(
- serde_json::json!({"double_clicked": {"x": x, "y": y, "ref": selector}}),
- ),
- Err(error) => Response::err(format!("Double-click failed: {error}")),
- },
- None => Response::err(format!("Could not resolve selector: {selector}")),
+ ResolveResult::Match(entry) => {
+ let (x, y) = entry.center();
+ match state.backend.dblclick(x, y) {
+ Ok(()) => Response::ok(
+ serde_json::json!({"double_clicked": {"x": x, "y": y, "selector": selector, "window_id": entry.window_id}}),
+ ),
+ Err(error) => Response::err(format!("Double-click failed: {error}")),
+ }
+ }
+ outcome => selector_failure_response(outcome),
}
}
@@ -218,9 +246,15 @@ async fn handle_window_action(
};
let mut state = state.lock().await;
+ let selector_query = SelectorQuery::parse(&selector);
+ if selector_query.needs_live_refresh() {
+ if let Err(error) = refresh_windows(&mut state) {
+ return Response::err(format!("{action} failed: {error}"));
+ }
+ }
let entry = match state.ref_map.resolve(&selector) {
- Some(entry) => entry.clone(),
- None => return Response::err(format!("Could not resolve window: {selector}")),
+ ResolveResult::Match(entry) => entry,
+ outcome => return selector_failure_response(outcome),
};
let result = match action {
@@ -248,9 +282,15 @@ async fn handle_move_window(request: &Request, state: &Arc>)
let y = request.extra.get("y").and_then(|v| v.as_i64()).unwrap_or(0) as i32;
let mut state = state.lock().await;
+ let selector_query = SelectorQuery::parse(&selector);
+ if selector_query.needs_live_refresh() {
+ if let Err(error) = refresh_windows(&mut state) {
+ return Response::err(format!("Move failed: {error}"));
+ }
+ }
let entry = match state.ref_map.resolve(&selector) {
- Some(entry) => entry.clone(),
- None => return Response::err(format!("Could not resolve window: {selector}")),
+ ResolveResult::Match(entry) => entry,
+ outcome => return selector_failure_response(outcome),
};
match state.backend.move_window(entry.backend_window_id, x, y) {
@@ -281,9 +321,15 @@ async fn handle_resize_window(request: &Request, state: &Arc>
.unwrap_or(600) as u32;
let mut state = state.lock().await;
+ let selector_query = SelectorQuery::parse(&selector);
+ if selector_query.needs_live_refresh() {
+ if let Err(error) = refresh_windows(&mut state) {
+ return Response::err(format!("Resize failed: {error}"));
+ }
+ }
let entry = match state.ref_map.resolve(&selector) {
- Some(entry) => entry.clone(),
- None => return Response::err(format!("Could not resolve window: {selector}")),
+ ResolveResult::Match(entry) => entry,
+ outcome => return selector_failure_response(outcome),
};
match state
@@ -324,6 +370,185 @@ async fn handle_get_mouse_position(state: &Arc>) -> Response
}
}
+async fn handle_get_active_window(state: &Arc>) -> Response {
+ let mut state = state.lock().await;
+ let active_backend_window = match state.backend.active_window() {
+ Ok(window) => window,
+ Err(error) => return Response::err(format!("Failed: {error}")),
+ };
+
+ let windows = match refresh_windows(&mut state) {
+ Ok(windows) => windows,
+ Err(error) => return Response::err(format!("Failed: {error}")),
+ };
+
+ let active_window = if let Some(active_backend_window) = active_backend_window {
+ state
+ .ref_map
+ .entries()
+ .find_map(|(_, entry)| {
+ (entry.backend_window_id == active_backend_window.native_id)
+ .then(|| entry.to_window_info())
+ })
+ .or_else(|| windows.iter().find(|window| window.focused).cloned())
+ } else {
+ windows.iter().find(|window| window.focused).cloned()
+ };
+
+ if let Some(window) = active_window {
+ Response::ok(serde_json::json!({"window": window}))
+ } else {
+ Response::err_with_data(
+ "No focused window is available",
+ serde_json::json!({"kind": "not_found", "mode": "focused"}),
+ )
+ }
+}
+
+async fn handle_get_monitors(state: &Arc>) -> Response {
+ let state = state.lock().await;
+ match state.backend.list_monitors() {
+ Ok(monitors) => {
+ let monitors: Vec = monitors.into_iter().map(Into::into).collect();
+ Response::ok(serde_json::json!({
+ "count": monitors.len(),
+ "monitors": monitors,
+ }))
+ }
+ Err(error) => Response::err(format!("Failed: {error}")),
+ }
+}
+
+async fn handle_get_version(state: &Arc>) -> Response {
+ let state = state.lock().await;
+ let info = VersionInfo {
+ version: env!("CARGO_PKG_VERSION").to_string(),
+ backend: state.backend.backend_name().to_string(),
+ };
+ Response::ok(serde_json::to_value(info).unwrap_or_default())
+}
+
+async fn handle_get_systeminfo(state: &Arc>) -> Response {
+ let state = state.lock().await;
+ let screen = match state.backend.screen_size() {
+ Ok((width, height)) => ScreenSize { width, height },
+ Err(error) => return Response::err(format!("Failed: {error}")),
+ };
+ let monitors = match state.backend.list_monitors() {
+ Ok(monitors) => monitors.into_iter().map(Into::into).collect::>(),
+ Err(error) => return Response::err(format!("Failed: {error}")),
+ };
+
+ let info = SystemInfo {
+ backend: state.backend.backend_name().to_string(),
+ display: std::env::var("DISPLAY")
+ .ok()
+ .filter(|value| !value.is_empty()),
+ session_type: std::env::var("XDG_SESSION_TYPE")
+ .ok()
+ .filter(|value| !value.is_empty()),
+ session: state.session.clone(),
+ socket_path: state.socket_path.display().to_string(),
+ screen,
+ monitor_count: monitors.len(),
+ monitors,
+ };
+
+ Response::ok(serde_json::to_value(info).unwrap_or_default())
+}
+
+async fn handle_wait(
+ request: &Request,
+ state: &Arc>,
+ wait_kind: WaitKind,
+) -> Response {
+ let selector = match request.extra.get("selector").and_then(|v| v.as_str()) {
+ Some(selector) => selector.to_string(),
+ None => return Response::err("Missing 'selector' field"),
+ };
+ let timeout_ms = request
+ .extra
+ .get("timeout_ms")
+ .and_then(|v| v.as_u64())
+ .unwrap_or(10_000);
+ let poll_ms = request
+ .extra
+ .get("poll_ms")
+ .and_then(|v| v.as_u64())
+ .unwrap_or(250);
+
+ let start = Instant::now();
+ let deadline = Instant::now() + Duration::from_millis(timeout_ms);
+ let mut last_observation: serde_json::Value;
+
+ loop {
+ let outcome = {
+ let mut state = state.lock().await;
+ if let Err(error) = refresh_windows(&mut state) {
+ return Response::err(format!("Wait failed: {error}"));
+ }
+ observe_wait(&state, &selector, wait_kind)
+ };
+
+ match outcome {
+ WaitObservation::Satisfied(window) => {
+ let elapsed_ms = start.elapsed().as_millis() as u64;
+ return Response::ok(serde_json::json!({
+ "wait": wait_kind.as_str(),
+ "selector": selector,
+ "elapsed_ms": elapsed_ms,
+ "window": window,
+ }));
+ }
+ WaitObservation::Retry { observation } => {
+ last_observation = observation;
+ }
+ WaitObservation::Failure(response) => return response,
+ }
+
+ if Instant::now() >= deadline {
+ return Response::err_with_data(
+ format!(
+ "Timed out waiting for {} to match selector: {}",
+ wait_kind.as_str(),
+ selector
+ ),
+ serde_json::json!({
+ "kind": "timeout",
+ "wait": wait_kind.as_str(),
+ "selector": selector,
+ "timeout_ms": timeout_ms,
+ "poll_ms": poll_ms,
+ "last_observation": last_observation,
+ }),
+ );
+ }
+
+ sleep(Duration::from_millis(poll_ms)).await;
+ }
+}
+
+#[derive(Clone, Copy)]
+enum WaitKind {
+ Window,
+ Focus,
+}
+
+impl WaitKind {
+ fn as_str(self) -> &'static str {
+ match self {
+ Self::Window => "window",
+ Self::Focus => "focus",
+ }
+ }
+}
+
+enum WaitObservation {
+ Satisfied(WindowInfo),
+ Retry { observation: serde_json::Value },
+ Failure(Response),
+}
+
async fn handle_screenshot(request: &Request, state: &Arc>) -> Response {
let annotate = request
.extra
@@ -387,6 +612,97 @@ fn refresh_windows(state: &mut DaemonState) -> Result> {
Ok(state.ref_map.rebuild(&windows))
}
+fn selector_failure_response(result: ResolveResult) -> Response {
+ match result {
+ ResolveResult::NotFound { selector, mode } => Response::err_with_data(
+ format!("Could not resolve selector: {selector}"),
+ serde_json::json!({
+ "kind": "selector_not_found",
+ "selector": selector,
+ "mode": mode,
+ }),
+ ),
+ ResolveResult::Ambiguous {
+ selector,
+ mode,
+ candidates,
+ } => Response::err_with_data(
+ format!("Selector is ambiguous: {selector}"),
+ serde_json::json!({
+ "kind": "selector_ambiguous",
+ "selector": selector,
+ "mode": mode,
+ "candidates": candidates,
+ }),
+ ),
+ ResolveResult::Invalid {
+ selector,
+ mode,
+ message,
+ } => Response::err_with_data(
+ format!("Invalid selector '{selector}': {message}"),
+ serde_json::json!({
+ "kind": "selector_invalid",
+ "selector": selector,
+ "mode": mode,
+ "message": message,
+ }),
+ ),
+ ResolveResult::Match(_) => unreachable!(),
+ }
+}
+
+fn observe_wait(state: &DaemonState, selector: &str, wait_kind: WaitKind) -> WaitObservation {
+ match state.ref_map.resolve(selector) {
+ ResolveResult::Match(entry) => {
+ let window = entry.to_window_info();
+ match wait_kind {
+ WaitKind::Window => WaitObservation::Satisfied(window),
+ WaitKind::Focus if window.focused => WaitObservation::Satisfied(window),
+ WaitKind::Focus => WaitObservation::Retry {
+ observation: serde_json::json!({
+ "kind": "window_not_focused",
+ "window": window,
+ }),
+ },
+ }
+ }
+ ResolveResult::NotFound { selector, mode } => WaitObservation::Retry {
+ observation: serde_json::json!({
+ "kind": "selector_not_found",
+ "selector": selector,
+ "mode": mode,
+ }),
+ },
+ ResolveResult::Ambiguous {
+ selector,
+ mode,
+ candidates,
+ } => WaitObservation::Failure(Response::err_with_data(
+ format!("Selector is ambiguous: {selector}"),
+ serde_json::json!({
+ "kind": "selector_ambiguous",
+ "selector": selector,
+ "mode": mode,
+ "candidates": candidates,
+ }),
+ )),
+ ResolveResult::Invalid {
+ selector,
+ mode,
+ message,
+ } => WaitObservation::Failure(Response::err_with_data(
+ format!("Invalid selector '{selector}': {message}"),
+ serde_json::json!({
+ "kind": "selector_invalid",
+ "selector": selector,
+ "mode": mode,
+ "message": message,
+ }),
+ )),
+ }
+}
+
fn capture_snapshot(
state: &mut DaemonState,
annotate: bool,
@@ -438,3 +754,19 @@ fn parse_coords(value: &str) -> Option<(i32, i32)> {
let y = parts[1].trim().parse().ok()?;
Some((x, y))
}
+
+impl From for MonitorInfo {
+ fn from(value: crate::backend::BackendMonitor) -> Self {
+ Self {
+ name: value.name,
+ x: value.x,
+ y: value.y,
+ width: value.width,
+ height: value.height,
+ width_mm: value.width_mm,
+ height_mm: value.height_mm,
+ primary: value.primary,
+ automatic: value.automatic,
+ }
+ }
+}
diff --git a/tests/support/mod.rs b/tests/support/mod.rs
index d8b93a1..5c6f0be 100644
--- a/tests/support/mod.rs
+++ b/tests/support/mod.rs
@@ -21,6 +21,13 @@ pub fn env_lock() -> &'static Mutex<()> {
LOCK.get_or_init(|| Mutex::new(()))
}
+pub fn env_lock_guard() -> std::sync::MutexGuard<'static, ()> {
+ match env_lock().lock() {
+ Ok(guard) => guard,
+ Err(poisoned) => poisoned.into_inner(),
+ }
+}
+
pub struct SessionEnvGuard {
old_session_type: Option,
}
@@ -218,3 +225,7 @@ pub fn successful_json_response(output: Output) -> Result {
serde_json::from_slice(&output.stdout).context("Failed to parse JSON output from deskctl")
}
+
+pub fn json_response(output: &Output) -> Result {
+ serde_json::from_slice(&output.stdout).context("Failed to parse JSON output from deskctl")
+}
diff --git a/tests/x11_runtime.rs b/tests/x11_runtime.rs
index ef09411..2aac58c 100644
--- a/tests/x11_runtime.rs
+++ b/tests/x11_runtime.rs
@@ -8,13 +8,13 @@ use deskctl::core::doctor;
use deskctl::core::protocol::Request;
use self::support::{
- deskctl_tmp_screenshot_count, env_lock, successful_json_response, FixtureWindow,
- SessionEnvGuard, TestSession,
+ deskctl_tmp_screenshot_count, env_lock_guard, json_response, successful_json_response,
+ FixtureWindow, SessionEnvGuard, TestSession,
};
#[test]
fn doctor_reports_healthy_x11_environment() -> Result<()> {
- let _guard = env_lock().lock().unwrap();
+ let _guard = env_lock_guard();
let Some(_env) = SessionEnvGuard::prepare() else {
eprintln!("Skipping X11 integration test because DISPLAY is not set");
return Ok(());
@@ -46,7 +46,7 @@ fn doctor_reports_healthy_x11_environment() -> Result<()> {
#[test]
fn list_windows_is_side_effect_free() -> Result<()> {
- let _guard = env_lock().lock().unwrap();
+ let _guard = env_lock_guard();
let Some(_env) = SessionEnvGuard::prepare() else {
eprintln!("Skipping X11 integration test because DISPLAY is not set");
return Ok(());
@@ -84,7 +84,7 @@ fn list_windows_is_side_effect_free() -> Result<()> {
#[test]
fn daemon_start_recovers_from_stale_socket() -> Result<()> {
- let _guard = env_lock().lock().unwrap();
+ let _guard = env_lock_guard();
let Some(_env) = SessionEnvGuard::prepare() else {
eprintln!("Skipping X11 integration test because DISPLAY is not set");
return Ok(());
@@ -113,3 +113,123 @@ fn daemon_start_recovers_from_stale_socket() -> Result<()> {
Ok(())
}
+
+#[test]
+fn wait_window_returns_matched_window_payload() -> Result<()> {
+ let _guard = env_lock_guard();
+ let Some(_env) = SessionEnvGuard::prepare() else {
+ eprintln!("Skipping X11 integration test because DISPLAY is not set");
+ return Ok(());
+ };
+
+ let title = "deskctl wait window test";
+ let _window = FixtureWindow::create(title, "DeskctlWait")?;
+ let session = TestSession::new("wait-window-success")?;
+ let response = successful_json_response(session.run_cli([
+ "--json",
+ "wait",
+ "window",
+ "--selector",
+ &format!("title={title}"),
+ "--timeout",
+ "1",
+ "--poll-ms",
+ "50",
+ ])?)?;
+
+ let window = response
+ .get("data")
+ .and_then(|data| data.get("window"))
+ .expect("wait window should return a matched window");
+ assert_eq!(
+ window.get("title").and_then(|value| value.as_str()),
+ Some(title)
+ );
+ assert_eq!(
+ response
+ .get("data")
+ .and_then(|data| data.get("wait"))
+ .and_then(|value| value.as_str()),
+ Some("window")
+ );
+
+ Ok(())
+}
+
+#[test]
+fn ambiguous_fuzzy_selector_returns_candidates() -> Result<()> {
+ let _guard = env_lock_guard();
+ let Some(_env) = SessionEnvGuard::prepare() else {
+ eprintln!("Skipping X11 integration test because DISPLAY is not set");
+ return Ok(());
+ };
+
+ let _window_one = FixtureWindow::create("deskctl ambiguity alpha", "DeskctlAmbiguous")?;
+ let _window_two = FixtureWindow::create("deskctl ambiguity beta", "DeskctlAmbiguous")?;
+ let session = TestSession::new("selector-ambiguity")?;
+ let output = session.run_cli(["--json", "focus", "ambiguity"])?;
+ let response = json_response(&output)?;
+
+ assert!(!output.status.success());
+ assert_eq!(
+ response.get("success").and_then(|value| value.as_bool()),
+ Some(false)
+ );
+ assert_eq!(
+ response
+ .get("data")
+ .and_then(|data| data.get("kind"))
+ .and_then(|value| value.as_str()),
+ Some("selector_ambiguous")
+ );
+ assert!(response
+ .get("data")
+ .and_then(|data| data.get("candidates"))
+ .and_then(|value| value.as_array())
+ .map(|candidates| candidates.len() >= 2)
+ .unwrap_or(false));
+
+ Ok(())
+}
+
+#[test]
+fn wait_focus_timeout_is_structured() -> Result<()> {
+ let _guard = env_lock_guard();
+ let Some(_env) = SessionEnvGuard::prepare() else {
+ eprintln!("Skipping X11 integration test because DISPLAY is not set");
+ return Ok(());
+ };
+
+ let session = TestSession::new("wait-focus-timeout")?;
+ let output = session.run_cli([
+ "--json",
+ "wait",
+ "focus",
+ "--selector",
+ "title=missing-window-for-wait-focus",
+ "--timeout",
+ "1",
+ "--poll-ms",
+ "50",
+ ])?;
+ let response = json_response(&output)?;
+
+ assert!(!output.status.success());
+ assert_eq!(
+ response
+ .get("data")
+ .and_then(|data| data.get("kind"))
+ .and_then(|value| value.as_str()),
+ Some("timeout")
+ );
+ assert_eq!(
+ response
+ .get("data")
+ .and_then(|data| data.get("last_observation"))
+ .and_then(|value| value.get("kind"))
+ .and_then(|value| value.as_str()),
+ Some("selector_not_found")
+ );
+
+ Ok(())
+}
From 61f47383119517173b3da54932f1a92e97e4194e Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
Date: Thu, 26 Mar 2026 01:16:24 +0000
Subject: [PATCH 10/49] release: v0.1.4 [skip ci]
---
Cargo.lock | 2 +-
Cargo.toml | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
index 0a64e12..0e7194d 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -400,7 +400,7 @@ checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "deskctl"
-version = "0.1.3"
+version = "0.1.4"
dependencies = [
"ab_glyph",
"anyhow",
diff --git a/Cargo.toml b/Cargo.toml
index a4fd4fe..555c599 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "deskctl"
-version = "0.1.3"
+version = "0.1.4"
edition = "2021"
description = "X11 desktop control CLI for agents"
license = "MIT"
From 543d41c3a24dc2a2ccebb8533f7f9ef6f21f2900 Mon Sep 17 00:00:00 2001
From: Hari <73809867+harivansh-afk@users.noreply.github.com>
Date: Wed, 25 Mar 2026 22:00:16 -0400
Subject: [PATCH 11/49] runtime contract enforcement (#6)
---
CONTRIBUTING.md | 1 +
README.md | 11 +
docs/runtime-output.md | 178 +++++++++
skills/SKILL.md | 8 +
src/cli/mod.rs | 872 ++++++++++++++++++++++++++++++++++-------
src/core/types.rs | 16 +-
src/daemon/handler.rs | 29 +-
7 files changed, 958 insertions(+), 157 deletions(-)
create mode 100644 docs/runtime-output.md
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 3c44332..7a1a2a2 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -21,6 +21,7 @@ pnpm --dir site install
- `src/` holds production code and unit tests
- `tests/` holds integration tests
- `tests/support/` holds shared X11 and daemon helpers for integration coverage
+- `docs/runtime-output.md` is the stable-vs-best-effort runtime output contract for agent-facing CLI work
Keep integration-only helpers out of `src/`.
diff --git a/README.md b/README.md
index 387f3e9..6920615 100644
--- a/README.md
+++ b/README.md
@@ -100,6 +100,7 @@ deskctl doctor
- `@wN` refs are short-lived handles assigned by `snapshot` and `list-windows`
- `--json` output includes a stable `window_id` for programmatic targeting within the current daemon session
- `list-windows` is a cheap read-only operation and does not capture or write a screenshot
+- the stable runtime JSON/error contract is documented in [docs/runtime-output.md](docs/runtime-output.md)
## Read and Wait Surface
@@ -147,6 +148,16 @@ Successful `get active-window`, `wait window`, and `wait focus` responses return
Wait timeout and selector failures are structured in `--json` mode so agents can recover without string parsing.
+## Output Policy
+
+Text mode is compact and follow-up-oriented, but JSON is the parsing contract.
+
+- use `--json` when an agent needs strict parsing
+- rely on `window_id`, selector-related fields, grouped read payloads, and structured error `kind` values for stable automation
+- treat monitor naming, incidental whitespace, and default screenshot file names as best-effort
+
+See [docs/runtime-output.md](docs/runtime-output.md) for the exact stable-vs-best-effort breakdown.
+
## Selector Contract
Explicit selector modes:
diff --git a/docs/runtime-output.md b/docs/runtime-output.md
new file mode 100644
index 0000000..7312357
--- /dev/null
+++ b/docs/runtime-output.md
@@ -0,0 +1,178 @@
+# Runtime Output Contract
+
+This document defines the current output contract for `deskctl`.
+
+It is intentionally scoped to the current Linux X11 runtime surface.
+It does not promise stability for future Wayland or window-manager-specific features.
+
+## Goals
+
+- Keep `deskctl` fully non-interactive
+- Make text output actionable for quick terminal and agent loops
+- Make `--json` safe for agent consumption without depending on incidental formatting
+
+## JSON Envelope
+
+Every runtime command uses the same top-level JSON envelope:
+
+```json
+{
+ "success": true,
+ "data": {},
+ "error": null
+}
+```
+
+Stable top-level fields:
+
+- `success`
+- `data`
+- `error`
+
+`success` is always the authoritative success/failure bit.
+When `success` is `false`, the CLI exits non-zero in both text mode and `--json` mode.
+
+## Stable Fields
+
+These fields are stable for agent consumption in the current Phase 1 runtime contract.
+
+### Window Identity
+
+Whenever a runtime response includes a window payload, these fields are stable:
+
+- `ref_id`
+- `window_id`
+- `title`
+- `app_name`
+- `x`
+- `y`
+- `width`
+- `height`
+- `focused`
+- `minimized`
+
+`window_id` is the stable public identifier for a live daemon session.
+`ref_id` is a short-lived convenience handle for the current window snapshot/ref map.
+
+### Grouped Reads
+
+`deskctl get active-window`
+
+- stable: `data.window`
+
+`deskctl get monitors`
+
+- stable: `data.count`
+- stable: `data.monitors`
+- stable per monitor:
+ - `name`
+ - `x`
+ - `y`
+ - `width`
+ - `height`
+ - `width_mm`
+ - `height_mm`
+ - `primary`
+ - `automatic`
+
+`deskctl get version`
+
+- stable: `data.version`
+- stable: `data.backend`
+
+`deskctl get systeminfo`
+
+- stable: `data.backend`
+- stable: `data.display`
+- stable: `data.session_type`
+- stable: `data.session`
+- stable: `data.socket_path`
+- stable: `data.screen`
+- stable: `data.monitor_count`
+- stable: `data.monitors`
+
+### Waits
+
+`deskctl wait window`
+`deskctl wait focus`
+
+- stable: `data.wait`
+- stable: `data.selector`
+- stable: `data.elapsed_ms`
+- stable: `data.window`
+
+### Selector-Driven Action Success
+
+For selector-driven action commands that resolve a window target, these identifiers are stable when present:
+
+- `data.ref_id`
+- `data.window_id`
+- `data.title`
+- `data.selector`
+
+This applies to:
+
+- `click`
+- `dblclick`
+- `focus`
+- `close`
+- `move-window`
+- `resize-window`
+
+The exact human-readable text rendering of those commands is not part of the JSON contract.
+
+### Artifact-Producing Commands
+
+`snapshot`
+`screenshot`
+
+- stable: `data.screenshot`
+
+When the command also returns windows, `data.windows` uses the stable window payload documented above.
+
+## Stable Structured Error Kinds
+
+When a runtime command returns structured JSON failure data, these error kinds are stable:
+
+- `selector_not_found`
+- `selector_ambiguous`
+- `selector_invalid`
+- `timeout`
+- `not_found`
+- `window_not_focused` as `data.last_observation.kind` or equivalent observation payload
+
+Stable structured failure fields include:
+
+- `data.kind`
+- `data.selector` when selector-related
+- `data.mode` when selector-related
+- `data.candidates` for ambiguous selector failures
+- `data.message` for invalid selector failures
+- `data.wait`
+- `data.timeout_ms`
+- `data.poll_ms`
+- `data.last_observation`
+
+## Best-Effort Fields
+
+These values are useful but environment-dependent and should be treated as best-effort:
+
+- exact monitor naming conventions
+- EWMH/window-manager-dependent window ordering details
+- cosmetic text formatting in non-JSON mode
+- screenshot file names when the caller did not provide an explicit path
+- command stderr wording outside the structured `kind` classifications above
+
+## Text Mode Expectations
+
+Text mode is intended to stay compact and follow-up-useful.
+
+The exact whitespace/alignment of text output is not stable.
+The following expectations are stable at the behavioral level:
+
+- important runtime reads print actionable identifiers or geometry
+- selector failures print enough detail to recover without `--json`
+- artifact-producing commands print the artifact path
+- window listings print both `@wN` refs and `window_id` values
+
+If an agent needs strict parsing, it should use `--json`.
diff --git a/skills/SKILL.md b/skills/SKILL.md
index 3b1733d..efbd188 100644
--- a/skills/SKILL.md
+++ b/skills/SKILL.md
@@ -90,6 +90,14 @@ deskctl daemon status # Check daemon status
- `--session NAME` : Session name for multiple daemon instances (default: "default")
- `--socket PATH` : Custom Unix socket path
+## Output Contract
+
+- Prefer `--json` when an agent needs strict parsing.
+- Use `window_id` for stable targeting inside a live daemon session.
+- Use `ref_id` / `@wN` for quick short-lived follow-up actions after `snapshot` or `list-windows`.
+- Structured JSON failures expose machine-usable `kind` values for selector and wait failures.
+- The exact text formatting is intentionally compact but not the parsing contract. See `docs/runtime-output.md` for the stable field policy.
+
## Window Refs
After `snapshot` or `list-windows`, windows are assigned short refs:
diff --git a/src/cli/mod.rs b/src/cli/mod.rs
index ccd5b28..bab44c9 100644
--- a/src/cli/mod.rs
+++ b/src/cli/mod.rs
@@ -33,32 +33,38 @@ pub struct GlobalOpts {
#[derive(Subcommand)]
pub enum Command {
/// Take a screenshot and list windows with @wN refs
+ #[command(after_help = SNAPSHOT_EXAMPLES)]
Snapshot {
/// Draw bounding boxes and labels on the screenshot
#[arg(long)]
annotate: bool,
},
/// Click a window ref or coordinates
+ #[command(after_help = CLICK_EXAMPLES)]
Click {
- /// @w1 or x,y coordinates
+ /// Selector (ref=w1, id=win1, title=Firefox, class=firefox, focused) or x,y coordinates
selector: String,
},
/// Double-click a window ref or coordinates
+ #[command(after_help = DBLCLICK_EXAMPLES)]
Dblclick {
- /// @w1 or x,y coordinates
+ /// Selector (ref=w1, id=win1, title=Firefox, class=firefox, focused) or x,y coordinates
selector: String,
},
/// Type text into the focused window
+ #[command(after_help = TYPE_EXAMPLES)]
Type {
/// Text to type
text: String,
},
/// Press a key (e.g. enter, tab, escape)
+ #[command(after_help = PRESS_EXAMPLES)]
Press {
/// Key name
key: String,
},
/// Send a hotkey combination (e.g. ctrl c)
+ #[command(after_help = HOTKEY_EXAMPLES)]
Hotkey {
/// Key names (e.g. ctrl shift t)
keys: Vec,
@@ -67,18 +73,21 @@ pub enum Command {
#[command(subcommand)]
Mouse(MouseCmd),
/// Focus a window by ref or name
+ #[command(after_help = FOCUS_EXAMPLES)]
Focus {
- /// @w1 or window name substring
+ /// Selector: ref=w1, id=win1, title=Firefox, class=firefox, focused, or a fuzzy substring
selector: String,
},
/// Close a window by ref or name
+ #[command(after_help = CLOSE_EXAMPLES)]
Close {
- /// @w1 or window name substring
+ /// Selector: ref=w1, id=win1, title=Firefox, class=firefox, focused, or a fuzzy substring
selector: String,
},
/// Move a window
+ #[command(after_help = MOVE_WINDOW_EXAMPLES)]
MoveWindow {
- /// @w1 or window name substring
+ /// Selector: ref=w1, id=win1, title=Firefox, class=firefox, focused, or a fuzzy substring
selector: String,
/// X position
x: i32,
@@ -86,8 +95,9 @@ pub enum Command {
y: i32,
},
/// Resize a window
+ #[command(after_help = RESIZE_WINDOW_EXAMPLES)]
ResizeWindow {
- /// @w1 or window name substring
+ /// Selector: ref=w1, id=win1, title=Firefox, class=firefox, focused, or a fuzzy substring
selector: String,
/// Width
w: u32,
@@ -95,12 +105,16 @@ pub enum Command {
h: u32,
},
/// List all windows (same as snapshot but without screenshot)
+ #[command(after_help = LIST_WINDOWS_EXAMPLES)]
ListWindows,
/// Get screen resolution
+ #[command(after_help = GET_SCREEN_SIZE_EXAMPLES)]
GetScreenSize,
/// Get current mouse position
+ #[command(after_help = GET_MOUSE_POSITION_EXAMPLES)]
GetMousePosition,
/// Diagnose X11 runtime, screenshot, and daemon health
+ #[command(after_help = DOCTOR_EXAMPLES)]
Doctor,
/// Query runtime state
#[command(subcommand)]
@@ -109,6 +123,7 @@ pub enum Command {
#[command(subcommand)]
Wait(WaitCmd),
/// Take a screenshot without window tree
+ #[command(after_help = SCREENSHOT_EXAMPLES)]
Screenshot {
/// Save path (default: /tmp/deskctl-{timestamp}.png)
path: Option,
@@ -117,6 +132,7 @@ pub enum Command {
annotate: bool,
},
/// Launch an application
+ #[command(after_help = LAUNCH_EXAMPLES)]
Launch {
/// Command to run
command: String,
@@ -132,6 +148,7 @@ pub enum Command {
#[derive(Subcommand)]
pub enum MouseCmd {
/// Move the mouse cursor
+ #[command(after_help = MOUSE_MOVE_EXAMPLES)]
Move {
/// X coordinate
x: i32,
@@ -139,6 +156,7 @@ pub enum MouseCmd {
y: i32,
},
/// Scroll the mouse wheel
+ #[command(after_help = MOUSE_SCROLL_EXAMPLES)]
Scroll {
/// Amount (positive = down, negative = up)
amount: i32,
@@ -147,6 +165,7 @@ pub enum MouseCmd {
axis: String,
},
/// Drag from one position to another
+ #[command(after_help = MOUSE_DRAG_EXAMPLES)]
Drag {
/// Start X
x1: i32,
@@ -177,13 +196,47 @@ pub enum DaemonAction {
const GET_ACTIVE_WINDOW_EXAMPLES: &str =
"Examples:\n deskctl get active-window\n deskctl --json get active-window";
+const SNAPSHOT_EXAMPLES: &str =
+ "Examples:\n deskctl snapshot\n deskctl snapshot --annotate\n deskctl --json snapshot --annotate";
+const LIST_WINDOWS_EXAMPLES: &str =
+ "Examples:\n deskctl list-windows\n deskctl --json list-windows";
+const CLICK_EXAMPLES: &str =
+ "Examples:\n deskctl click @w1\n deskctl click 'title=Firefox'\n deskctl click 500,300";
+const DBLCLICK_EXAMPLES: &str =
+ "Examples:\n deskctl dblclick @w2\n deskctl dblclick 'class=firefox'\n deskctl dblclick 500,300";
+const TYPE_EXAMPLES: &str =
+ "Examples:\n deskctl type \"hello world\"\n deskctl type \"https://example.com\"";
+const PRESS_EXAMPLES: &str = "Examples:\n deskctl press enter\n deskctl press escape";
+const HOTKEY_EXAMPLES: &str = "Examples:\n deskctl hotkey ctrl l\n deskctl hotkey ctrl shift t";
+const FOCUS_EXAMPLES: &str =
+ "Examples:\n deskctl focus @w1\n deskctl focus 'title=Firefox'\n deskctl focus focused";
+const CLOSE_EXAMPLES: &str =
+ "Examples:\n deskctl close @w3\n deskctl close 'id=win2'\n deskctl close 'class=firefox'";
+const MOVE_WINDOW_EXAMPLES: &str =
+ "Examples:\n deskctl move-window @w1 100 200\n deskctl move-window 'title=Firefox' 0 0";
+const RESIZE_WINDOW_EXAMPLES: &str =
+ "Examples:\n deskctl resize-window @w1 1280 720\n deskctl resize-window 'id=win2' 800 600";
const GET_MONITORS_EXAMPLES: &str =
"Examples:\n deskctl get monitors\n deskctl --json get monitors";
const GET_VERSION_EXAMPLES: &str = "Examples:\n deskctl get version\n deskctl --json get version";
const GET_SYSTEMINFO_EXAMPLES: &str =
"Examples:\n deskctl get systeminfo\n deskctl --json get systeminfo";
+const GET_SCREEN_SIZE_EXAMPLES: &str =
+ "Examples:\n deskctl get-screen-size\n deskctl --json get-screen-size";
+const GET_MOUSE_POSITION_EXAMPLES: &str =
+ "Examples:\n deskctl get-mouse-position\n deskctl --json get-mouse-position";
+const DOCTOR_EXAMPLES: &str = "Examples:\n deskctl doctor\n deskctl --json doctor";
const WAIT_WINDOW_EXAMPLES: &str = "Examples:\n deskctl wait window --selector 'title=Firefox' --timeout 10\n deskctl --json wait window --selector 'class=firefox' --poll-ms 100";
const WAIT_FOCUS_EXAMPLES: &str = "Examples:\n deskctl wait focus --selector 'id=win3' --timeout 5\n deskctl wait focus --selector focused --poll-ms 200";
+const SCREENSHOT_EXAMPLES: &str =
+ "Examples:\n deskctl screenshot\n deskctl screenshot /tmp/screen.png\n deskctl screenshot --annotate";
+const LAUNCH_EXAMPLES: &str =
+ "Examples:\n deskctl launch firefox\n deskctl launch code -- --new-window";
+const MOUSE_MOVE_EXAMPLES: &str =
+ "Examples:\n deskctl mouse move 500 300\n deskctl mouse move 0 0";
+const MOUSE_SCROLL_EXAMPLES: &str =
+ "Examples:\n deskctl mouse scroll 3\n deskctl mouse scroll -3 --axis vertical";
+const MOUSE_DRAG_EXAMPLES: &str = "Examples:\n deskctl mouse drag 100 100 500 500";
#[derive(Subcommand)]
pub enum GetCmd {
@@ -338,154 +391,523 @@ fn build_request(cmd: &Command) -> Result {
fn print_response(cmd: &Command, response: &Response) -> Result<()> {
if !response.success {
- if let Some(ref err) = response.error {
- eprintln!("Error: {err}");
- }
- if let Some(ref data) = response.data {
- if let Some(kind) = data.get("kind").and_then(|value| value.as_str()) {
- match kind {
- "selector_ambiguous" => {
- if let Some(candidates) = data.get("candidates").and_then(|v| v.as_array())
- {
- eprintln!("Candidates:");
- for candidate in candidates {
- print_window_to_stderr(candidate);
- }
- }
- }
- "timeout" => {
- if let Some(selector) = data.get("selector").and_then(|v| v.as_str()) {
- let wait = data.get("wait").and_then(|v| v.as_str()).unwrap_or("wait");
- let timeout_ms =
- data.get("timeout_ms").and_then(|v| v.as_u64()).unwrap_or(0);
- eprintln!(
- "Timed out after {timeout_ms}ms waiting for {wait} selector {selector}"
- );
- }
- }
- _ => {}
- }
- }
+ for line in render_error_lines(response) {
+ eprintln!("{line}");
}
std::process::exit(1);
}
- if let Some(ref data) = response.data {
- // For snapshot, print compact text format
- if matches!(cmd, Command::Snapshot { .. } | Command::ListWindows) {
- if let Some(screenshot) = data.get("screenshot").and_then(|v| v.as_str()) {
- println!("Screenshot: {screenshot}");
- }
- if let Some(windows) = data.get("windows").and_then(|v| v.as_array()) {
- println!("Windows:");
- for w in windows {
- let ref_id = w.get("ref_id").and_then(|v| v.as_str()).unwrap_or("?");
- let title = w.get("title").and_then(|v| v.as_str()).unwrap_or("");
- let focused = w.get("focused").and_then(|v| v.as_bool()).unwrap_or(false);
- let minimized = w
- .get("minimized")
- .and_then(|v| v.as_bool())
- .unwrap_or(false);
- let x = w.get("x").and_then(|v| v.as_i64()).unwrap_or(0);
- let y = w.get("y").and_then(|v| v.as_i64()).unwrap_or(0);
- let width = w.get("width").and_then(|v| v.as_u64()).unwrap_or(0);
- let height = w.get("height").and_then(|v| v.as_u64()).unwrap_or(0);
- let state = if focused {
- "focused"
- } else if minimized {
- "hidden"
- } else {
- "visible"
- };
- let display_title = truncate_display(title, 30);
- println!(
- "@{:<4} {:<30} ({:<7}) {},{} {}x{}",
- ref_id, display_title, state, x, y, width, height
- );
- }
- }
- } else if matches!(
- cmd,
- Command::Get(GetCmd::ActiveWindow)
- | Command::Wait(WaitCmd::Window(_))
- | Command::Wait(WaitCmd::Focus(_))
- ) {
- if let Some(window) = data.get("window") {
- print_window(window);
- if let Some(elapsed_ms) = data.get("elapsed_ms").and_then(|v| v.as_u64()) {
- println!("Elapsed: {elapsed_ms}ms");
- }
- } else {
- println!("{}", serde_json::to_string_pretty(data)?);
- }
- } else if matches!(cmd, Command::Get(GetCmd::Monitors)) {
- if let Some(monitors) = data.get("monitors").and_then(|v| v.as_array()) {
- for monitor in monitors {
- let name = monitor
- .get("name")
- .and_then(|v| v.as_str())
- .unwrap_or("monitor");
- let x = monitor.get("x").and_then(|v| v.as_i64()).unwrap_or(0);
- let y = monitor.get("y").and_then(|v| v.as_i64()).unwrap_or(0);
- let width = monitor.get("width").and_then(|v| v.as_u64()).unwrap_or(0);
- let height = monitor.get("height").and_then(|v| v.as_u64()).unwrap_or(0);
- let primary = monitor
- .get("primary")
- .and_then(|v| v.as_bool())
- .unwrap_or(false);
- let primary_suffix = if primary { " primary" } else { "" };
- println!(
- "{name:<16} {},{} {}x{}{primary_suffix}",
- x, y, width, height
- );
- }
- }
- } else if matches!(cmd, Command::Get(GetCmd::Version)) {
- let version = data
- .get("version")
- .and_then(|v| v.as_str())
- .unwrap_or("unknown");
- let backend = data
- .get("backend")
- .and_then(|v| v.as_str())
- .unwrap_or("unknown");
- println!("deskctl {version} ({backend})");
- } else if matches!(cmd, Command::Get(GetCmd::Systeminfo)) {
- println!("{}", serde_json::to_string_pretty(data)?);
- } else {
- // Generic: print JSON data
- println!("{}", serde_json::to_string_pretty(data)?);
- }
+ for line in render_success_lines(cmd, response.data.as_ref())? {
+ println!("{line}");
}
Ok(())
}
-fn print_window(window: &serde_json::Value) {
- print_window_line(window, false);
+fn render_success_lines(cmd: &Command, data: Option<&serde_json::Value>) -> Result> {
+ let Some(data) = data else {
+ return Ok(vec!["ok".to_string()]);
+ };
+
+ let lines = match cmd {
+ Command::Snapshot { .. } | Command::ListWindows => render_window_listing(data),
+ Command::Get(GetCmd::ActiveWindow)
+ | Command::Wait(WaitCmd::Window(_))
+ | Command::Wait(WaitCmd::Focus(_)) => render_window_wait_or_read(data),
+ Command::Get(GetCmd::Monitors) => render_monitor_listing(data),
+ Command::Get(GetCmd::Version) => vec![render_version_line(data)],
+ Command::Get(GetCmd::Systeminfo) => render_systeminfo_lines(data),
+ Command::GetScreenSize => vec![render_screen_size_line(data)],
+ Command::GetMousePosition => vec![render_mouse_position_line(data)],
+ Command::Screenshot { annotate, .. } => render_screenshot_lines(data, *annotate),
+ Command::Click { .. } => vec![render_click_line(data, false)],
+ Command::Dblclick { .. } => vec![render_click_line(data, true)],
+ Command::Type { .. } => vec![render_type_line(data)],
+ Command::Press { .. } => vec![render_press_line(data)],
+ Command::Hotkey { .. } => vec![render_hotkey_line(data)],
+ Command::Mouse(sub) => vec![render_mouse_line(sub, data)],
+ Command::Focus { .. } => vec![render_window_action_line("Focused", data)],
+ Command::Close { .. } => vec![render_window_action_line("Closed", data)],
+ Command::MoveWindow { .. } => vec![render_move_window_line(data)],
+ Command::ResizeWindow { .. } => vec![render_resize_window_line(data)],
+ Command::Launch { .. } => vec![render_launch_line(data)],
+ Command::Doctor | Command::Daemon(_) => vec![serde_json::to_string_pretty(data)?],
+ };
+
+ Ok(lines)
}
-fn print_window_to_stderr(window: &serde_json::Value) {
- print_window_line(window, true);
+fn render_error_lines(response: &Response) -> Vec {
+ let mut lines = Vec::new();
+ if let Some(err) = &response.error {
+ lines.push(format!("Error: {err}"));
+ }
+
+ let Some(data) = response.data.as_ref() else {
+ return lines;
+ };
+
+ let Some(kind) = data.get("kind").and_then(|value| value.as_str()) else {
+ return lines;
+ };
+
+ match kind {
+ "selector_not_found" => {
+ let selector = data
+ .get("selector")
+ .and_then(|value| value.as_str())
+ .unwrap_or("unknown");
+ let mode = data
+ .get("mode")
+ .and_then(|value| value.as_str())
+ .unwrap_or("unknown");
+ lines.push(format!("Selector: {selector} (mode: {mode})"));
+ }
+ "selector_invalid" => {
+ let selector = data
+ .get("selector")
+ .and_then(|value| value.as_str())
+ .unwrap_or("unknown");
+ let mode = data
+ .get("mode")
+ .and_then(|value| value.as_str())
+ .unwrap_or("unknown");
+ lines.push(format!("Selector: {selector} (mode: {mode})"));
+ if let Some(message) = data.get("message").and_then(|value| value.as_str()) {
+ lines.push(format!("Reason: {message}"));
+ }
+ }
+ "selector_ambiguous" => {
+ let selector = data
+ .get("selector")
+ .and_then(|value| value.as_str())
+ .unwrap_or("unknown");
+ let mode = data
+ .get("mode")
+ .and_then(|value| value.as_str())
+ .unwrap_or("unknown");
+ lines.push(format!("Selector: {selector} (mode: {mode})"));
+ if let Some(candidates) = data.get("candidates").and_then(|value| value.as_array()) {
+ lines.push("Candidates:".to_string());
+ for candidate in candidates {
+ lines.push(window_line(candidate));
+ }
+ }
+ }
+ "timeout" => {
+ let selector = data
+ .get("selector")
+ .and_then(|value| value.as_str())
+ .unwrap_or("unknown");
+ let wait = data
+ .get("wait")
+ .and_then(|value| value.as_str())
+ .unwrap_or("wait");
+ let timeout_ms = data
+ .get("timeout_ms")
+ .and_then(|value| value.as_u64())
+ .unwrap_or(0);
+ lines.push(format!(
+ "Timed out after {timeout_ms}ms waiting for {wait} selector {selector}"
+ ));
+ if let Some(observation) = data.get("last_observation") {
+ lines.extend(render_last_observation_lines(observation));
+ }
+ }
+ "not_found" => {
+ if data
+ .get("mode")
+ .and_then(|value| value.as_str())
+ .is_some_and(|mode| mode == "focused")
+ {
+ lines.push("No focused window is available.".to_string());
+ }
+ }
+ _ => {}
+ }
+
+ lines
}
-fn print_window_line(window: &serde_json::Value, stderr: bool) {
- let ref_id = window.get("ref_id").and_then(|v| v.as_str()).unwrap_or("?");
+fn render_last_observation_lines(observation: &serde_json::Value) -> Vec {
+ let mut lines = Vec::new();
+ let Some(kind) = observation.get("kind").and_then(|value| value.as_str()) else {
+ return lines;
+ };
+
+ match kind {
+ "window_not_focused" => {
+ lines.push(
+ "Last observation: matching window exists but is not focused yet.".to_string(),
+ );
+ if let Some(window) = observation.get("window") {
+ lines.push(window_line(window));
+ }
+ }
+ "selector_not_found" => {
+ let selector = observation
+ .get("selector")
+ .and_then(|value| value.as_str())
+ .unwrap_or("unknown");
+ let mode = observation
+ .get("mode")
+ .and_then(|value| value.as_str())
+ .unwrap_or("unknown");
+ lines.push(format!(
+ "Last observation: no window matched selector {selector} (mode: {mode})"
+ ));
+ }
+ _ => {
+ lines.push(format!(
+ "Last observation: {}",
+ serde_json::to_string(observation).unwrap_or_else(|_| kind.to_string())
+ ));
+ }
+ }
+
+ lines
+}
+
+fn render_window_listing(data: &serde_json::Value) -> Vec {
+ let mut lines = Vec::new();
+ if let Some(screenshot) = data.get("screenshot").and_then(|value| value.as_str()) {
+ lines.push(format!("Screenshot: {screenshot}"));
+ }
+ if let Some(windows) = data.get("windows").and_then(|value| value.as_array()) {
+ lines.push(format!("Windows: {}", windows.len()));
+ for window in windows {
+ lines.push(window_line(window));
+ }
+ }
+ lines
+}
+
+fn render_window_wait_or_read(data: &serde_json::Value) -> Vec {
+ let mut lines = Vec::new();
+ if let Some(window) = data.get("window") {
+ lines.push(window_line(window));
+ }
+ if let Some(elapsed_ms) = data.get("elapsed_ms").and_then(|value| value.as_u64()) {
+ lines.push(format!("Elapsed: {elapsed_ms}ms"));
+ }
+ lines
+}
+
+fn render_monitor_listing(data: &serde_json::Value) -> Vec {
+ let mut lines = Vec::new();
+ if let Some(count) = data.get("count").and_then(|value| value.as_u64()) {
+ lines.push(format!("Monitors: {count}"));
+ }
+ if let Some(monitors) = data.get("monitors").and_then(|value| value.as_array()) {
+ for monitor in monitors {
+ let name = monitor
+ .get("name")
+ .and_then(|value| value.as_str())
+ .unwrap_or("monitor");
+ let x = monitor
+ .get("x")
+ .and_then(|value| value.as_i64())
+ .unwrap_or(0);
+ let y = monitor
+ .get("y")
+ .and_then(|value| value.as_i64())
+ .unwrap_or(0);
+ let width = monitor
+ .get("width")
+ .and_then(|value| value.as_u64())
+ .unwrap_or(0);
+ let height = monitor
+ .get("height")
+ .and_then(|value| value.as_u64())
+ .unwrap_or(0);
+ let primary = monitor
+ .get("primary")
+ .and_then(|value| value.as_bool())
+ .unwrap_or(false);
+ let automatic = monitor
+ .get("automatic")
+ .and_then(|value| value.as_bool())
+ .unwrap_or(false);
+ let mut flags = Vec::new();
+ if primary {
+ flags.push("primary");
+ }
+ if automatic {
+ flags.push("automatic");
+ }
+ let suffix = if flags.is_empty() {
+ String::new()
+ } else {
+ format!(" [{}]", flags.join(", "))
+ };
+ lines.push(format!("{name:<16} {x},{y} {width}x{height}{suffix}"));
+ }
+ }
+ lines
+}
+
+fn render_version_line(data: &serde_json::Value) -> String {
+ let version = data
+ .get("version")
+ .and_then(|value| value.as_str())
+ .unwrap_or("unknown");
+ let backend = data
+ .get("backend")
+ .and_then(|value| value.as_str())
+ .unwrap_or("unknown");
+ format!("deskctl {version} ({backend})")
+}
+
+fn render_systeminfo_lines(data: &serde_json::Value) -> Vec {
+ let mut lines = Vec::new();
+ let backend = data
+ .get("backend")
+ .and_then(|value| value.as_str())
+ .unwrap_or("unknown");
+ lines.push(format!("Backend: {backend}"));
+ if let Some(display) = data.get("display").and_then(|value| value.as_str()) {
+ lines.push(format!("Display: {display}"));
+ }
+ if let Some(session_type) = data.get("session_type").and_then(|value| value.as_str()) {
+ lines.push(format!("Session type: {session_type}"));
+ }
+ if let Some(session) = data.get("session").and_then(|value| value.as_str()) {
+ lines.push(format!("Session: {session}"));
+ }
+ if let Some(socket_path) = data.get("socket_path").and_then(|value| value.as_str()) {
+ lines.push(format!("Socket: {socket_path}"));
+ }
+ if let Some(screen) = data.get("screen") {
+ lines.push(format!("Screen: {}", screen_dimensions(screen)));
+ }
+ if let Some(count) = data.get("monitor_count").and_then(|value| value.as_u64()) {
+ lines.push(format!("Monitor count: {count}"));
+ }
+ if let Some(monitors) = data.get("monitors").and_then(|value| value.as_array()) {
+ for monitor in monitors {
+ lines.push(format!(
+ " {}",
+ render_monitor_listing(&serde_json::json!({"monitors": [monitor]}))[0]
+ ));
+ }
+ }
+ lines
+}
+
+fn render_screen_size_line(data: &serde_json::Value) -> String {
+ format!("Screen: {}", screen_dimensions(data))
+}
+
+fn render_mouse_position_line(data: &serde_json::Value) -> String {
+ let x = data.get("x").and_then(|value| value.as_i64()).unwrap_or(0);
+ let y = data.get("y").and_then(|value| value.as_i64()).unwrap_or(0);
+ format!("Pointer: {x},{y}")
+}
+
+fn render_screenshot_lines(data: &serde_json::Value, annotate: bool) -> Vec {
+ let mut lines = Vec::new();
+ if let Some(screenshot) = data.get("screenshot").and_then(|value| value.as_str()) {
+ lines.push(format!("Screenshot: {screenshot}"));
+ }
+ if annotate {
+ if let Some(windows) = data.get("windows").and_then(|value| value.as_array()) {
+ lines.push(format!("Annotated windows: {}", windows.len()));
+ for window in windows {
+ lines.push(window_line(window));
+ }
+ }
+ }
+ lines
+}
+
+fn render_click_line(data: &serde_json::Value, double: bool) -> String {
+ let action = if double { "Double-clicked" } else { "Clicked" };
+ let key = if double { "double_clicked" } else { "clicked" };
+ let x = data
+ .get(key)
+ .and_then(|value| value.get("x"))
+ .and_then(|value| value.as_i64())
+ .unwrap_or(0);
+ let y = data
+ .get(key)
+ .and_then(|value| value.get("y"))
+ .and_then(|value| value.as_i64())
+ .unwrap_or(0);
+ match target_summary(data) {
+ Some(target) => format!("{action} {x},{y} on {target}"),
+ None => format!("{action} {x},{y}"),
+ }
+}
+
+fn render_type_line(data: &serde_json::Value) -> String {
+ let typed = data
+ .get("typed")
+ .and_then(|value| value.as_str())
+ .unwrap_or("");
+ format!("Typed: {}", quoted_summary(typed, 60))
+}
+
+fn render_press_line(data: &serde_json::Value) -> String {
+ let key = data
+ .get("pressed")
+ .and_then(|value| value.as_str())
+ .unwrap_or("unknown");
+ format!("Pressed: {key}")
+}
+
+fn render_hotkey_line(data: &serde_json::Value) -> String {
+ let keys = data
+ .get("hotkey")
+ .and_then(|value| value.as_array())
+ .map(|items| {
+ items
+ .iter()
+ .filter_map(|value| value.as_str())
+ .collect::>()
+ .join("+")
+ })
+ .filter(|value| !value.is_empty())
+ .unwrap_or_else(|| "unknown".to_string());
+ format!("Hotkey: {keys}")
+}
+
+fn render_mouse_line(sub: &MouseCmd, data: &serde_json::Value) -> String {
+ match sub {
+ MouseCmd::Move { .. } => {
+ let x = data
+ .get("moved")
+ .and_then(|value| value.get("x"))
+ .and_then(|value| value.as_i64())
+ .unwrap_or(0);
+ let y = data
+ .get("moved")
+ .and_then(|value| value.get("y"))
+ .and_then(|value| value.as_i64())
+ .unwrap_or(0);
+ format!("Moved pointer to {x},{y}")
+ }
+ MouseCmd::Scroll { .. } => {
+ let amount = data
+ .get("scrolled")
+ .and_then(|value| value.get("amount"))
+ .and_then(|value| value.as_i64())
+ .unwrap_or(0);
+ let axis = data
+ .get("scrolled")
+ .and_then(|value| value.get("axis"))
+ .and_then(|value| value.as_str())
+ .unwrap_or("vertical");
+ format!("Scrolled {axis} by {amount}")
+ }
+ MouseCmd::Drag { .. } => {
+ let x1 = data
+ .get("dragged")
+ .and_then(|value| value.get("from"))
+ .and_then(|value| value.get("x"))
+ .and_then(|value| value.as_i64())
+ .unwrap_or(0);
+ let y1 = data
+ .get("dragged")
+ .and_then(|value| value.get("from"))
+ .and_then(|value| value.get("y"))
+ .and_then(|value| value.as_i64())
+ .unwrap_or(0);
+ let x2 = data
+ .get("dragged")
+ .and_then(|value| value.get("to"))
+ .and_then(|value| value.get("x"))
+ .and_then(|value| value.as_i64())
+ .unwrap_or(0);
+ let y2 = data
+ .get("dragged")
+ .and_then(|value| value.get("to"))
+ .and_then(|value| value.get("y"))
+ .and_then(|value| value.as_i64())
+ .unwrap_or(0);
+ format!("Dragged {x1},{y1} -> {x2},{y2}")
+ }
+ }
+}
+
+fn render_window_action_line(action: &str, data: &serde_json::Value) -> String {
+ match target_summary(data) {
+ Some(target) => format!("{action} {target}"),
+ None => action.to_string(),
+ }
+}
+
+fn render_move_window_line(data: &serde_json::Value) -> String {
+ let x = data.get("x").and_then(|value| value.as_i64()).unwrap_or(0);
+ let y = data.get("y").and_then(|value| value.as_i64()).unwrap_or(0);
+ match target_summary(data) {
+ Some(target) => format!("Moved {target} to {x},{y}"),
+ None => format!("Moved window to {x},{y}"),
+ }
+}
+
+fn render_resize_window_line(data: &serde_json::Value) -> String {
+ let width = data
+ .get("width")
+ .and_then(|value| value.as_u64())
+ .unwrap_or(0);
+ let height = data
+ .get("height")
+ .and_then(|value| value.as_u64())
+ .unwrap_or(0);
+ match target_summary(data) {
+ Some(target) => format!("Resized {target} to {width}x{height}"),
+ None => format!("Resized window to {width}x{height}"),
+ }
+}
+
+fn render_launch_line(data: &serde_json::Value) -> String {
+ let command = data
+ .get("command")
+ .and_then(|value| value.as_str())
+ .unwrap_or("command");
+ let pid = data
+ .get("pid")
+ .and_then(|value| value.as_u64())
+ .map(|value| value.to_string())
+ .unwrap_or_else(|| "unknown".to_string());
+ format!("Launched {command} (pid {pid})")
+}
+
+fn window_line(window: &serde_json::Value) -> String {
+ let ref_id = window
+ .get("ref_id")
+ .and_then(|value| value.as_str())
+ .unwrap_or("?");
let window_id = window
.get("window_id")
- .and_then(|v| v.as_str())
+ .and_then(|value| value.as_str())
.unwrap_or("unknown");
- let title = window.get("title").and_then(|v| v.as_str()).unwrap_or("");
+ let title = window
+ .get("title")
+ .and_then(|value| value.as_str())
+ .unwrap_or("");
let focused = window
.get("focused")
- .and_then(|v| v.as_bool())
+ .and_then(|value| value.as_bool())
.unwrap_or(false);
let minimized = window
.get("minimized")
- .and_then(|v| v.as_bool())
+ .and_then(|value| value.as_bool())
.unwrap_or(false);
- let x = window.get("x").and_then(|v| v.as_i64()).unwrap_or(0);
- let y = window.get("y").and_then(|v| v.as_i64()).unwrap_or(0);
- let width = window.get("width").and_then(|v| v.as_u64()).unwrap_or(0);
- let height = window.get("height").and_then(|v| v.as_u64()).unwrap_or(0);
+ let x = window
+ .get("x")
+ .and_then(|value| value.as_i64())
+ .unwrap_or(0);
+ let y = window
+ .get("y")
+ .and_then(|value| value.as_i64())
+ .unwrap_or(0);
+ let width = window
+ .get("width")
+ .and_then(|value| value.as_u64())
+ .unwrap_or(0);
+ let height = window
+ .get("height")
+ .and_then(|value| value.as_u64())
+ .unwrap_or(0);
let state = if focused {
"focused"
} else if minimized {
@@ -493,24 +915,50 @@ fn print_window_line(window: &serde_json::Value, stderr: bool) {
} else {
"visible"
};
- let line = format!(
- "@{:<4} {:<30} ({:<7}) {},{} {}x{} [{}]",
- ref_id,
- truncate_display(title, 30),
- state,
- x,
- y,
- width,
- height,
- window_id
- );
- if stderr {
- eprintln!("{line}");
- } else {
- println!("{line}");
+ format!(
+ "@{ref_id:<4} {:<30} ({state:<7}) {x},{y} {width}x{height} [{window_id}]",
+ truncate_display(title, 30)
+ )
+}
+
+fn target_summary(data: &serde_json::Value) -> Option {
+ let ref_id = data.get("ref_id").and_then(|value| value.as_str());
+ let window_id = data.get("window_id").and_then(|value| value.as_str());
+ let title = data
+ .get("title")
+ .or_else(|| data.get("window"))
+ .and_then(|value| value.as_str());
+
+ match (ref_id, window_id, title) {
+ (Some(ref_id), Some(window_id), Some(title)) => Some(format!(
+ "@{ref_id} [{window_id}] {}",
+ quoted_summary(title, 40)
+ )),
+ (None, Some(window_id), Some(title)) => {
+ Some(format!("[{window_id}] {}", quoted_summary(title, 40)))
+ }
+ (Some(ref_id), Some(window_id), None) => Some(format!("@{ref_id} [{window_id}]")),
+ (None, Some(window_id), None) => Some(format!("[{window_id}]")),
+ _ => None,
}
}
+fn quoted_summary(value: &str, max_chars: usize) -> String {
+ format!("\"{}\"", truncate_display(value, max_chars))
+}
+
+fn screen_dimensions(data: &serde_json::Value) -> String {
+ let width = data
+ .get("width")
+ .and_then(|value| value.as_u64())
+ .unwrap_or(0);
+ let height = data
+ .get("height")
+ .and_then(|value| value.as_u64())
+ .unwrap_or(0);
+ format!("{width}x{height}")
+}
+
fn truncate_display(value: &str, max_chars: usize) -> String {
let char_count = value.chars().count();
if char_count <= max_chars {
@@ -520,3 +968,129 @@ fn truncate_display(value: &str, max_chars: usize) -> String {
let truncated: String = value.chars().take(max_chars.saturating_sub(3)).collect();
format!("{truncated}...")
}
+
+#[cfg(test)]
+mod tests {
+ use super::{
+ render_error_lines, render_screen_size_line, render_success_lines, target_summary,
+ truncate_display, App, Command, Response,
+ };
+ use clap::CommandFactory;
+ use serde_json::json;
+
+ #[test]
+ fn help_examples_include_snapshot_examples() {
+ let help = App::command()
+ .find_subcommand_mut("snapshot")
+ .expect("snapshot subcommand must exist")
+ .render_long_help()
+ .to_string();
+ assert!(help.contains("deskctl snapshot --annotate"));
+ }
+
+ #[test]
+ fn window_listing_text_includes_window_ids() {
+ let lines = render_success_lines(
+ &Command::ListWindows,
+ Some(&json!({
+ "windows": [{
+ "ref_id": "w1",
+ "window_id": "win1",
+ "title": "Firefox",
+ "app_name": "firefox",
+ "x": 0,
+ "y": 0,
+ "width": 1280,
+ "height": 720,
+ "focused": true,
+ "minimized": false
+ }]
+ })),
+ )
+ .unwrap();
+
+ assert_eq!(lines[0], "Windows: 1");
+ assert!(lines[1].contains("[win1]"));
+ assert!(lines[1].contains("@w1"));
+ }
+
+ #[test]
+ fn action_text_includes_target_identity() {
+ let lines = render_success_lines(
+ &Command::Focus {
+ selector: "title=Firefox".to_string(),
+ },
+ Some(&json!({
+ "action": "focus",
+ "window": "Firefox",
+ "title": "Firefox",
+ "ref_id": "w2",
+ "window_id": "win7"
+ })),
+ )
+ .unwrap();
+
+ assert_eq!(lines, vec!["Focused @w2 [win7] \"Firefox\""]);
+ }
+
+ #[test]
+ fn timeout_errors_render_last_observation() {
+ let lines = render_error_lines(&Response::err_with_data(
+ "Timed out waiting for focus to match selector: title=Firefox",
+ json!({
+ "kind": "timeout",
+ "wait": "focus",
+ "selector": "title=Firefox",
+ "timeout_ms": 1000,
+ "last_observation": {
+ "kind": "window_not_focused",
+ "window": {
+ "ref_id": "w1",
+ "window_id": "win1",
+ "title": "Firefox",
+ "app_name": "firefox",
+ "x": 0,
+ "y": 0,
+ "width": 1280,
+ "height": 720,
+ "focused": false,
+ "minimized": false
+ }
+ }
+ }),
+ ));
+
+ assert!(lines
+ .iter()
+ .any(|line| line
+ .contains("Timed out after 1000ms waiting for focus selector title=Firefox")));
+ assert!(lines
+ .iter()
+ .any(|line| line.contains("matching window exists but is not focused yet")));
+ assert!(lines.iter().any(|line| line.contains("[win1]")));
+ }
+
+ #[test]
+ fn screen_size_text_is_compact() {
+ assert_eq!(
+ render_screen_size_line(&json!({"width": 1440, "height": 900})),
+ "Screen: 1440x900"
+ );
+ }
+
+ #[test]
+ fn target_summary_prefers_ref_and_window_id() {
+ let summary = target_summary(&json!({
+ "ref_id": "w1",
+ "window_id": "win1",
+ "title": "Firefox"
+ }));
+ assert_eq!(summary.as_deref(), Some("@w1 [win1] \"Firefox\""));
+ }
+
+ #[test]
+ fn truncate_display_is_char_safe() {
+ let input = format!("fire{}fox", '\u{00E9}');
+ assert_eq!(truncate_display(&input, 7), "fire...");
+ }
+}
diff --git a/src/core/types.rs b/src/core/types.rs
index 845a4c0..0dca365 100644
--- a/src/core/types.rs
+++ b/src/core/types.rs
@@ -88,9 +88,21 @@ impl std::fmt::Display for WindowInfo {
#[allow(dead_code)]
fn truncate(s: &str, max: usize) -> String {
- if s.len() <= max {
+ if s.chars().count() <= max {
s.to_string()
} else {
- format!("{}...", &s[..max - 3])
+ let truncated: String = s.chars().take(max.saturating_sub(3)).collect();
+ format!("{truncated}...")
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::truncate;
+
+ #[test]
+ fn truncate_is_char_safe() {
+ let input = format!("fire{}fox", '\u{00E9}');
+ assert_eq!(truncate(&input, 7), "fire...");
}
}
diff --git a/src/daemon/handler.rs b/src/daemon/handler.rs
index c0f4f1d..e8cab3a 100644
--- a/src/daemon/handler.rs
+++ b/src/daemon/handler.rs
@@ -81,9 +81,13 @@ async fn handle_click(request: &Request, state: &Arc>) -> Res
ResolveResult::Match(entry) => {
let (x, y) = entry.center();
match state.backend.click(x, y) {
- Ok(()) => Response::ok(
- serde_json::json!({"clicked": {"x": x, "y": y, "selector": selector, "window_id": entry.window_id}}),
- ),
+ Ok(()) => Response::ok(serde_json::json!({
+ "clicked": {"x": x, "y": y},
+ "selector": selector,
+ "ref_id": entry.ref_id,
+ "window_id": entry.window_id,
+ "title": entry.title,
+ })),
Err(error) => Response::err(format!("Click failed: {error}")),
}
}
@@ -117,9 +121,13 @@ async fn handle_dblclick(request: &Request, state: &Arc>) ->
ResolveResult::Match(entry) => {
let (x, y) = entry.center();
match state.backend.dblclick(x, y) {
- Ok(()) => Response::ok(
- serde_json::json!({"double_clicked": {"x": x, "y": y, "selector": selector, "window_id": entry.window_id}}),
- ),
+ Ok(()) => Response::ok(serde_json::json!({
+ "double_clicked": {"x": x, "y": y},
+ "selector": selector,
+ "ref_id": entry.ref_id,
+ "window_id": entry.window_id,
+ "title": entry.title,
+ })),
Err(error) => Response::err(format!("Double-click failed: {error}")),
}
}
@@ -267,7 +275,10 @@ async fn handle_window_action(
Ok(()) => Response::ok(serde_json::json!({
"action": action,
"window": entry.title,
+ "title": entry.title,
+ "ref_id": entry.ref_id,
"window_id": entry.window_id,
+ "selector": selector,
})),
Err(error) => Response::err(format!("{action} failed: {error}")),
}
@@ -296,7 +307,10 @@ async fn handle_move_window(request: &Request, state: &Arc>)
match state.backend.move_window(entry.backend_window_id, x, y) {
Ok(()) => Response::ok(serde_json::json!({
"moved": entry.title,
+ "title": entry.title,
+ "ref_id": entry.ref_id,
"window_id": entry.window_id,
+ "selector": selector,
"x": x,
"y": y,
})),
@@ -338,7 +352,10 @@ async fn handle_resize_window(request: &Request, state: &Arc>
{
Ok(()) => Response::ok(serde_json::json!({
"resized": entry.title,
+ "title": entry.title,
+ "ref_id": entry.ref_id,
"window_id": entry.window_id,
+ "selector": selector,
"width": width,
"height": height,
})),
From 425a71095a46f2ab4543ec4c11dcee919c32fa1b Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
Date: Thu, 26 Mar 2026 02:04:56 +0000
Subject: [PATCH 12/49] release: v0.1.5 [skip ci]
---
Cargo.lock | 2 +-
Cargo.toml | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
index 0e7194d..1355d04 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -400,7 +400,7 @@ checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "deskctl"
-version = "0.1.4"
+version = "0.1.5"
dependencies = [
"ab_glyph",
"anyhow",
diff --git a/Cargo.toml b/Cargo.toml
index 555c599..023e18a 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "deskctl"
-version = "0.1.4"
+version = "0.1.5"
edition = "2021"
description = "X11 desktop control CLI for agents"
license = "MIT"
From 714e34ba1920e0267ce059c43074adfeabadc2d0 Mon Sep 17 00:00:00 2001
From: Hari <73809867+harivansh-afk@users.noreply.github.com>
Date: Wed, 25 Mar 2026 23:18:28 -0400
Subject: [PATCH 13/49] nix (#7)
npm
cargo
---
.github/workflows/ci.yml | 130 +++++++++++---------
.github/workflows/publish.yml | 102 +++++++++++++++
.gitignore | 2 +
CONTRIBUTING.md | 18 +++
Cargo.toml | 13 ++
Makefile | 32 ++++-
README.md | 53 +++++++-
docs/releasing.md | 110 +++++++++++++++++
flake.lock | 61 +++++++++
flake.nix | 77 ++++++++++++
npm/deskctl-cli/README.md | 36 ++++++
npm/deskctl-cli/bin/deskctl.js | 36 ++++++
npm/deskctl-cli/package.json | 36 ++++++
npm/deskctl-cli/scripts/postinstall.js | 49 ++++++++
npm/deskctl-cli/scripts/support.js | 120 ++++++++++++++++++
npm/deskctl-cli/scripts/validate-package.js | 40 ++++++
16 files changed, 849 insertions(+), 66 deletions(-)
create mode 100644 .github/workflows/publish.yml
create mode 100644 docs/releasing.md
create mode 100644 flake.lock
create mode 100644 flake.nix
create mode 100644 npm/deskctl-cli/README.md
create mode 100644 npm/deskctl-cli/bin/deskctl.js
create mode 100644 npm/deskctl-cli/package.json
create mode 100644 npm/deskctl-cli/scripts/postinstall.js
create mode 100644 npm/deskctl-cli/scripts/support.js
create mode 100644 npm/deskctl-cli/scripts/validate-package.js
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 18311e0..e95b27a 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -13,7 +13,6 @@ on:
permissions:
contents: write
- packages: write
jobs:
changes:
@@ -37,7 +36,11 @@ jobs:
- 'tests/**'
- 'Cargo.toml'
- 'Cargo.lock'
+ - 'npm/**'
+ - 'flake.nix'
+ - 'flake.lock'
- 'docker/**'
+ - '.github/workflows/**'
- 'Makefile'
- name: Set outputs
@@ -137,72 +140,36 @@ jobs:
- name: Xvfb integration tests
run: make test-integration
- build:
- name: Build (${{ matrix.target }})
- needs: [changes, validate, integration]
- if: github.event_name != 'pull_request' && needs.changes.outputs.rust == 'true'
+ distribution:
+ name: Distribution Validate
+ needs: changes
+ if: needs.changes.outputs.rust == 'true'
runs-on: ubuntu-latest
- strategy:
- fail-fast: true
- matrix:
- target: [cargo, docker]
steps:
- uses: actions/checkout@v4
- # --- Cargo steps ---
- uses: dtolnay/rust-toolchain@stable
- if: matrix.target == 'cargo'
- with:
- components: clippy
- uses: Swatinem/rust-cache@v2
- if: matrix.target == 'cargo'
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22
+
+ - uses: cachix/install-nix-action@v30
+ with:
+ extra_nix_config: |
+ experimental-features = nix-command flakes
- name: Install system dependencies
- if: matrix.target == 'cargo'
run: sudo apt-get update && sudo apt-get install -y libx11-dev libxtst-dev
- - name: Clippy
- if: matrix.target == 'cargo'
- run: cargo clippy -- -D warnings
-
- - name: Build
- if: matrix.target == 'cargo'
- run: cargo build --release --locked
-
- - uses: actions/upload-artifact@v4
- if: matrix.target == 'cargo'
- with:
- name: deskctl-linux-x86_64
- path: target/release/deskctl
- retention-days: 7
-
- # --- Docker steps ---
- - uses: docker/setup-buildx-action@v3
- if: matrix.target == 'docker'
-
- - uses: docker/login-action@v3
- if: matrix.target == 'docker'
- with:
- registry: ghcr.io
- username: ${{ github.actor }}
- password: ${{ secrets.GITHUB_TOKEN }}
-
- - uses: docker/build-push-action@v6
- if: matrix.target == 'docker'
- with:
- context: .
- file: docker/Dockerfile
- push: true
- tags: |
- ghcr.io/${{ github.repository }}:latest
- ghcr.io/${{ github.repository }}:${{ needs.changes.outputs.tag }}
- cache-from: type=gha
- cache-to: type=gha,mode=max
+ - name: Distribution validation
+ run: make dist-validate
update-manifests:
name: Update Manifests
- needs: [changes, build]
+ needs: [changes, validate, integration, distribution]
if: github.event_name != 'pull_request'
runs-on: ubuntu-latest
steps:
@@ -212,12 +179,17 @@ jobs:
- uses: dtolnay/rust-toolchain@stable
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22
+
- name: Update version in Cargo.toml
run: |
CURRENT=$(grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/')
NEW="${{ needs.changes.outputs.version }}"
if [ "$CURRENT" != "$NEW" ]; then
sed -i "0,/^version = \"${CURRENT}\"/s//version = \"${NEW}\"/" Cargo.toml
+ node -e 'const fs=require("node:fs"); const path="npm/deskctl-cli/package.json"; const pkg=JSON.parse(fs.readFileSync(path,"utf8")); pkg.version=process.argv[1]; fs.writeFileSync(path, JSON.stringify(pkg, null, 2)+"\n");' "$NEW"
cargo generate-lockfile
fi
@@ -227,7 +199,7 @@ jobs:
git config user.email "github-actions[bot]@users.noreply.github.com"
if ! git diff --quiet; then
- git add Cargo.toml Cargo.lock
+ git add Cargo.toml Cargo.lock npm/deskctl-cli/package.json
git commit -m "release: ${{ needs.changes.outputs.tag }} [skip ci]"
fi
@@ -236,6 +208,38 @@ jobs:
fi
git push origin main --tags
+ build:
+ name: Build Release Asset
+ needs: [changes, update-manifests]
+ if: github.event_name != 'pull_request' && needs.changes.outputs.rust == 'true'
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ ref: ${{ needs.changes.outputs.tag }}
+ fetch-depth: 0
+
+ - uses: dtolnay/rust-toolchain@stable
+ with:
+ components: clippy
+
+ - uses: Swatinem/rust-cache@v2
+
+ - name: Install system dependencies
+ run: sudo apt-get update && sudo apt-get install -y libx11-dev libxtst-dev
+
+ - name: Clippy
+ run: cargo clippy -- -D warnings
+
+ - name: Build
+ run: cargo build --release --locked
+
+ - uses: actions/upload-artifact@v4
+ with:
+ name: deskctl-linux-x86_64
+ path: target/release/deskctl
+ retention-days: 7
+
release:
name: Release
needs: [changes, build, update-manifests]
@@ -256,9 +260,15 @@ jobs:
chmod +x artifacts/deskctl
mv artifacts/deskctl artifacts/deskctl-linux-x86_64
cd artifacts && sha256sum deskctl-linux-x86_64 > checksums.txt && cd ..
-
- gh release create "${{ needs.changes.outputs.tag }}" \
- --title "${{ needs.changes.outputs.tag }}" \
- --generate-notes \
- artifacts/deskctl-linux-x86_64 \
- artifacts/checksums.txt
+ if gh release view "${{ needs.changes.outputs.tag }}" >/dev/null 2>&1; then
+ gh release upload "${{ needs.changes.outputs.tag }}" \
+ artifacts/deskctl-linux-x86_64 \
+ artifacts/checksums.txt \
+ --clobber
+ else
+ gh release create "${{ needs.changes.outputs.tag }}" \
+ --title "${{ needs.changes.outputs.tag }}" \
+ --generate-notes \
+ artifacts/deskctl-linux-x86_64 \
+ artifacts/checksums.txt
+ fi
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
new file mode 100644
index 0000000..329f151
--- /dev/null
+++ b/.github/workflows/publish.yml
@@ -0,0 +1,102 @@
+name: Publish Registries
+
+on:
+ workflow_dispatch:
+ inputs:
+ tag:
+ description: Release tag to publish (for example v0.1.5)
+ required: true
+ type: string
+ publish_npm:
+ description: Publish deskctl-cli to npm
+ required: true
+ type: boolean
+ default: false
+ publish_crates:
+ description: Publish deskctl to crates.io
+ required: true
+ type: boolean
+ default: false
+
+permissions:
+ contents: read
+
+jobs:
+ publish:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ ref: ${{ inputs.tag }}
+
+ - uses: dtolnay/rust-toolchain@stable
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22
+
+ - name: Install system dependencies
+ run: sudo apt-get update && sudo apt-get install -y libx11-dev libxtst-dev
+
+ - name: Verify release exists and contains canonical assets
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ gh release view "${{ inputs.tag }}" --json assets --jq '.assets[].name' > /tmp/release-assets.txt
+ grep -Fx "deskctl-linux-x86_64" /tmp/release-assets.txt >/dev/null
+ grep -Fx "checksums.txt" /tmp/release-assets.txt >/dev/null
+
+ - name: Verify versions align with tag
+ run: |
+ TAG="${{ inputs.tag }}"
+ VERSION="${TAG#v}"
+ CARGO_VERSION=$(grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/')
+ NPM_VERSION=$(node -p 'require("./npm/deskctl-cli/package.json").version')
+
+ test "$VERSION" = "$CARGO_VERSION"
+ test "$VERSION" = "$NPM_VERSION"
+
+ - name: Check current published state
+ id: published
+ run: |
+ VERSION="${{ inputs.tag }}"
+ VERSION="${VERSION#v}"
+
+ if npm view "deskctl-cli@${VERSION}" version >/dev/null 2>&1; then
+ echo "npm=true" >> "$GITHUB_OUTPUT"
+ else
+ echo "npm=false" >> "$GITHUB_OUTPUT"
+ fi
+
+ if curl -fsSL "https://crates.io/api/v1/crates/deskctl/${VERSION}" >/dev/null 2>&1; then
+ echo "crates=true" >> "$GITHUB_OUTPUT"
+ else
+ echo "crates=false" >> "$GITHUB_OUTPUT"
+ fi
+
+ - name: Validate npm package
+ run: |
+ mkdir -p ./tmp/npm-pack
+ node npm/deskctl-cli/scripts/validate-package.js
+ npm pack ./npm/deskctl-cli --pack-destination ./tmp/npm-pack >/dev/null
+
+ - name: Validate crate publish path
+ run: cargo publish --dry-run --locked
+
+ - name: Publish npm
+ if: inputs.publish_npm && steps.published.outputs.npm != 'true'
+ env:
+ NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
+ run: npm publish ./npm/deskctl-cli --access public
+
+ - name: Publish crates.io
+ if: inputs.publish_crates && steps.published.outputs.crates != 'true'
+ env:
+ CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
+ run: cargo publish --locked
+
+ - name: Summary
+ run: |
+ echo "tag=${{ inputs.tag }}"
+ echo "npm_already_published=${{ steps.published.outputs.npm }}"
+ echo "crates_already_published=${{ steps.published.outputs.crates }}"
diff --git a/.gitignore b/.gitignore
index 7406874..db552f7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,3 +5,5 @@ secret/
.claude/
.codex/
openspec/
+npm/deskctl-cli/vendor/
+npm/deskctl-cli/*.tgz
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 7a1a2a2..bdbce4e 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -35,10 +35,15 @@ make lint
make test-unit
make test-integration
make site-format-check
+make cargo-publish-dry-run
+make npm-package-check
+make nix-flake-check
+make dist-validate
make validate
```
`make validate` runs the full Phase 2 validation stack. It requires Linux, `xvfb-run`, and site dependencies to be installed.
+`make dist-validate` runs the distribution validation stack. It requires `npm`, `nix`, and Linux for the full npm runtime smoke path.
## Pre-commit Hooks
@@ -60,6 +65,19 @@ The hook config intentionally stays small:
- Site files reuse the existing `site/` Prettier setup
- Slower checks stay in CI or `make validate`
+## Distribution Work
+
+Distribution support currently ships through:
+
+- crate: `deskctl`
+- npm package: `deskctl-cli`
+- repo flake: `flake.nix`
+- command name on every channel: `deskctl`
+
+For maintainer release and publish steps, see [docs/releasing.md](docs/releasing.md).
+
+Source-build and packaging work should keep Docker as a local Linux build convenience, not as the canonical registry release path.
+
## Integration Tests
Integration coverage is Linux/X11-only in this phase. The supported local entrypoint is:
diff --git a/Cargo.toml b/Cargo.toml
index 023e18a..f373679 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -5,6 +5,19 @@ edition = "2021"
description = "X11 desktop control CLI for agents"
license = "MIT"
repository = "https://github.com/harivansh-afk/deskctl"
+homepage = "https://github.com/harivansh-afk/deskctl"
+readme = "README.md"
+keywords = ["x11", "desktop", "automation", "cli", "agent"]
+categories = ["command-line-utilities"]
+rust-version = "1.75"
+include = [
+ "/Cargo.toml",
+ "/Cargo.lock",
+ "/README.md",
+ "/LICENCE",
+ "/assets/**",
+ "/src/**",
+]
[dependencies]
clap = { version = "4", features = ["derive", "env"] }
diff --git a/Makefile b/Makefile
index bb02037..97857e3 100644
--- a/Makefile
+++ b/Makefile
@@ -1,4 +1,4 @@
-.PHONY: fmt fmt-check lint test-unit test-integration site-format-check validate
+.PHONY: fmt fmt-check lint test-unit test-integration site-format-check cargo-publish-dry-run npm-package-check nix-flake-check dist-validate validate
fmt:
cargo fmt --all
@@ -30,4 +30,34 @@ site-format-check:
fi
pnpm --dir site format:check
+cargo-publish-dry-run:
+ cargo publish --dry-run --allow-dirty --locked
+
+npm-package-check:
+ @if ! command -v npm >/dev/null 2>&1; then \
+ echo "npm is required for npm packaging validation."; \
+ exit 1; \
+ fi
+ node npm/deskctl-cli/scripts/validate-package.js
+ rm -rf tmp/npm-pack tmp/npm-install
+ mkdir -p tmp/npm-pack tmp/npm-install/bin
+ npm pack ./npm/deskctl-cli --pack-destination ./tmp/npm-pack >/dev/null
+ @if [ "$$(uname -s)" != "Linux" ]; then \
+ echo "Skipping npm package runtime smoke test on non-Linux host."; \
+ else \
+ cargo build && \
+ PACK_TGZ=$$(ls ./tmp/npm-pack/*.tgz | head -n 1) && \
+ DESKCTL_BINARY_PATH="$$(pwd)/target/debug/deskctl" npm install --prefix ./tmp/npm-install "$${PACK_TGZ}" && \
+ ./tmp/npm-install/node_modules/.bin/deskctl --version; \
+ fi
+
+nix-flake-check:
+ @if ! command -v nix >/dev/null 2>&1; then \
+ echo "nix is required for flake validation."; \
+ exit 1; \
+ fi
+ nix flake check
+
+dist-validate: test-unit cargo-publish-dry-run npm-package-check nix-flake-check
+
validate: fmt-check lint test-unit test-integration site-format-check
diff --git a/README.md b/README.md
index 6920615..036396a 100644
--- a/README.md
+++ b/README.md
@@ -4,11 +4,45 @@ Desktop control CLI for AI agents on Linux X11.
## Install
+### Cargo
+
```bash
cargo install deskctl
```
-Build a Linux binary with Docker:
+Source builds on Linux require:
+
+- Rust 1.75+
+- `pkg-config`
+- X11 development libraries for input and windowing, typically `libx11-dev` and `libxtst-dev` on Debian/Ubuntu
+
+### npm
+
+```bash
+npm install -g deskctl-cli
+deskctl --help
+```
+
+One-shot execution is also supported:
+
+```bash
+npx deskctl-cli --help
+```
+
+`deskctl-cli` currently supports `linux-x64` and installs the `deskctl` command by downloading the matching GitHub Release asset.
+
+### Nix
+
+```bash
+nix run github:harivansh-afk/deskctl -- --help
+nix profile install github:harivansh-afk/deskctl
+```
+
+The repo flake is the supported Nix install surface in this phase.
+
+### Docker Convenience
+
+Build a Linux binary locally with Docker:
```bash
docker compose -f docker/docker-compose.yml run --rm build
@@ -28,13 +62,12 @@ Run it on an X11 session:
DISPLAY=:1 XDG_SESSION_TYPE=x11 ~/deskctl --json snapshot --annotate
```
-Local source build requirements:
+### Local Source Build
+
```bash
cargo build
```
-At the moment there are no extra native build dependencies beyond a Rust toolchain.
-
## Quick Start
```bash
@@ -78,7 +111,7 @@ Source layout:
## Runtime Requirements
- Linux with X11 session
-- Rust 1.75+ (for build)
+- Rust 1.75+ plus the source-build dependencies above when building from source
The binary itself only links the standard glibc runtime on Linux (`libc`, `libm`, `libgcc_s`).
@@ -158,6 +191,16 @@ Text mode is compact and follow-up-oriented, but JSON is the parsing contract.
See [docs/runtime-output.md](docs/runtime-output.md) for the exact stable-vs-best-effort breakdown.
+## Distribution
+
+- GitHub Releases are the canonical binary source
+- crates.io package: `deskctl`
+- npm package: `deskctl-cli`
+- installed command on every channel: `deskctl`
+- repo-owned Nix install path: `flake.nix`
+
+For maintainer publishing and release steps, see [docs/releasing.md](docs/releasing.md).
+
## Selector Contract
Explicit selector modes:
diff --git a/docs/releasing.md b/docs/releasing.md
new file mode 100644
index 0000000..7271b83
--- /dev/null
+++ b/docs/releasing.md
@@ -0,0 +1,110 @@
+# Releasing deskctl
+
+This document covers the operator flow for shipping `deskctl` across:
+
+- GitHub Releases
+- crates.io
+- npm
+- the repo flake
+
+GitHub Releases are the canonical binary source. The npm package consumes those release assets instead of building a separate binary.
+
+## Package Names
+
+- crate: `deskctl`
+- npm package: `deskctl-cli`
+- installed command: `deskctl`
+
+## Prerequisites
+
+Before the first live publish on each registry:
+
+- npm ownership for `deskctl-cli`
+- crates.io ownership for `deskctl`
+- repository secrets:
+ - `NPM_TOKEN`
+ - `CARGO_REGISTRY_TOKEN`
+
+These are user-owned prerequisites. The repo can validate and automate the rest, but it cannot create registry ownership for you.
+
+## Normal Release Flow
+
+1. Merge release-ready changes to `main`.
+2. Let CI run:
+ - validation
+ - integration
+ - distribution validation
+ - release asset build
+3. Confirm the GitHub Release exists for the version tag and includes:
+ - `deskctl-linux-x86_64`
+ - `checksums.txt`
+4. Trigger the `Publish Registries` workflow with:
+ - `tag`
+ - `publish_npm`
+ - `publish_crates`
+5. Confirm the publish summary for each channel.
+
+## What CI Validates
+
+The repository validates:
+
+- `cargo publish --dry-run --locked`
+- npm package metadata and packability
+- npm install smoke path on Linux using the packaged `deskctl` command
+- repo flake evaluation/build
+
+The repository release workflow:
+
+- builds the Linux release binary
+- publishes the canonical GitHub Release asset
+- uploads `checksums.txt`
+
+The registry publish workflow:
+
+- targets an existing release tag
+- checks that Cargo, npm, and the requested tag all agree on version
+- checks whether that version is already published on npm and crates.io
+- only publishes the channels explicitly requested
+
+## Rerun Safety
+
+Registry publishing is intentionally separate from release asset creation.
+
+If a partial failure happens:
+
+- GitHub Release assets remain the source of truth
+- rerun the `Publish Registries` workflow for the same tag
+- already-published channels are reported and skipped
+- remaining channels can still be published
+
+## Local Validation
+
+Run the distribution checks locally with:
+
+```bash
+make cargo-publish-dry-run
+make npm-package-check
+make nix-flake-check
+make dist-validate
+```
+
+Notes:
+
+- `make npm-package-check` does a runtime smoke test only on Linux
+- `make nix-flake-check` requires a local Nix installation
+- Docker remains a local Linux build convenience, not the canonical release path
+
+## Nix Boundary
+
+The repo-owned `flake.nix` is the supported Nix surface in this phase.
+
+In scope:
+
+- `nix run github:harivansh-afk/deskctl`
+- `nix profile install github:harivansh-afk/deskctl`
+- CI validation for the repo flake
+
+Out of scope for this phase:
+
+- `nixpkgs` upstreaming
+- extra distro packaging outside the repo
diff --git a/flake.lock b/flake.lock
new file mode 100644
index 0000000..f194334
--- /dev/null
+++ b/flake.lock
@@ -0,0 +1,61 @@
+{
+ "nodes": {
+ "flake-utils": {
+ "inputs": {
+ "systems": "systems"
+ },
+ "locked": {
+ "lastModified": 1731533236,
+ "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
+ "owner": "numtide",
+ "repo": "flake-utils",
+ "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
+ "type": "github"
+ },
+ "original": {
+ "owner": "numtide",
+ "repo": "flake-utils",
+ "type": "github"
+ }
+ },
+ "nixpkgs": {
+ "locked": {
+ "lastModified": 1774386573,
+ "narHash": "sha256-4hAV26quOxdC6iyG7kYaZcM3VOskcPUrdCQd/nx8obc=",
+ "owner": "NixOS",
+ "repo": "nixpkgs",
+ "rev": "46db2e09e1d3f113a13c0d7b81e2f221c63b8ce9",
+ "type": "github"
+ },
+ "original": {
+ "owner": "NixOS",
+ "ref": "nixos-unstable",
+ "repo": "nixpkgs",
+ "type": "github"
+ }
+ },
+ "root": {
+ "inputs": {
+ "flake-utils": "flake-utils",
+ "nixpkgs": "nixpkgs"
+ }
+ },
+ "systems": {
+ "locked": {
+ "lastModified": 1681028828,
+ "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
+ "owner": "nix-systems",
+ "repo": "default",
+ "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
+ "type": "github"
+ },
+ "original": {
+ "owner": "nix-systems",
+ "repo": "default",
+ "type": "github"
+ }
+ }
+ },
+ "root": "root",
+ "version": 7
+}
diff --git a/flake.nix b/flake.nix
new file mode 100644
index 0000000..1eafbaa
--- /dev/null
+++ b/flake.nix
@@ -0,0 +1,77 @@
+{
+ description = "deskctl - Desktop control CLI for AI agents on Linux X11";
+
+ inputs = {
+ nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
+ flake-utils.url = "github:numtide/flake-utils";
+ };
+
+ outputs =
+ { self, nixpkgs, flake-utils }:
+ flake-utils.lib.eachDefaultSystem (
+ system:
+ let
+ pkgs = import nixpkgs { inherit system; };
+ lib = pkgs.lib;
+ cargoToml = builtins.fromTOML (builtins.readFile ./Cargo.toml);
+
+ deskctl =
+ pkgs.rustPlatform.buildRustPackage {
+ pname = cargoToml.package.name;
+ version = cargoToml.package.version;
+ src = ./.;
+ cargoLock.lockFile = ./Cargo.lock;
+ nativeBuildInputs = [ pkgs.pkg-config ];
+ buildInputs = lib.optionals pkgs.stdenv.isLinux [
+ pkgs.libx11
+ pkgs.libxtst
+ ];
+ doCheck = false;
+
+ meta = with lib; {
+ description = cargoToml.package.description;
+ homepage = cargoToml.package.homepage;
+ license = licenses.mit;
+ mainProgram = "deskctl";
+ platforms = platforms.linux;
+ };
+ };
+ in
+ {
+ formatter = pkgs.nixfmt;
+
+ packages = lib.optionalAttrs pkgs.stdenv.isLinux {
+ inherit deskctl;
+ default = deskctl;
+ };
+
+ apps = lib.optionalAttrs pkgs.stdenv.isLinux {
+ default = flake-utils.lib.mkApp { drv = deskctl; };
+ deskctl = flake-utils.lib.mkApp { drv = deskctl; };
+ };
+
+ checks = lib.optionalAttrs pkgs.stdenv.isLinux {
+ build = deskctl;
+ };
+
+ devShells.default = pkgs.mkShell {
+ packages =
+ [
+ pkgs.cargo
+ pkgs.clippy
+ pkgs.nodejs
+ pkgs.nixfmt
+ pkgs.pkg-config
+ pkgs.pnpm
+ pkgs.rustc
+ pkgs.rustfmt
+ ]
+ ++ lib.optionals pkgs.stdenv.isLinux [
+ pkgs.libx11
+ pkgs.libxtst
+ pkgs.xorg.xorgserver
+ ];
+ };
+ }
+ );
+}
diff --git a/npm/deskctl-cli/README.md b/npm/deskctl-cli/README.md
new file mode 100644
index 0000000..fd6f610
--- /dev/null
+++ b/npm/deskctl-cli/README.md
@@ -0,0 +1,36 @@
+# deskctl-cli
+
+`deskctl-cli` installs the `deskctl` command for Linux X11 systems.
+
+## Install
+
+```bash
+npm install -g deskctl-cli
+```
+
+After install, run:
+
+```bash
+deskctl --help
+```
+
+One-shot usage is also supported:
+
+```bash
+npx deskctl-cli --help
+```
+
+## Runtime Support
+
+- Linux
+- X11 session
+- currently packaged release asset: `linux-x64`
+
+`deskctl-cli` downloads the matching GitHub Release binary during install.
+Unsupported targets fail during install with a clear runtime support error instead of installing a broken command.
+
+If you want the Rust source-install path instead, use:
+
+```bash
+cargo install deskctl
+```
diff --git a/npm/deskctl-cli/bin/deskctl.js b/npm/deskctl-cli/bin/deskctl.js
new file mode 100644
index 0000000..9f9b480
--- /dev/null
+++ b/npm/deskctl-cli/bin/deskctl.js
@@ -0,0 +1,36 @@
+#!/usr/bin/env node
+
+const fs = require("node:fs");
+const { spawn } = require("node:child_process");
+
+const { readPackageJson, releaseTag, supportedTarget, vendorBinaryPath } = require("../scripts/support");
+
+function main() {
+ const pkg = readPackageJson();
+ const target = supportedTarget();
+ const binaryPath = vendorBinaryPath(target);
+
+ if (!fs.existsSync(binaryPath)) {
+ console.error(
+ [
+ "deskctl binary is missing from the npm package install.",
+ `Expected: ${binaryPath}`,
+ `Package version: ${pkg.version}`,
+ `Release tag: ${releaseTag(pkg)}`,
+ "Try reinstalling deskctl-cli or check that your target is supported."
+ ].join("\n")
+ );
+ process.exit(1);
+ }
+
+ const child = spawn(binaryPath, process.argv.slice(2), { stdio: "inherit" });
+ child.on("exit", (code, signal) => {
+ if (signal) {
+ process.kill(process.pid, signal);
+ return;
+ }
+ process.exit(code ?? 1);
+ });
+}
+
+main();
diff --git a/npm/deskctl-cli/package.json b/npm/deskctl-cli/package.json
new file mode 100644
index 0000000..c1cdbbc
--- /dev/null
+++ b/npm/deskctl-cli/package.json
@@ -0,0 +1,36 @@
+{
+ "name": "deskctl-cli",
+ "version": "0.1.5",
+ "description": "Installable deskctl CLI package for Linux X11 agents",
+ "license": "MIT",
+ "homepage": "https://github.com/harivansh-afk/deskctl",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/harivansh-afk/deskctl.git"
+ },
+ "bugs": {
+ "url": "https://github.com/harivansh-afk/deskctl/issues"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "bin": {
+ "deskctl": "bin/deskctl.js"
+ },
+ "files": [
+ "README.md",
+ "bin",
+ "scripts"
+ ],
+ "scripts": {
+ "postinstall": "node scripts/postinstall.js",
+ "validate": "node scripts/validate-package.js"
+ },
+ "keywords": [
+ "deskctl",
+ "x11",
+ "desktop",
+ "automation",
+ "cli"
+ ]
+}
diff --git a/npm/deskctl-cli/scripts/postinstall.js b/npm/deskctl-cli/scripts/postinstall.js
new file mode 100644
index 0000000..de1b1d0
--- /dev/null
+++ b/npm/deskctl-cli/scripts/postinstall.js
@@ -0,0 +1,49 @@
+const fs = require("node:fs");
+
+const {
+ checksumsUrl,
+ checksumForAsset,
+ download,
+ ensureVendorDir,
+ installLocalBinary,
+ readPackageJson,
+ releaseAssetUrl,
+ releaseTag,
+ sha256,
+ supportedTarget,
+ vendorBinaryPath
+} = require("./support");
+
+async function main() {
+ const pkg = readPackageJson();
+ const target = supportedTarget();
+ const targetPath = vendorBinaryPath(target);
+
+ ensureVendorDir();
+
+ if (process.env.DESKCTL_BINARY_PATH) {
+ installLocalBinary(process.env.DESKCTL_BINARY_PATH, targetPath);
+ return;
+ }
+
+ const tag = releaseTag(pkg);
+ const assetUrl = releaseAssetUrl(tag, target.assetName);
+ const checksumText = (await download(checksumsUrl(tag))).toString("utf8");
+ const expectedSha = checksumForAsset(checksumText, target.assetName);
+ const asset = await download(assetUrl);
+ const actualSha = sha256(asset);
+
+ if (actualSha !== expectedSha) {
+ throw new Error(
+ `Checksum mismatch for ${target.assetName}. Expected ${expectedSha}, got ${actualSha}.`
+ );
+ }
+
+ fs.writeFileSync(targetPath, asset);
+ fs.chmodSync(targetPath, 0o755);
+}
+
+main().catch((error) => {
+ console.error(`deskctl-cli install failed: ${error.message}`);
+ process.exit(1);
+});
diff --git a/npm/deskctl-cli/scripts/support.js b/npm/deskctl-cli/scripts/support.js
new file mode 100644
index 0000000..8d41520
--- /dev/null
+++ b/npm/deskctl-cli/scripts/support.js
@@ -0,0 +1,120 @@
+const crypto = require("node:crypto");
+const fs = require("node:fs");
+const path = require("node:path");
+const https = require("node:https");
+
+const PACKAGE_ROOT = path.resolve(__dirname, "..");
+const VENDOR_DIR = path.join(PACKAGE_ROOT, "vendor");
+const PACKAGE_JSON = path.join(PACKAGE_ROOT, "package.json");
+
+function readPackageJson() {
+ return JSON.parse(fs.readFileSync(PACKAGE_JSON, "utf8"));
+}
+
+function releaseTag(pkg) {
+ return process.env.DESKCTL_RELEASE_TAG || `v${pkg.version}`;
+}
+
+function supportedTarget(platform = process.platform, arch = process.arch) {
+ if (platform === "linux" && arch === "x64") {
+ return {
+ platform,
+ arch,
+ assetName: "deskctl-linux-x86_64",
+ binaryName: "deskctl-linux-x86_64"
+ };
+ }
+
+ throw new Error(
+ `deskctl-cli currently supports linux-x64 only. Received ${platform}-${arch}.`
+ );
+}
+
+function vendorBinaryPath(target) {
+ return path.join(VENDOR_DIR, target.binaryName);
+}
+
+function releaseBaseUrl(tag) {
+ return (
+ process.env.DESKCTL_RELEASE_BASE_URL ||
+ `https://github.com/harivansh-afk/deskctl/releases/download/${tag}`
+ );
+}
+
+function releaseAssetUrl(tag, assetName) {
+ return process.env.DESKCTL_DOWNLOAD_URL || `${releaseBaseUrl(tag)}/${assetName}`;
+}
+
+function checksumsUrl(tag) {
+ return `${releaseBaseUrl(tag)}/checksums.txt`;
+}
+
+function ensureVendorDir() {
+ fs.mkdirSync(VENDOR_DIR, { recursive: true });
+}
+
+function checksumForAsset(contents, assetName) {
+ const line = contents
+ .split("\n")
+ .map((value) => value.trim())
+ .find((value) => value.endsWith(` ${assetName}`) || value.endsWith(` *${assetName}`));
+
+ if (!line) {
+ throw new Error(`Could not find checksum entry for ${assetName}.`);
+ }
+
+ return line.split(/\s+/)[0];
+}
+
+function sha256(buffer) {
+ return crypto.createHash("sha256").update(buffer).digest("hex");
+}
+
+function download(url) {
+ return new Promise((resolve, reject) => {
+ https
+ .get(url, (response) => {
+ if (
+ response.statusCode &&
+ response.statusCode >= 300 &&
+ response.statusCode < 400 &&
+ response.headers.location
+ ) {
+ response.resume();
+ resolve(download(response.headers.location));
+ return;
+ }
+
+ if (response.statusCode !== 200) {
+ reject(new Error(`Download failed for ${url}: HTTP ${response.statusCode}`));
+ return;
+ }
+
+ const chunks = [];
+ response.on("data", (chunk) => chunks.push(chunk));
+ response.on("end", () => resolve(Buffer.concat(chunks)));
+ })
+ .on("error", reject);
+ });
+}
+
+function installLocalBinary(sourcePath, targetPath) {
+ fs.copyFileSync(sourcePath, targetPath);
+ fs.chmodSync(targetPath, 0o755);
+}
+
+module.exports = {
+ PACKAGE_ROOT,
+ VENDOR_DIR,
+ checksumsUrl,
+ checksumForAsset,
+ download,
+ ensureVendorDir,
+ installLocalBinary,
+ readPackageJson,
+ releaseAssetUrl,
+ releaseTag,
+ sha256,
+ supportedTarget,
+ vendorBinaryPath
+};
diff --git a/npm/deskctl-cli/scripts/validate-package.js b/npm/deskctl-cli/scripts/validate-package.js
new file mode 100644
index 0000000..46d3e87
--- /dev/null
+++ b/npm/deskctl-cli/scripts/validate-package.js
@@ -0,0 +1,40 @@
+const fs = require("node:fs");
+const path = require("node:path");
+
+const { readPackageJson, supportedTarget, vendorBinaryPath } = require("./support");
+
+function readCargoVersion() {
+ const cargoToml = fs.readFileSync(
+ path.resolve(__dirname, "..", "..", "..", "Cargo.toml"),
+ "utf8"
+ );
+ const match = cargoToml.match(/^version = "([^"]+)"/m);
+ if (!match) {
+ throw new Error("Could not determine Cargo.toml version.");
+ }
+ return match[1];
+}
+
+function main() {
+ const pkg = readPackageJson();
+ const cargoVersion = readCargoVersion();
+
+ if (pkg.version !== cargoVersion) {
+ throw new Error(
+ `Version mismatch: npm package is ${pkg.version}, Cargo.toml is ${cargoVersion}.`
+ );
+ }
+
+ if (pkg.bin?.deskctl !== "bin/deskctl.js") {
+ throw new Error("deskctl-cli must expose the deskctl bin entrypoint.");
+ }
+
+ const target = supportedTarget("linux", "x64");
+ const targetPath = vendorBinaryPath(target);
+ const vendorDir = path.dirname(targetPath);
+ if (!vendorDir.endsWith(path.join("deskctl-cli", "vendor"))) {
+ throw new Error("Vendor binary directory resolved unexpectedly.");
+ }
+}
+
+main();
From 1092e503be7bcd3de55429b4c6894bdd1841598b Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
Date: Thu, 26 Mar 2026 03:25:14 +0000
Subject: [PATCH 14/49] release: v0.1.6 [skip ci]
---
Cargo.lock | 4 ++--
Cargo.toml | 2 +-
npm/deskctl-cli/package.json | 2 +-
3 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
index 1355d04..71a9a54 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1,6 +1,6 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
-version = 4
+version = 3
[[package]]
name = "ab_glyph"
@@ -400,7 +400,7 @@ checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "deskctl"
-version = "0.1.5"
+version = "0.1.6"
dependencies = [
"ab_glyph",
"anyhow",
diff --git a/Cargo.toml b/Cargo.toml
index f373679..b05507b 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "deskctl"
-version = "0.1.5"
+version = "0.1.6"
edition = "2021"
description = "X11 desktop control CLI for agents"
license = "MIT"
diff --git a/npm/deskctl-cli/package.json b/npm/deskctl-cli/package.json
index c1cdbbc..84f27ee 100644
--- a/npm/deskctl-cli/package.json
+++ b/npm/deskctl-cli/package.json
@@ -1,6 +1,6 @@
{
"name": "deskctl-cli",
- "version": "0.1.5",
+ "version": "0.1.6",
"description": "Installable deskctl CLI package for Linux X11 agents",
"license": "MIT",
"homepage": "https://github.com/harivansh-afk/deskctl",
From 3dbd9ce52d09759b0ffa96fd60061fab5535cf89 Mon Sep 17 00:00:00 2001
From: Harivansh Rathi
Date: Thu, 26 Mar 2026 00:07:03 -0400
Subject: [PATCH 15/49] init with runtime contract
---
CONTRIBUTING.md | 2 +-
README.md | 14 +-
...{runtime-output.md => runtime-contract.md} | 0
skills/SKILL.md | 149 ------------------
skills/deskctl/SKILL.md | 132 ++++++++++++++++
skills/deskctl/references/commands.md | 75 +++++++++
skills/deskctl/references/install.md | 75 +++++++++
skills/deskctl/references/sandbox-agent.md | 61 +++++++
.../deskctl/templates/install-deskctl-npm.sh | 27 ++++
.../templates/sandbox-agent-desktop-loop.sh | 7 +
10 files changed, 390 insertions(+), 152 deletions(-)
rename docs/{runtime-output.md => runtime-contract.md} (100%)
delete mode 100644 skills/SKILL.md
create mode 100644 skills/deskctl/SKILL.md
create mode 100644 skills/deskctl/references/commands.md
create mode 100644 skills/deskctl/references/install.md
create mode 100644 skills/deskctl/references/sandbox-agent.md
create mode 100644 skills/deskctl/templates/install-deskctl-npm.sh
create mode 100644 skills/deskctl/templates/sandbox-agent-desktop-loop.sh
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index bdbce4e..926c58a 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -21,7 +21,7 @@ pnpm --dir site install
- `src/` holds production code and unit tests
- `tests/` holds integration tests
- `tests/support/` holds shared X11 and daemon helpers for integration coverage
-- `docs/runtime-output.md` is the stable-vs-best-effort runtime output contract for agent-facing CLI work
+- `docs/runtime-contract.md` is the stable-vs-best-effort runtime output contract for agent-facing CLI work
Keep integration-only helpers out of `src/`.
diff --git a/README.md b/README.md
index 036396a..db7d92f 100644
--- a/README.md
+++ b/README.md
@@ -31,6 +31,16 @@ npx deskctl-cli --help
`deskctl-cli` currently supports `linux-x64` and installs the `deskctl` command by downloading the matching GitHub Release asset.
+### Installable skill
+
+For `skills.sh` / agent skill ecosystems:
+
+```bash
+npx skills add harivansh-afk/deskctl -s deskctl
+```
+
+The installable skill lives under [`skills/deskctl`](skills/deskctl) and is designed for X11 sandboxes, VMs, and sandbox-agent desktop sessions. It points agents to the npm install path first so they can get `deskctl` without Cargo.
+
### Nix
```bash
@@ -133,7 +143,7 @@ deskctl doctor
- `@wN` refs are short-lived handles assigned by `snapshot` and `list-windows`
- `--json` output includes a stable `window_id` for programmatic targeting within the current daemon session
- `list-windows` is a cheap read-only operation and does not capture or write a screenshot
-- the stable runtime JSON/error contract is documented in [docs/runtime-output.md](docs/runtime-output.md)
+- the stable runtime JSON/error contract is documented in [docs/runtime-contract.md](docs/runtime-contract.md)
## Read and Wait Surface
@@ -189,7 +199,7 @@ Text mode is compact and follow-up-oriented, but JSON is the parsing contract.
- rely on `window_id`, selector-related fields, grouped read payloads, and structured error `kind` values for stable automation
- treat monitor naming, incidental whitespace, and default screenshot file names as best-effort
-See [docs/runtime-output.md](docs/runtime-output.md) for the exact stable-vs-best-effort breakdown.
+See [docs/runtime-conract.md](docs/runtime-contract.md) for the exact stable-vs-best-effort breakdown.
## Distribution
diff --git a/docs/runtime-output.md b/docs/runtime-contract.md
similarity index 100%
rename from docs/runtime-output.md
rename to docs/runtime-contract.md
diff --git a/skills/SKILL.md b/skills/SKILL.md
deleted file mode 100644
index efbd188..0000000
--- a/skills/SKILL.md
+++ /dev/null
@@ -1,149 +0,0 @@
----
-name: deskctl
-description: Desktop control CLI for AI agents
-allowed-tools: Bash(deskctl:*)
----
-
-# deskctl
-
-Desktop control CLI for AI agents on Linux X11. Provides a unified interface for screenshots, mouse/keyboard input, and window management with compact `@wN` window references.
-
-## Core Workflow
-
-1. **Snapshot** to see the desktop and get window refs
-2. **Query / wait** using grouped `get` and `wait` commands
-3. **Act** using refs, explicit selectors, or coordinates
-4. **Repeat** as needed
-
-## Quick Reference
-
-### See the Desktop
-
-```bash
-deskctl snapshot # Screenshot + window tree with @wN refs
-deskctl snapshot --annotate # Screenshot with bounding boxes and labels
-deskctl snapshot --json # Structured JSON output
-deskctl list-windows # Window tree without screenshot
-deskctl screenshot /tmp/s.png # Screenshot only (no window tree)
-deskctl get active-window # Currently focused window
-deskctl get monitors # Monitor geometry
-deskctl get version # deskctl version + backend
-deskctl get systeminfo # Runtime-scoped diagnostics
-deskctl wait window --selector 'title=Firefox' --timeout 10
-deskctl wait focus --selector 'class=firefox' --timeout 5
-```
-
-### Click and Type
-
-```bash
-deskctl click @w1 # Click center of window @w1
-deskctl click 500,300 # Click absolute coordinates
-deskctl dblclick @w2 # Double-click window @w2
-deskctl type "hello world" # Type text into focused window
-deskctl press enter # Press a key
-deskctl hotkey ctrl c # Send Ctrl+C
-deskctl hotkey ctrl shift t # Send Ctrl+Shift+T
-```
-
-### Mouse Control
-
-```bash
-deskctl mouse move 500 300 # Move cursor to coordinates
-deskctl mouse scroll 3 # Scroll down 3 units
-deskctl mouse scroll -3 # Scroll up 3 units
-deskctl mouse drag 100 100 500 500 # Drag from (100,100) to (500,500)
-```
-
-### Window Management
-
-```bash
-deskctl focus @w2 # Focus window by ref
-deskctl focus 'title=Firefox' # Focus by explicit title selector
-deskctl focus 'class=firefox' # Focus by explicit class selector
-deskctl focus "firefox" # Fuzzy substring match (fails on ambiguity)
-deskctl close @w3 # Close window gracefully
-deskctl move-window @w1 100 200 # Move window to position
-deskctl resize-window @w1 800 600 # Resize window
-```
-
-### Utilities
-
-```bash
-deskctl doctor # Diagnose X11, screenshot, and daemon health
-deskctl get-screen-size # Screen resolution
-deskctl get-mouse-position # Current cursor position
-deskctl launch firefox # Launch an application
-deskctl launch code -- --new-window # Launch with arguments
-```
-
-### Daemon
-
-```bash
-deskctl daemon start # Start daemon manually
-deskctl daemon stop # Stop daemon
-deskctl daemon status # Check daemon status
-```
-
-## Global Options
-
-- `--json` : Output as structured JSON (all commands)
-- `--session NAME` : Session name for multiple daemon instances (default: "default")
-- `--socket PATH` : Custom Unix socket path
-
-## Output Contract
-
-- Prefer `--json` when an agent needs strict parsing.
-- Use `window_id` for stable targeting inside a live daemon session.
-- Use `ref_id` / `@wN` for quick short-lived follow-up actions after `snapshot` or `list-windows`.
-- Structured JSON failures expose machine-usable `kind` values for selector and wait failures.
-- The exact text formatting is intentionally compact but not the parsing contract. See `docs/runtime-output.md` for the stable field policy.
-
-## Window Refs
-
-After `snapshot` or `list-windows`, windows are assigned short refs:
-- `@w1` is the topmost (usually focused) window
-- `@w2`, `@w3`, etc. follow z-order (front to back)
-- Refs reset on each `snapshot` call
-- Use `--json` to see stable `window_id` values for programmatic tracking within the current daemon session
-
-## Selector Contract
-
-Prefer explicit selectors when an agent needs deterministic targeting:
-
-```bash
-ref=w1
-id=win1
-title=Firefox
-class=firefox
-focused
-```
-
-Bare selectors such as `firefox` still work as fuzzy substring matches, but they now fail with candidate windows if multiple matches exist.
-
-## Example Agent Workflow
-
-```bash
-# 1. See what's on screen
-deskctl snapshot --annotate
-
-# 2. Wait for the browser and focus it deterministically
-deskctl wait window --selector 'class=firefox' --timeout 10
-deskctl focus 'class=firefox'
-
-# 3. Navigate to a URL
-deskctl hotkey ctrl l
-deskctl type "https://example.com"
-deskctl press enter
-
-# 4. Take a new snapshot to see the result
-deskctl snapshot
-```
-
-## Key Names for press/hotkey
-
-Modifiers: `ctrl`, `alt`, `shift`, `super`
-Navigation: `enter`, `tab`, `escape`, `backspace`, `delete`, `space`
-Arrows: `up`, `down`, `left`, `right`
-Page: `home`, `end`, `pageup`, `pagedown`
-Function: `f1` through `f12`
-Characters: any single character (e.g. `a`, `1`, `/`)
diff --git a/skills/deskctl/SKILL.md b/skills/deskctl/SKILL.md
new file mode 100644
index 0000000..1522703
--- /dev/null
+++ b/skills/deskctl/SKILL.md
@@ -0,0 +1,132 @@
+---
+name: deskctl
+description: Desktop control CLI for AI agents on Linux X11. Use when operating an X11 desktop in a sandbox, VM, or sandbox-agent session via screenshots, grouped get/wait commands, selectors, and mouse or keyboard input. Prefer this skill when the task is "control the desktop", "inspect windows", "wait for a window", "click/type in the sandbox desktop", or "use deskctl inside sandbox-agent".
+allowed-tools: Bash(deskctl:*), Bash(npx deskctl-cli:*), Bash(npm:*), Bash(which:*), Bash(printenv:*), Bash(echo:*), Bash(sandbox-agent:*)
+---
+
+# deskctl
+
+`deskctl` is a non-interactive desktop control CLI for Linux X11 agents. It works well inside sandbox-agent desktop environments because it gives agents a tight `observe -> wait -> act -> verify` loop.
+
+## Install skill (optional)
+
+### npx
+
+```bash
+npx skills add harivansh-afk/deskctl -s deskctl
+```
+
+### bunx
+
+```bash
+bunx skills add harivansh-afk/deskctl -s deskctl
+```
+
+## Install the CLI
+
+Preferred install path:
+
+```bash
+npm install -g deskctl-cli
+deskctl --help
+```
+
+If global npm installs are not writable, use a user prefix:
+
+```bash
+mkdir -p "$HOME/.local/bin"
+npm install -g --prefix "$HOME/.local" deskctl-cli
+export PATH="$HOME/.local/bin:$PATH"
+deskctl --help
+```
+
+One-shot usage also works:
+
+```bash
+npx deskctl-cli --help
+```
+
+For install details and fallback paths, see [references/install.md](references/install.md).
+
+## Sandbox-Agent Notes
+
+Before using `deskctl` inside sandbox-agent:
+
+1. Make sure the sandbox has desktop runtime packages installed.
+2. Make sure the session is actually running X11.
+3. Run `deskctl doctor` before trying to click or type.
+
+Typical sandbox-agent prep:
+
+```bash
+sandbox-agent install desktop --yes
+deskctl doctor
+```
+
+If `doctor` fails, inspect `DISPLAY`, `XDG_SESSION_TYPE`, and whether the sandbox actually has a desktop session. See [references/sandbox-agent.md](references/sandbox-agent.md).
+
+## Core Workflow
+
+Every desktop task should follow this loop:
+
+1. **Observe**
+2. **Target**
+3. **Wait**
+4. **Act**
+5. **Verify**
+
+```bash
+deskctl doctor
+deskctl snapshot --annotate
+deskctl get active-window
+deskctl wait window --selector 'class=firefox' --timeout 10
+deskctl focus 'class=firefox'
+deskctl hotkey ctrl l
+deskctl type "https://example.com"
+deskctl press enter
+deskctl snapshot
+```
+
+## What To Reach For First
+
+- `deskctl doctor`
+- `deskctl snapshot --annotate`
+- `deskctl list-windows`
+- `deskctl get active-window`
+- `deskctl wait window --selector ...`
+- `deskctl wait focus --selector ...`
+
+Use `--json` when you need strict parsing. Use explicit selectors when you need deterministic targeting.
+
+## Selector Rules
+
+Prefer explicit selectors:
+
+```bash
+ref=w1
+id=win1
+title=Firefox
+class=firefox
+focused
+```
+
+Legacy refs still work:
+
+```bash
+@w1
+w1
+win1
+```
+
+Bare strings such as `firefox` are fuzzy substring selectors. They fail on ambiguity instead of silently picking the wrong window.
+
+## References
+
+- [references/install.md](references/install.md) - install paths, npm-first bootstrap, runtime prerequisites
+- [references/commands.md](references/commands.md) - grouped reads, waits, selectors, and core action commands
+- [references/sandbox-agent.md](references/sandbox-agent.md) - using `deskctl` inside sandbox-agent desktop sessions
+
+## Templates
+
+- [templates/install-deskctl-npm.sh](templates/install-deskctl-npm.sh) - install `deskctl-cli` into a user prefix
+- [templates/sandbox-agent-desktop-loop.sh](templates/sandbox-agent-desktop-loop.sh) - minimal observe/wait/act loop for desktop tasks
diff --git a/skills/deskctl/references/commands.md b/skills/deskctl/references/commands.md
new file mode 100644
index 0000000..2d2dc1f
--- /dev/null
+++ b/skills/deskctl/references/commands.md
@@ -0,0 +1,75 @@
+# deskctl command guide
+
+## Observe
+
+```bash
+deskctl doctor
+deskctl snapshot
+deskctl snapshot --annotate
+deskctl list-windows
+deskctl screenshot /tmp/current.png
+deskctl get active-window
+deskctl get monitors
+deskctl get version
+deskctl get systeminfo
+```
+
+Use `snapshot --annotate` when you need both the screenshot artifact and the short `@wN` labels. Use `list-windows` when you only need the window tree and do not want screenshot side effects.
+
+## Wait
+
+```bash
+deskctl wait window --selector 'title=Firefox' --timeout 10
+deskctl wait focus --selector 'class=firefox' --timeout 5
+```
+
+Wait commands return the matched window payload on success. In `--json` mode, failures include structured `kind` values so the caller can recover without string parsing.
+
+## Selectors
+
+Prefer explicit selectors:
+
+```bash
+ref=w1
+id=win1
+title=Firefox
+class=firefox
+focused
+```
+
+Legacy refs still work:
+
+```bash
+@w1
+w1
+win1
+```
+
+Bare fuzzy selectors such as `firefox` are supported, but they fail on ambiguity.
+
+## Act
+
+```bash
+deskctl focus 'class=firefox'
+deskctl click @w1
+deskctl dblclick @w2
+deskctl type "hello world"
+deskctl press enter
+deskctl hotkey ctrl shift t
+deskctl mouse move 500 300
+deskctl mouse scroll 3
+deskctl mouse drag 100 100 500 500
+deskctl move-window @w1 100 120
+deskctl resize-window @w1 1280 720
+deskctl close @w3
+deskctl launch firefox
+```
+
+## Agent loop
+
+The safe pattern is:
+
+1. Observe with `snapshot`, `list-windows`, or `get ...`
+2. Wait for the target window if needed
+3. Act using explicit selectors or refs
+4. Snapshot again to verify the result
diff --git a/skills/deskctl/references/install.md b/skills/deskctl/references/install.md
new file mode 100644
index 0000000..cb97a5c
--- /dev/null
+++ b/skills/deskctl/references/install.md
@@ -0,0 +1,75 @@
+# Install `deskctl`
+
+`deskctl` is designed to be used non-interactively by agents. The easiest install path is the npm package because it installs the `deskctl` command directly from GitHub Release assets without needing Cargo on the target machine.
+
+## Preferred: npm global install
+
+```bash
+npm install -g deskctl-cli
+deskctl --help
+```
+
+This is the preferred path for sandboxes, VMs, and sandbox-agent sessions where Node/npm already exists.
+
+## User-prefix npm install
+
+If global npm installs are not writable:
+
+```bash
+mkdir -p "$HOME/.local/bin"
+npm install -g --prefix "$HOME/.local" deskctl-cli
+export PATH="$HOME/.local/bin:$PATH"
+deskctl --help
+```
+
+This avoids `sudo` and keeps the install inside the user home directory.
+
+## One-shot npm execution
+
+```bash
+npx deskctl-cli --help
+```
+
+Use this for quick testing. For repeated desktop control, install the command once so the runtime is predictable.
+
+## Fallback: Cargo
+
+```bash
+cargo install deskctl
+```
+
+Use this only when the machine already has a Rust toolchain or when you explicitly want a source build.
+
+## Fallback: local Docker build
+
+If you need a Linux binary from macOS or another non-Linux host:
+
+```bash
+docker compose -f docker/docker-compose.yml run --rm build
+```
+
+Then copy `dist/deskctl-linux-x86_64` into the target machine.
+
+## Runtime prerequisites
+
+`deskctl` needs:
+
+- Linux
+- X11
+- a valid `DISPLAY`
+- a working desktop/window-manager session
+
+Quick verification:
+
+```bash
+printenv DISPLAY
+printenv XDG_SESSION_TYPE
+deskctl doctor
+```
+
+Inside sandbox-agent, you may need to install desktop dependencies first:
+
+```bash
+sandbox-agent install desktop --yes
+deskctl doctor
+```
diff --git a/skills/deskctl/references/sandbox-agent.md b/skills/deskctl/references/sandbox-agent.md
new file mode 100644
index 0000000..d994062
--- /dev/null
+++ b/skills/deskctl/references/sandbox-agent.md
@@ -0,0 +1,61 @@
+# deskctl inside sandbox-agent
+
+Use `deskctl` when the sandbox-agent session includes a Linux desktop and you want a tight local desktop-control loop from the shell.
+
+## When it fits
+
+`deskctl` is a good fit when:
+
+- the sandbox already has an X11 desktop session
+- you want fast local desktop control from inside the sandbox
+- you want short-lived refs like `@w1` and grouped `get` or `wait` primitives
+
+It is not a replacement for sandbox-agent session orchestration itself. Use sandbox-agent to provision the sandbox and desktop runtime, then use `deskctl` inside that environment to control the GUI.
+
+## Minimal bootstrap
+
+```bash
+sandbox-agent install desktop --yes
+npm install -g deskctl-cli
+deskctl doctor
+deskctl snapshot --annotate
+```
+
+If npm global installs are not writable:
+
+```bash
+mkdir -p "$HOME/.local/bin"
+npm install -g --prefix "$HOME/.local" deskctl-cli
+export PATH="$HOME/.local/bin:$PATH"
+deskctl doctor
+```
+
+## Expected environment
+
+Check:
+
+```bash
+printenv DISPLAY
+printenv XDG_SESSION_TYPE
+deskctl --json get systeminfo
+```
+
+Healthy `deskctl` usage usually means:
+
+- `DISPLAY` is set
+- `XDG_SESSION_TYPE=x11`
+- `deskctl doctor` succeeds
+
+## Recommended workflow
+
+```bash
+deskctl snapshot --annotate
+deskctl wait window --selector 'class=firefox' --timeout 10
+deskctl focus 'class=firefox'
+deskctl hotkey ctrl l
+deskctl type "https://example.com"
+deskctl press enter
+deskctl snapshot
+```
+
+Prefer `--json` for strict machine parsing and explicit selectors for deterministic targeting.
diff --git a/skills/deskctl/templates/install-deskctl-npm.sh b/skills/deskctl/templates/install-deskctl-npm.sh
new file mode 100644
index 0000000..a0ab596
--- /dev/null
+++ b/skills/deskctl/templates/install-deskctl-npm.sh
@@ -0,0 +1,27 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+if command -v deskctl >/dev/null 2>&1; then
+ echo "deskctl already installed: $(command -v deskctl)"
+ exit 0
+fi
+
+if ! command -v npm >/dev/null 2>&1; then
+ echo "npm is required for the preferred deskctl install path"
+ exit 1
+fi
+
+prefix="${DESKCTL_NPM_PREFIX:-$HOME/.local}"
+bin_dir="$prefix/bin"
+
+mkdir -p "$bin_dir"
+npm install -g --prefix "$prefix" deskctl-cli
+
+if ! command -v deskctl >/dev/null 2>&1; then
+ echo "deskctl installed to $bin_dir"
+ echo "add this to PATH if needed:"
+ echo "export PATH=\"$bin_dir:\$PATH\""
+fi
+
+"$bin_dir/deskctl" --help >/dev/null 2>&1 || true
+echo "deskctl bootstrap complete"
diff --git a/skills/deskctl/templates/sandbox-agent-desktop-loop.sh b/skills/deskctl/templates/sandbox-agent-desktop-loop.sh
new file mode 100644
index 0000000..f47dbb8
--- /dev/null
+++ b/skills/deskctl/templates/sandbox-agent-desktop-loop.sh
@@ -0,0 +1,7 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+deskctl doctor
+deskctl snapshot --annotate
+deskctl get active-window
+deskctl wait window --selector "${1:-focused}" --timeout "${2:-5}"
From c37589ccf403106ebba3414ceeb9263c19c96e4f Mon Sep 17 00:00:00 2001
From: Harivansh Rathi
Date: Thu, 26 Mar 2026 00:30:05 -0400
Subject: [PATCH 16/49] skill validated with workflows
---
skills/deskctl/SKILL.md | 128 ++++--------------
skills/deskctl/references/commands.md | 64 ++++-----
skills/deskctl/references/install.md | 75 ----------
skills/deskctl/references/runtime-contract.md | 1 +
skills/deskctl/references/sandbox-agent.md | 61 ---------
.../deskctl/templates/install-deskctl-npm.sh | 27 ----
.../templates/sandbox-agent-desktop-loop.sh | 7 -
skills/deskctl/workflows/observe-act.sh | 37 +++++
skills/deskctl/workflows/poll-condition.sh | 42 ++++++
9 files changed, 134 insertions(+), 308 deletions(-)
delete mode 100644 skills/deskctl/references/install.md
create mode 120000 skills/deskctl/references/runtime-contract.md
delete mode 100644 skills/deskctl/references/sandbox-agent.md
delete mode 100644 skills/deskctl/templates/install-deskctl-npm.sh
delete mode 100644 skills/deskctl/templates/sandbox-agent-desktop-loop.sh
create mode 100755 skills/deskctl/workflows/observe-act.sh
create mode 100755 skills/deskctl/workflows/poll-condition.sh
diff --git a/skills/deskctl/SKILL.md b/skills/deskctl/SKILL.md
index 1522703..81dea19 100644
--- a/skills/deskctl/SKILL.md
+++ b/skills/deskctl/SKILL.md
@@ -1,132 +1,54 @@
---
name: deskctl
-description: Desktop control CLI for AI agents on Linux X11. Use when operating an X11 desktop in a sandbox, VM, or sandbox-agent session via screenshots, grouped get/wait commands, selectors, and mouse or keyboard input. Prefer this skill when the task is "control the desktop", "inspect windows", "wait for a window", "click/type in the sandbox desktop", or "use deskctl inside sandbox-agent".
-allowed-tools: Bash(deskctl:*), Bash(npx deskctl-cli:*), Bash(npm:*), Bash(which:*), Bash(printenv:*), Bash(echo:*), Bash(sandbox-agent:*)
+description: Non-interactive X11 desktop control for AI agents. Use when the task involves controlling a Linux desktop - clicking, typing, reading windows, waiting for UI state, or taking screenshots inside a sandbox or VM.
+allowed-tools: Bash(deskctl:*), Bash(npx deskctl-cli:*), Bash(npm:*), Bash(which:*), Bash(printenv:*), Bash(echo:*)
---
# deskctl
-`deskctl` is a non-interactive desktop control CLI for Linux X11 agents. It works well inside sandbox-agent desktop environments because it gives agents a tight `observe -> wait -> act -> verify` loop.
+Non-interactive desktop control CLI for Linux X11 agents.
-## Install skill (optional)
+All output follows the runtime contract defined in [references/runtime-contract.md](references/runtime-contract.md). Every command returns a stable JSON envelope when called with `--json`. Use `--json` whenever you need to parse output programmatically.
-### npx
-
-```bash
-npx skills add harivansh-afk/deskctl -s deskctl
-```
-
-### bunx
-
-```bash
-bunx skills add harivansh-afk/deskctl -s deskctl
-```
-
-## Install the CLI
-
-Preferred install path:
+## Quick start
```bash
npm install -g deskctl-cli
-deskctl --help
-```
-
-If global npm installs are not writable, use a user prefix:
-
-```bash
-mkdir -p "$HOME/.local/bin"
-npm install -g --prefix "$HOME/.local" deskctl-cli
-export PATH="$HOME/.local/bin:$PATH"
-deskctl --help
-```
-
-One-shot usage also works:
-
-```bash
-npx deskctl-cli --help
-```
-
-For install details and fallback paths, see [references/install.md](references/install.md).
-
-## Sandbox-Agent Notes
-
-Before using `deskctl` inside sandbox-agent:
-
-1. Make sure the sandbox has desktop runtime packages installed.
-2. Make sure the session is actually running X11.
-3. Run `deskctl doctor` before trying to click or type.
-
-Typical sandbox-agent prep:
-
-```bash
-sandbox-agent install desktop --yes
-deskctl doctor
-```
-
-If `doctor` fails, inspect `DISPLAY`, `XDG_SESSION_TYPE`, and whether the sandbox actually has a desktop session. See [references/sandbox-agent.md](references/sandbox-agent.md).
-
-## Core Workflow
-
-Every desktop task should follow this loop:
-
-1. **Observe**
-2. **Target**
-3. **Wait**
-4. **Act**
-5. **Verify**
-
-```bash
deskctl doctor
deskctl snapshot --annotate
-deskctl get active-window
-deskctl wait window --selector 'class=firefox' --timeout 10
-deskctl focus 'class=firefox'
-deskctl hotkey ctrl l
-deskctl type "https://example.com"
-deskctl press enter
-deskctl snapshot
```
-## What To Reach For First
+## Agent loop
-- `deskctl doctor`
-- `deskctl snapshot --annotate`
-- `deskctl list-windows`
-- `deskctl get active-window`
-- `deskctl wait window --selector ...`
-- `deskctl wait focus --selector ...`
-
-Use `--json` when you need strict parsing. Use explicit selectors when you need deterministic targeting.
-
-## Selector Rules
-
-Prefer explicit selectors:
+Every desktop interaction follows: **observe -> wait -> act -> verify**.
```bash
-ref=w1
-id=win1
-title=Firefox
-class=firefox
-focused
+deskctl snapshot --annotate # observe
+deskctl wait window --selector 'title=Firefox' --timeout 10 # wait
+deskctl click 'title=Firefox' # act
+deskctl snapshot # verify
```
-Legacy refs still work:
+See [workflows/observe-act.sh](workflows/observe-act.sh) for a reusable script. See [workflows/poll-condition.sh](workflows/poll-condition.sh) for polling loops.
+
+## Selectors
```bash
-@w1
-w1
-win1
+ref=w1 # snapshot ref (short-lived)
+id=win1 # stable window ID (session-scoped)
+title=Firefox # match by title
+class=firefox # match by WM class
+focused # currently focused window
```
-Bare strings such as `firefox` are fuzzy substring selectors. They fail on ambiguity instead of silently picking the wrong window.
+Bare strings like `firefox` do fuzzy matching but fail on ambiguity. Prefer explicit selectors.
## References
-- [references/install.md](references/install.md) - install paths, npm-first bootstrap, runtime prerequisites
-- [references/commands.md](references/commands.md) - grouped reads, waits, selectors, and core action commands
-- [references/sandbox-agent.md](references/sandbox-agent.md) - using `deskctl` inside sandbox-agent desktop sessions
+- [references/runtime-contract.md](references/runtime-contract.md) - output contract, stable fields, error kinds
+- [references/commands.md](references/commands.md) - all available commands
-## Templates
+## Workflows
-- [templates/install-deskctl-npm.sh](templates/install-deskctl-npm.sh) - install `deskctl-cli` into a user prefix
-- [templates/sandbox-agent-desktop-loop.sh](templates/sandbox-agent-desktop-loop.sh) - minimal observe/wait/act loop for desktop tasks
+- [workflows/observe-act.sh](workflows/observe-act.sh) - main observe-act loop
+- [workflows/poll-condition.sh](workflows/poll-condition.sh) - poll for a condition on screen
diff --git a/skills/deskctl/references/commands.md b/skills/deskctl/references/commands.md
index 2d2dc1f..d0e7c9f 100644
--- a/skills/deskctl/references/commands.md
+++ b/skills/deskctl/references/commands.md
@@ -1,21 +1,23 @@
-# deskctl command guide
+# deskctl commands
+
+All commands support `--json` for machine-parseable output following the runtime contract.
## Observe
```bash
-deskctl doctor
-deskctl snapshot
-deskctl snapshot --annotate
-deskctl list-windows
-deskctl screenshot /tmp/current.png
-deskctl get active-window
-deskctl get monitors
-deskctl get version
-deskctl get systeminfo
+deskctl doctor # check X11 runtime and daemon health
+deskctl snapshot # screenshot + window list
+deskctl snapshot --annotate # screenshot with @wN labels overlaid
+deskctl list-windows # window list only (no screenshot)
+deskctl screenshot /tmp/screen.png # screenshot to explicit path
+deskctl get active-window # focused window info
+deskctl get monitors # monitor geometry
+deskctl get version # version and backend
+deskctl get systeminfo # full runtime diagnostics
+deskctl get-screen-size # screen resolution
+deskctl get-mouse-position # cursor coordinates
```
-Use `snapshot --annotate` when you need both the screenshot artifact and the short `@wN` labels. Use `list-windows` when you only need the window tree and do not want screenshot side effects.
-
## Wait
```bash
@@ -23,29 +25,19 @@ deskctl wait window --selector 'title=Firefox' --timeout 10
deskctl wait focus --selector 'class=firefox' --timeout 5
```
-Wait commands return the matched window payload on success. In `--json` mode, failures include structured `kind` values so the caller can recover without string parsing.
+Returns the matched window payload on success. Failures include structured `kind` values in `--json` mode.
## Selectors
-Prefer explicit selectors:
-
```bash
-ref=w1
-id=win1
-title=Firefox
-class=firefox
-focused
+ref=w1 # snapshot ref (short-lived, from last snapshot)
+id=win1 # stable window ID (session-scoped)
+title=Firefox # match by window title
+class=firefox # match by WM class
+focused # currently focused window
```
-Legacy refs still work:
-
-```bash
-@w1
-w1
-win1
-```
-
-Bare fuzzy selectors such as `firefox` are supported, but they fail on ambiguity.
+Legacy shorthand: `@w1`, `w1`, `win1`. Bare strings do fuzzy matching but fail on ambiguity.
## Act
@@ -58,6 +50,7 @@ deskctl press enter
deskctl hotkey ctrl shift t
deskctl mouse move 500 300
deskctl mouse scroll 3
+deskctl mouse scroll 3 --axis horizontal
deskctl mouse drag 100 100 500 500
deskctl move-window @w1 100 120
deskctl resize-window @w1 1280 720
@@ -65,11 +58,12 @@ deskctl close @w3
deskctl launch firefox
```
-## Agent loop
+## Daemon
-The safe pattern is:
+```bash
+deskctl daemon start
+deskctl daemon stop
+deskctl daemon status
+```
-1. Observe with `snapshot`, `list-windows`, or `get ...`
-2. Wait for the target window if needed
-3. Act using explicit selectors or refs
-4. Snapshot again to verify the result
+The daemon starts automatically on first command. Manual control is rarely needed.
diff --git a/skills/deskctl/references/install.md b/skills/deskctl/references/install.md
deleted file mode 100644
index cb97a5c..0000000
--- a/skills/deskctl/references/install.md
+++ /dev/null
@@ -1,75 +0,0 @@
-# Install `deskctl`
-
-`deskctl` is designed to be used non-interactively by agents. The easiest install path is the npm package because it installs the `deskctl` command directly from GitHub Release assets without needing Cargo on the target machine.
-
-## Preferred: npm global install
-
-```bash
-npm install -g deskctl-cli
-deskctl --help
-```
-
-This is the preferred path for sandboxes, VMs, and sandbox-agent sessions where Node/npm already exists.
-
-## User-prefix npm install
-
-If global npm installs are not writable:
-
-```bash
-mkdir -p "$HOME/.local/bin"
-npm install -g --prefix "$HOME/.local" deskctl-cli
-export PATH="$HOME/.local/bin:$PATH"
-deskctl --help
-```
-
-This avoids `sudo` and keeps the install inside the user home directory.
-
-## One-shot npm execution
-
-```bash
-npx deskctl-cli --help
-```
-
-Use this for quick testing. For repeated desktop control, install the command once so the runtime is predictable.
-
-## Fallback: Cargo
-
-```bash
-cargo install deskctl
-```
-
-Use this only when the machine already has a Rust toolchain or when you explicitly want a source build.
-
-## Fallback: local Docker build
-
-If you need a Linux binary from macOS or another non-Linux host:
-
-```bash
-docker compose -f docker/docker-compose.yml run --rm build
-```
-
-Then copy `dist/deskctl-linux-x86_64` into the target machine.
-
-## Runtime prerequisites
-
-`deskctl` needs:
-
-- Linux
-- X11
-- a valid `DISPLAY`
-- a working desktop/window-manager session
-
-Quick verification:
-
-```bash
-printenv DISPLAY
-printenv XDG_SESSION_TYPE
-deskctl doctor
-```
-
-Inside sandbox-agent, you may need to install desktop dependencies first:
-
-```bash
-sandbox-agent install desktop --yes
-deskctl doctor
-```
diff --git a/skills/deskctl/references/runtime-contract.md b/skills/deskctl/references/runtime-contract.md
new file mode 120000
index 0000000..8de0781
--- /dev/null
+++ b/skills/deskctl/references/runtime-contract.md
@@ -0,0 +1 @@
+../../../docs/runtime-contract.md
\ No newline at end of file
diff --git a/skills/deskctl/references/sandbox-agent.md b/skills/deskctl/references/sandbox-agent.md
deleted file mode 100644
index d994062..0000000
--- a/skills/deskctl/references/sandbox-agent.md
+++ /dev/null
@@ -1,61 +0,0 @@
-# deskctl inside sandbox-agent
-
-Use `deskctl` when the sandbox-agent session includes a Linux desktop and you want a tight local desktop-control loop from the shell.
-
-## When it fits
-
-`deskctl` is a good fit when:
-
-- the sandbox already has an X11 desktop session
-- you want fast local desktop control from inside the sandbox
-- you want short-lived refs like `@w1` and grouped `get` or `wait` primitives
-
-It is not a replacement for sandbox-agent session orchestration itself. Use sandbox-agent to provision the sandbox and desktop runtime, then use `deskctl` inside that environment to control the GUI.
-
-## Minimal bootstrap
-
-```bash
-sandbox-agent install desktop --yes
-npm install -g deskctl-cli
-deskctl doctor
-deskctl snapshot --annotate
-```
-
-If npm global installs are not writable:
-
-```bash
-mkdir -p "$HOME/.local/bin"
-npm install -g --prefix "$HOME/.local" deskctl-cli
-export PATH="$HOME/.local/bin:$PATH"
-deskctl doctor
-```
-
-## Expected environment
-
-Check:
-
-```bash
-printenv DISPLAY
-printenv XDG_SESSION_TYPE
-deskctl --json get systeminfo
-```
-
-Healthy `deskctl` usage usually means:
-
-- `DISPLAY` is set
-- `XDG_SESSION_TYPE=x11`
-- `deskctl doctor` succeeds
-
-## Recommended workflow
-
-```bash
-deskctl snapshot --annotate
-deskctl wait window --selector 'class=firefox' --timeout 10
-deskctl focus 'class=firefox'
-deskctl hotkey ctrl l
-deskctl type "https://example.com"
-deskctl press enter
-deskctl snapshot
-```
-
-Prefer `--json` for strict machine parsing and explicit selectors for deterministic targeting.
diff --git a/skills/deskctl/templates/install-deskctl-npm.sh b/skills/deskctl/templates/install-deskctl-npm.sh
deleted file mode 100644
index a0ab596..0000000
--- a/skills/deskctl/templates/install-deskctl-npm.sh
+++ /dev/null
@@ -1,27 +0,0 @@
-#!/usr/bin/env bash
-set -euo pipefail
-
-if command -v deskctl >/dev/null 2>&1; then
- echo "deskctl already installed: $(command -v deskctl)"
- exit 0
-fi
-
-if ! command -v npm >/dev/null 2>&1; then
- echo "npm is required for the preferred deskctl install path"
- exit 1
-fi
-
-prefix="${DESKCTL_NPM_PREFIX:-$HOME/.local}"
-bin_dir="$prefix/bin"
-
-mkdir -p "$bin_dir"
-npm install -g --prefix "$prefix" deskctl-cli
-
-if ! command -v deskctl >/dev/null 2>&1; then
- echo "deskctl installed to $bin_dir"
- echo "add this to PATH if needed:"
- echo "export PATH=\"$bin_dir:\$PATH\""
-fi
-
-"$bin_dir/deskctl" --help >/dev/null 2>&1 || true
-echo "deskctl bootstrap complete"
diff --git a/skills/deskctl/templates/sandbox-agent-desktop-loop.sh b/skills/deskctl/templates/sandbox-agent-desktop-loop.sh
deleted file mode 100644
index f47dbb8..0000000
--- a/skills/deskctl/templates/sandbox-agent-desktop-loop.sh
+++ /dev/null
@@ -1,7 +0,0 @@
-#!/usr/bin/env bash
-set -euo pipefail
-
-deskctl doctor
-deskctl snapshot --annotate
-deskctl get active-window
-deskctl wait window --selector "${1:-focused}" --timeout "${2:-5}"
diff --git a/skills/deskctl/workflows/observe-act.sh b/skills/deskctl/workflows/observe-act.sh
new file mode 100755
index 0000000..0e336ae
--- /dev/null
+++ b/skills/deskctl/workflows/observe-act.sh
@@ -0,0 +1,37 @@
+#!/usr/bin/env bash
+# observe-act.sh - main desktop interaction loop
+# usage: ./observe-act.sh [action] [action-args...]
+# example: ./observe-act.sh 'title=Firefox' click
+# example: ./observe-act.sh 'class=terminal' type "ls -la"
+set -euo pipefail
+
+SELECTOR="${1:?usage: observe-act.sh [action] [action-args...]}"
+ACTION="${2:-click}"
+shift 2 2>/dev/null || true
+
+# 1. observe - snapshot the desktop, get current state
+echo "--- observe ---"
+deskctl snapshot --annotate --json | head -1
+deskctl get active-window
+
+# 2. wait - ensure target exists
+echo "--- wait ---"
+deskctl wait window --selector "$SELECTOR" --timeout 10
+
+# 3. act - perform the action on the target
+echo "--- act ---"
+case "$ACTION" in
+ click) deskctl click "$SELECTOR" ;;
+ dblclick) deskctl dblclick "$SELECTOR" ;;
+ focus) deskctl focus "$SELECTOR" ;;
+ type) deskctl focus "$SELECTOR" && deskctl type "$@" ;;
+ press) deskctl focus "$SELECTOR" && deskctl press "$@" ;;
+ hotkey) deskctl focus "$SELECTOR" && deskctl hotkey "$@" ;;
+ close) deskctl close "$SELECTOR" ;;
+ *) echo "unknown action: $ACTION"; exit 1 ;;
+esac
+
+# 4. verify - snapshot again to confirm result
+echo "--- verify ---"
+sleep 0.5
+deskctl snapshot --json | head -1
diff --git a/skills/deskctl/workflows/poll-condition.sh b/skills/deskctl/workflows/poll-condition.sh
new file mode 100755
index 0000000..e173bf5
--- /dev/null
+++ b/skills/deskctl/workflows/poll-condition.sh
@@ -0,0 +1,42 @@
+#!/usr/bin/env bash
+# poll-condition.sh - poll the desktop until a condition is met
+# usage: ./poll-condition.sh [interval-seconds] [max-attempts]
+# example: ./poll-condition.sh "Tickets Available" 5 60
+# example: ./poll-condition.sh "Order Confirmed" 3 20
+# example: ./poll-condition.sh "Download Complete" 10 30
+#
+# checks window titles for the match string every N seconds.
+# exits 0 when found, exits 1 after max attempts.
+set -euo pipefail
+
+MATCH="${1:?usage: poll-condition.sh [interval] [max-attempts]}"
+INTERVAL="${2:-5}"
+MAX="${3:-60}"
+
+attempt=0
+while [ "$attempt" -lt "$MAX" ]; do
+ attempt=$((attempt + 1))
+
+ # snapshot and check window titles
+ windows=$(deskctl list-windows --json 2>/dev/null || echo '{"success":false}')
+ if echo "$windows" | grep -qi "$MATCH"; then
+ echo "FOUND: '$MATCH' detected on attempt $attempt"
+ deskctl snapshot --annotate
+ exit 0
+ fi
+
+ # also check screenshot text via active window title
+ active=$(deskctl get active-window --json 2>/dev/null || echo '{}')
+ if echo "$active" | grep -qi "$MATCH"; then
+ echo "FOUND: '$MATCH' in active window on attempt $attempt"
+ deskctl snapshot --annotate
+ exit 0
+ fi
+
+ echo "attempt $attempt/$MAX - '$MATCH' not found, waiting ${INTERVAL}s..."
+ sleep "$INTERVAL"
+done
+
+echo "NOT FOUND: '$MATCH' after $MAX attempts"
+deskctl snapshot --annotate
+exit 1
From 14c89563211a8fec4b916bc4686ee1b4b86070d4 Mon Sep 17 00:00:00 2001
From: Harivansh Rathi
Date: Thu, 26 Mar 2026 08:17:07 -0400
Subject: [PATCH 17/49] align docs and contract
---
README.md | 268 ++++----------------------
docs/runtime-contract.md | 168 +++-------------
site/src/pages/architecture.mdx | 104 ++++++----
site/src/pages/commands.mdx | 219 ++++++++-------------
site/src/pages/index.astro | 57 +++++-
site/src/pages/installation.mdx | 75 ++++---
site/src/pages/quick-start.mdx | 106 +++++-----
site/src/pages/runtime-contract.mdx | 177 +++++++++++++++++
site/src/styles/base.css | 21 ++
skills/deskctl/references/commands.md | 52 +++--
10 files changed, 590 insertions(+), 657 deletions(-)
create mode 100644 site/src/pages/runtime-contract.mdx
diff --git a/README.md b/README.md
index db7d92f..32144f0 100644
--- a/README.md
+++ b/README.md
@@ -1,266 +1,68 @@
# deskctl
-Desktop control CLI for AI agents on Linux X11.
+[](https://www.npmjs.com/package/deskctl-cli)
+[](https://github.com/harivansh-afk/deskctl/releases)
+[](#support-boundary)
+[](skills/deskctl)
+
+Non-interactive desktop control for AI agents on Linux X11.
## Install
-### Cargo
-
-```bash
-cargo install deskctl
-```
-
-Source builds on Linux require:
-
-- Rust 1.75+
-- `pkg-config`
-- X11 development libraries for input and windowing, typically `libx11-dev` and `libxtst-dev` on Debian/Ubuntu
-
-### npm
-
```bash
npm install -g deskctl-cli
-deskctl --help
+deskctl doctor
+deskctl snapshot --annotate
```
-One-shot execution is also supported:
+One-shot execution also works:
```bash
npx deskctl-cli --help
```
-`deskctl-cli` currently supports `linux-x64` and installs the `deskctl` command by downloading the matching GitHub Release asset.
+`deskctl-cli` installs the `deskctl` command by downloading the matching GitHub Release asset for the supported runtime target.
-### Installable skill
-
-For `skills.sh` / agent skill ecosystems:
+## Installable skill
```bash
npx skills add harivansh-afk/deskctl -s deskctl
```
-The installable skill lives under [`skills/deskctl`](skills/deskctl) and is designed for X11 sandboxes, VMs, and sandbox-agent desktop sessions. It points agents to the npm install path first so they can get `deskctl` without Cargo.
+The installable skill lives in [`skills/deskctl`](skills/deskctl) and is built around the same observe -> wait -> act -> verify loop as the CLI.
-### Nix
+## Quick example
+
+```bash
+deskctl doctor
+deskctl snapshot --annotate
+deskctl wait window --selector 'title=Firefox' --timeout 10
+deskctl focus 'title=Firefox'
+deskctl type "hello world"
+```
+
+## Docs
+
+- runtime contract: [docs/runtime-contract.md](docs/runtime-contract.md)
+- release flow: [docs/releasing.md](docs/releasing.md)
+- installable skill: [skills/deskctl](skills/deskctl)
+- contributor workflow: [CONTRIBUTING.md](CONTRIBUTING.md)
+
+## Other install paths
+
+Nix:
```bash
nix run github:harivansh-afk/deskctl -- --help
nix profile install github:harivansh-afk/deskctl
```
-The repo flake is the supported Nix install surface in this phase.
-
-### Docker Convenience
-
-Build a Linux binary locally with Docker:
-
-```bash
-docker compose -f docker/docker-compose.yml run --rm build
-```
-
-This writes `dist/deskctl-linux-x86_64`.
-
-Copy it to an SSH machine where `scp` is unavailable:
-
-```bash
-ssh -p 443 deskctl@ssh.agentcomputer.ai 'cat > ~/deskctl && chmod +x ~/deskctl' < dist/deskctl-linux-x86_64
-```
-
-Run it on an X11 session:
-
-```bash
-DISPLAY=:1 XDG_SESSION_TYPE=x11 ~/deskctl --json snapshot --annotate
-```
-
-### Local Source Build
+Source build:
```bash
cargo build
```
-## Quick Start
+## Support boundary
-```bash
-# Diagnose the environment first
-deskctl doctor
-
-# See the desktop
-deskctl snapshot
-
-# Query focused runtime state
-deskctl get active-window
-deskctl get monitors
-
-# Click a window
-deskctl click @w1
-
-# Type text
-deskctl type "hello world"
-
-# Wait for a window or focus transition
-deskctl wait window --selector 'title=Firefox' --timeout 10
-deskctl wait focus --selector 'class=firefox' --timeout 5
-
-# Focus by explicit selector
-deskctl focus 'title=Firefox'
-```
-
-## Architecture
-
-Client-daemon architecture over Unix sockets (NDJSON wire protocol).
-The daemon starts automatically on first command and keeps the X11 connection alive for fast repeated calls.
-
-Source layout:
-
-- `src/lib.rs` exposes the shared library target
-- `src/main.rs` is the thin CLI wrapper
-- `src/` contains production code and unit tests
-- `tests/` contains Linux/X11 integration tests
-- `tests/support/` contains shared integration helpers
-
-## Runtime Requirements
-
-- Linux with X11 session
-- Rust 1.75+ plus the source-build dependencies above when building from source
-
-The binary itself only links the standard glibc runtime on Linux (`libc`, `libm`, `libgcc_s`).
-
-For deskctl to be fully functional on a fresh VM you still need:
-
-- an X11 server and an active `DISPLAY`
-- `XDG_SESSION_TYPE=x11` or an equivalent X11 session environment
-- a window manager or desktop environment that exposes standard EWMH properties such as `_NET_CLIENT_LIST_STACKING` and `_NET_ACTIVE_WINDOW`
-- an X server with the extensions needed for input simulation and screen metadata, which is standard on normal desktop X11 setups
-
-If setup fails, run:
-
-```bash
-deskctl doctor
-```
-
-## Contract Notes
-
-- `@wN` refs are short-lived handles assigned by `snapshot` and `list-windows`
-- `--json` output includes a stable `window_id` for programmatic targeting within the current daemon session
-- `list-windows` is a cheap read-only operation and does not capture or write a screenshot
-- the stable runtime JSON/error contract is documented in [docs/runtime-contract.md](docs/runtime-contract.md)
-
-## Read and Wait Surface
-
-The grouped runtime reads are:
-
-```bash
-deskctl get active-window
-deskctl get monitors
-deskctl get version
-deskctl get systeminfo
-```
-
-The grouped runtime waits are:
-
-```bash
-deskctl wait window --selector 'title=Firefox' --timeout 10
-deskctl wait focus --selector 'id=win3' --timeout 5
-```
-
-Successful `get active-window`, `wait window`, and `wait focus` responses return a `window` payload with:
-- `ref_id`
-- `window_id`
-- `title`
-- `app_name`
-- geometry (`x`, `y`, `width`, `height`)
-- state flags (`focused`, `minimized`)
-
-`get monitors` returns:
-- `count`
-- `monitors[]` with geometry and primary/automatic flags
-
-`get version` returns:
-- `version`
-- `backend`
-
-`get systeminfo` stays runtime-scoped and returns:
-- `backend`
-- `display`
-- `session_type`
-- `session`
-- `socket_path`
-- `screen`
-- `monitor_count`
-- `monitors`
-
-Wait timeout and selector failures are structured in `--json` mode so agents can recover without string parsing.
-
-## Output Policy
-
-Text mode is compact and follow-up-oriented, but JSON is the parsing contract.
-
-- use `--json` when an agent needs strict parsing
-- rely on `window_id`, selector-related fields, grouped read payloads, and structured error `kind` values for stable automation
-- treat monitor naming, incidental whitespace, and default screenshot file names as best-effort
-
-See [docs/runtime-conract.md](docs/runtime-contract.md) for the exact stable-vs-best-effort breakdown.
-
-## Distribution
-
-- GitHub Releases are the canonical binary source
-- crates.io package: `deskctl`
-- npm package: `deskctl-cli`
-- installed command on every channel: `deskctl`
-- repo-owned Nix install path: `flake.nix`
-
-For maintainer publishing and release steps, see [docs/releasing.md](docs/releasing.md).
-
-## Selector Contract
-
-Explicit selector modes:
-
-```bash
-ref=w1
-id=win1
-title=Firefox
-class=firefox
-focused
-```
-
-Legacy refs remain supported:
-
-```bash
-@w1
-w1
-win1
-```
-
-Bare selectors such as `firefox` are still supported as fuzzy substring matches, but they now fail on ambiguity and return candidate windows instead of silently picking the first match.
-
-## Support Boundary
-
-`deskctl` supports Linux X11 in this phase. Wayland and Hyprland are explicitly out of scope for the current runtime contract.
-
-## Workflow
-
-Local validation uses the root `Makefile`:
-
-```bash
-make fmt-check
-make lint
-make test-unit
-make test-integration
-make site-format-check
-make validate
-```
-
-`make validate` is the full repo-quality check and requires Linux with `xvfb-run` plus `pnpm --dir site install`.
-
-The repository standardizes on `pre-commit` for fast commit-time checks:
-
-```bash
-pre-commit install
-pre-commit run --all-files
-```
-
-See [CONTRIBUTING.md](CONTRIBUTING.md) for the full contributor guide.
-
-## Acknowledgements
-
-- [@barrettruth](github.com/barrettruth) - i stole the website from [vimdoc](https://github.com/barrettruth/vimdoc-language-server)
+`deskctl` currently supports Linux X11. Use `--json` for stable machine parsing, use `window_id` for programmatic targeting inside a live session, and use `deskctl doctor` first when the runtime looks broken.
diff --git a/docs/runtime-contract.md b/docs/runtime-contract.md
index 7312357..0316c06 100644
--- a/docs/runtime-contract.md
+++ b/docs/runtime-contract.md
@@ -1,19 +1,6 @@
-# Runtime Output Contract
+# deskctl runtime contract
-This document defines the current output contract for `deskctl`.
-
-It is intentionally scoped to the current Linux X11 runtime surface.
-It does not promise stability for future Wayland or window-manager-specific features.
-
-## Goals
-
-- Keep `deskctl` fully non-interactive
-- Make text output actionable for quick terminal and agent loops
-- Make `--json` safe for agent consumption without depending on incidental formatting
-
-## JSON Envelope
-
-Every runtime command uses the same top-level JSON envelope:
+All commands support `--json` and use the same top-level envelope:
```json
{
@@ -23,22 +10,11 @@ Every runtime command uses the same top-level JSON envelope:
}
```
-Stable top-level fields:
+Use `--json` whenever you need to parse output programmatically.
-- `success`
-- `data`
-- `error`
+## Stable window fields
-`success` is always the authoritative success/failure bit.
-When `success` is `false`, the CLI exits non-zero in both text mode and `--json` mode.
-
-## Stable Fields
-
-These fields are stable for agent consumption in the current Phase 1 runtime contract.
-
-### Window Identity
-
-Whenever a runtime response includes a window payload, these fields are stable:
+Whenever a response includes a window payload, these fields are stable:
- `ref_id`
- `window_id`
@@ -51,128 +27,46 @@ Whenever a runtime response includes a window payload, these fields are stable:
- `focused`
- `minimized`
-`window_id` is the stable public identifier for a live daemon session.
-`ref_id` is a short-lived convenience handle for the current window snapshot/ref map.
+Use `window_id` for stable targeting inside a live daemon session. Use
+`ref_id` or `@wN` for short-lived follow-up actions after `snapshot` or
+`list-windows`.
-### Grouped Reads
+## Stable grouped reads
-`deskctl get active-window`
+- `deskctl get active-window` -> `data.window`
+- `deskctl get monitors` -> `data.count`, `data.monitors`
+- `deskctl get version` -> `data.version`, `data.backend`
+- `deskctl get systeminfo` -> runtime-scoped diagnostic fields such as
+ `backend`, `display`, `session_type`, `session`, `socket_path`, `screen`,
+ `monitor_count`, and `monitors`
-- stable: `data.window`
+## Stable waits
-`deskctl get monitors`
+- `deskctl wait window` -> `data.wait`, `data.selector`, `data.elapsed_ms`,
+ `data.window`
+- `deskctl wait focus` -> `data.wait`, `data.selector`, `data.elapsed_ms`,
+ `data.window`
-- stable: `data.count`
-- stable: `data.monitors`
-- stable per monitor:
- - `name`
- - `x`
- - `y`
- - `width`
- - `height`
- - `width_mm`
- - `height_mm`
- - `primary`
- - `automatic`
+## Stable structured error kinds
-`deskctl get version`
-
-- stable: `data.version`
-- stable: `data.backend`
-
-`deskctl get systeminfo`
-
-- stable: `data.backend`
-- stable: `data.display`
-- stable: `data.session_type`
-- stable: `data.session`
-- stable: `data.socket_path`
-- stable: `data.screen`
-- stable: `data.monitor_count`
-- stable: `data.monitors`
-
-### Waits
-
-`deskctl wait window`
-`deskctl wait focus`
-
-- stable: `data.wait`
-- stable: `data.selector`
-- stable: `data.elapsed_ms`
-- stable: `data.window`
-
-### Selector-Driven Action Success
-
-For selector-driven action commands that resolve a window target, these identifiers are stable when present:
-
-- `data.ref_id`
-- `data.window_id`
-- `data.title`
-- `data.selector`
-
-This applies to:
-
-- `click`
-- `dblclick`
-- `focus`
-- `close`
-- `move-window`
-- `resize-window`
-
-The exact human-readable text rendering of those commands is not part of the JSON contract.
-
-### Artifact-Producing Commands
-
-`snapshot`
-`screenshot`
-
-- stable: `data.screenshot`
-
-When the command also returns windows, `data.windows` uses the stable window payload documented above.
-
-## Stable Structured Error Kinds
-
-When a runtime command returns structured JSON failure data, these error kinds are stable:
+When a command fails with structured JSON data, these `kind` values are stable:
- `selector_not_found`
- `selector_ambiguous`
- `selector_invalid`
- `timeout`
- `not_found`
-- `window_not_focused` as `data.last_observation.kind` or equivalent observation payload
-Stable structured failure fields include:
+Wait failures may also include `window_not_focused` in the last observation
+payload.
-- `data.kind`
-- `data.selector` when selector-related
-- `data.mode` when selector-related
-- `data.candidates` for ambiguous selector failures
-- `data.message` for invalid selector failures
-- `data.wait`
-- `data.timeout_ms`
-- `data.poll_ms`
-- `data.last_observation`
+## Best-effort fields
-## Best-Effort Fields
+Treat these as useful but non-contractual:
-These values are useful but environment-dependent and should be treated as best-effort:
+- exact monitor names
+- incidental text formatting in non-JSON mode
+- default screenshot file names when no explicit path was provided
+- environment-dependent ordering details from the window manager
-- exact monitor naming conventions
-- EWMH/window-manager-dependent window ordering details
-- cosmetic text formatting in non-JSON mode
-- screenshot file names when the caller did not provide an explicit path
-- command stderr wording outside the structured `kind` classifications above
-
-## Text Mode Expectations
-
-Text mode is intended to stay compact and follow-up-useful.
-
-The exact whitespace/alignment of text output is not stable.
-The following expectations are stable at the behavioral level:
-
-- important runtime reads print actionable identifiers or geometry
-- selector failures print enough detail to recover without `--json`
-- artifact-producing commands print the artifact path
-- window listings print both `@wN` refs and `window_id` values
-
-If an agent needs strict parsing, it should use `--json`.
+For the full repo copy, see `docs/runtime-contract.md`.
diff --git a/site/src/pages/architecture.mdx b/site/src/pages/architecture.mdx
index 87b2b4e..9478246 100644
--- a/site/src/pages/architecture.mdx
+++ b/site/src/pages/architecture.mdx
@@ -6,73 +6,93 @@ toc: true
# Architecture
-## Client-daemon model
+## Public model
-deskctl uses a client-daemon architecture over Unix sockets. The daemon starts automatically on the first command and keeps the X11 connection alive so repeated calls skip the connection setup overhead.
+`deskctl` is a thin, non-interactive X11 control primitive for agent loops.
+The public flow is:
-Each command opens a new connection to the daemon, sends a single NDJSON request, reads one NDJSON response, and exits.
+- diagnose with `deskctl doctor`
+- observe with `snapshot`, `list-windows`, and grouped `get` commands
+- wait with grouped `wait` commands instead of shell `sleep`
+- act with explicit selectors or coordinates
+- verify with another read or snapshot
-## Wire protocol
+The tool stays intentionally narrow. It does not try to be a full desktop shell
+or a speculative Wayland abstraction.
+
+## Client-daemon architecture
+
+The CLI talks to an auto-managed daemon over a Unix socket. The daemon keeps
+the X11 connection alive so repeated commands stay fast and share the same
+session-scoped window identity map.
+
+Each CLI invocation sends one request, reads one response, and exits.
+
+## Runtime contract
Requests and responses are newline-delimited JSON (NDJSON) over a Unix socket.
-**Request:**
+All commands share the same JSON envelope:
```json
-{ "id": "r123456", "action": "snapshot", "annotate": true }
+{
+ "success": true,
+ "data": {},
+ "error": null
+}
```
-**Response:**
+For window payloads, the public identity is `window_id`, not an X11 handle.
+That keeps the contract backend-neutral even though the current support
+boundary is X11-only.
-```json
-{"success": true, "data": {"screenshot": "/tmp/deskctl-1234567890.png", "windows": [...]}}
-```
+The complete stable-vs-best-effort policy lives on the
+[runtime contract](/runtime-contract) page.
-Error responses include an `error` field:
+## Sessions and sockets
-```json
-{ "success": false, "error": "window not found: @w99" }
-```
+Each session gets its own socket path, PID file, and live window mapping.
-## Socket location
+Public socket resolution order:
-The daemon socket is resolved in this order:
-
-1. `--socket` flag (highest priority)
-2. `$DESKCTL_SOCKET_DIR/{session}.sock`
-3. `$XDG_RUNTIME_DIR/deskctl/{session}.sock`
+1. `--socket`
+2. `DESKCTL_SOCKET_DIR/{session}.sock`
+3. `XDG_RUNTIME_DIR/deskctl/{session}.sock`
4. `~/.deskctl/{session}.sock`
-PID files are stored alongside the socket.
+Most users should let `deskctl` manage this automatically. `--session` is the
+main public knob when you need isolated daemon instances.
-## Sessions
+## Diagnostics and failure handling
-Multiple isolated daemon instances can run simultaneously using the `--session` flag:
+`deskctl doctor` runs before daemon startup and checks:
-```sh
-deskctl --session workspace1 snapshot
-deskctl --session workspace2 snapshot
-```
+- display/session setup
+- X11 connectivity
+- basic window enumeration
+- screenshot viability
+- socket directory and stale-socket health
-Each session has its own socket, PID file, and window ref map.
+Selector and wait failures are structured in `--json` mode so clients can
+recover without scraping text.
-## Backend design
+## Backend notes
-The core is built around a `DesktopBackend` trait. The current implementation uses `x11rb` for X11 protocol operations and `enigo` for input simulation.
+The backend is built around a `DesktopBackend` trait and currently ships with
+an X11 implementation backed by `x11rb`.
-The trait-based design means adding Wayland support is a single trait implementation with no changes to the core, CLI, or daemon code.
+The important public guarantee is not "portable desktop automation." The
+important guarantee is "a correct and unsurprising Linux X11 runtime contract."
-## X11 integration
+## X11 support boundary
-Window detection uses EWMH properties:
+This phase supports Linux X11 only.
-| Property | Purpose |
-| --------------------------- | ------------------------ |
-| `_NET_CLIENT_LIST_STACKING` | Window stacking order |
-| `_NET_ACTIVE_WINDOW` | Currently focused window |
-| `_NET_WM_NAME` | Window title (UTF-8) |
-| `_NET_WM_STATE_HIDDEN` | Minimized state |
-| `_NET_CLOSE_WINDOW` | Graceful close |
-| `WM_CLASS` | Application class/name |
+That means:
-Falls back to `XQueryTree` if `_NET_CLIENT_LIST_STACKING` is unavailable.
+- EWMH/window-manager properties matter
+- monitor naming and some ordering details are best-effort
+- Wayland and Hyprland are out of scope for the current contract
+
+The runtime documents those boundaries explicitly instead of pretending the
+surface is broader than it is.
diff --git a/site/src/pages/commands.mdx b/site/src/pages/commands.mdx
index e1fc509..8a5132b 100644
--- a/site/src/pages/commands.mdx
+++ b/site/src/pages/commands.mdx
@@ -6,167 +6,101 @@ toc: true
# Commands
-## Snapshot
-
-Capture a screenshot and get the window tree:
+## Observe
```sh
+deskctl doctor
deskctl snapshot
deskctl snapshot --annotate
-```
-
-With `--annotate`, colored bounding boxes and `@wN` labels are drawn on the screenshot. Each window gets a unique color from an 8-color palette. Minimized windows are skipped.
-
-The screenshot is saved to `/tmp/deskctl-{timestamp}.png`.
-
-## Click
-
-Click the center of a window by ref, or click exact coordinates:
-
-```sh
-deskctl click @w1
-deskctl click 960,540
-```
-
-## Double click
-
-```sh
-deskctl dblclick @w1
-deskctl dblclick 500,300
-```
-
-## Type
-
-Type a string into the focused window:
-
-```sh
-deskctl type "hello world"
-```
-
-## Press
-
-Press a single key:
-
-```sh
-deskctl press enter
-deskctl press tab
-deskctl press escape
-```
-
-Supported key names: `enter`, `tab`, `escape`, `backspace`, `delete`, `space`, `up`, `down`, `left`, `right`, `home`, `end`, `pageup`, `pagedown`, `f1`-`f12`, or any single character.
-
-## Hotkey
-
-Send a key combination. List modifier keys first, then the target key:
-
-```sh
-deskctl hotkey ctrl c
-deskctl hotkey ctrl shift t
-deskctl hotkey alt f4
-```
-
-Modifier names: `ctrl`, `alt`, `shift`, `super` (also `meta` or `win`).
-
-## Mouse move
-
-Move the cursor to absolute coordinates:
-
-```sh
-deskctl mouse move 100 200
-```
-
-## Mouse scroll
-
-Scroll the mouse wheel. Positive values scroll down, negative scroll up:
-
-```sh
-deskctl mouse scroll 3
-deskctl mouse scroll -5
-deskctl mouse scroll 3 --axis horizontal
-```
-
-## Mouse drag
-
-Drag from one position to another:
-
-```sh
-deskctl mouse drag 100 200 500 600
-```
-
-## Focus
-
-Focus a window by ref or by name (case-insensitive substring match):
-
-```sh
-deskctl focus @w1
-deskctl focus "firefox"
-```
-
-## Close
-
-Close a window gracefully:
-
-```sh
-deskctl close @w2
-deskctl close "terminal"
-```
-
-## Move window
-
-Move a window to an absolute position:
-
-```sh
-deskctl move-window @w1 0 0
-deskctl move-window "firefox" 100 100
-```
-
-## Resize window
-
-Resize a window:
-
-```sh
-deskctl resize-window @w1 1280 720
-```
-
-## List windows
-
-List all windows without taking a screenshot:
-
-```sh
deskctl list-windows
-```
-
-## Get screen size
-
-```sh
+deskctl screenshot
+deskctl screenshot /tmp/screen.png
+deskctl get active-window
+deskctl get monitors
+deskctl get version
+deskctl get systeminfo
deskctl get-screen-size
-```
-
-## Get mouse position
-
-```sh
deskctl get-mouse-position
```
-## Screenshot
+`doctor` checks the runtime before daemon startup. `snapshot` produces a
+screenshot plus window refs. `list-windows` is the same window tree without the
+side effect of writing a screenshot.
-Take a screenshot without the window tree. Optionally specify a save path:
+## Wait
```sh
-deskctl screenshot
-deskctl screenshot /tmp/my-screenshot.png
-deskctl screenshot --annotate
+deskctl wait window --selector 'title=Firefox' --timeout 10
+deskctl wait focus --selector 'id=win3' --timeout 5
+deskctl --json wait window --selector 'class=firefox' --poll-ms 100
```
-## Launch
+Wait commands return the matched window payload on success. In `--json` mode,
+timeouts and selector failures expose structured `kind` values.
-Launch an application:
+## Act on a window
```sh
deskctl launch firefox
-deskctl launch code --args /path/to/project
+deskctl focus @w1
+deskctl focus 'title=Firefox'
+deskctl click @w1
+deskctl click 960,540
+deskctl dblclick @w2
+deskctl close @w3
+deskctl move-window @w1 100 120
+deskctl resize-window @w1 1280 720
```
+Selector-driven actions accept refs, explicit selector modes, or absolute
+coordinates where appropriate.
+
+## Input and mouse
+
+```sh
+deskctl type "hello world"
+deskctl press enter
+deskctl hotkey ctrl shift t
+deskctl mouse move 100 200
+deskctl mouse scroll 3
+deskctl mouse scroll 3 --axis horizontal
+deskctl mouse drag 100 200 500 600
+```
+
+Supported key names include `enter`, `tab`, `escape`, `backspace`, `delete`,
+`space`, arrow keys, paging keys, `f1` through `f12`, and any single
+character.
+
+## Launch
+
+```sh
+deskctl launch firefox
+deskctl launch code -- --new-window
+```
+
+## Selectors
+
+Prefer explicit selectors when the target matters:
+
+```sh
+ref=w1
+id=win1
+title=Firefox
+class=firefox
+focused
+```
+
+Legacy shorthand is still supported:
+
+```sh
+@w1
+w1
+win1
+```
+
+Bare strings like `firefox` are fuzzy matches. They resolve when there is one
+match and fail with candidate windows when there are multiple matches.
+
## Global options
| Flag | Env | Description |
@@ -174,3 +108,6 @@ deskctl launch code --args /path/to/project
| `--json` | | Output as JSON |
| `--socket ` | `DESKCTL_SOCKET` | Path to daemon Unix socket |
| `--session ` | | Session name for multiple daemons (default: `default`) |
+
+`deskctl` manages the daemon automatically. Most users never need to think
+about it beyond `--session` and `--socket`.
diff --git a/site/src/pages/index.astro b/site/src/pages/index.astro
index 9327dc5..4263549 100644
--- a/site/src/pages/index.astro
+++ b/site/src/pages/index.astro
@@ -8,17 +8,49 @@ import DocLayout from "../layouts/DocLayout.astro";
-
- Desktop control CLI for AI agents on Linux X11. Compact JSON output for
- agent loops. Screenshot, click, type, scroll, drag, and manage windows
- through a fast client-daemon architecture. 100% native Rust.
+
non-interactive desktop control for AI agents
+
+
+
+
+ deskctl is a thin X11 control primitive for agent loops: diagnose
+ the runtime, observe the desktop, wait for state transitions, act deterministically,
+ then verify.
- Getting started
+ npm install -g deskctl-cli
+deskctl doctor
+deskctl snapshot --annotate
+
+ Start here
Reference
@@ -28,14 +60,27 @@ import DocLayout from "../layouts/DocLayout.astro";
Architecture
+ Agent skill
+
+
+ There is also an installable skill for `skills.sh`-style agent runtimes:
+
+
+ npx skills add harivansh-afk/deskctl -s deskctl
+
Links
diff --git a/site/src/pages/installation.mdx b/site/src/pages/installation.mdx
index e05772d..985cf99 100644
--- a/site/src/pages/installation.mdx
+++ b/site/src/pages/installation.mdx
@@ -6,43 +6,68 @@ toc: true
# Installation
-## Cargo
+## Default install
```sh
-cargo install deskctl
+npm install -g deskctl-cli
+deskctl --help
```
-## From source
+`deskctl-cli` is the default install path. It installs the `deskctl` command by
+downloading the matching GitHub Release asset for the supported runtime target.
+
+## One-shot usage
+
+```sh
+npx deskctl-cli --help
+```
+
+## Agent skill
+
+For `skills.sh`-style runtimes:
+
+```sh
+npx skills add harivansh-afk/deskctl -s deskctl
+```
+
+The repo skill lives under `skills/deskctl` and is designed around the same
+observe -> wait -> act -> verify loop as the CLI.
+
+## Other install paths
+
+### Nix
+
+```sh
+nix run github:harivansh-afk/deskctl -- --help
+nix profile install github:harivansh-afk/deskctl
+```
+
+### Build from source
```sh
git clone https://github.com/harivansh-afk/deskctl
cd deskctl
-cargo build --release
+cargo build
```
-## Docker (cross-compile for Linux)
+Source builds on Linux require:
-Build a static Linux binary from any platform:
+- Rust 1.75+
+- `pkg-config`
+- X11 development libraries such as `libx11-dev` and `libxtst-dev`
-```sh
-docker compose -f docker/docker-compose.yml run --rm build
-```
-
-This writes `dist/deskctl-linux-x86_64`.
-
-## Deploy to a remote machine
-
-Copy the binary over SSH when `scp` is not available:
-
-```sh
-ssh -p 443 user@host 'cat > ~/deskctl && chmod +x ~/deskctl' < dist/deskctl-linux-x86_64
-```
-
-## Requirements
+## Runtime requirements
- Linux with an active X11 session
-- `DISPLAY` environment variable set (e.g. `DISPLAY=:1`)
-- `XDG_SESSION_TYPE=x11`
-- A window manager that exposes EWMH properties (`_NET_CLIENT_LIST_STACKING`, `_NET_ACTIVE_WINDOW`)
+- `DISPLAY` set to a usable X11 display, such as `DISPLAY=:1`
+- `XDG_SESSION_TYPE=x11` or an equivalent X11 session environment
+- a window manager or desktop environment that exposes standard EWMH properties
+ such as `_NET_CLIENT_LIST_STACKING` and `_NET_ACTIVE_WINDOW`
-No extra native libraries are needed beyond the standard glibc runtime (`libc`, `libm`, `libgcc_s`).
+The binary itself only depends on the standard Linux glibc runtime.
+
+If setup fails, run:
+
+```sh
+deskctl doctor
+```
diff --git a/site/src/pages/quick-start.mdx b/site/src/pages/quick-start.mdx
index 7f3bc07..c783b9e 100644
--- a/site/src/pages/quick-start.mdx
+++ b/site/src/pages/quick-start.mdx
@@ -6,50 +6,72 @@ toc: true
# Quick start
-## Core workflow
-
-The typical agent loop is: snapshot the desktop, interpret the result, act on it.
+## Install and diagnose
```sh
-# 1. see the desktop
-deskctl --json snapshot --annotate
+npm install -g deskctl-cli
+deskctl doctor
+```
-# 2. click a window by its ref
-deskctl click @w1
+Use `deskctl doctor` first. It checks X11 connectivity, basic enumeration,
+screenshot viability, and socket health before you start driving the desktop.
-# 3. type into the focused window
-deskctl type "hello world"
+## Observe
-# 4. press a key
+```sh
+deskctl snapshot --annotate
+deskctl list-windows
+deskctl get active-window
+deskctl get monitors
+```
+
+Use `snapshot` when you want a screenshot artifact plus window refs. Use
+`list-windows` when you only need the current window tree without writing a
+screenshot.
+
+## Target windows cleanly
+
+Prefer explicit selectors when you need deterministic targeting:
+
+```sh
+ref=w1
+id=win1
+title=Firefox
+class=firefox
+focused
+```
+
+Legacy refs such as `@w1` still work after `snapshot` or `list-windows`. Bare
+strings like `firefox` are fuzzy matches and now fail on ambiguity.
+
+## Wait, act, verify
+
+The core loop is:
+
+```sh
+# observe
+deskctl snapshot --annotate
+
+# wait
+deskctl wait window --selector 'title=Firefox' --timeout 10
+
+# act
+deskctl focus 'title=Firefox'
+deskctl hotkey ctrl l
+deskctl type "https://example.com"
deskctl press enter
+
+# verify
+deskctl wait focus --selector 'title=Firefox' --timeout 5
+deskctl snapshot
```
-The `--annotate` flag draws colored bounding boxes and `@wN` labels on the screenshot so agents can visually identify windows.
+The wait commands return the matched window payload on success, so they compose
+cleanly into the next action.
-## Window refs
+## Use `--json` when parsing matters
-Every `snapshot` assigns refs like `@w1`, `@w2`, etc. to each visible window, ordered top-to-bottom by stacking order. Use these refs anywhere a selector is expected:
-
-```sh
-deskctl click @w1
-deskctl focus @w3
-deskctl close @w2
-```
-
-You can also select windows by name (case-insensitive substring match):
-
-```sh
-deskctl focus "firefox"
-deskctl close "terminal"
-```
-
-## JSON output
-
-Pass `--json` for machine-readable output. This is the primary mode for agent integrations:
-
-```sh
-deskctl --json snapshot
-```
+Every command supports `--json` and uses the same top-level envelope:
```json
{
@@ -59,7 +81,7 @@ deskctl --json snapshot
"windows": [
{
"ref_id": "w1",
- "xcb_id": 12345678,
+ "window_id": "win1",
"title": "Firefox",
"app_name": "firefox",
"x": 0,
@@ -74,14 +96,8 @@ deskctl --json snapshot
}
```
-## Daemon lifecycle
+Use `window_id` for stable targeting inside a live daemon session. The exact
+text formatting is intentionally compact, but JSON is the parsing contract.
-The daemon starts automatically on the first command. It keeps the X11 connection alive so repeated calls are fast. You do not need to manage it manually.
-
-```sh
-# check if the daemon is running
-deskctl daemon status
-
-# stop it explicitly
-deskctl daemon stop
-```
+The full stable-vs-best-effort contract lives on the
+[runtime contract](/runtime-contract) page.
diff --git a/site/src/pages/runtime-contract.mdx b/site/src/pages/runtime-contract.mdx
new file mode 100644
index 0000000..4fca14c
--- /dev/null
+++ b/site/src/pages/runtime-contract.mdx
@@ -0,0 +1,177 @@
+---
+layout: ../layouts/DocLayout.astro
+title: Runtime contract
+toc: true
+---
+
+# Runtime contract
+
+This page defines the current public output contract for `deskctl`.
+
+It is intentionally scoped to the current Linux X11 runtime surface. It does
+not promise stability for future Wayland or window-manager-specific features.
+
+## JSON envelope
+
+Every command supports `--json` and uses the same top-level envelope:
+
+```json
+{
+ "success": true,
+ "data": {},
+ "error": null
+}
+```
+
+Stable top-level fields:
+
+- `success`
+- `data`
+- `error`
+
+If `success` is `false`, the command exits non-zero in both text mode and JSON
+mode.
+
+## Stable window fields
+
+Whenever a response includes a window payload, these fields are stable:
+
+- `ref_id`
+- `window_id`
+- `title`
+- `app_name`
+- `x`
+- `y`
+- `width`
+- `height`
+- `focused`
+- `minimized`
+
+`window_id` is the public session-scoped identifier for programmatic targeting.
+`ref_id` is a short-lived convenience handle from the current ref map.
+
+## Stable grouped reads
+
+`deskctl get active-window`
+
+- stable: `data.window`
+
+`deskctl get monitors`
+
+- stable: `data.count`
+- stable: `data.monitors`
+
+Stable per-monitor fields:
+
+- `name`
+- `x`
+- `y`
+- `width`
+- `height`
+- `width_mm`
+- `height_mm`
+- `primary`
+- `automatic`
+
+`deskctl get version`
+
+- stable: `data.version`
+- stable: `data.backend`
+
+`deskctl get systeminfo`
+
+- stable: `data.backend`
+- stable: `data.display`
+- stable: `data.session_type`
+- stable: `data.session`
+- stable: `data.socket_path`
+- stable: `data.screen`
+- stable: `data.monitor_count`
+- stable: `data.monitors`
+
+## Stable waits
+
+`deskctl wait window`
+`deskctl wait focus`
+
+- stable: `data.wait`
+- stable: `data.selector`
+- stable: `data.elapsed_ms`
+- stable: `data.window`
+
+## Stable selector-driven action fields
+
+When selector-driven actions return resolved window data, these fields are
+stable when present:
+
+- `data.ref_id`
+- `data.window_id`
+- `data.title`
+- `data.selector`
+
+This applies to:
+
+- `click`
+- `dblclick`
+- `focus`
+- `close`
+- `move-window`
+- `resize-window`
+
+## Stable artifact fields
+
+For `snapshot` and `screenshot`:
+
+- stable: `data.screenshot`
+
+When a command also returns windows, `data.windows` uses the stable window
+payload documented above.
+
+## Stable structured error kinds
+
+When a command fails with structured JSON data, these error kinds are stable:
+
+- `selector_not_found`
+- `selector_ambiguous`
+- `selector_invalid`
+- `timeout`
+- `not_found`
+- `window_not_focused` in `data.last_observation.kind` or an equivalent wait
+ observation payload
+
+Stable structured failure fields include:
+
+- `data.kind`
+- `data.selector`
+- `data.mode`
+- `data.candidates`
+- `data.message`
+- `data.wait`
+- `data.timeout_ms`
+- `data.poll_ms`
+- `data.last_observation`
+
+## Best-effort fields
+
+These values are useful but environment-dependent and should not be treated as
+strict parsing guarantees:
+
+- exact monitor naming conventions
+- EWMH/window-manager-dependent ordering details
+- cosmetic text formatting in non-JSON mode
+- default screenshot file names when no explicit path was provided
+- stderr wording outside the structured `kind` classifications above
+
+## Text mode expectations
+
+Text mode is intended to stay compact and follow-up-useful.
+
+The exact whitespace and alignment are not stable. The stable behavioral
+expectations are:
+
+- important reads print actionable identifiers or geometry
+- selector failures print enough detail to recover without `--json`
+- artifact-producing commands print the artifact path
+- window listings print both `@wN` refs and `window_id` values
+
+If you need strict parsing, use `--json`.
diff --git a/site/src/styles/base.css b/site/src/styles/base.css
index 86fd6a8..f60c0e6 100644
--- a/site/src/styles/base.css
+++ b/site/src/styles/base.css
@@ -65,6 +65,23 @@ main {
font-style: italic;
}
+.lede {
+ font-size: 1.05rem;
+ max-width: 42rem;
+}
+
+.badges {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.6rem;
+ margin-bottom: 1.25rem;
+}
+
+.badges a,
+.badges img {
+ display: block;
+}
+
header {
display: flex;
align-items: center;
@@ -117,6 +134,10 @@ a:hover {
text-decoration-thickness: 2px;
}
+img {
+ max-width: 100%;
+}
+
ul,
ol {
padding-left: 1.25em;
diff --git a/skills/deskctl/references/commands.md b/skills/deskctl/references/commands.md
index d0e7c9f..77b9513 100644
--- a/skills/deskctl/references/commands.md
+++ b/skills/deskctl/references/commands.md
@@ -1,21 +1,22 @@
# deskctl commands
-All commands support `--json` for machine-parseable output following the runtime contract.
+All commands support `--json` for machine-parseable output following the
+runtime contract.
## Observe
```bash
-deskctl doctor # check X11 runtime and daemon health
-deskctl snapshot # screenshot + window list
-deskctl snapshot --annotate # screenshot with @wN labels overlaid
-deskctl list-windows # window list only (no screenshot)
-deskctl screenshot /tmp/screen.png # screenshot to explicit path
-deskctl get active-window # focused window info
-deskctl get monitors # monitor geometry
-deskctl get version # version and backend
-deskctl get systeminfo # full runtime diagnostics
-deskctl get-screen-size # screen resolution
-deskctl get-mouse-position # cursor coordinates
+deskctl doctor
+deskctl snapshot
+deskctl snapshot --annotate
+deskctl list-windows
+deskctl screenshot /tmp/screen.png
+deskctl get active-window
+deskctl get monitors
+deskctl get version
+deskctl get systeminfo
+deskctl get-screen-size
+deskctl get-mouse-position
```
## Wait
@@ -25,19 +26,21 @@ deskctl wait window --selector 'title=Firefox' --timeout 10
deskctl wait focus --selector 'class=firefox' --timeout 5
```
-Returns the matched window payload on success. Failures include structured `kind` values in `--json` mode.
+Returns the matched window payload on success. Failures include structured
+`kind` values in `--json` mode.
## Selectors
```bash
-ref=w1 # snapshot ref (short-lived, from last snapshot)
-id=win1 # stable window ID (session-scoped)
-title=Firefox # match by window title
-class=firefox # match by WM class
-focused # currently focused window
+ref=w1
+id=win1
+title=Firefox
+class=firefox
+focused
```
-Legacy shorthand: `@w1`, `w1`, `win1`. Bare strings do fuzzy matching but fail on ambiguity.
+Legacy shorthand: `@w1`, `w1`, `win1`. Bare strings do fuzzy matching but fail
+on ambiguity.
## Act
@@ -58,12 +61,5 @@ deskctl close @w3
deskctl launch firefox
```
-## Daemon
-
-```bash
-deskctl daemon start
-deskctl daemon stop
-deskctl daemon status
-```
-
-The daemon starts automatically on first command. Manual control is rarely needed.
+The daemon starts automatically on first command. In normal usage you should
+not need to manage it directly.
From 88f9ff85a3fa5b95028bb1e7811078416eaf43ae Mon Sep 17 00:00:00 2001
From: Harivansh Rathi
Date: Thu, 26 Mar 2026 08:18:57 -0400
Subject: [PATCH 18/49] clean
---
site/src/pages/index.astro | 25 -------------------------
site/src/styles/base.css | 12 ------------
2 files changed, 37 deletions(-)
diff --git a/site/src/pages/index.astro b/site/src/pages/index.astro
index 4263549..b8bf92b 100644
--- a/site/src/pages/index.astro
+++ b/site/src/pages/index.astro
@@ -10,31 +10,6 @@ import DocLayout from "../layouts/DocLayout.astro";
non-interactive desktop control for AI agents
-
-
deskctl is a thin X11 control primitive for agent loops: diagnose
the runtime, observe the desktop, wait for state transitions, act deterministically,
diff --git a/site/src/styles/base.css b/site/src/styles/base.css
index f60c0e6..cd569a9 100644
--- a/site/src/styles/base.css
+++ b/site/src/styles/base.css
@@ -70,18 +70,6 @@ main {
max-width: 42rem;
}
-.badges {
- display: flex;
- flex-wrap: wrap;
- gap: 0.6rem;
- margin-bottom: 1.25rem;
-}
-
-.badges a,
-.badges img {
- display: block;
-}
-
header {
display: flex;
align-items: center;
From eac3a61ceb35002bf3957e6d0ebe4c2025ab1203 Mon Sep 17 00:00:00 2001
From: Hari <73809867+harivansh-afk@users.noreply.github.com>
Date: Thu, 26 Mar 2026 08:44:10 -0400
Subject: [PATCH 19/49] rename (#11)
* align docs and contract
* clean
* rename from deskctl-cli to deskctl
* runtime
---
.github/workflows/ci.yml | 4 ++--
.github/workflows/publish.yml | 12 ++++++------
.gitignore | 4 ++--
CONTRIBUTING.md | 2 +-
Makefile | 4 ++--
README.md | 9 +++++----
docs/releasing.md | 4 ++--
docs/runtime-contract.md | 2 --
npm/{deskctl-cli => deskctl}/README.md | 10 +++++-----
npm/{deskctl-cli => deskctl}/bin/deskctl.js | 2 +-
npm/{deskctl-cli => deskctl}/package.json | 4 ++--
npm/{deskctl-cli => deskctl}/scripts/postinstall.js | 2 +-
npm/{deskctl-cli => deskctl}/scripts/support.js | 2 +-
.../scripts/validate-package.js | 4 ++--
site/src/pages/index.astro | 11 +++--------
site/src/pages/installation.mdx | 6 +++---
site/src/pages/quick-start.mdx | 2 +-
skills/deskctl/SKILL.md | 4 ++--
18 files changed, 41 insertions(+), 47 deletions(-)
rename npm/{deskctl-cli => deskctl}/README.md (67%)
rename npm/{deskctl-cli => deskctl}/bin/deskctl.js (91%)
rename npm/{deskctl-cli => deskctl}/package.json (86%)
rename npm/{deskctl-cli => deskctl}/scripts/postinstall.js (94%)
rename npm/{deskctl-cli => deskctl}/scripts/support.js (97%)
rename npm/{deskctl-cli => deskctl}/scripts/validate-package.js (87%)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index e95b27a..b7a4d6f 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -189,7 +189,7 @@ jobs:
NEW="${{ needs.changes.outputs.version }}"
if [ "$CURRENT" != "$NEW" ]; then
sed -i "0,/^version = \"${CURRENT}\"/s//version = \"${NEW}\"/" Cargo.toml
- node -e 'const fs=require("node:fs"); const path="npm/deskctl-cli/package.json"; const pkg=JSON.parse(fs.readFileSync(path,"utf8")); pkg.version=process.argv[1]; fs.writeFileSync(path, JSON.stringify(pkg, null, 2)+"\n");' "$NEW"
+ node -e 'const fs=require("node:fs"); const path="npm/deskctl/package.json"; const pkg=JSON.parse(fs.readFileSync(path,"utf8")); pkg.version=process.argv[1]; fs.writeFileSync(path, JSON.stringify(pkg, null, 2)+"\n");' "$NEW"
cargo generate-lockfile
fi
@@ -199,7 +199,7 @@ jobs:
git config user.email "github-actions[bot]@users.noreply.github.com"
if ! git diff --quiet; then
- git add Cargo.toml Cargo.lock npm/deskctl-cli/package.json
+ git add Cargo.toml Cargo.lock npm/deskctl/package.json
git commit -m "release: ${{ needs.changes.outputs.tag }} [skip ci]"
fi
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index 329f151..c4b1ecf 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -8,7 +8,7 @@ on:
required: true
type: string
publish_npm:
- description: Publish deskctl-cli to npm
+ description: Publish deskctl to npm
required: true
type: boolean
default: false
@@ -51,7 +51,7 @@ jobs:
TAG="${{ inputs.tag }}"
VERSION="${TAG#v}"
CARGO_VERSION=$(grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/')
- NPM_VERSION=$(node -p 'require("./npm/deskctl-cli/package.json").version')
+ NPM_VERSION=$(node -p 'require("./npm/deskctl/package.json").version')
test "$VERSION" = "$CARGO_VERSION"
test "$VERSION" = "$NPM_VERSION"
@@ -62,7 +62,7 @@ jobs:
VERSION="${{ inputs.tag }}"
VERSION="${VERSION#v}"
- if npm view "deskctl-cli@${VERSION}" version >/dev/null 2>&1; then
+ if npm view "deskctl@${VERSION}" version >/dev/null 2>&1; then
echo "npm=true" >> "$GITHUB_OUTPUT"
else
echo "npm=false" >> "$GITHUB_OUTPUT"
@@ -77,8 +77,8 @@ jobs:
- name: Validate npm package
run: |
mkdir -p ./tmp/npm-pack
- node npm/deskctl-cli/scripts/validate-package.js
- npm pack ./npm/deskctl-cli --pack-destination ./tmp/npm-pack >/dev/null
+ node npm/deskctl/scripts/validate-package.js
+ npm pack ./npm/deskctl --pack-destination ./tmp/npm-pack >/dev/null
- name: Validate crate publish path
run: cargo publish --dry-run --locked
@@ -87,7 +87,7 @@ jobs:
if: inputs.publish_npm && steps.published.outputs.npm != 'true'
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- run: npm publish ./npm/deskctl-cli --access public
+ run: npm publish ./npm/deskctl --access public
- name: Publish crates.io
if: inputs.publish_crates && steps.published.outputs.crates != 'true'
diff --git a/.gitignore b/.gitignore
index db552f7..40542a9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,5 +5,5 @@ secret/
.claude/
.codex/
openspec/
-npm/deskctl-cli/vendor/
-npm/deskctl-cli/*.tgz
+npm/deskctl/vendor/
+npm/deskctl/*.tgz
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 926c58a..97e8c7c 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -70,7 +70,7 @@ The hook config intentionally stays small:
Distribution support currently ships through:
- crate: `deskctl`
-- npm package: `deskctl-cli`
+- npm package: `deskctl`
- repo flake: `flake.nix`
- command name on every channel: `deskctl`
diff --git a/Makefile b/Makefile
index 97857e3..7e1f852 100644
--- a/Makefile
+++ b/Makefile
@@ -38,10 +38,10 @@ npm-package-check:
echo "npm is required for npm packaging validation."; \
exit 1; \
fi
- node npm/deskctl-cli/scripts/validate-package.js
+ node npm/deskctl/scripts/validate-package.js
rm -rf tmp/npm-pack tmp/npm-install
mkdir -p tmp/npm-pack tmp/npm-install/bin
- npm pack ./npm/deskctl-cli --pack-destination ./tmp/npm-pack >/dev/null
+ npm pack ./npm/deskctl --pack-destination ./tmp/npm-pack >/dev/null
@if [ "$$(uname -s)" != "Linux" ]; then \
echo "Skipping npm package runtime smoke test on non-Linux host."; \
else \
diff --git a/README.md b/README.md
index 32144f0..4b42b5f 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# deskctl
-[](https://www.npmjs.com/package/deskctl-cli)
+[](https://www.npmjs.com/package/deskctl)
[](https://github.com/harivansh-afk/deskctl/releases)
[](#support-boundary)
[](skills/deskctl)
@@ -10,7 +10,7 @@ Non-interactive desktop control for AI agents on Linux X11.
## Install
```bash
-npm install -g deskctl-cli
+npm install -g deskctl
deskctl doctor
deskctl snapshot --annotate
```
@@ -18,10 +18,11 @@ deskctl snapshot --annotate
One-shot execution also works:
```bash
-npx deskctl-cli --help
+npx deskctl --help
```
-`deskctl-cli` installs the `deskctl` command by downloading the matching GitHub Release asset for the supported runtime target.
+`deskctl` installs the command by downloading the matching GitHub Release asset for the supported runtime target.
+
## Installable skill
diff --git a/docs/releasing.md b/docs/releasing.md
index 7271b83..8f39d3f 100644
--- a/docs/releasing.md
+++ b/docs/releasing.md
@@ -12,14 +12,14 @@ GitHub Releases are the canonical binary source. The npm package consumes those
## Package Names
- crate: `deskctl`
-- npm package: `deskctl-cli`
+- npm package: `deskctl`
- installed command: `deskctl`
## Prerequisites
Before the first live publish on each registry:
-- npm ownership for `deskctl-cli`
+- npm ownership for `deskctl`
- crates.io ownership for `deskctl`
- repository secrets:
- `NPM_TOKEN`
diff --git a/docs/runtime-contract.md b/docs/runtime-contract.md
index 0316c06..ee4727b 100644
--- a/docs/runtime-contract.md
+++ b/docs/runtime-contract.md
@@ -68,5 +68,3 @@ Treat these as useful but non-contractual:
- incidental text formatting in non-JSON mode
- default screenshot file names when no explicit path was provided
- environment-dependent ordering details from the window manager
-
-For the full repo copy, see `docs/runtime-contract.md`.
diff --git a/npm/deskctl-cli/README.md b/npm/deskctl/README.md
similarity index 67%
rename from npm/deskctl-cli/README.md
rename to npm/deskctl/README.md
index fd6f610..7bb42a9 100644
--- a/npm/deskctl-cli/README.md
+++ b/npm/deskctl/README.md
@@ -1,11 +1,11 @@
-# deskctl-cli
+# deskctl
-`deskctl-cli` installs the `deskctl` command for Linux X11 systems.
+`deskctl` installs the command for Linux X11 systems.
## Install
```bash
-npm install -g deskctl-cli
+npm install -g deskctl
```
After install, run:
@@ -17,7 +17,7 @@ deskctl --help
One-shot usage is also supported:
```bash
-npx deskctl-cli --help
+npx deskctl --help
```
## Runtime Support
@@ -26,7 +26,7 @@ npx deskctl-cli --help
- X11 session
- currently packaged release asset: `linux-x64`
-`deskctl-cli` downloads the matching GitHub Release binary during install.
+`deskctl` downloads the matching GitHub Release binary during install.
Unsupported targets fail during install with a clear runtime support error instead of installing a broken command.
If you want the Rust source-install path instead, use:
diff --git a/npm/deskctl-cli/bin/deskctl.js b/npm/deskctl/bin/deskctl.js
similarity index 91%
rename from npm/deskctl-cli/bin/deskctl.js
rename to npm/deskctl/bin/deskctl.js
index 9f9b480..b8514cf 100644
--- a/npm/deskctl-cli/bin/deskctl.js
+++ b/npm/deskctl/bin/deskctl.js
@@ -17,7 +17,7 @@ function main() {
`Expected: ${binaryPath}`,
`Package version: ${pkg.version}`,
`Release tag: ${releaseTag(pkg)}`,
- "Try reinstalling deskctl-cli or check that your target is supported."
+ "Try reinstalling deskctl or check that your target is supported."
].join("\n")
);
process.exit(1);
diff --git a/npm/deskctl-cli/package.json b/npm/deskctl/package.json
similarity index 86%
rename from npm/deskctl-cli/package.json
rename to npm/deskctl/package.json
index 84f27ee..4dbaba6 100644
--- a/npm/deskctl-cli/package.json
+++ b/npm/deskctl/package.json
@@ -1,7 +1,7 @@
{
- "name": "deskctl-cli",
+ "name": "deskctl",
"version": "0.1.6",
- "description": "Installable deskctl CLI package for Linux X11 agents",
+ "description": "Installable deskctl package for Linux X11 agents",
"license": "MIT",
"homepage": "https://github.com/harivansh-afk/deskctl",
"repository": {
diff --git a/npm/deskctl-cli/scripts/postinstall.js b/npm/deskctl/scripts/postinstall.js
similarity index 94%
rename from npm/deskctl-cli/scripts/postinstall.js
rename to npm/deskctl/scripts/postinstall.js
index de1b1d0..1f43ad0 100644
--- a/npm/deskctl-cli/scripts/postinstall.js
+++ b/npm/deskctl/scripts/postinstall.js
@@ -44,6 +44,6 @@ async function main() {
}
main().catch((error) => {
- console.error(`deskctl-cli install failed: ${error.message}`);
+ console.error(`deskctl install failed: ${error.message}`);
process.exit(1);
});
diff --git a/npm/deskctl-cli/scripts/support.js b/npm/deskctl/scripts/support.js
similarity index 97%
rename from npm/deskctl-cli/scripts/support.js
rename to npm/deskctl/scripts/support.js
index 8d41520..1fd0d47 100644
--- a/npm/deskctl-cli/scripts/support.js
+++ b/npm/deskctl/scripts/support.js
@@ -26,7 +26,7 @@ function supportedTarget(platform = process.platform, arch = process.arch) {
}
throw new Error(
- `deskctl-cli currently supports linux-x64 only. Received ${platform}-${arch}.`
+ `deskctl currently supports linux-x64 only. Received ${platform}-${arch}.`
);
}
diff --git a/npm/deskctl-cli/scripts/validate-package.js b/npm/deskctl/scripts/validate-package.js
similarity index 87%
rename from npm/deskctl-cli/scripts/validate-package.js
rename to npm/deskctl/scripts/validate-package.js
index 46d3e87..450fd6c 100644
--- a/npm/deskctl-cli/scripts/validate-package.js
+++ b/npm/deskctl/scripts/validate-package.js
@@ -26,13 +26,13 @@ function main() {
}
if (pkg.bin?.deskctl !== "bin/deskctl.js") {
- throw new Error("deskctl-cli must expose the deskctl bin entrypoint.");
+ throw new Error("deskctl must expose the deskctl bin entrypoint.");
}
const target = supportedTarget("linux", "x64");
const targetPath = vendorBinaryPath(target);
const vendorDir = path.dirname(targetPath);
- if (!vendorDir.endsWith(path.join("deskctl-cli", "vendor"))) {
+ if (!vendorDir.endsWith(path.join("deskctl", "vendor"))) {
throw new Error("Vendor binary directory resolved unexpectedly.");
}
}
diff --git a/site/src/pages/index.astro b/site/src/pages/index.astro
index b8bf92b..8b8d4b4 100644
--- a/site/src/pages/index.astro
+++ b/site/src/pages/index.astro
@@ -16,10 +16,6 @@ import DocLayout from "../layouts/DocLayout.astro";
then verify.
- npm install -g deskctl-cli
-deskctl doctor
-deskctl snapshot --annotate
-
Start here
@@ -33,6 +29,7 @@ deskctl snapshot --annotate
Agent skill
@@ -47,15 +44,13 @@ deskctl snapshot --annotate
diff --git a/site/src/pages/installation.mdx b/site/src/pages/installation.mdx
index 985cf99..df53fcc 100644
--- a/site/src/pages/installation.mdx
+++ b/site/src/pages/installation.mdx
@@ -9,17 +9,17 @@ toc: true
## Default install
```sh
-npm install -g deskctl-cli
+npm install -g deskctl
deskctl --help
```
-`deskctl-cli` is the default install path. It installs the `deskctl` command by
+`deskctl` is the default install path. It installs the command by
downloading the matching GitHub Release asset for the supported runtime target.
## One-shot usage
```sh
-npx deskctl-cli --help
+npx deskctl --help
```
## Agent skill
diff --git a/site/src/pages/quick-start.mdx b/site/src/pages/quick-start.mdx
index c783b9e..10f3ec0 100644
--- a/site/src/pages/quick-start.mdx
+++ b/site/src/pages/quick-start.mdx
@@ -9,7 +9,7 @@ toc: true
## Install and diagnose
```sh
-npm install -g deskctl-cli
+npm install -g deskctl
deskctl doctor
```
diff --git a/skills/deskctl/SKILL.md b/skills/deskctl/SKILL.md
index 81dea19..244a1fb 100644
--- a/skills/deskctl/SKILL.md
+++ b/skills/deskctl/SKILL.md
@@ -1,7 +1,7 @@
---
name: deskctl
description: Non-interactive X11 desktop control for AI agents. Use when the task involves controlling a Linux desktop - clicking, typing, reading windows, waiting for UI state, or taking screenshots inside a sandbox or VM.
-allowed-tools: Bash(deskctl:*), Bash(npx deskctl-cli:*), Bash(npm:*), Bash(which:*), Bash(printenv:*), Bash(echo:*)
+allowed-tools: Bash(deskctl:*), Bash(npx deskctl:*), Bash(npm:*), Bash(which:*), Bash(printenv:*), Bash(echo:*)
---
# deskctl
@@ -13,7 +13,7 @@ All output follows the runtime contract defined in [references/runtime-contract.
## Quick start
```bash
-npm install -g deskctl-cli
+npm install -g deskctl
deskctl doctor
deskctl snapshot --annotate
```
From 86c36a3b509aac8ea5869feb02df931fbcf7d752 Mon Sep 17 00:00:00 2001
From: Harivansh Rathi
Date: Thu, 26 Mar 2026 08:53:50 -0400
Subject: [PATCH 20/49] release: v0.1.7 [skip ci]
---
Cargo.lock | 2 +-
Cargo.toml | 2 +-
npm/deskctl/package.json | 2 +-
3 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
index 71a9a54..6922004 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -400,7 +400,7 @@ checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "deskctl"
-version = "0.1.6"
+version = "0.1.7"
dependencies = [
"ab_glyph",
"anyhow",
diff --git a/Cargo.toml b/Cargo.toml
index b05507b..5872639 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "deskctl"
-version = "0.1.6"
+version = "0.1.7"
edition = "2021"
description = "X11 desktop control CLI for agents"
license = "MIT"
diff --git a/npm/deskctl/package.json b/npm/deskctl/package.json
index 4dbaba6..6085bca 100644
--- a/npm/deskctl/package.json
+++ b/npm/deskctl/package.json
@@ -1,6 +1,6 @@
{
"name": "deskctl",
- "version": "0.1.6",
+ "version": "0.1.7",
"description": "Installable deskctl package for Linux X11 agents",
"license": "MIT",
"homepage": "https://github.com/harivansh-afk/deskctl",
From 1d72c7b852e4195f20b002a4aaf25e2b1a2b8e26 Mon Sep 17 00:00:00 2001
From: Harivansh Rathi
Date: Thu, 26 Mar 2026 09:01:12 -0400
Subject: [PATCH 21/49] fix: add registry-url to setup-node for npm auth [skip
ci]
---
.github/workflows/publish.yml | 1 +
1 file changed, 1 insertion(+)
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index c4b1ecf..1f6b282 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -34,6 +34,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: 22
+ registry-url: https://registry.npmjs.org
- name: Install system dependencies
run: sudo apt-get update && sudo apt-get install -y libx11-dev libxtst-dev
From deaffff45a574b1701482ac04043f7af557e46f5 Mon Sep 17 00:00:00 2001
From: Harivansh Rathi
Date: Thu, 26 Mar 2026 09:07:56 -0400
Subject: [PATCH 22/49] major/minor/patch
---
.github/workflows/publish.yml | 12 ++++++++----
1 file changed, 8 insertions(+), 4 deletions(-)
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index 1f6b282..31b3f4f 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -3,15 +3,19 @@ name: Publish Registries
on:
workflow_dispatch:
inputs:
- tag:
- description: Release tag to publish (for example v0.1.5)
+ bump:
+ description: Version bump type
required: true
- type: string
+ type: choice
+ options:
+ - patch
+ - minor
+ - major
publish_npm:
description: Publish deskctl to npm
required: true
type: boolean
- default: false
+ default: true
publish_crates:
description: Publish deskctl to crates.io
required: true
From 47047e90641bf5e4b90f31aeb1157cd9b054868e Mon Sep 17 00:00:00 2001
From: Harivansh Rathi
Date: Thu, 26 Mar 2026 09:11:13 -0400
Subject: [PATCH 23/49] migrate update manifest job to publish workflow
---
.github/workflows/ci.yml | 75 +++--------------------------------
.github/workflows/publish.yml | 54 +++++++++++++++++--------
2 files changed, 43 insertions(+), 86 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index b7a4d6f..cb36e61 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -52,32 +52,13 @@ jobs:
echo "rust=${{ steps.filter.outputs.rust }}" >> "$GITHUB_OUTPUT"
fi
- - name: Calculate next version
+ - name: Read current version
id: version
if: github.event_name != 'pull_request' && steps.check.outputs.rust == 'true'
run: |
- BASE=$(grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/')
- IFS='.' read -r MAJOR MINOR PATCH <<< "$BASE"
-
- LATEST=$(git tag -l "v${MAJOR}.${MINOR}.*" | sort -V | tail -1)
-
- if [ -z "$LATEST" ]; then
- NEW="$BASE"
- else
- LATEST_VER="${LATEST#v}"
- IFS='.' read -r _ _ LATEST_PATCH <<< "$LATEST_VER"
- NEW_PATCH=$((LATEST_PATCH + 1))
- NEW="${MAJOR}.${MINOR}.${NEW_PATCH}"
- fi
-
- # Ensure the computed version does not already have a tag
- while git rev-parse "v${NEW}" >/dev/null 2>&1; do
- IFS='.' read -r MAJOR MINOR PATCH <<< "$NEW"
- NEW="${MAJOR}.${MINOR}.$((PATCH + 1))"
- done
-
- echo "version=${NEW}" >> "$GITHUB_OUTPUT"
- echo "tag=v${NEW}" >> "$GITHUB_OUTPUT"
+ VERSION=$(grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/')
+ echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
+ echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT"
validate:
name: Validate
@@ -167,57 +148,13 @@ jobs:
- name: Distribution validation
run: make dist-validate
- update-manifests:
- name: Update Manifests
- needs: [changes, validate, integration, distribution]
- if: github.event_name != 'pull_request'
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v4
- with:
- fetch-depth: 0
-
- - uses: dtolnay/rust-toolchain@stable
-
- - uses: actions/setup-node@v4
- with:
- node-version: 22
-
- - name: Update version in Cargo.toml
- run: |
- CURRENT=$(grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/')
- NEW="${{ needs.changes.outputs.version }}"
- if [ "$CURRENT" != "$NEW" ]; then
- sed -i "0,/^version = \"${CURRENT}\"/s//version = \"${NEW}\"/" Cargo.toml
- node -e 'const fs=require("node:fs"); const path="npm/deskctl/package.json"; const pkg=JSON.parse(fs.readFileSync(path,"utf8")); pkg.version=process.argv[1]; fs.writeFileSync(path, JSON.stringify(pkg, null, 2)+"\n");' "$NEW"
- cargo generate-lockfile
- fi
-
- - name: Commit, tag, and push
- run: |
- git config user.name "github-actions[bot]"
- git config user.email "github-actions[bot]@users.noreply.github.com"
-
- if ! git diff --quiet; then
- git add Cargo.toml Cargo.lock npm/deskctl/package.json
- git commit -m "release: ${{ needs.changes.outputs.tag }} [skip ci]"
- fi
-
- if ! git rev-parse "${{ needs.changes.outputs.tag }}" >/dev/null 2>&1; then
- git tag "${{ needs.changes.outputs.tag }}"
- fi
- git push origin main --tags
-
build:
name: Build Release Asset
- needs: [changes, update-manifests]
+ needs: [changes, validate, integration, distribution]
if: github.event_name != 'pull_request' && needs.changes.outputs.rust == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- with:
- ref: ${{ needs.changes.outputs.tag }}
- fetch-depth: 0
- uses: dtolnay/rust-toolchain@stable
with:
@@ -242,7 +179,7 @@ jobs:
release:
name: Release
- needs: [changes, build, update-manifests]
+ needs: [changes, build]
if: github.event_name != 'pull_request'
runs-on: ubuntu-latest
steps:
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index 31b3f4f..60aed4d 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -28,10 +28,12 @@ permissions:
jobs:
publish:
runs-on: ubuntu-latest
+ permissions:
+ contents: write
steps:
- uses: actions/checkout@v4
with:
- ref: ${{ inputs.tag }}
+ fetch-depth: 0
- uses: dtolnay/rust-toolchain@stable
@@ -43,29 +45,46 @@ jobs:
- name: Install system dependencies
run: sudo apt-get update && sudo apt-get install -y libx11-dev libxtst-dev
- - name: Verify release exists and contains canonical assets
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ - name: Compute next version
+ id: version
run: |
- gh release view "${{ inputs.tag }}" --json assets --jq '.assets[].name' > /tmp/release-assets.txt
- grep -Fx "deskctl-linux-x86_64" /tmp/release-assets.txt >/dev/null
- grep -Fx "checksums.txt" /tmp/release-assets.txt >/dev/null
+ CURRENT=$(grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/')
+ IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT"
- - name: Verify versions align with tag
+ case "${{ inputs.bump }}" in
+ major) MAJOR=$((MAJOR + 1)); MINOR=0; PATCH=0 ;;
+ minor) MINOR=$((MINOR + 1)); PATCH=0 ;;
+ patch) PATCH=$((PATCH + 1)) ;;
+ esac
+
+ NEW="${MAJOR}.${MINOR}.${PATCH}"
+ TAG="v${NEW}"
+
+ echo "version=${NEW}" >> "$GITHUB_OUTPUT"
+ echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
+ echo "Bumping ${CURRENT} -> ${NEW} (${TAG})"
+
+ - name: Bump versions
run: |
- TAG="${{ inputs.tag }}"
- VERSION="${TAG#v}"
- CARGO_VERSION=$(grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/')
- NPM_VERSION=$(node -p 'require("./npm/deskctl/package.json").version')
+ NEW="${{ steps.version.outputs.version }}"
+ CURRENT=$(grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/')
+ sed -i "0,/^version = \"${CURRENT}\"/s//version = \"${NEW}\"/" Cargo.toml
+ node -e 'const fs=require("node:fs"); const p="npm/deskctl/package.json"; const pkg=JSON.parse(fs.readFileSync(p,"utf8")); pkg.version=process.argv[1]; fs.writeFileSync(p, JSON.stringify(pkg, null, 2)+"\n");' "$NEW"
+ cargo generate-lockfile
- test "$VERSION" = "$CARGO_VERSION"
- test "$VERSION" = "$NPM_VERSION"
+ - name: Commit, tag, and push
+ run: |
+ git config user.name "github-actions[bot]"
+ git config user.email "github-actions[bot]@users.noreply.github.com"
+ git add Cargo.toml Cargo.lock npm/deskctl/package.json
+ git commit -m "release: ${{ steps.version.outputs.tag }} [skip ci]"
+ git tag "${{ steps.version.outputs.tag }}"
+ git push origin main --tags
- name: Check current published state
id: published
run: |
- VERSION="${{ inputs.tag }}"
- VERSION="${VERSION#v}"
+ VERSION="${{ steps.version.outputs.version }}"
if npm view "deskctl@${VERSION}" version >/dev/null 2>&1; then
echo "npm=true" >> "$GITHUB_OUTPUT"
@@ -102,6 +121,7 @@ jobs:
- name: Summary
run: |
- echo "tag=${{ inputs.tag }}"
+ echo "tag=${{ steps.version.outputs.tag }}"
+ echo "bump=${{ inputs.bump }}"
echo "npm_already_published=${{ steps.published.outputs.npm }}"
echo "crates_already_published=${{ steps.published.outputs.crates }}"
From eedb5de2d478acebe6dbd75f17f716ccbb8f0d8c Mon Sep 17 00:00:00 2001
From: Harivansh Rathi
Date: Thu, 26 Mar 2026 09:13:10 -0400
Subject: [PATCH 24/49] refresh contributor cache [skip ci]
From 2a8b51b4f5249969c6adb2a28ea0ef9238b84667 Mon Sep 17 00:00:00 2001
From: Harivansh Rathi
Date: Thu, 26 Mar 2026 09:21:43 -0400
Subject: [PATCH 25/49] docs: tighten skill install docs and bundle
Co-authored-by: Codex
---
README.md | 7 +-
site/src/pages/index.astro | 2 +-
site/src/pages/installation.mdx | 8 +-
skills/deskctl/agents/openai.yaml | 7 ++
skills/deskctl/references/runtime-contract.md | 74 ++++++++++++++++++-
5 files changed, 91 insertions(+), 7 deletions(-)
create mode 100644 skills/deskctl/agents/openai.yaml
mode change 120000 => 100644 skills/deskctl/references/runtime-contract.md
diff --git a/README.md b/README.md
index 4b42b5f..f2e746f 100644
--- a/README.md
+++ b/README.md
@@ -27,10 +27,13 @@ npx deskctl --help
## Installable skill
```bash
-npx skills add harivansh-afk/deskctl -s deskctl
+npx skills add harivansh-afk/deskctl --skill deskctl -g
```
-The installable skill lives in [`skills/deskctl`](skills/deskctl) and is built around the same observe -> wait -> act -> verify loop as the CLI.
+The installable skill lives in [`skills/deskctl`](skills/deskctl), follows the
+standard `skills/` repo layout, and installs directly from this GitHub repo via
+`npx skills add ...`. It is built around the same observe -> wait -> act ->
+verify loop as the CLI.
## Quick example
diff --git a/site/src/pages/index.astro b/site/src/pages/index.astro
index 8b8d4b4..e97b599 100644
--- a/site/src/pages/index.astro
+++ b/site/src/pages/index.astro
@@ -38,7 +38,7 @@ import DocLayout from "../layouts/DocLayout.astro";
There is also an installable skill for `skills.sh`-style agent runtimes:
- npx skills add harivansh-afk/deskctl -s deskctl
+ npx skills add harivansh-afk/deskctl --skill deskctl -g
Links
diff --git a/site/src/pages/installation.mdx b/site/src/pages/installation.mdx
index df53fcc..7754e6b 100644
--- a/site/src/pages/installation.mdx
+++ b/site/src/pages/installation.mdx
@@ -27,11 +27,13 @@ npx deskctl --help
For `skills.sh`-style runtimes:
```sh
-npx skills add harivansh-afk/deskctl -s deskctl
+npx skills add harivansh-afk/deskctl --skill deskctl -g
```
-The repo skill lives under `skills/deskctl` and is designed around the same
-observe -> wait -> act -> verify loop as the CLI.
+The repo skill lives under `skills/deskctl`, so `skills` can install it
+directly from this GitHub repo. It is designed around the same observe -> wait
+-> act -> verify loop as the CLI. `-g` installs it globally; omit that flag if
+you want a project-local install.
## Other install paths
diff --git a/skills/deskctl/agents/openai.yaml b/skills/deskctl/agents/openai.yaml
new file mode 100644
index 0000000..8a5ca13
--- /dev/null
+++ b/skills/deskctl/agents/openai.yaml
@@ -0,0 +1,7 @@
+interface:
+ display_name: "deskctl"
+ short_description: "Control Linux X11 desktops from agent loops"
+ default_prompt: "Use $deskctl to diagnose the desktop, observe state, wait for UI changes, act deterministically, and verify the result."
+
+policy:
+ allow_implicit_invocation: true
diff --git a/skills/deskctl/references/runtime-contract.md b/skills/deskctl/references/runtime-contract.md
deleted file mode 120000
index 8de0781..0000000
--- a/skills/deskctl/references/runtime-contract.md
+++ /dev/null
@@ -1 +0,0 @@
-../../../docs/runtime-contract.md
\ No newline at end of file
diff --git a/skills/deskctl/references/runtime-contract.md b/skills/deskctl/references/runtime-contract.md
new file mode 100644
index 0000000..6efd2bc
--- /dev/null
+++ b/skills/deskctl/references/runtime-contract.md
@@ -0,0 +1,73 @@
+# deskctl runtime contract
+
+This copy ships inside the installable skill so `npx skills add ...` installs a
+self-contained reference bundle.
+
+All commands support `--json` and use the same top-level envelope:
+
+```json
+{
+ "success": true,
+ "data": {},
+ "error": null
+}
+```
+
+Use `--json` whenever you need to parse output programmatically.
+
+## Stable window fields
+
+Whenever a response includes a window payload, these fields are stable:
+
+- `ref_id`
+- `window_id`
+- `title`
+- `app_name`
+- `x`
+- `y`
+- `width`
+- `height`
+- `focused`
+- `minimized`
+
+Use `window_id` for stable targeting inside a live daemon session. Use
+`ref_id` or `@wN` for short-lived follow-up actions after `snapshot` or
+`list-windows`.
+
+## Stable grouped reads
+
+- `deskctl get active-window` -> `data.window`
+- `deskctl get monitors` -> `data.count`, `data.monitors`
+- `deskctl get version` -> `data.version`, `data.backend`
+- `deskctl get systeminfo` -> runtime-scoped diagnostic fields such as
+ `backend`, `display`, `session_type`, `session`, `socket_path`, `screen`,
+ `monitor_count`, and `monitors`
+
+## Stable waits
+
+- `deskctl wait window` -> `data.wait`, `data.selector`, `data.elapsed_ms`,
+ `data.window`
+- `deskctl wait focus` -> `data.wait`, `data.selector`, `data.elapsed_ms`,
+ `data.window`
+
+## Stable structured error kinds
+
+When a command fails with structured JSON data, these `kind` values are stable:
+
+- `selector_not_found`
+- `selector_ambiguous`
+- `selector_invalid`
+- `timeout`
+- `not_found`
+
+Wait failures may also include `window_not_focused` in the last observation
+payload.
+
+## Best-effort fields
+
+Treat these as useful but non-contractual:
+
+- exact monitor names
+- incidental text formatting in non-JSON mode
+- default screenshot file names when no explicit path was provided
+- environment-dependent ordering details from the window manager
From c907e800af804ad44dd844e09f1a0c02d36316a6 Mon Sep 17 00:00:00 2001
From: Harivansh Rathi
Date: Thu, 26 Mar 2026 09:21:58 -0400
Subject: [PATCH 26/49] change client bin name
---
src/cli/mod.rs | 13 ++++++++++++-
1 file changed, 12 insertions(+), 1 deletion(-)
diff --git a/src/cli/mod.rs b/src/cli/mod.rs
index bab44c9..b24465a 100644
--- a/src/cli/mod.rs
+++ b/src/cli/mod.rs
@@ -7,7 +7,12 @@ use std::path::PathBuf;
use crate::core::protocol::{Request, Response};
#[derive(Parser)]
-#[command(name = "deskctl", version, about = "Desktop control CLI for AI agents")]
+#[command(
+ name = "deskctl",
+ bin_name = "deskctl",
+ version,
+ about = "Desktop control CLI for AI agents"
+)]
pub struct App {
#[command(flatten)]
pub global: GlobalOpts,
@@ -988,6 +993,12 @@ mod tests {
assert!(help.contains("deskctl snapshot --annotate"));
}
+ #[test]
+ fn root_help_uses_public_bin_name() {
+ let help = App::command().render_help().to_string();
+ assert!(help.contains("Usage: deskctl [OPTIONS] "));
+ }
+
#[test]
fn window_listing_text_includes_window_ids() {
let lines = render_success_lines(
From 3bfec9eecc890208d6f4f37b97a95534b2a982f5 Mon Sep 17 00:00:00 2001
From: Harivansh Rathi
Date: Thu, 26 Mar 2026 09:27:43 -0400
Subject: [PATCH 27/49] edit docs
---
site/src/pages/index.astro | 13 -------------
site/src/pages/installation.mdx | 15 ---------------
2 files changed, 28 deletions(-)
diff --git a/site/src/pages/index.astro b/site/src/pages/index.astro
index e97b599..b770178 100644
--- a/site/src/pages/index.astro
+++ b/site/src/pages/index.astro
@@ -21,7 +21,6 @@ import DocLayout from "../layouts/DocLayout.astro";
Reference
@@ -34,23 +33,11 @@ import DocLayout from "../layouts/DocLayout.astro";
Agent skill
-
- There is also an installable skill for `skills.sh`-style agent runtimes:
-
-
- npx skills add harivansh-afk/deskctl --skill deskctl -g
-
Links
diff --git a/site/src/pages/installation.mdx b/site/src/pages/installation.mdx
index 7754e6b..ed4e737 100644
--- a/site/src/pages/installation.mdx
+++ b/site/src/pages/installation.mdx
@@ -10,26 +10,11 @@ toc: true
```sh
npm install -g deskctl
-deskctl --help
```
`deskctl` is the default install path. It installs the command by
downloading the matching GitHub Release asset for the supported runtime target.
-## One-shot usage
-
-```sh
-npx deskctl --help
-```
-
-## Agent skill
-
-For `skills.sh`-style runtimes:
-
-```sh
-npx skills add harivansh-afk/deskctl --skill deskctl -g
-```
-
The repo skill lives under `skills/deskctl`, so `skills` can install it
directly from this GitHub repo. It is designed around the same observe -> wait
-> act -> verify loop as the CLI. `-g` installs it globally; omit that flag if
From bf603671f95f28270e4ede426f03442c9203b328 Mon Sep 17 00:00:00 2001
From: Harivansh Rathi
Date: Thu, 26 Mar 2026 09:28:18 -0400
Subject: [PATCH 28/49] rm:
---
site/src/pages/index.astro | 2 --
1 file changed, 2 deletions(-)
diff --git a/site/src/pages/index.astro b/site/src/pages/index.astro
index b770178..16a4b29 100644
--- a/site/src/pages/index.astro
+++ b/site/src/pages/index.astro
@@ -31,8 +31,6 @@ import DocLayout from "../layouts/DocLayout.astro";
Runtime contract
- Agent skill
-
Links
From 848ef97e87c321dffda0a6e4823c3ce8871569e1 Mon Sep 17 00:00:00 2001
From: Harivansh Rathi
Date: Thu, 26 Mar 2026 09:34:10 -0400
Subject: [PATCH 29/49] edit readme
---
README.md | 45 +++++++--------------------------------------
1 file changed, 7 insertions(+), 38 deletions(-)
diff --git a/README.md b/README.md
index f2e746f..4bc24c8 100644
--- a/README.md
+++ b/README.md
@@ -1,11 +1,9 @@
# deskctl
[](https://www.npmjs.com/package/deskctl)
-[](https://github.com/harivansh-afk/deskctl/releases)
-[](#support-boundary)
[](skills/deskctl)
-Non-interactive desktop control for AI agents on Linux X11.
+Desktop control cli for AI agents on Linux X11.
## Install
@@ -15,44 +13,19 @@ deskctl doctor
deskctl snapshot --annotate
```
-One-shot execution also works:
+## Skill
```bash
-npx deskctl --help
-```
-
-`deskctl` installs the command by downloading the matching GitHub Release asset for the supported runtime target.
-
-
-## Installable skill
-
-```bash
-npx skills add harivansh-afk/deskctl --skill deskctl -g
-```
-
-The installable skill lives in [`skills/deskctl`](skills/deskctl), follows the
-standard `skills/` repo layout, and installs directly from this GitHub repo via
-`npx skills add ...`. It is built around the same observe -> wait -> act ->
-verify loop as the CLI.
-
-## Quick example
-
-```bash
-deskctl doctor
-deskctl snapshot --annotate
-deskctl wait window --selector 'title=Firefox' --timeout 10
-deskctl focus 'title=Firefox'
-deskctl type "hello world"
+npx skills add harivansh-afk/deskctl
```
## Docs
- runtime contract: [docs/runtime-contract.md](docs/runtime-contract.md)
-- release flow: [docs/releasing.md](docs/releasing.md)
-- installable skill: [skills/deskctl](skills/deskctl)
-- contributor workflow: [CONTRIBUTING.md](CONTRIBUTING.md)
+- releasing: [docs/releasing.md](docs/releasing.md)
+- contributing: [CONTRIBUTING.md](CONTRIBUTING.md)
-## Other install paths
+## Install paths
Nix:
@@ -61,12 +34,8 @@ nix run github:harivansh-afk/deskctl -- --help
nix profile install github:harivansh-afk/deskctl
```
-Source build:
+Rust:
```bash
cargo build
```
-
-## Support boundary
-
-`deskctl` currently supports Linux X11. Use `--json` for stable machine parsing, use `window_id` for programmatic targeting inside a live session, and use `deskctl doctor` first when the runtime looks broken.
From 6c6f33040f8be0aec4855c5fd9eef33c6adef4c1 Mon Sep 17 00:00:00 2001
From: Harivansh Rathi
Date: Thu, 26 Mar 2026 09:35:46 -0400
Subject: [PATCH 30/49] update readme
---
README.md | 3 +++
1 file changed, 3 insertions(+)
diff --git a/README.md b/README.md
index 4bc24c8..935f329 100644
--- a/README.md
+++ b/README.md
@@ -9,6 +9,9 @@ Desktop control cli for AI agents on Linux X11.
```bash
npm install -g deskctl
+```
+
+```bash
deskctl doctor
deskctl snapshot --annotate
```
From 844f2f2bc6ddb989d1f29bea2725be3741737e53 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
Date: Thu, 26 Mar 2026 13:37:41 +0000
Subject: [PATCH 31/49] release: v0.1.8 [skip ci]
---
Cargo.lock | 2 +-
Cargo.toml | 2 +-
npm/deskctl/package.json | 2 +-
3 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
index 6922004..3fb1666 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -400,7 +400,7 @@ checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "deskctl"
-version = "0.1.7"
+version = "0.1.8"
dependencies = [
"ab_glyph",
"anyhow",
diff --git a/Cargo.toml b/Cargo.toml
index 5872639..fc7816c 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "deskctl"
-version = "0.1.7"
+version = "0.1.8"
edition = "2021"
description = "X11 desktop control CLI for agents"
license = "MIT"
diff --git a/npm/deskctl/package.json b/npm/deskctl/package.json
index 6085bca..45daefe 100644
--- a/npm/deskctl/package.json
+++ b/npm/deskctl/package.json
@@ -1,6 +1,6 @@
{
"name": "deskctl",
- "version": "0.1.7",
+ "version": "0.1.8",
"description": "Installable deskctl package for Linux X11 agents",
"license": "MIT",
"homepage": "https://github.com/harivansh-afk/deskctl",
From 2b02513d6ef22ba238e50fea98cc8133c94f2131 Mon Sep 17 00:00:00 2001
From: Hari <73809867+harivansh-afk@users.noreply.github.com>
Date: Thu, 26 Mar 2026 11:27:35 -0400
Subject: [PATCH 32/49] Improve docs structure and navigation (#12)
* Improve docs structure and navigation
Co-authored-by: Codex
* rm
* handwrite docs
---------
Co-authored-by: Codex
---
site/src/layouts/DocLayout.astro | 2 +-
site/src/pages/architecture.mdx | 98 -----------------------------
site/src/pages/commands.mdx | 24 ++++---
site/src/pages/index.astro | 29 ++++++---
site/src/pages/installation.mdx | 30 ++++++---
site/src/pages/quick-start.mdx | 14 +++--
site/src/pages/runtime-contract.mdx | 4 +-
site/src/styles/base.css | 10 +--
8 files changed, 69 insertions(+), 142 deletions(-)
delete mode 100644 site/src/pages/architecture.mdx
diff --git a/site/src/layouts/DocLayout.astro b/site/src/layouts/DocLayout.astro
index f2608de..afc8648 100644
--- a/site/src/layouts/DocLayout.astro
+++ b/site/src/layouts/DocLayout.astro
@@ -30,7 +30,7 @@ function formatTocText(text: string): string {
{
!isIndex && (
-
+
deskctl
diff --git a/site/src/pages/architecture.mdx b/site/src/pages/architecture.mdx
deleted file mode 100644
index 9478246..0000000
--- a/site/src/pages/architecture.mdx
+++ /dev/null
@@ -1,98 +0,0 @@
----
-layout: ../layouts/DocLayout.astro
-title: Architecture
-toc: true
----
-
-# Architecture
-
-## Public model
-
-`deskctl` is a thin, non-interactive X11 control primitive for agent loops.
-The public flow is:
-
-- diagnose with `deskctl doctor`
-- observe with `snapshot`, `list-windows`, and grouped `get` commands
-- wait with grouped `wait` commands instead of shell `sleep`
-- act with explicit selectors or coordinates
-- verify with another read or snapshot
-
-The tool stays intentionally narrow. It does not try to be a full desktop shell
-or a speculative Wayland abstraction.
-
-## Client-daemon architecture
-
-The CLI talks to an auto-managed daemon over a Unix socket. The daemon keeps
-the X11 connection alive so repeated commands stay fast and share the same
-session-scoped window identity map.
-
-Each CLI invocation sends one request, reads one response, and exits.
-
-## Runtime contract
-
-Requests and responses are newline-delimited JSON (NDJSON) over a Unix socket.
-
-All commands share the same JSON envelope:
-
-```json
-{
- "success": true,
- "data": {},
- "error": null
-}
-```
-
-For window payloads, the public identity is `window_id`, not an X11 handle.
-That keeps the contract backend-neutral even though the current support
-boundary is X11-only.
-
-The complete stable-vs-best-effort policy lives on the
-[runtime contract](/runtime-contract) page.
-
-## Sessions and sockets
-
-Each session gets its own socket path, PID file, and live window mapping.
-
-Public socket resolution order:
-
-1. `--socket`
-2. `DESKCTL_SOCKET_DIR/{session}.sock`
-3. `XDG_RUNTIME_DIR/deskctl/{session}.sock`
-4. `~/.deskctl/{session}.sock`
-
-Most users should let `deskctl` manage this automatically. `--session` is the
-main public knob when you need isolated daemon instances.
-
-## Diagnostics and failure handling
-
-`deskctl doctor` runs before daemon startup and checks:
-
-- display/session setup
-- X11 connectivity
-- basic window enumeration
-- screenshot viability
-- socket directory and stale-socket health
-
-Selector and wait failures are structured in `--json` mode so clients can
-recover without scraping text.
-
-## Backend notes
-
-The backend is built around a `DesktopBackend` trait and currently ships with
-an X11 implementation backed by `x11rb`.
-
-The important public guarantee is not "portable desktop automation." The
-important guarantee is "a correct and unsurprising Linux X11 runtime contract."
-
-## X11 support boundary
-
-This phase supports Linux X11 only.
-
-That means:
-
-- EWMH/window-manager properties matter
-- monitor naming and some ordering details are best-effort
-- Wayland and Hyprland are out of scope for the current contract
-
-The runtime documents those boundaries explicitly instead of pretending the
-surface is broader than it is.
diff --git a/site/src/pages/commands.mdx b/site/src/pages/commands.mdx
index 8a5132b..dc9c578 100644
--- a/site/src/pages/commands.mdx
+++ b/site/src/pages/commands.mdx
@@ -6,7 +6,10 @@ toc: true
# Commands
-## Observe
+The public CLI is intentionally small. Most workflows boil down to grouped
+reads, grouped waits, selector-driven actions, and a few input primitives.
+
+## Observe and inspect
```sh
deskctl doctor
@@ -25,9 +28,10 @@ deskctl get-mouse-position
`doctor` checks the runtime before daemon startup. `snapshot` produces a
screenshot plus window refs. `list-windows` is the same window tree without the
-side effect of writing a screenshot.
+side effect of writing a screenshot. The grouped `get` commands are the
+preferred read surface for focused state queries.
-## Wait
+## Wait for state transitions
```sh
deskctl wait window --selector 'title=Firefox' --timeout 10
@@ -38,7 +42,7 @@ deskctl --json wait window --selector 'class=firefox' --poll-ms 100
Wait commands return the matched window payload on success. In `--json` mode,
timeouts and selector failures expose structured `kind` values.
-## Act on a window
+## Act on windows
```sh
deskctl launch firefox
@@ -55,7 +59,7 @@ deskctl resize-window @w1 1280 720
Selector-driven actions accept refs, explicit selector modes, or absolute
coordinates where appropriate.
-## Input and mouse
+## Keyboard and mouse input
```sh
deskctl type "hello world"
@@ -71,16 +75,10 @@ Supported key names include `enter`, `tab`, `escape`, `backspace`, `delete`,
`space`, arrow keys, paging keys, `f1` through `f12`, and any single
character.
-## Launch
-
-```sh
-deskctl launch firefox
-deskctl launch code -- --new-window
-```
-
## Selectors
-Prefer explicit selectors when the target matters:
+Prefer explicit selectors when the target matters. They are clearer in logs,
+more deterministic for automation, and easier to retry safely.
```sh
ref=w1
diff --git a/site/src/pages/index.astro b/site/src/pages/index.astro
index 16a4b29..b914e16 100644
--- a/site/src/pages/index.astro
+++ b/site/src/pages/index.astro
@@ -8,27 +8,33 @@ import DocLayout from "../layouts/DocLayout.astro";
- non-interactive desktop control for AI agents
+ non-interactive desktop control cli for AI agents
- deskctl is a thin X11 control primitive for agent loops: diagnose
- the runtime, observe the desktop, wait for state transitions, act deterministically,
- then verify.
+ A thin X11 control primitive for agent loops: diagnose the runtime, observe
+ the desktop, wait for state transitions, act deterministically, then verify.
- Start here
+ Start
Reference
Links
@@ -37,5 +43,8 @@ import DocLayout from "../layouts/DocLayout.astro";
GitHub
+
+ npm
+
diff --git a/site/src/pages/installation.mdx b/site/src/pages/installation.mdx
index ed4e737..e35f4eb 100644
--- a/site/src/pages/installation.mdx
+++ b/site/src/pages/installation.mdx
@@ -6,19 +6,30 @@ toc: true
# Installation
-## Default install
+Install the public `deskctl` command first, then validate the desktop runtime
+with `deskctl doctor` before trying to automate anything.
+
+## Recommended path
```sh
npm install -g deskctl
+deskctl doctor
```
`deskctl` is the default install path. It installs the command by
downloading the matching GitHub Release asset for the supported runtime target.
-The repo skill lives under `skills/deskctl`, so `skills` can install it
-directly from this GitHub repo. It is designed around the same observe -> wait
--> act -> verify loop as the CLI. `-g` installs it globally; omit that flag if
-you want a project-local install.
+This path does not require a Rust toolchain. The installed command is always
+`deskctl`, even though the release asset itself is target-specific.
+
+## Skill install
+
+The repo skill lives under `skills/deskctl`, so you can install it
+directly uring `skills.sh`
+
+```sh
+npx skills add harivansh-afk/deskctl
+```
## Other install paths
@@ -29,7 +40,7 @@ nix run github:harivansh-afk/deskctl -- --help
nix profile install github:harivansh-afk/deskctl
```
-### Build from source
+### Rust
```sh
git clone https://github.com/harivansh-afk/deskctl
@@ -53,8 +64,13 @@ Source builds on Linux require:
The binary itself only depends on the standard Linux glibc runtime.
-If setup fails, run:
+## Verification
+
+If setup fails for any reason start here:
```sh
deskctl doctor
```
+
+`doctor` checks X11 connectivity, window enumeration, screenshot viability, and
+daemon/socket health before normal command execution.
diff --git a/site/src/pages/quick-start.mdx b/site/src/pages/quick-start.mdx
index 10f3ec0..7ecf5a7 100644
--- a/site/src/pages/quick-start.mdx
+++ b/site/src/pages/quick-start.mdx
@@ -6,17 +6,19 @@ toc: true
# Quick start
-## Install and diagnose
+The fastest way to use `deskctl` is to follow the same four-step loop : observe, wait, act, verify.
+
+## 1. Install and diagnose
```sh
npm install -g deskctl
deskctl doctor
```
-Use `deskctl doctor` first. It checks X11 connectivity, basic enumeration,
+Run `deskctl doctor` first. It checks X11 connectivity, basic enumeration,
screenshot viability, and socket health before you start driving the desktop.
-## Observe
+## 2. Observe the desktop
```sh
deskctl snapshot --annotate
@@ -29,7 +31,7 @@ Use `snapshot` when you want a screenshot artifact plus window refs. Use
`list-windows` when you only need the current window tree without writing a
screenshot.
-## Target windows cleanly
+## 3. Pick selectors that stay readable
Prefer explicit selectors when you need deterministic targeting:
@@ -44,7 +46,7 @@ focused
Legacy refs such as `@w1` still work after `snapshot` or `list-windows`. Bare
strings like `firefox` are fuzzy matches and now fail on ambiguity.
-## Wait, act, verify
+## 4. Wait, act, verify
The core loop is:
@@ -69,7 +71,7 @@ deskctl snapshot
The wait commands return the matched window payload on success, so they compose
cleanly into the next action.
-## Use `--json` when parsing matters
+## 5. Use `--json` when parsing matters
Every command supports `--json` and uses the same top-level envelope:
diff --git a/site/src/pages/runtime-contract.mdx b/site/src/pages/runtime-contract.mdx
index 4fca14c..e33e999 100644
--- a/site/src/pages/runtime-contract.mdx
+++ b/site/src/pages/runtime-contract.mdx
@@ -11,7 +11,7 @@ This page defines the current public output contract for `deskctl`.
It is intentionally scoped to the current Linux X11 runtime surface. It does
not promise stability for future Wayland or window-manager-specific features.
-## JSON envelope
+## Stable top-level envelope
Every command supports `--json` and uses the same top-level envelope:
@@ -32,7 +32,7 @@ Stable top-level fields:
If `success` is `false`, the command exits non-zero in both text mode and JSON
mode.
-## Stable window fields
+## Stable window payload
Whenever a response includes a window payload, these fields are stable:
diff --git a/site/src/styles/base.css b/site/src/styles/base.css
index cd569a9..e05552e 100644
--- a/site/src/styles/base.css
+++ b/site/src/styles/base.css
@@ -224,30 +224,30 @@ hr {
}
}
-nav {
+.breadcrumbs {
max-width: 50rem;
margin: 0 auto;
padding: 1.5rem clamp(1.25rem, 5vw, 3rem) 0;
font-size: 0.9rem;
}
-nav a {
+.breadcrumbs a {
color: inherit;
text-decoration: none;
opacity: 0.6;
transition: opacity 0.15s;
}
-nav a:hover {
+.breadcrumbs a:hover {
opacity: 1;
}
-nav .title {
+.breadcrumbs .title {
font-weight: 500;
opacity: 1;
}
-nav .sep {
+.breadcrumbs .sep {
opacity: 0.3;
margin: 0 0.5em;
}
From a64b46b479b45310adedf365888fffa458268bf3 Mon Sep 17 00:00:00 2001
From: Hari <73809867+harivansh-afk@users.noreply.github.com>
Date: Thu, 26 Mar 2026 11:53:15 -0400
Subject: [PATCH 33/49] deskctl upgrade (#13)
* deskctl upgrade
* interactive update
as well as --yes flag
---
npm/deskctl/README.md | 12 +
site/src/pages/commands.mdx | 5 +-
skills/deskctl/SKILL.md | 6 +
skills/deskctl/references/commands.md | 1 +
src/cli/mod.rs | 116 ++++++-
src/cli/upgrade.rs | 465 ++++++++++++++++++++++++++
6 files changed, 603 insertions(+), 2 deletions(-)
create mode 100644 src/cli/upgrade.rs
diff --git a/npm/deskctl/README.md b/npm/deskctl/README.md
index 7bb42a9..81f07f4 100644
--- a/npm/deskctl/README.md
+++ b/npm/deskctl/README.md
@@ -14,6 +14,18 @@ After install, run:
deskctl --help
```
+To upgrade version:
+
+```bash
+deskctl upgrade
+```
+
+For non-interactive use:
+
+```bash
+deskctl upgrade --yes
+```
+
One-shot usage is also supported:
```bash
diff --git a/site/src/pages/commands.mdx b/site/src/pages/commands.mdx
index dc9c578..934cdb8 100644
--- a/site/src/pages/commands.mdx
+++ b/site/src/pages/commands.mdx
@@ -13,6 +13,7 @@ reads, grouped waits, selector-driven actions, and a few input primitives.
```sh
deskctl doctor
+deskctl upgrade
deskctl snapshot
deskctl snapshot --annotate
deskctl list-windows
@@ -26,7 +27,9 @@ deskctl get-screen-size
deskctl get-mouse-position
```
-`doctor` checks the runtime before daemon startup. `snapshot` produces a
+`doctor` checks the runtime before daemon startup. `upgrade` checks for a newer
+published version, shows a short confirmation prompt when an update is
+available, and supports `--yes` for non-interactive use. `snapshot` produces a
screenshot plus window refs. `list-windows` is the same window tree without the
side effect of writing a screenshot. The grouped `get` commands are the
preferred read surface for focused state queries.
diff --git a/skills/deskctl/SKILL.md b/skills/deskctl/SKILL.md
index 244a1fb..67a77c5 100644
--- a/skills/deskctl/SKILL.md
+++ b/skills/deskctl/SKILL.md
@@ -18,6 +18,12 @@ deskctl doctor
deskctl snapshot --annotate
```
+If `deskctl` was installed through npm, refresh it later with:
+
+```bash
+deskctl upgrade --yes
+```
+
## Agent loop
Every desktop interaction follows: **observe -> wait -> act -> verify**.
diff --git a/skills/deskctl/references/commands.md b/skills/deskctl/references/commands.md
index 77b9513..27b4310 100644
--- a/skills/deskctl/references/commands.md
+++ b/skills/deskctl/references/commands.md
@@ -7,6 +7,7 @@ runtime contract.
```bash
deskctl doctor
+deskctl upgrade
deskctl snapshot
deskctl snapshot --annotate
deskctl list-windows
diff --git a/src/cli/mod.rs b/src/cli/mod.rs
index b24465a..28092d7 100644
--- a/src/cli/mod.rs
+++ b/src/cli/mod.rs
@@ -1,4 +1,5 @@
pub mod connection;
+pub mod upgrade;
use anyhow::Result;
use clap::{Args, Parser, Subcommand};
@@ -121,6 +122,9 @@ pub enum Command {
/// Diagnose X11 runtime, screenshot, and daemon health
#[command(after_help = DOCTOR_EXAMPLES)]
Doctor,
+ /// Upgrade deskctl using the current install channel
+ #[command(after_help = UPGRADE_EXAMPLES)]
+ Upgrade(UpgradeOpts),
/// Query runtime state
#[command(subcommand)]
Get(GetCmd),
@@ -231,6 +235,8 @@ const GET_SCREEN_SIZE_EXAMPLES: &str =
const GET_MOUSE_POSITION_EXAMPLES: &str =
"Examples:\n deskctl get-mouse-position\n deskctl --json get-mouse-position";
const DOCTOR_EXAMPLES: &str = "Examples:\n deskctl doctor\n deskctl --json doctor";
+const UPGRADE_EXAMPLES: &str =
+ "Examples:\n deskctl upgrade\n deskctl upgrade --yes\n deskctl --json upgrade --yes";
const WAIT_WINDOW_EXAMPLES: &str = "Examples:\n deskctl wait window --selector 'title=Firefox' --timeout 10\n deskctl --json wait window --selector 'class=firefox' --poll-ms 100";
const WAIT_FOCUS_EXAMPLES: &str = "Examples:\n deskctl wait focus --selector 'id=win3' --timeout 5\n deskctl wait focus --selector focused --poll-ms 200";
const SCREENSHOT_EXAMPLES: &str =
@@ -284,6 +290,13 @@ pub struct WaitSelectorOpts {
pub poll_ms: u64,
}
+#[derive(Args)]
+pub struct UpgradeOpts {
+ /// Skip confirmation and upgrade non-interactively
+ #[arg(long)]
+ pub yes: bool,
+}
+
pub fn run() -> Result<()> {
let app = App::parse();
@@ -300,6 +313,22 @@ pub fn run() -> Result<()> {
return connection::run_doctor(&app.global);
}
+ if let Command::Upgrade(ref upgrade_opts) = app.command {
+ let response = upgrade::run_upgrade(&app.global, upgrade_opts)?;
+ let success = response.success;
+
+ if app.global.json {
+ println!("{}", serde_json::to_string_pretty(&response)?);
+ if !success {
+ std::process::exit(1);
+ }
+ } else {
+ print_response(&app.command, &response)?;
+ }
+
+ return Ok(());
+ }
+
// All other commands need a daemon connection
let request = build_request(&app.command)?;
let response = connection::send_command(&app.global, &request)?;
@@ -363,6 +392,7 @@ fn build_request(cmd: &Command) -> Result {
Command::GetScreenSize => Request::new("get-screen-size"),
Command::GetMousePosition => Request::new("get-mouse-position"),
Command::Doctor => unreachable!(),
+ Command::Upgrade(_) => unreachable!(),
Command::Get(sub) => match sub {
GetCmd::ActiveWindow => Request::new("get-active-window"),
GetCmd::Monitors => Request::new("get-monitors"),
@@ -422,6 +452,7 @@ fn render_success_lines(cmd: &Command, data: Option<&serde_json::Value>) -> Resu
Command::Get(GetCmd::Systeminfo) => render_systeminfo_lines(data),
Command::GetScreenSize => vec![render_screen_size_line(data)],
Command::GetMousePosition => vec![render_mouse_position_line(data)],
+ Command::Upgrade(_) => render_upgrade_lines(data),
Command::Screenshot { annotate, .. } => render_screenshot_lines(data, *annotate),
Command::Click { .. } => vec![render_click_line(data, false)],
Command::Dblclick { .. } => vec![render_click_line(data, true)],
@@ -526,6 +557,41 @@ fn render_error_lines(response: &Response) -> Vec {
lines.push("No focused window is available.".to_string());
}
}
+ "upgrade_failed" => {
+ if let Some(reason) = data.get("io_error").and_then(|value| value.as_str()) {
+ lines.push(format!("Reason: {reason}"));
+ }
+ if let Some(reason) = data.get("reason").and_then(|value| value.as_str()) {
+ lines.push(format!("Reason: {reason}"));
+ }
+ if let Some(command) = data.get("command").and_then(|value| value.as_str()) {
+ lines.push(format!("Command: {command}"));
+ }
+ if let Some(hint) = data.get("hint").and_then(|value| value.as_str()) {
+ lines.push(format!("Hint: {hint}"));
+ }
+ }
+ "upgrade_unsupported" => {
+ if let Some(hint) = data.get("hint").and_then(|value| value.as_str()) {
+ lines.push(format!("Hint: {hint}"));
+ }
+ }
+ "upgrade_confirmation_required" => {
+ if let Some(current_version) =
+ data.get("current_version").and_then(|value| value.as_str())
+ {
+ if let Some(latest_version) =
+ data.get("latest_version").and_then(|value| value.as_str())
+ {
+ lines.push(format!(
+ "Update available: {current_version} -> {latest_version}"
+ ));
+ }
+ }
+ if let Some(hint) = data.get("hint").and_then(|value| value.as_str()) {
+ lines.push(format!("Hint: {hint}"));
+ }
+ }
_ => {}
}
@@ -723,6 +789,36 @@ fn render_screenshot_lines(data: &serde_json::Value, annotate: bool) -> Vec Vec {
+ match data.get("status").and_then(|value| value.as_str()) {
+ Some("up_to_date") => {
+ let version = data
+ .get("latest_version")
+ .and_then(|value| value.as_str())
+ .or_else(|| data.get("current_version").and_then(|value| value.as_str()))
+ .unwrap_or("unknown");
+ vec![format!(
+ "✔ You're already on the latest version! ({version})"
+ )]
+ }
+ Some("upgraded") => {
+ let current_version = data
+ .get("current_version")
+ .and_then(|value| value.as_str())
+ .unwrap_or("unknown");
+ let latest_version = data
+ .get("latest_version")
+ .and_then(|value| value.as_str())
+ .unwrap_or("unknown");
+ vec![format!(
+ "✔ Upgraded deskctl from {current_version} -> {latest_version}"
+ )]
+ }
+ Some("cancelled") => vec!["No changes made.".to_string()],
+ _ => vec!["Upgrade completed.".to_string()],
+ }
+}
+
fn render_click_line(data: &serde_json::Value, double: bool) -> String {
let action = if double { "Double-clicked" } else { "Clicked" };
let key = if double { "double_clicked" } else { "clicked" };
@@ -978,7 +1074,7 @@ fn truncate_display(value: &str, max_chars: usize) -> String {
mod tests {
use super::{
render_error_lines, render_screen_size_line, render_success_lines, target_summary,
- truncate_display, App, Command, Response,
+ truncate_display, App, Command, Response, UpgradeOpts,
};
use clap::CommandFactory;
use serde_json::json;
@@ -1104,4 +1200,22 @@ mod tests {
let input = format!("fire{}fox", '\u{00E9}');
assert_eq!(truncate_display(&input, 7), "fire...");
}
+
+ #[test]
+ fn upgrade_success_text_is_neat() {
+ let lines = render_success_lines(
+ &Command::Upgrade(UpgradeOpts { yes: false }),
+ Some(&json!({
+ "status": "up_to_date",
+ "current_version": "0.1.8",
+ "latest_version": "0.1.8"
+ })),
+ )
+ .unwrap();
+
+ assert_eq!(
+ lines,
+ vec!["✔ You're already on the latest version! (0.1.8)"]
+ );
+ }
}
diff --git a/src/cli/upgrade.rs b/src/cli/upgrade.rs
new file mode 100644
index 0000000..acc844e
--- /dev/null
+++ b/src/cli/upgrade.rs
@@ -0,0 +1,465 @@
+use std::io::{self, IsTerminal, Write};
+use std::path::{Path, PathBuf};
+use std::process::Command;
+
+use anyhow::{Context, Result};
+use serde_json::json;
+
+use crate::cli::{GlobalOpts, UpgradeOpts};
+use crate::core::protocol::Response;
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+enum InstallMethod {
+ Npm,
+ Cargo,
+ Nix,
+ Source,
+ Unknown,
+}
+
+impl InstallMethod {
+ fn as_str(self) -> &'static str {
+ match self {
+ Self::Npm => "npm",
+ Self::Cargo => "cargo",
+ Self::Nix => "nix",
+ Self::Source => "source",
+ Self::Unknown => "unknown",
+ }
+ }
+}
+
+#[derive(Debug)]
+struct UpgradePlan {
+ install_method: InstallMethod,
+ program: &'static str,
+ args: Vec<&'static str>,
+}
+
+impl UpgradePlan {
+ fn command_line(&self) -> String {
+ std::iter::once(self.program)
+ .chain(self.args.iter().copied())
+ .collect::>()
+ .join(" ")
+ }
+}
+
+#[derive(Debug)]
+struct VersionInfo {
+ current: String,
+ latest: String,
+}
+
+pub fn run_upgrade(opts: &GlobalOpts, upgrade_opts: &UpgradeOpts) -> Result {
+ let current_exe = std::env::current_exe().context("Failed to determine executable path")?;
+ let install_method = detect_install_method(¤t_exe);
+
+ let Some(plan) = upgrade_plan(install_method) else {
+ return Ok(Response::err_with_data(
+ format!(
+ "deskctl upgrade is not supported for {} installs.",
+ install_method.as_str()
+ ),
+ json!({
+ "kind": "upgrade_unsupported",
+ "install_method": install_method.as_str(),
+ "current_exe": current_exe.display().to_string(),
+ "hint": upgrade_hint(install_method),
+ }),
+ ));
+ };
+
+ if !opts.json {
+ println!("- Checking for updates...");
+ }
+
+ let versions = match resolve_versions(&plan) {
+ Ok(versions) => versions,
+ Err(response) => return Ok(response),
+ };
+
+ if versions.current == versions.latest {
+ return Ok(Response::ok(json!({
+ "action": "upgrade",
+ "status": "up_to_date",
+ "install_method": plan.install_method.as_str(),
+ "current_version": versions.current,
+ "latest_version": versions.latest,
+ })));
+ }
+
+ if !upgrade_opts.yes {
+ if opts.json || !io::stdin().is_terminal() {
+ return Ok(Response::err_with_data(
+ format!(
+ "Upgrade confirmation required for {} -> {}.",
+ versions.current, versions.latest
+ ),
+ json!({
+ "kind": "upgrade_confirmation_required",
+ "install_method": plan.install_method.as_str(),
+ "current_version": versions.current,
+ "latest_version": versions.latest,
+ "command": plan.command_line(),
+ "hint": "Re-run with --yes to upgrade non-interactively.",
+ }),
+ ));
+ }
+
+ if !confirm_upgrade(&versions)? {
+ return Ok(Response::ok(json!({
+ "action": "upgrade",
+ "status": "cancelled",
+ "install_method": plan.install_method.as_str(),
+ "current_version": versions.current,
+ "latest_version": versions.latest,
+ })));
+ }
+ }
+
+ if !opts.json {
+ println!(
+ "- Upgrading deskctl from {} -> {}...",
+ versions.current, versions.latest
+ );
+ }
+
+ let output = match Command::new(plan.program).args(&plan.args).output() {
+ Ok(output) => output,
+ Err(error) => return Ok(upgrade_spawn_error_response(&plan, &versions, &error)),
+ };
+
+ if output.status.success() {
+ return Ok(Response::ok(json!({
+ "action": "upgrade",
+ "status": "upgraded",
+ "install_method": plan.install_method.as_str(),
+ "current_version": versions.current,
+ "latest_version": versions.latest,
+ "command": plan.command_line(),
+ "exit_code": output.status.code(),
+ })));
+ }
+
+ Ok(upgrade_command_failed_response(&plan, &versions, &output))
+}
+
+fn resolve_versions(plan: &UpgradePlan) -> std::result::Result {
+ let current = env!("CARGO_PKG_VERSION").to_string();
+ let latest = match plan.install_method {
+ InstallMethod::Npm => query_npm_latest_version()?,
+ InstallMethod::Cargo => query_cargo_latest_version()?,
+ InstallMethod::Nix | InstallMethod::Source | InstallMethod::Unknown => {
+ return Err(Response::err_with_data(
+ "Could not determine the latest published version.".to_string(),
+ json!({
+ "kind": "upgrade_failed",
+ "install_method": plan.install_method.as_str(),
+ "reason": "Could not determine the latest published version for this install method.",
+ "command": plan.command_line(),
+ "hint": upgrade_hint(plan.install_method),
+ }),
+ ));
+ }
+ };
+
+ Ok(VersionInfo { current, latest })
+}
+
+fn query_npm_latest_version() -> std::result::Result {
+ let output = Command::new("npm")
+ .args(["view", "deskctl", "version", "--json"])
+ .output()
+ .map_err(|error| {
+ Response::err_with_data(
+ "Failed to check the latest npm version.".to_string(),
+ json!({
+ "kind": "upgrade_failed",
+ "install_method": InstallMethod::Npm.as_str(),
+ "reason": "Failed to run npm view deskctl version --json.",
+ "io_error": error.to_string(),
+ "command": "npm view deskctl version --json",
+ "hint": upgrade_hint(InstallMethod::Npm),
+ }),
+ )
+ })?;
+
+ if !output.status.success() {
+ return Err(Response::err_with_data(
+ "Failed to check the latest npm version.".to_string(),
+ json!({
+ "kind": "upgrade_failed",
+ "install_method": InstallMethod::Npm.as_str(),
+ "reason": command_failure_reason(&output),
+ "command": "npm view deskctl version --json",
+ "hint": upgrade_hint(InstallMethod::Npm),
+ }),
+ ));
+ }
+
+ serde_json::from_slice::(&output.stdout).map_err(|_| {
+ Response::err_with_data(
+ "Failed to parse the latest npm version.".to_string(),
+ json!({
+ "kind": "upgrade_failed",
+ "install_method": InstallMethod::Npm.as_str(),
+ "reason": "npm view returned an unexpected version payload.",
+ "command": "npm view deskctl version --json",
+ "hint": upgrade_hint(InstallMethod::Npm),
+ }),
+ )
+ })
+}
+
+fn query_cargo_latest_version() -> std::result::Result {
+ let output = Command::new("cargo")
+ .args(["search", "deskctl", "--limit", "1"])
+ .output()
+ .map_err(|error| {
+ Response::err_with_data(
+ "Failed to check the latest crates.io version.".to_string(),
+ json!({
+ "kind": "upgrade_failed",
+ "install_method": InstallMethod::Cargo.as_str(),
+ "reason": "Failed to run cargo search deskctl --limit 1.",
+ "io_error": error.to_string(),
+ "command": "cargo search deskctl --limit 1",
+ "hint": upgrade_hint(InstallMethod::Cargo),
+ }),
+ )
+ })?;
+
+ if !output.status.success() {
+ return Err(Response::err_with_data(
+ "Failed to check the latest crates.io version.".to_string(),
+ json!({
+ "kind": "upgrade_failed",
+ "install_method": InstallMethod::Cargo.as_str(),
+ "reason": command_failure_reason(&output),
+ "command": "cargo search deskctl --limit 1",
+ "hint": upgrade_hint(InstallMethod::Cargo),
+ }),
+ ));
+ }
+
+ let stdout = String::from_utf8_lossy(&output.stdout);
+ let latest = stdout
+ .split('"')
+ .nth(1)
+ .map(str::to_string)
+ .filter(|value| !value.is_empty());
+
+ latest.ok_or_else(|| {
+ Response::err_with_data(
+ "Failed to determine the latest crates.io version.".to_string(),
+ json!({
+ "kind": "upgrade_failed",
+ "install_method": InstallMethod::Cargo.as_str(),
+ "reason": "cargo search did not return a published deskctl crate version.",
+ "command": "cargo search deskctl --limit 1",
+ "hint": upgrade_hint(InstallMethod::Cargo),
+ }),
+ )
+ })
+}
+
+fn confirm_upgrade(versions: &VersionInfo) -> Result {
+ print!(
+ "Upgrade deskctl from {} -> {}? [y/N] ",
+ versions.current, versions.latest
+ );
+ io::stdout().flush()?;
+
+ let mut input = String::new();
+ io::stdin().read_line(&mut input)?;
+
+ let trimmed = input.trim();
+ Ok(matches!(trimmed, "y" | "Y" | "yes" | "YES" | "Yes"))
+}
+
+fn upgrade_command_failed_response(
+ plan: &UpgradePlan,
+ versions: &VersionInfo,
+ output: &std::process::Output,
+) -> Response {
+ Response::err_with_data(
+ format!("Upgrade command failed: {}", plan.command_line()),
+ json!({
+ "kind": "upgrade_failed",
+ "install_method": plan.install_method.as_str(),
+ "current_version": versions.current,
+ "latest_version": versions.latest,
+ "command": plan.command_line(),
+ "exit_code": output.status.code(),
+ "reason": command_failure_reason(output),
+ "hint": upgrade_hint(plan.install_method),
+ }),
+ )
+}
+
+fn upgrade_spawn_error_response(
+ plan: &UpgradePlan,
+ versions: &VersionInfo,
+ error: &std::io::Error,
+) -> Response {
+ Response::err_with_data(
+ format!("Failed to run {}", plan.command_line()),
+ json!({
+ "kind": "upgrade_failed",
+ "install_method": plan.install_method.as_str(),
+ "current_version": versions.current,
+ "latest_version": versions.latest,
+ "command": plan.command_line(),
+ "io_error": error.to_string(),
+ "hint": upgrade_hint(plan.install_method),
+ }),
+ )
+}
+
+fn command_failure_reason(output: &std::process::Output) -> String {
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ let stdout = String::from_utf8_lossy(&output.stdout);
+
+ stderr
+ .lines()
+ .chain(stdout.lines())
+ .map(str::trim)
+ .find(|line| !line.is_empty())
+ .map(str::to_string)
+ .unwrap_or_else(|| {
+ output
+ .status
+ .code()
+ .map(|code| format!("Command exited with status {code}."))
+ .unwrap_or_else(|| "Command exited unsuccessfully.".to_string())
+ })
+}
+
+fn upgrade_plan(install_method: InstallMethod) -> Option {
+ match install_method {
+ InstallMethod::Npm => Some(UpgradePlan {
+ install_method,
+ program: "npm",
+ args: vec!["install", "-g", "deskctl@latest"],
+ }),
+ InstallMethod::Cargo => Some(UpgradePlan {
+ install_method,
+ program: "cargo",
+ args: vec!["install", "deskctl", "--locked"],
+ }),
+ InstallMethod::Nix | InstallMethod::Source | InstallMethod::Unknown => None,
+ }
+}
+
+fn upgrade_hint(install_method: InstallMethod) -> &'static str {
+ match install_method {
+ InstallMethod::Nix => {
+ "Use nix profile upgrade or update the flake reference you installed from."
+ }
+ InstallMethod::Source => {
+ "Rebuild from source or reinstall deskctl through npm, cargo, or nix."
+ }
+ InstallMethod::Unknown => {
+ "Reinstall deskctl through a supported channel such as npm, cargo, or nix."
+ }
+ InstallMethod::Npm => "Retry with --yes or run npm install -g deskctl@latest directly.",
+ InstallMethod::Cargo => "Retry with --yes or run cargo install deskctl --locked directly.",
+ }
+}
+
+fn detect_install_method(current_exe: &Path) -> InstallMethod {
+ if looks_like_npm_install(current_exe) {
+ return InstallMethod::Npm;
+ }
+ if looks_like_nix_install(current_exe) {
+ return InstallMethod::Nix;
+ }
+ if looks_like_cargo_install(current_exe) {
+ return InstallMethod::Cargo;
+ }
+ if looks_like_source_tree(current_exe) {
+ return InstallMethod::Source;
+ }
+ InstallMethod::Unknown
+}
+
+fn looks_like_npm_install(path: &Path) -> bool {
+ let value = normalize(path);
+ value.contains("/node_modules/deskctl/") && value.contains("/vendor/")
+}
+
+fn looks_like_nix_install(path: &Path) -> bool {
+ normalize(path).starts_with("/nix/store/")
+}
+
+fn looks_like_cargo_install(path: &Path) -> bool {
+ let Some(home) = std::env::var_os("HOME") else {
+ return false;
+ };
+
+ let cargo_home = std::env::var_os("CARGO_HOME")
+ .map(PathBuf::from)
+ .unwrap_or_else(|| PathBuf::from(home).join(".cargo"));
+ path == cargo_home.join("bin").join("deskctl")
+}
+
+fn looks_like_source_tree(path: &Path) -> bool {
+ let value = normalize(path);
+ value.contains("/target/debug/deskctl") || value.contains("/target/release/deskctl")
+}
+
+fn normalize(path: &Path) -> String {
+ path.to_string_lossy().replace('\\', "/")
+}
+
+#[cfg(test)]
+mod tests {
+ use std::os::unix::process::ExitStatusExt;
+ use std::path::Path;
+
+ use super::{command_failure_reason, detect_install_method, upgrade_plan, InstallMethod};
+
+ #[test]
+ fn detects_npm_install_path() {
+ let method = detect_install_method(Path::new(
+ "/usr/local/lib/node_modules/deskctl/vendor/deskctl-linux-x86_64",
+ ));
+ assert_eq!(method, InstallMethod::Npm);
+ }
+
+ #[test]
+ fn detects_nix_install_path() {
+ let method = detect_install_method(Path::new("/nix/store/abc123-deskctl/bin/deskctl"));
+ assert_eq!(method, InstallMethod::Nix);
+ }
+
+ #[test]
+ fn detects_source_tree_path() {
+ let method =
+ detect_install_method(Path::new("/Users/example/src/deskctl/target/debug/deskctl"));
+ assert_eq!(method, InstallMethod::Source);
+ }
+
+ #[test]
+ fn npm_upgrade_plan_uses_global_install() {
+ let plan = upgrade_plan(InstallMethod::Npm).expect("npm installs should support upgrade");
+ assert_eq!(plan.command_line(), "npm install -g deskctl@latest");
+ }
+
+ #[test]
+ fn nix_install_has_no_upgrade_plan() {
+ assert!(upgrade_plan(InstallMethod::Nix).is_none());
+ }
+
+ #[test]
+ fn failure_reason_prefers_stderr() {
+ let output = std::process::Output {
+ status: std::process::ExitStatus::from_raw(1 << 8),
+ stdout: b"".to_vec(),
+ stderr: b"boom\n".to_vec(),
+ };
+
+ assert_eq!(command_failure_reason(&output), "boom");
+ }
+}
From e61c5bc33f7f51d8b43703cfc8e0c068f751e57a Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
Date: Thu, 26 Mar 2026 16:03:29 +0000
Subject: [PATCH 34/49] release: v0.1.9 [skip ci]
---
Cargo.lock | 2 +-
Cargo.toml | 2 +-
npm/deskctl/package.json | 2 +-
3 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
index 3fb1666..157dbc7 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -400,7 +400,7 @@ checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "deskctl"
-version = "0.1.8"
+version = "0.1.9"
dependencies = [
"ab_glyph",
"anyhow",
diff --git a/Cargo.toml b/Cargo.toml
index fc7816c..2ebe138 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "deskctl"
-version = "0.1.8"
+version = "0.1.9"
edition = "2021"
description = "X11 desktop control CLI for agents"
license = "MIT"
diff --git a/npm/deskctl/package.json b/npm/deskctl/package.json
index 45daefe..5dfeaa0 100644
--- a/npm/deskctl/package.json
+++ b/npm/deskctl/package.json
@@ -1,6 +1,6 @@
{
"name": "deskctl",
- "version": "0.1.8",
+ "version": "0.1.9",
"description": "Installable deskctl package for Linux X11 agents",
"license": "MIT",
"homepage": "https://github.com/harivansh-afk/deskctl",
From 07a478b0eed0e5df22cb5a1bd16989c3b8f57d33 Mon Sep 17 00:00:00 2001
From: Harivansh Rathi
Date: Thu, 26 Mar 2026 14:21:41 -0400
Subject: [PATCH 35/49] couple CI with publish
---
.github/workflows/ci.yml | 143 ++++++++++++++++++++++++++++++++--
.github/workflows/publish.yml | 127 ------------------------------
2 files changed, 137 insertions(+), 133 deletions(-)
delete mode 100644 .github/workflows/publish.yml
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index cb36e61..bcb02b3 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -10,6 +10,23 @@ on:
push:
branches: [main]
workflow_dispatch:
+ inputs:
+ bump:
+ description: Version bump type (only for workflow_dispatch)
+ type: choice
+ options:
+ - patch
+ - minor
+ - major
+ default: patch
+ publish_npm:
+ description: Publish to npm
+ type: boolean
+ default: true
+ publish_crates:
+ description: Publish to crates.io
+ type: boolean
+ default: false
permissions:
contents: write
@@ -52,13 +69,34 @@ jobs:
echo "rust=${{ steps.filter.outputs.rust }}" >> "$GITHUB_OUTPUT"
fi
- - name: Read current version
+ - name: Calculate next version
id: version
if: github.event_name != 'pull_request' && steps.check.outputs.rust == 'true'
run: |
- VERSION=$(grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/')
- echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
- echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT"
+ CURRENT=$(grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/')
+ IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT"
+
+ BUMP="${{ inputs.bump || 'patch' }}"
+ case "$BUMP" in
+ major) MAJOR=$((MAJOR + 1)); MINOR=0; PATCH=0 ;;
+ minor) MINOR=$((MINOR + 1)); PATCH=0 ;;
+ patch)
+ LATEST=$(git tag -l "v${MAJOR}.${MINOR}.*" | sort -V | tail -1)
+ if [ -z "$LATEST" ]; then
+ NEW_PATCH=$PATCH
+ else
+ LATEST_VER="${LATEST#v}"
+ IFS='.' read -r _ _ LATEST_PATCH <<< "$LATEST_VER"
+ NEW_PATCH=$((LATEST_PATCH + 1))
+ fi
+ PATCH=$NEW_PATCH
+ ;;
+ esac
+
+ NEW="${MAJOR}.${MINOR}.${PATCH}"
+ echo "version=${NEW}" >> "$GITHUB_OUTPUT"
+ echo "tag=v${NEW}" >> "$GITHUB_OUTPUT"
+ echo "Computed version: ${NEW} (v${NEW})"
validate:
name: Validate
@@ -177,10 +215,53 @@ jobs:
path: target/release/deskctl
retention-days: 7
+ update-manifests:
+ name: Update Manifests
+ needs: [changes, build]
+ if: github.event_name != 'pull_request' && needs.changes.outputs.rust == 'true'
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - uses: dtolnay/rust-toolchain@stable
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22
+
+ - name: Update versions
+ run: |
+ CURRENT=$(grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/')
+ NEW="${{ needs.changes.outputs.version }}"
+ if [ "$CURRENT" != "$NEW" ]; then
+ sed -i "0,/^version = \"${CURRENT}\"/s//version = \"${NEW}\"/" Cargo.toml
+ cargo generate-lockfile
+ fi
+ node -e '
+ const fs = require("node:fs");
+ const p = "npm/deskctl/package.json";
+ const pkg = JSON.parse(fs.readFileSync(p, "utf8"));
+ pkg.version = process.argv[1];
+ fs.writeFileSync(p, JSON.stringify(pkg, null, 2) + "\n");
+ ' "$NEW"
+
+ - name: Commit, tag, and push
+ run: |
+ git config user.name "github-actions[bot]"
+ git config user.email "github-actions[bot]@users.noreply.github.com"
+ git add Cargo.toml Cargo.lock npm/deskctl/package.json
+ if ! git diff --cached --quiet; then
+ git commit -m "release: ${{ needs.changes.outputs.tag }} [skip ci]"
+ fi
+ git tag "${{ needs.changes.outputs.tag }}"
+ git push origin main --tags
+
release:
name: Release
- needs: [changes, build]
- if: github.event_name != 'pull_request'
+ needs: [changes, build, update-manifests]
+ if: github.event_name != 'pull_request' && needs.changes.outputs.rust == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@@ -209,3 +290,53 @@ jobs:
artifacts/deskctl-linux-x86_64 \
artifacts/checksums.txt
fi
+
+ publish:
+ name: Publish
+ needs: [changes, update-manifests, release]
+ if: github.event_name != 'pull_request' && needs.changes.outputs.rust == 'true'
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ ref: ${{ needs.changes.outputs.tag }}
+
+ - uses: dtolnay/rust-toolchain@stable
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ registry-url: https://registry.npmjs.org
+
+ - name: Install system dependencies
+ run: sudo apt-get update && sudo apt-get install -y libx11-dev libxtst-dev
+
+ - name: Check current published state
+ id: published
+ run: |
+ VERSION="${{ needs.changes.outputs.version }}"
+ if npm view "deskctl@${VERSION}" version >/dev/null 2>&1; then
+ echo "npm=true" >> "$GITHUB_OUTPUT"
+ else
+ echo "npm=false" >> "$GITHUB_OUTPUT"
+ fi
+ if curl -fsSL "https://crates.io/api/v1/crates/deskctl/${VERSION}" >/dev/null 2>&1; then
+ echo "crates=true" >> "$GITHUB_OUTPUT"
+ else
+ echo "crates=false" >> "$GITHUB_OUTPUT"
+ fi
+
+ - name: Validate npm package
+ run: node npm/deskctl/scripts/validate-package.js
+
+ - name: Publish npm
+ if: steps.published.outputs.npm != 'true'
+ env:
+ NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
+ run: npm publish ./npm/deskctl --access public
+
+ - name: Publish crates.io
+ if: inputs.publish_crates && steps.published.outputs.crates != 'true'
+ env:
+ CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
+ run: cargo publish --locked
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
deleted file mode 100644
index 60aed4d..0000000
--- a/.github/workflows/publish.yml
+++ /dev/null
@@ -1,127 +0,0 @@
-name: Publish Registries
-
-on:
- workflow_dispatch:
- inputs:
- bump:
- description: Version bump type
- required: true
- type: choice
- options:
- - patch
- - minor
- - major
- publish_npm:
- description: Publish deskctl to npm
- required: true
- type: boolean
- default: true
- publish_crates:
- description: Publish deskctl to crates.io
- required: true
- type: boolean
- default: false
-
-permissions:
- contents: read
-
-jobs:
- publish:
- runs-on: ubuntu-latest
- permissions:
- contents: write
- steps:
- - uses: actions/checkout@v4
- with:
- fetch-depth: 0
-
- - uses: dtolnay/rust-toolchain@stable
-
- - uses: actions/setup-node@v4
- with:
- node-version: 22
- registry-url: https://registry.npmjs.org
-
- - name: Install system dependencies
- run: sudo apt-get update && sudo apt-get install -y libx11-dev libxtst-dev
-
- - name: Compute next version
- id: version
- run: |
- CURRENT=$(grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/')
- IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT"
-
- case "${{ inputs.bump }}" in
- major) MAJOR=$((MAJOR + 1)); MINOR=0; PATCH=0 ;;
- minor) MINOR=$((MINOR + 1)); PATCH=0 ;;
- patch) PATCH=$((PATCH + 1)) ;;
- esac
-
- NEW="${MAJOR}.${MINOR}.${PATCH}"
- TAG="v${NEW}"
-
- echo "version=${NEW}" >> "$GITHUB_OUTPUT"
- echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
- echo "Bumping ${CURRENT} -> ${NEW} (${TAG})"
-
- - name: Bump versions
- run: |
- NEW="${{ steps.version.outputs.version }}"
- CURRENT=$(grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/')
- sed -i "0,/^version = \"${CURRENT}\"/s//version = \"${NEW}\"/" Cargo.toml
- node -e 'const fs=require("node:fs"); const p="npm/deskctl/package.json"; const pkg=JSON.parse(fs.readFileSync(p,"utf8")); pkg.version=process.argv[1]; fs.writeFileSync(p, JSON.stringify(pkg, null, 2)+"\n");' "$NEW"
- cargo generate-lockfile
-
- - name: Commit, tag, and push
- run: |
- git config user.name "github-actions[bot]"
- git config user.email "github-actions[bot]@users.noreply.github.com"
- git add Cargo.toml Cargo.lock npm/deskctl/package.json
- git commit -m "release: ${{ steps.version.outputs.tag }} [skip ci]"
- git tag "${{ steps.version.outputs.tag }}"
- git push origin main --tags
-
- - name: Check current published state
- id: published
- run: |
- VERSION="${{ steps.version.outputs.version }}"
-
- if npm view "deskctl@${VERSION}" version >/dev/null 2>&1; then
- echo "npm=true" >> "$GITHUB_OUTPUT"
- else
- echo "npm=false" >> "$GITHUB_OUTPUT"
- fi
-
- if curl -fsSL "https://crates.io/api/v1/crates/deskctl/${VERSION}" >/dev/null 2>&1; then
- echo "crates=true" >> "$GITHUB_OUTPUT"
- else
- echo "crates=false" >> "$GITHUB_OUTPUT"
- fi
-
- - name: Validate npm package
- run: |
- mkdir -p ./tmp/npm-pack
- node npm/deskctl/scripts/validate-package.js
- npm pack ./npm/deskctl --pack-destination ./tmp/npm-pack >/dev/null
-
- - name: Validate crate publish path
- run: cargo publish --dry-run --locked
-
- - name: Publish npm
- if: inputs.publish_npm && steps.published.outputs.npm != 'true'
- env:
- NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- run: npm publish ./npm/deskctl --access public
-
- - name: Publish crates.io
- if: inputs.publish_crates && steps.published.outputs.crates != 'true'
- env:
- CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
- run: cargo publish --locked
-
- - name: Summary
- run: |
- echo "tag=${{ steps.version.outputs.tag }}"
- echo "bump=${{ inputs.bump }}"
- echo "npm_already_published=${{ steps.published.outputs.npm }}"
- echo "crates_already_published=${{ steps.published.outputs.crates }}"
From 8d690a62b43a54d41f5b49c07f025bb4d419e3e6 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
Date: Thu, 26 Mar 2026 18:28:13 +0000
Subject: [PATCH 36/49] release: v0.1.10 [skip ci]
---
Cargo.lock | 2 +-
Cargo.toml | 2 +-
npm/deskctl/package.json | 2 +-
3 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
index 157dbc7..9680966 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -400,7 +400,7 @@ checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "deskctl"
-version = "0.1.9"
+version = "0.1.10"
dependencies = [
"ab_glyph",
"anyhow",
diff --git a/Cargo.toml b/Cargo.toml
index 2ebe138..cc6d11a 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "deskctl"
-version = "0.1.9"
+version = "0.1.10"
edition = "2021"
description = "X11 desktop control CLI for agents"
license = "MIT"
diff --git a/npm/deskctl/package.json b/npm/deskctl/package.json
index 5dfeaa0..adb142c 100644
--- a/npm/deskctl/package.json
+++ b/npm/deskctl/package.json
@@ -1,6 +1,6 @@
{
"name": "deskctl",
- "version": "0.1.9",
+ "version": "0.1.10",
"description": "Installable deskctl package for Linux X11 agents",
"license": "MIT",
"homepage": "https://github.com/harivansh-afk/deskctl",
From a58912284b2a797c0d422182aa16c52ade05e580 Mon Sep 17 00:00:00 2001
From: Harivansh Rathi
Date: Thu, 26 Mar 2026 14:40:45 -0400
Subject: [PATCH 37/49] reorder pipeline
---
.github/workflows/ci.yml | 76 +++++++++++++++++++++++++---------------
1 file changed, 47 insertions(+), 29 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index bcb02b3..dcef6fb 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -28,6 +28,9 @@ on:
type: boolean
default: false
+env:
+ FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
+
permissions:
contents: write
@@ -186,38 +189,12 @@ jobs:
- name: Distribution validation
run: make dist-validate
- build:
- name: Build Release Asset
- needs: [changes, validate, integration, distribution]
- if: github.event_name != 'pull_request' && needs.changes.outputs.rust == 'true'
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v4
-
- - uses: dtolnay/rust-toolchain@stable
- with:
- components: clippy
-
- - uses: Swatinem/rust-cache@v2
-
- - name: Install system dependencies
- run: sudo apt-get update && sudo apt-get install -y libx11-dev libxtst-dev
-
- - name: Clippy
- run: cargo clippy -- -D warnings
-
- - name: Build
- run: cargo build --release --locked
-
- - uses: actions/upload-artifact@v4
- with:
- name: deskctl-linux-x86_64
- path: target/release/deskctl
- retention-days: 7
+ # --- Release pipeline: update-manifests -> build -> release -> publish ---
+ # Version bump happens BEFORE build so the binary has the correct version.
update-manifests:
name: Update Manifests
- needs: [changes, build]
+ needs: [changes, validate, integration, distribution]
if: github.event_name != 'pull_request' && needs.changes.outputs.rust == 'true'
runs-on: ubuntu-latest
steps:
@@ -258,6 +235,47 @@ jobs:
git tag "${{ needs.changes.outputs.tag }}"
git push origin main --tags
+ build:
+ name: Build Release Asset
+ needs: [changes, update-manifests]
+ if: github.event_name != 'pull_request' && needs.changes.outputs.rust == 'true'
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ ref: ${{ needs.changes.outputs.tag }}
+
+ - uses: dtolnay/rust-toolchain@stable
+ with:
+ components: clippy
+
+ - uses: Swatinem/rust-cache@v2
+
+ - name: Install system dependencies
+ run: sudo apt-get update && sudo apt-get install -y libx11-dev libxtst-dev
+
+ - name: Verify version
+ run: |
+ CARGO_VER=$(grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/')
+ EXPECTED="${{ needs.changes.outputs.version }}"
+ if [ "$CARGO_VER" != "$EXPECTED" ]; then
+ echo "Version mismatch: Cargo.toml=$CARGO_VER expected=$EXPECTED"
+ exit 1
+ fi
+ echo "Building version $CARGO_VER"
+
+ - name: Clippy
+ run: cargo clippy -- -D warnings
+
+ - name: Build
+ run: cargo build --release --locked
+
+ - uses: actions/upload-artifact@v4
+ with:
+ name: deskctl-linux-x86_64
+ path: target/release/deskctl
+ retention-days: 7
+
release:
name: Release
needs: [changes, build, update-manifests]
From 580ea79c276fb5eafb35300415441263bb523d04 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
Date: Thu, 26 Mar 2026 18:47:09 +0000
Subject: [PATCH 38/49] release: v0.1.11 [skip ci]
---
Cargo.lock | 2 +-
Cargo.toml | 2 +-
npm/deskctl/package.json | 2 +-
3 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
index 9680966..b411e80 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -400,7 +400,7 @@ checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "deskctl"
-version = "0.1.10"
+version = "0.1.11"
dependencies = [
"ab_glyph",
"anyhow",
diff --git a/Cargo.toml b/Cargo.toml
index cc6d11a..59108df 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "deskctl"
-version = "0.1.10"
+version = "0.1.11"
edition = "2021"
description = "X11 desktop control CLI for agents"
license = "MIT"
diff --git a/npm/deskctl/package.json b/npm/deskctl/package.json
index adb142c..60b8ed4 100644
--- a/npm/deskctl/package.json
+++ b/npm/deskctl/package.json
@@ -1,6 +1,6 @@
{
"name": "deskctl",
- "version": "0.1.10",
+ "version": "0.1.11",
"description": "Installable deskctl package for Linux X11 agents",
"license": "MIT",
"homepage": "https://github.com/harivansh-afk/deskctl",
From ff26c570351fc9d3690bf20958291da5f0bf0e89 Mon Sep 17 00:00:00 2001
From: Hari <73809867+harivansh-afk@users.noreply.github.com>
Date: Thu, 26 Mar 2026 15:25:40 -0400
Subject: [PATCH 39/49] replace firefox with chrome (#14)
---
site/src/pages/commands.mdx | 14 +++---
site/src/pages/quick-start.mdx | 16 +++----
skills/deskctl/SKILL.md | 10 ++---
skills/deskctl/references/commands.md | 12 ++---
skills/deskctl/workflows/observe-act.sh | 2 +-
src/cli/mod.rs | 58 ++++++++++++-------------
src/core/refs.rs | 12 ++---
7 files changed, 61 insertions(+), 63 deletions(-)
diff --git a/site/src/pages/commands.mdx b/site/src/pages/commands.mdx
index 934cdb8..0696558 100644
--- a/site/src/pages/commands.mdx
+++ b/site/src/pages/commands.mdx
@@ -37,9 +37,9 @@ preferred read surface for focused state queries.
## Wait for state transitions
```sh
-deskctl wait window --selector 'title=Firefox' --timeout 10
+deskctl wait window --selector 'title=Chromium' --timeout 10
deskctl wait focus --selector 'id=win3' --timeout 5
-deskctl --json wait window --selector 'class=firefox' --poll-ms 100
+deskctl --json wait window --selector 'class=chromium' --poll-ms 100
```
Wait commands return the matched window payload on success. In `--json` mode,
@@ -48,9 +48,9 @@ timeouts and selector failures expose structured `kind` values.
## Act on windows
```sh
-deskctl launch firefox
+deskctl launch chromium
deskctl focus @w1
-deskctl focus 'title=Firefox'
+deskctl focus 'title=Chromium'
deskctl click @w1
deskctl click 960,540
deskctl dblclick @w2
@@ -86,8 +86,8 @@ more deterministic for automation, and easier to retry safely.
```sh
ref=w1
id=win1
-title=Firefox
-class=firefox
+title=Chromium
+class=chromium
focused
```
@@ -99,7 +99,7 @@ w1
win1
```
-Bare strings like `firefox` are fuzzy matches. They resolve when there is one
+Bare strings like `chromium` are fuzzy matches. They resolve when there is one
match and fail with candidate windows when there are multiple matches.
## Global options
diff --git a/site/src/pages/quick-start.mdx b/site/src/pages/quick-start.mdx
index 7ecf5a7..4cc0e25 100644
--- a/site/src/pages/quick-start.mdx
+++ b/site/src/pages/quick-start.mdx
@@ -38,13 +38,13 @@ Prefer explicit selectors when you need deterministic targeting:
```sh
ref=w1
id=win1
-title=Firefox
-class=firefox
+title=Chromium
+class=chromium
focused
```
Legacy refs such as `@w1` still work after `snapshot` or `list-windows`. Bare
-strings like `firefox` are fuzzy matches and now fail on ambiguity.
+strings like `chromium` are fuzzy matches and now fail on ambiguity.
## 4. Wait, act, verify
@@ -55,16 +55,16 @@ The core loop is:
deskctl snapshot --annotate
# wait
-deskctl wait window --selector 'title=Firefox' --timeout 10
+deskctl wait window --selector 'title=Chromium' --timeout 10
# act
-deskctl focus 'title=Firefox'
+deskctl focus 'title=Chromium'
deskctl hotkey ctrl l
deskctl type "https://example.com"
deskctl press enter
# verify
-deskctl wait focus --selector 'title=Firefox' --timeout 5
+deskctl wait focus --selector 'title=Chromium' --timeout 5
deskctl snapshot
```
@@ -84,8 +84,8 @@ Every command supports `--json` and uses the same top-level envelope:
{
"ref_id": "w1",
"window_id": "win1",
- "title": "Firefox",
- "app_name": "firefox",
+ "title": "Chromium",
+ "app_name": "chromium",
"x": 0,
"y": 0,
"width": 1920,
diff --git a/skills/deskctl/SKILL.md b/skills/deskctl/SKILL.md
index 67a77c5..c79ca21 100644
--- a/skills/deskctl/SKILL.md
+++ b/skills/deskctl/SKILL.md
@@ -30,8 +30,8 @@ Every desktop interaction follows: **observe -> wait -> act -> verify**.
```bash
deskctl snapshot --annotate # observe
-deskctl wait window --selector 'title=Firefox' --timeout 10 # wait
-deskctl click 'title=Firefox' # act
+deskctl wait window --selector 'title=Chromium' --timeout 10 # wait
+deskctl click 'title=Chromium' # act
deskctl snapshot # verify
```
@@ -42,12 +42,12 @@ See [workflows/observe-act.sh](workflows/observe-act.sh) for a reusable script.
```bash
ref=w1 # snapshot ref (short-lived)
id=win1 # stable window ID (session-scoped)
-title=Firefox # match by title
-class=firefox # match by WM class
+title=Chromium # match by title
+class=chromium # match by WM class
focused # currently focused window
```
-Bare strings like `firefox` do fuzzy matching but fail on ambiguity. Prefer explicit selectors.
+Bare strings like `chromium` do fuzzy matching but fail on ambiguity. Prefer explicit selectors.
## References
diff --git a/skills/deskctl/references/commands.md b/skills/deskctl/references/commands.md
index 27b4310..df69350 100644
--- a/skills/deskctl/references/commands.md
+++ b/skills/deskctl/references/commands.md
@@ -23,8 +23,8 @@ deskctl get-mouse-position
## Wait
```bash
-deskctl wait window --selector 'title=Firefox' --timeout 10
-deskctl wait focus --selector 'class=firefox' --timeout 5
+deskctl wait window --selector 'title=Chromium' --timeout 10
+deskctl wait focus --selector 'class=chromium' --timeout 5
```
Returns the matched window payload on success. Failures include structured
@@ -35,8 +35,8 @@ Returns the matched window payload on success. Failures include structured
```bash
ref=w1
id=win1
-title=Firefox
-class=firefox
+title=Chromium
+class=chromium
focused
```
@@ -46,7 +46,7 @@ on ambiguity.
## Act
```bash
-deskctl focus 'class=firefox'
+deskctl focus 'class=chromium'
deskctl click @w1
deskctl dblclick @w2
deskctl type "hello world"
@@ -59,7 +59,7 @@ deskctl mouse drag 100 100 500 500
deskctl move-window @w1 100 120
deskctl resize-window @w1 1280 720
deskctl close @w3
-deskctl launch firefox
+deskctl launch chromium
```
The daemon starts automatically on first command. In normal usage you should
diff --git a/skills/deskctl/workflows/observe-act.sh b/skills/deskctl/workflows/observe-act.sh
index 0e336ae..8c3abc2 100755
--- a/skills/deskctl/workflows/observe-act.sh
+++ b/skills/deskctl/workflows/observe-act.sh
@@ -1,7 +1,7 @@
#!/usr/bin/env bash
# observe-act.sh - main desktop interaction loop
# usage: ./observe-act.sh [action] [action-args...]
-# example: ./observe-act.sh 'title=Firefox' click
+# example: ./observe-act.sh 'title=Chromium' click
# example: ./observe-act.sh 'class=terminal' type "ls -la"
set -euo pipefail
diff --git a/src/cli/mod.rs b/src/cli/mod.rs
index 28092d7..79008de 100644
--- a/src/cli/mod.rs
+++ b/src/cli/mod.rs
@@ -48,13 +48,13 @@ pub enum Command {
/// Click a window ref or coordinates
#[command(after_help = CLICK_EXAMPLES)]
Click {
- /// Selector (ref=w1, id=win1, title=Firefox, class=firefox, focused) or x,y coordinates
+ /// Selector (ref=w1, id=win1, title=Chromium, class=chromium, focused) or x,y coordinates
selector: String,
},
/// Double-click a window ref or coordinates
#[command(after_help = DBLCLICK_EXAMPLES)]
Dblclick {
- /// Selector (ref=w1, id=win1, title=Firefox, class=firefox, focused) or x,y coordinates
+ /// Selector (ref=w1, id=win1, title=Chromium, class=chromium, focused) or x,y coordinates
selector: String,
},
/// Type text into the focused window
@@ -81,19 +81,19 @@ pub enum Command {
/// Focus a window by ref or name
#[command(after_help = FOCUS_EXAMPLES)]
Focus {
- /// Selector: ref=w1, id=win1, title=Firefox, class=firefox, focused, or a fuzzy substring
+ /// Selector: ref=w1, id=win1, title=Chromium, class=chromium, focused, or a fuzzy substring
selector: String,
},
/// Close a window by ref or name
#[command(after_help = CLOSE_EXAMPLES)]
Close {
- /// Selector: ref=w1, id=win1, title=Firefox, class=firefox, focused, or a fuzzy substring
+ /// Selector: ref=w1, id=win1, title=Chromium, class=chromium, focused, or a fuzzy substring
selector: String,
},
/// Move a window
#[command(after_help = MOVE_WINDOW_EXAMPLES)]
MoveWindow {
- /// Selector: ref=w1, id=win1, title=Firefox, class=firefox, focused, or a fuzzy substring
+ /// Selector: ref=w1, id=win1, title=Chromium, class=chromium, focused, or a fuzzy substring
selector: String,
/// X position
x: i32,
@@ -103,7 +103,7 @@ pub enum Command {
/// Resize a window
#[command(after_help = RESIZE_WINDOW_EXAMPLES)]
ResizeWindow {
- /// Selector: ref=w1, id=win1, title=Firefox, class=firefox, focused, or a fuzzy substring
+ /// Selector: ref=w1, id=win1, title=Chromium, class=chromium, focused, or a fuzzy substring
selector: String,
/// Width
w: u32,
@@ -210,19 +210,19 @@ const SNAPSHOT_EXAMPLES: &str =
const LIST_WINDOWS_EXAMPLES: &str =
"Examples:\n deskctl list-windows\n deskctl --json list-windows";
const CLICK_EXAMPLES: &str =
- "Examples:\n deskctl click @w1\n deskctl click 'title=Firefox'\n deskctl click 500,300";
+ "Examples:\n deskctl click @w1\n deskctl click 'title=Chromium'\n deskctl click 500,300";
const DBLCLICK_EXAMPLES: &str =
- "Examples:\n deskctl dblclick @w2\n deskctl dblclick 'class=firefox'\n deskctl dblclick 500,300";
+ "Examples:\n deskctl dblclick @w2\n deskctl dblclick 'class=chromium'\n deskctl dblclick 500,300";
const TYPE_EXAMPLES: &str =
"Examples:\n deskctl type \"hello world\"\n deskctl type \"https://example.com\"";
const PRESS_EXAMPLES: &str = "Examples:\n deskctl press enter\n deskctl press escape";
const HOTKEY_EXAMPLES: &str = "Examples:\n deskctl hotkey ctrl l\n deskctl hotkey ctrl shift t";
const FOCUS_EXAMPLES: &str =
- "Examples:\n deskctl focus @w1\n deskctl focus 'title=Firefox'\n deskctl focus focused";
+ "Examples:\n deskctl focus @w1\n deskctl focus 'title=Chromium'\n deskctl focus focused";
const CLOSE_EXAMPLES: &str =
- "Examples:\n deskctl close @w3\n deskctl close 'id=win2'\n deskctl close 'class=firefox'";
+ "Examples:\n deskctl close @w3\n deskctl close 'id=win2'\n deskctl close 'class=chromium'";
const MOVE_WINDOW_EXAMPLES: &str =
- "Examples:\n deskctl move-window @w1 100 200\n deskctl move-window 'title=Firefox' 0 0";
+ "Examples:\n deskctl move-window @w1 100 200\n deskctl move-window 'title=Chromium' 0 0";
const RESIZE_WINDOW_EXAMPLES: &str =
"Examples:\n deskctl resize-window @w1 1280 720\n deskctl resize-window 'id=win2' 800 600";
const GET_MONITORS_EXAMPLES: &str =
@@ -237,12 +237,12 @@ const GET_MOUSE_POSITION_EXAMPLES: &str =
const DOCTOR_EXAMPLES: &str = "Examples:\n deskctl doctor\n deskctl --json doctor";
const UPGRADE_EXAMPLES: &str =
"Examples:\n deskctl upgrade\n deskctl upgrade --yes\n deskctl --json upgrade --yes";
-const WAIT_WINDOW_EXAMPLES: &str = "Examples:\n deskctl wait window --selector 'title=Firefox' --timeout 10\n deskctl --json wait window --selector 'class=firefox' --poll-ms 100";
+const WAIT_WINDOW_EXAMPLES: &str = "Examples:\n deskctl wait window --selector 'title=Chromium' --timeout 10\n deskctl --json wait window --selector 'class=chromium' --poll-ms 100";
const WAIT_FOCUS_EXAMPLES: &str = "Examples:\n deskctl wait focus --selector 'id=win3' --timeout 5\n deskctl wait focus --selector focused --poll-ms 200";
const SCREENSHOT_EXAMPLES: &str =
"Examples:\n deskctl screenshot\n deskctl screenshot /tmp/screen.png\n deskctl screenshot --annotate";
const LAUNCH_EXAMPLES: &str =
- "Examples:\n deskctl launch firefox\n deskctl launch code -- --new-window";
+ "Examples:\n deskctl launch chromium\n deskctl launch code -- --new-window";
const MOUSE_MOVE_EXAMPLES: &str =
"Examples:\n deskctl mouse move 500 300\n deskctl mouse move 0 0";
const MOUSE_SCROLL_EXAMPLES: &str =
@@ -277,7 +277,7 @@ pub enum WaitCmd {
#[derive(Args)]
pub struct WaitSelectorOpts {
- /// Selector: ref=w1, id=win1, title=Firefox, class=firefox, focused, or a fuzzy substring
+ /// Selector: ref=w1, id=win1, title=Chromium, class=chromium, focused, or a fuzzy substring
#[arg(long)]
pub selector: String,
@@ -1103,8 +1103,8 @@ mod tests {
"windows": [{
"ref_id": "w1",
"window_id": "win1",
- "title": "Firefox",
- "app_name": "firefox",
+ "title": "Chromium",
+ "app_name": "chromium",
"x": 0,
"y": 0,
"width": 1280,
@@ -1125,37 +1125,37 @@ mod tests {
fn action_text_includes_target_identity() {
let lines = render_success_lines(
&Command::Focus {
- selector: "title=Firefox".to_string(),
+ selector: "title=Chromium".to_string(),
},
Some(&json!({
"action": "focus",
- "window": "Firefox",
- "title": "Firefox",
+ "window": "Chromium",
+ "title": "Chromium",
"ref_id": "w2",
"window_id": "win7"
})),
)
.unwrap();
- assert_eq!(lines, vec!["Focused @w2 [win7] \"Firefox\""]);
+ assert_eq!(lines, vec!["Focused @w2 [win7] \"Chromium\""]);
}
#[test]
fn timeout_errors_render_last_observation() {
let lines = render_error_lines(&Response::err_with_data(
- "Timed out waiting for focus to match selector: title=Firefox",
+ "Timed out waiting for focus to match selector: title=Chromium",
json!({
"kind": "timeout",
"wait": "focus",
- "selector": "title=Firefox",
+ "selector": "title=Chromium",
"timeout_ms": 1000,
"last_observation": {
"kind": "window_not_focused",
"window": {
"ref_id": "w1",
"window_id": "win1",
- "title": "Firefox",
- "app_name": "firefox",
+ "title": "Chromium",
+ "app_name": "chromium",
"x": 0,
"y": 0,
"width": 1280,
@@ -1167,10 +1167,8 @@ mod tests {
}),
));
- assert!(lines
- .iter()
- .any(|line| line
- .contains("Timed out after 1000ms waiting for focus selector title=Firefox")));
+ assert!(lines.iter().any(|line| line
+ .contains("Timed out after 1000ms waiting for focus selector title=Chromium")));
assert!(lines
.iter()
.any(|line| line.contains("matching window exists but is not focused yet")));
@@ -1190,9 +1188,9 @@ mod tests {
let summary = target_summary(&json!({
"ref_id": "w1",
"window_id": "win1",
- "title": "Firefox"
+ "title": "Chromium"
}));
- assert_eq!(summary.as_deref(), Some("@w1 [win1] \"Firefox\""));
+ assert_eq!(summary.as_deref(), Some("@w1 [win1] \"Chromium\""));
}
#[test]
diff --git a/src/core/refs.rs b/src/core/refs.rs
index 34e1ba7..7fd7b6c 100644
--- a/src/core/refs.rs
+++ b/src/core/refs.rs
@@ -412,8 +412,8 @@ mod tests {
SelectorQuery::WindowId("win4".to_string())
);
assert_eq!(
- SelectorQuery::parse("title=Firefox"),
- SelectorQuery::Title("Firefox".to_string())
+ SelectorQuery::parse("title=Chromium"),
+ SelectorQuery::Title("Chromium".to_string())
);
assert_eq!(
SelectorQuery::parse("class=Navigator"),
@@ -458,11 +458,11 @@ mod tests {
fn fuzzy_resolution_fails_with_candidates_when_ambiguous() {
let mut refs = RefMap::new();
refs.rebuild(&[
- sample_window(1, "Firefox"),
+ sample_window(1, "Chromium"),
BackendWindow {
native_id: 2,
- title: "Firefox Settings".to_string(),
- app_name: "Firefox".to_string(),
+ title: "Chromium Settings".to_string(),
+ app_name: "Chromium".to_string(),
x: 0,
y: 0,
width: 10,
@@ -472,7 +472,7 @@ mod tests {
},
]);
- match refs.resolve("firefox") {
+ match refs.resolve("chromium") {
ResolveResult::Ambiguous {
mode, candidates, ..
} => {
From 3a8d9f90c1ac036cfd5bdb30daf7275909870dd9 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
Date: Thu, 26 Mar 2026 19:31:47 +0000
Subject: [PATCH 40/49] release: v0.1.12 [skip ci]
---
Cargo.lock | 2 +-
Cargo.toml | 2 +-
npm/deskctl/package.json | 2 +-
3 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
index b411e80..4acd174 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -400,7 +400,7 @@ checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "deskctl"
-version = "0.1.11"
+version = "0.1.12"
dependencies = [
"ab_glyph",
"anyhow",
diff --git a/Cargo.toml b/Cargo.toml
index 59108df..d782ecd 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "deskctl"
-version = "0.1.11"
+version = "0.1.12"
edition = "2021"
description = "X11 desktop control CLI for agents"
license = "MIT"
diff --git a/npm/deskctl/package.json b/npm/deskctl/package.json
index 60b8ed4..1dd5bff 100644
--- a/npm/deskctl/package.json
+++ b/npm/deskctl/package.json
@@ -1,6 +1,6 @@
{
"name": "deskctl",
- "version": "0.1.11",
+ "version": "0.1.12",
"description": "Installable deskctl package for Linux X11 agents",
"license": "MIT",
"homepage": "https://github.com/harivansh-afk/deskctl",
From 3ca6c90eafc6020b99730904a70e5f1593ca8441 Mon Sep 17 00:00:00 2001
From: Harivansh Rathi
Date: Fri, 27 Mar 2026 00:20:37 -0400
Subject: [PATCH 41/49] fix termination bug
---
src/daemon/mod.rs | 58 +++++++++++++++++++++++++++++++-------------
tests/support/mod.rs | 30 +++++++++++++++++++++++
tests/x11_runtime.rs | 25 +++++++++++++++++++
3 files changed, 96 insertions(+), 17 deletions(-)
diff --git a/src/daemon/mod.rs b/src/daemon/mod.rs
index 3df1d9a..9e7e931 100644
--- a/src/daemon/mod.rs
+++ b/src/daemon/mod.rs
@@ -1,6 +1,7 @@
mod handler;
mod state;
+use std::path::{Path, PathBuf};
use std::sync::Arc;
use anyhow::{Context, Result};
@@ -12,6 +13,29 @@ use crate::core::paths::{pid_path_from_env, socket_path_from_env};
use crate::core::session;
use state::DaemonState;
+struct RuntimePathsGuard {
+ socket_path: PathBuf,
+ pid_path: Option,
+}
+
+impl RuntimePathsGuard {
+ fn new(socket_path: PathBuf, pid_path: Option) -> Self {
+ Self {
+ socket_path,
+ pid_path,
+ }
+ }
+}
+
+impl Drop for RuntimePathsGuard {
+ fn drop(&mut self) {
+ remove_runtime_path(&self.socket_path);
+ if let Some(ref pid_path) = self.pid_path {
+ remove_runtime_path(pid_path);
+ }
+ }
+}
+
pub fn run() -> Result<()> {
// Validate session before starting
session::detect_session()?;
@@ -25,7 +49,6 @@ pub fn run() -> Result<()> {
async fn async_run() -> Result<()> {
let socket_path = socket_path_from_env().context("DESKCTL_SOCKET_PATH not set")?;
-
let pid_path = pid_path_from_env();
// Clean up stale socket
@@ -33,20 +56,21 @@ async fn async_run() -> Result<()> {
std::fs::remove_file(&socket_path)?;
}
- // Write PID file
- if let Some(ref pid_path) = pid_path {
- std::fs::write(pid_path, std::process::id().to_string())?;
- }
-
- let listener = UnixListener::bind(&socket_path)
- .context(format!("Failed to bind socket: {}", socket_path.display()))?;
-
let session = std::env::var("DESKCTL_SESSION").unwrap_or_else(|_| "default".to_string());
let state = Arc::new(Mutex::new(
DaemonState::new(session, socket_path.clone())
.context("Failed to initialize daemon state")?,
));
+ let listener = UnixListener::bind(&socket_path)
+ .context(format!("Failed to bind socket: {}", socket_path.display()))?;
+ let _runtime_paths = RuntimePathsGuard::new(socket_path.clone(), pid_path.clone());
+
+ // Write PID file only after the daemon is ready to serve requests.
+ if let Some(ref pid_path) = pid_path {
+ std::fs::write(pid_path, std::process::id().to_string())?;
+ }
+
let shutdown = Arc::new(tokio::sync::Notify::new());
let shutdown_clone = shutdown.clone();
@@ -75,14 +99,6 @@ async fn async_run() -> Result<()> {
}
}
- // Cleanup
- if socket_path.exists() {
- let _ = std::fs::remove_file(&socket_path);
- }
- if let Some(ref pid_path) = pid_path {
- let _ = std::fs::remove_file(pid_path);
- }
-
Ok(())
}
@@ -123,3 +139,11 @@ async fn handle_connection(
Ok(())
}
+
+fn remove_runtime_path(path: &Path) {
+ if let Err(error) = std::fs::remove_file(path) {
+ if error.kind() != std::io::ErrorKind::NotFound {
+ eprintln!("Failed to remove runtime path {}: {error}", path.display());
+ }
+ }
+}
diff --git a/tests/support/mod.rs b/tests/support/mod.rs
index 5c6f0be..719334d 100644
--- a/tests/support/mod.rs
+++ b/tests/support/mod.rs
@@ -142,6 +142,10 @@ impl TestSession {
.expect("TestSession always has an explicit socket path")
}
+ pub fn pid_path(&self) -> PathBuf {
+ self.root.join("deskctl.pid")
+ }
+
pub fn create_stale_socket(&self) -> Result<()> {
let listener = UnixListener::bind(self.socket_path())
.with_context(|| format!("Failed to bind {}", self.socket_path().display()))?;
@@ -187,6 +191,29 @@ impl TestSession {
)
})
}
+
+ pub fn run_daemon(&self, env: I) -> Result
+ where
+ I: IntoIterator- ,
+ K: AsRef
,
+ V: AsRef,
+ {
+ let mut command = Command::new(env!("CARGO_BIN_EXE_deskctl"));
+ command
+ .env("DESKCTL_DAEMON", "1")
+ .env("DESKCTL_SOCKET_PATH", self.socket_path())
+ .env("DESKCTL_PID_PATH", self.pid_path())
+ .env("DESKCTL_SESSION", &self.opts.session)
+ .envs(env);
+
+ command.output().with_context(|| {
+ format!(
+ "Failed to run daemon {} against {}",
+ env!("CARGO_BIN_EXE_deskctl"),
+ self.socket_path().display()
+ )
+ })
+ }
}
impl Drop for TestSession {
@@ -195,6 +222,9 @@ impl Drop for TestSession {
if self.socket_path().exists() {
let _ = std::fs::remove_file(self.socket_path());
}
+ if self.pid_path().exists() {
+ let _ = std::fs::remove_file(self.pid_path());
+ }
let _ = std::fs::remove_dir_all(&self.root);
}
}
diff --git a/tests/x11_runtime.rs b/tests/x11_runtime.rs
index 2aac58c..30308cb 100644
--- a/tests/x11_runtime.rs
+++ b/tests/x11_runtime.rs
@@ -114,6 +114,31 @@ fn daemon_start_recovers_from_stale_socket() -> Result<()> {
Ok(())
}
+#[test]
+fn daemon_init_failure_cleans_runtime_state() -> Result<()> {
+ let _guard = env_lock_guard();
+ let session = TestSession::new("daemon-init-failure")?;
+
+ let output = session.run_daemon([("XDG_SESSION_TYPE", "x11"), ("DISPLAY", ":99999")])?;
+ assert!(!output.status.success(), "daemon startup should fail");
+
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ assert!(
+ stderr.contains("Failed to initialize daemon state"),
+ "unexpected stderr: {stderr}"
+ );
+ assert!(
+ !session.socket_path().exists(),
+ "failed startup should remove the socket path"
+ );
+ assert!(
+ !session.pid_path().exists(),
+ "failed startup should remove the pid path"
+ );
+
+ Ok(())
+}
+
#[test]
fn wait_window_returns_matched_window_payload() -> Result<()> {
let _guard = env_lock_guard();
From 9bfada8b4bb06a74d9e45a3f332efb949b02d2ff Mon Sep 17 00:00:00 2001
From: Harivansh Rathi
Date: Fri, 27 Mar 2026 10:04:10 -0400
Subject: [PATCH 42/49] fix helper
---
tests/support/mod.rs | 24 ++++++++++++++++++++++--
1 file changed, 22 insertions(+), 2 deletions(-)
diff --git a/tests/support/mod.rs b/tests/support/mod.rs
index 719334d..07cc5a7 100644
--- a/tests/support/mod.rs
+++ b/tests/support/mod.rs
@@ -4,6 +4,7 @@ use std::os::unix::net::UnixListener;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use std::sync::{Mutex, OnceLock};
+use std::thread;
use std::time::{SystemTime, UNIX_EPOCH};
use anyhow::{anyhow, bail, Context, Result};
@@ -60,8 +61,7 @@ pub struct FixtureWindow {
impl FixtureWindow {
pub fn create(title: &str, app_class: &str) -> Result {
- let (conn, screen_num) =
- x11rb::connect(None).context("Failed to connect to the integration test display")?;
+ let (conn, screen_num) = connect_to_test_display()?;
let screen = &conn.setup().roots[screen_num];
let window = conn.generate_id()?;
@@ -103,6 +103,26 @@ impl FixtureWindow {
}
}
+fn connect_to_test_display() -> Result<(RustConnection, usize)> {
+ let max_attempts = 10;
+ let mut last_error = None;
+
+ for attempt in 0..max_attempts {
+ match x11rb::connect(None) {
+ Ok(connection) => return Ok(connection),
+ Err(error) => {
+ last_error = Some(anyhow!(error));
+ if attempt + 1 < max_attempts {
+ thread::sleep(std::time::Duration::from_millis(100 * (attempt + 1) as u64));
+ }
+ }
+ }
+ }
+
+ Err(last_error.expect("x11 connection attempts should capture an error"))
+ .context("Failed to connect to the integration test display")
+}
+
impl Drop for FixtureWindow {
fn drop(&mut self) {
let _ = self.conn.destroy_window(self.window);
From 85e191663547943b5a3468f48ecba45768271f74 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
Date: Fri, 27 Mar 2026 14:10:07 +0000
Subject: [PATCH 43/49] release: v0.1.13 [skip ci]
---
Cargo.lock | 22 +++++++++++-----------
Cargo.toml | 2 +-
npm/deskctl/package.json | 2 +-
3 files changed, 13 insertions(+), 13 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
index 4acd174..c948da0 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -241,9 +241,9 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
[[package]]
name = "cc"
-version = "1.2.57"
+version = "1.2.58"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423"
+checksum = "e1e928d4b69e3077709075a938a05ffbedfa53a84c8f766efbf8220bb1ff60e1"
dependencies = [
"find-msvc-tools",
"jobserver",
@@ -400,7 +400,7 @@ checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "deskctl"
-version = "0.1.12"
+version = "0.1.13"
dependencies = [
"ab_glyph",
"anyhow",
@@ -1039,9 +1039,9 @@ dependencies = [
[[package]]
name = "mio"
-version = "1.1.1"
+version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc"
+checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
dependencies = [
"libc",
"wasi",
@@ -1699,9 +1699,9 @@ dependencies = [
[[package]]
name = "simd-adler32"
-version = "0.3.8"
+version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
+checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
[[package]]
name = "simd_helpers"
@@ -1861,9 +1861,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "uuid"
-version = "1.22.0"
+version = "1.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37"
+checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9"
dependencies = [
"getrandom 0.4.2",
"js-sys",
@@ -2297,9 +2297,9 @@ dependencies = [
[[package]]
name = "zune-jpeg"
-version = "0.5.14"
+version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0b7a1c0af6e5d8d1363f4994b7a091ccf963d8b694f7da5b0b9cceb82da2c0a6"
+checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
dependencies = [
"zune-core",
]
diff --git a/Cargo.toml b/Cargo.toml
index d782ecd..2c4745c 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "deskctl"
-version = "0.1.12"
+version = "0.1.13"
edition = "2021"
description = "X11 desktop control CLI for agents"
license = "MIT"
diff --git a/npm/deskctl/package.json b/npm/deskctl/package.json
index 1dd5bff..327fb33 100644
--- a/npm/deskctl/package.json
+++ b/npm/deskctl/package.json
@@ -1,6 +1,6 @@
{
"name": "deskctl",
- "version": "0.1.12",
+ "version": "0.1.13",
"description": "Installable deskctl package for Linux X11 agents",
"license": "MIT",
"homepage": "https://github.com/harivansh-afk/deskctl",
From 2107449d9bf1425de3c43d1036465cbed69535cf Mon Sep 17 00:00:00 2001
From: Hari <73809867+harivansh-afk@users.noreply.github.com>
Date: Fri, 27 Mar 2026 18:17:51 -0400
Subject: [PATCH 44/49] Update README with asset link and description change
Added a link to GitHub assets and removed 'Linux' from description.
---
README.md | 6 ++++--
1 file changed, 4 insertions(+), 2 deletions(-)
diff --git a/README.md b/README.md
index 935f329..dccbe04 100644
--- a/README.md
+++ b/README.md
@@ -1,9 +1,11 @@
# deskctl
-
[](https://www.npmjs.com/package/deskctl)
[](skills/deskctl)
-Desktop control cli for AI agents on Linux X11.
+Desktop control cli for AI agents on X11.
+
+https://github.com/user-attachments/assets/e820787e-4d1a-463f-bdcf-a829588778bf
+
## Install
From 19669fb4c14462abd2cfc864328cb9c3c2143bf0 Mon Sep 17 00:00:00 2001
From: Harivansh Rathi
Date: Fri, 27 Mar 2026 19:25:26 -0400
Subject: [PATCH 45/49] demo
---
demo/index.html | 969 ++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 969 insertions(+)
create mode 100644 demo/index.html
diff --git a/demo/index.html b/demo/index.html
new file mode 100644
index 0000000..70ac230
--- /dev/null
+++ b/demo/index.html
@@ -0,0 +1,969 @@
+
+
+
+
+
+deskctl - Desktop Control for AI Agents
+
+
+
+
+
+
deskctl
+
desktop control CLI for AI agents
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 📝
+ task_brief.txt
+ 2.1 KB
+
+
+ 📊
+ nvda_q1_data.csv
+ 48 KB
+
+
+ 📄
+ prev_report.pdf
+ 1.2 MB
+
+
+ 📁
+ archive/
+ --
+
+
+
+ task: Prepare NVDA Q1 earnings summary
+ source: finance.yahoo.com, local csv
+ output: Google Docs report with chart
+
+
+
+
+
+
+
+
+
Chrome - Yahoo Finance
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ $950
+ $800
+ $650
+
+
+
+
+
+
+
+
+
+
+
Chrome - Google Docs
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ NVDA 1Y
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Files
+
Yahoo Finance
+
Google Docs
+
+
+
+
+
+
+
+
+
AI agent controlling a live desktop via deskctl
+
↺ Replay
+
+
+
+
+
From 2b3d422c7b0d40b5523a07d3f2c3c81b5fb42702 Mon Sep 17 00:00:00 2001
From: Harivansh Rathi
Date: Fri, 27 Mar 2026 19:40:24 -0400
Subject: [PATCH 46/49] crates.io
---
.github/workflows/ci.yml | 54 +++++++++++++++++++++++++++-----------
docs/releasing.md | 10 +++----
site/src/pages/index.astro | 3 +++
3 files changed, 46 insertions(+), 21 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index dcef6fb..0bcc90c 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -26,7 +26,7 @@ on:
publish_crates:
description: Publish to crates.io
type: boolean
- default: false
+ default: true
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
@@ -309,27 +309,25 @@ jobs:
artifacts/checksums.txt
fi
- publish:
- name: Publish
+ publish-npm:
+ name: Publish npm
needs: [changes, update-manifests, release]
- if: github.event_name != 'pull_request' && needs.changes.outputs.rust == 'true'
+ if: >-
+ github.event_name != 'pull_request'
+ && needs.changes.outputs.rust == 'true'
+ && (inputs.publish_npm == true || inputs.publish_npm == '')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.changes.outputs.tag }}
- - uses: dtolnay/rust-toolchain@stable
-
- uses: actions/setup-node@v4
with:
node-version: 22
registry-url: https://registry.npmjs.org
- - name: Install system dependencies
- run: sudo apt-get update && sudo apt-get install -y libx11-dev libxtst-dev
-
- - name: Check current published state
+ - name: Check if already published
id: published
run: |
VERSION="${{ needs.changes.outputs.version }}"
@@ -338,13 +336,9 @@ jobs:
else
echo "npm=false" >> "$GITHUB_OUTPUT"
fi
- if curl -fsSL "https://crates.io/api/v1/crates/deskctl/${VERSION}" >/dev/null 2>&1; then
- echo "crates=true" >> "$GITHUB_OUTPUT"
- else
- echo "crates=false" >> "$GITHUB_OUTPUT"
- fi
- name: Validate npm package
+ if: steps.published.outputs.npm != 'true'
run: node npm/deskctl/scripts/validate-package.js
- name: Publish npm
@@ -353,8 +347,36 @@ jobs:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: npm publish ./npm/deskctl --access public
+ publish-crates:
+ name: Publish crates.io
+ needs: [changes, update-manifests, release]
+ if: >-
+ github.event_name != 'pull_request'
+ && needs.changes.outputs.rust == 'true'
+ && (inputs.publish_crates == true || inputs.publish_crates == '')
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ ref: ${{ needs.changes.outputs.tag }}
+
+ - uses: dtolnay/rust-toolchain@stable
+
+ - name: Install system dependencies
+ run: sudo apt-get update && sudo apt-get install -y libx11-dev libxtst-dev
+
+ - name: Check if already published
+ id: published
+ run: |
+ VERSION="${{ needs.changes.outputs.version }}"
+ if curl -fsSL "https://crates.io/api/v1/crates/deskctl/${VERSION}" >/dev/null 2>&1; then
+ echo "crates=true" >> "$GITHUB_OUTPUT"
+ else
+ echo "crates=false" >> "$GITHUB_OUTPUT"
+ fi
+
- name: Publish crates.io
- if: inputs.publish_crates && steps.published.outputs.crates != 'true'
+ if: steps.published.outputs.crates != 'true'
env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
run: cargo publish --locked
diff --git a/docs/releasing.md b/docs/releasing.md
index 8f39d3f..849d661 100644
--- a/docs/releasing.md
+++ b/docs/releasing.md
@@ -59,12 +59,12 @@ The repository release workflow:
- publishes the canonical GitHub Release asset
- uploads `checksums.txt`
-The registry publish workflow:
+The registry publish jobs (npm and crates.io run in parallel):
-- targets an existing release tag
-- checks that Cargo, npm, and the requested tag all agree on version
-- checks whether that version is already published on npm and crates.io
-- only publishes the channels explicitly requested
+- target an existing release tag
+- check whether that version is already published on the respective registry
+- skip already-published versions
+- both default to enabled; can be toggled via workflow_dispatch inputs
## Rerun Safety
diff --git a/site/src/pages/index.astro b/site/src/pages/index.astro
index b914e16..8dfde01 100644
--- a/site/src/pages/index.astro
+++ b/site/src/pages/index.astro
@@ -43,6 +43,9 @@ import DocLayout from "../layouts/DocLayout.astro";
GitHub
+
+ crates.io
+
npm
From 2b7de5fceff991d02308081161079fa1f6176aad Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
Date: Fri, 27 Mar 2026 23:46:33 +0000
Subject: [PATCH 47/49] release: v0.1.14 [skip ci]
---
Cargo.lock | 22 +++++++++++-----------
Cargo.toml | 2 +-
npm/deskctl/package.json | 2 +-
3 files changed, 13 insertions(+), 13 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
index c948da0..eb0e2ce 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -400,7 +400,7 @@ checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "deskctl"
-version = "0.1.13"
+version = "0.1.14"
dependencies = [
"ab_glyph",
"anyhow",
@@ -911,9 +911,9 @@ dependencies = [
[[package]]
name = "js-sys"
-version = "0.3.91"
+version = "0.3.92"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c"
+checksum = "cc4c90f45aa2e6eacbe8645f77fdea542ac97a494bcd117a67df9ff4d611f995"
dependencies = [
"once_cell",
"wasm-bindgen",
@@ -1907,9 +1907,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen"
-version = "0.2.114"
+version = "0.2.115"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e"
+checksum = "6523d69017b7633e396a89c5efab138161ed5aafcbc8d3e5c5a42ae38f50495a"
dependencies = [
"cfg-if",
"once_cell",
@@ -1920,9 +1920,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro"
-version = "0.2.114"
+version = "0.2.115"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6"
+checksum = "4e3a6c758eb2f701ed3d052ff5737f5bfe6614326ea7f3bbac7156192dc32e67"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
@@ -1930,9 +1930,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro-support"
-version = "0.2.114"
+version = "0.2.115"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3"
+checksum = "921de2737904886b52bcbb237301552d05969a6f9c40d261eb0533c8b055fedf"
dependencies = [
"bumpalo",
"proc-macro2",
@@ -1943,9 +1943,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-shared"
-version = "0.2.114"
+version = "0.2.115"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16"
+checksum = "a93e946af942b58934c604527337bad9ae33ba1d5c6900bbb41c2c07c2364a93"
dependencies = [
"unicode-ident",
]
diff --git a/Cargo.toml b/Cargo.toml
index 2c4745c..be051c7 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "deskctl"
-version = "0.1.13"
+version = "0.1.14"
edition = "2021"
description = "X11 desktop control CLI for agents"
license = "MIT"
diff --git a/npm/deskctl/package.json b/npm/deskctl/package.json
index 327fb33..c676924 100644
--- a/npm/deskctl/package.json
+++ b/npm/deskctl/package.json
@@ -1,6 +1,6 @@
{
"name": "deskctl",
- "version": "0.1.13",
+ "version": "0.1.14",
"description": "Installable deskctl package for Linux X11 agents",
"license": "MIT",
"homepage": "https://github.com/harivansh-afk/deskctl",
From 13119eecf7cd96024ac4f0e3f435f4eb45d2759f Mon Sep 17 00:00:00 2001
From: Harivansh Rathi
Date: Thu, 2 Apr 2026 14:53:56 -0400
Subject: [PATCH 48/49] update crates.io link
---
site/src/pages/index.astro | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/site/src/pages/index.astro b/site/src/pages/index.astro
index 8dfde01..478c7a2 100644
--- a/site/src/pages/index.astro
+++ b/site/src/pages/index.astro
@@ -44,7 +44,7 @@ import DocLayout from "../layouts/DocLayout.astro";
GitHub
- crates.io
+ crates.io
npm
From 32c6d337f102a2d68de6e7d30ac9e2a8162ff010 Mon Sep 17 00:00:00 2001
From: Harivansh Rathi
Date: Sun, 5 Apr 2026 11:46:45 -0400
Subject: [PATCH 49/49] ci: use self-hosted netty runners for validation jobs
---
.github/workflows/ci.yml | 28 +++++-----------------------
1 file changed, 5 insertions(+), 23 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 0bcc90c..1c2e7f4 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1,9 +1,5 @@
name: CI
-# Runners: uvacompute (https://uvacompute.com)
-# To enable, set the UVA_RUNNER repo variable to the correct runner label.
-# runs-on: ${{ vars.UVA_RUNNER || 'ubuntu-latest' }}
-
on:
pull_request:
branches: [main]
@@ -37,7 +33,7 @@ permissions:
jobs:
changes:
name: Changes
- runs-on: ubuntu-latest
+ runs-on: [self-hosted, netty]
outputs:
rust: ${{ steps.check.outputs.rust }}
version: ${{ steps.version.outputs.version }}
@@ -105,7 +101,7 @@ jobs:
name: Validate
needs: changes
if: needs.changes.outputs.rust == 'true'
- runs-on: ubuntu-latest
+ runs-on: [self-hosted, netty]
steps:
- uses: actions/checkout@v4
@@ -129,9 +125,6 @@ jobs:
- name: Install site dependencies
run: pnpm --dir site install --frozen-lockfile
- - name: Install system dependencies
- run: sudo apt-get update && sudo apt-get install -y libx11-dev libxtst-dev
-
- name: Format check
run: make fmt-check
@@ -148,7 +141,7 @@ jobs:
name: Integration (Xvfb)
needs: changes
if: needs.changes.outputs.rust == 'true'
- runs-on: ubuntu-latest
+ runs-on: [self-hosted, netty]
steps:
- uses: actions/checkout@v4
@@ -156,9 +149,6 @@ jobs:
- uses: Swatinem/rust-cache@v2
- - name: Install system dependencies
- run: sudo apt-get update && sudo apt-get install -y libx11-dev libxtst-dev xvfb
-
- name: Xvfb integration tests
run: make test-integration
@@ -166,7 +156,7 @@ jobs:
name: Distribution Validate
needs: changes
if: needs.changes.outputs.rust == 'true'
- runs-on: ubuntu-latest
+ runs-on: [self-hosted, netty]
steps:
- uses: actions/checkout@v4
@@ -178,19 +168,11 @@ jobs:
with:
node-version: 22
- - uses: cachix/install-nix-action@v30
- with:
- extra_nix_config: |
- experimental-features = nix-command flakes
-
- - name: Install system dependencies
- run: sudo apt-get update && sudo apt-get install -y libx11-dev libxtst-dev
-
- name: Distribution validation
run: make dist-validate
# --- Release pipeline: update-manifests -> build -> release -> publish ---
- # Version bump happens BEFORE build so the binary has the correct version.
+ # These stay on ubuntu-latest for artifact upload/download and registry publishing.
update-manifests:
name: Update Manifests