Compare commits

..

No commits in common. "main" and "v0.1.5" have entirely different histories.
main ... v0.1.5

40 changed files with 988 additions and 3349 deletions

View file

@ -1,39 +1,24 @@
name: CI 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: on:
pull_request: pull_request:
branches: [main] branches: [main]
push: push:
branches: [main] branches: [main]
workflow_dispatch: 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: true
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
permissions: permissions:
contents: write contents: write
packages: write
jobs: jobs:
changes: changes:
name: Changes name: Changes
runs-on: [self-hosted, netty] runs-on: ubuntu-latest
outputs: outputs:
rust: ${{ steps.check.outputs.rust }} rust: ${{ steps.check.outputs.rust }}
version: ${{ steps.version.outputs.version }} version: ${{ steps.version.outputs.version }}
@ -52,11 +37,7 @@ jobs:
- 'tests/**' - 'tests/**'
- 'Cargo.toml' - 'Cargo.toml'
- 'Cargo.lock' - 'Cargo.lock'
- 'npm/**'
- 'flake.nix'
- 'flake.lock'
- 'docker/**' - 'docker/**'
- '.github/workflows/**'
- 'Makefile' - 'Makefile'
- name: Set outputs - name: Set outputs
@ -72,36 +53,34 @@ jobs:
id: version id: version
if: github.event_name != 'pull_request' && steps.check.outputs.rust == 'true' if: github.event_name != 'pull_request' && steps.check.outputs.rust == 'true'
run: | run: |
CURRENT=$(grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/') BASE=$(grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/')
IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT" IFS='.' read -r MAJOR MINOR PATCH <<< "$BASE"
BUMP="${{ inputs.bump || 'patch' }}" LATEST=$(git tag -l "v${MAJOR}.${MINOR}.*" | sort -V | tail -1)
case "$BUMP" in
major) MAJOR=$((MAJOR + 1)); MINOR=0; PATCH=0 ;; if [ -z "$LATEST" ]; then
minor) MINOR=$((MINOR + 1)); PATCH=0 ;; NEW="$BASE"
patch) else
LATEST=$(git tag -l "v${MAJOR}.${MINOR}.*" | sort -V | tail -1) LATEST_VER="${LATEST#v}"
if [ -z "$LATEST" ]; then IFS='.' read -r _ _ LATEST_PATCH <<< "$LATEST_VER"
NEW_PATCH=$PATCH NEW_PATCH=$((LATEST_PATCH + 1))
else NEW="${MAJOR}.${MINOR}.${NEW_PATCH}"
LATEST_VER="${LATEST#v}" fi
IFS='.' read -r _ _ LATEST_PATCH <<< "$LATEST_VER"
NEW_PATCH=$((LATEST_PATCH + 1)) # Ensure the computed version does not already have a tag
fi while git rev-parse "v${NEW}" >/dev/null 2>&1; do
PATCH=$NEW_PATCH IFS='.' read -r MAJOR MINOR PATCH <<< "$NEW"
;; NEW="${MAJOR}.${MINOR}.$((PATCH + 1))"
esac done
NEW="${MAJOR}.${MINOR}.${PATCH}"
echo "version=${NEW}" >> "$GITHUB_OUTPUT" echo "version=${NEW}" >> "$GITHUB_OUTPUT"
echo "tag=v${NEW}" >> "$GITHUB_OUTPUT" echo "tag=v${NEW}" >> "$GITHUB_OUTPUT"
echo "Computed version: ${NEW} (v${NEW})"
validate: validate:
name: Validate name: Validate
needs: changes needs: changes
if: needs.changes.outputs.rust == 'true' if: needs.changes.outputs.rust == 'true'
runs-on: [self-hosted, netty] runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@ -125,6 +104,9 @@ jobs:
- name: Install site dependencies - name: Install site dependencies
run: pnpm --dir site install --frozen-lockfile 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 - name: Format check
run: make fmt-check run: make fmt-check
@ -141,7 +123,7 @@ jobs:
name: Integration (Xvfb) name: Integration (Xvfb)
needs: changes needs: changes
if: needs.changes.outputs.rust == 'true' if: needs.changes.outputs.rust == 'true'
runs-on: [self-hosted, netty] runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@ -149,35 +131,79 @@ jobs:
- uses: Swatinem/rust-cache@v2 - 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 - name: Xvfb integration tests
run: make test-integration run: make test-integration
distribution: build:
name: Distribution Validate name: Build (${{ matrix.target }})
needs: changes needs: [changes, validate, integration]
if: needs.changes.outputs.rust == 'true' if: github.event_name != 'pull_request' && needs.changes.outputs.rust == 'true'
runs-on: [self-hosted, netty] runs-on: ubuntu-latest
strategy:
fail-fast: true
matrix:
target: [cargo, docker]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
# --- Cargo steps ---
- uses: dtolnay/rust-toolchain@stable - uses: dtolnay/rust-toolchain@stable
if: matrix.target == 'cargo'
with:
components: clippy
- uses: Swatinem/rust-cache@v2 - uses: Swatinem/rust-cache@v2
if: matrix.target == 'cargo'
- uses: actions/setup-node@v4 - 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: with:
node-version: 22 name: deskctl-linux-x86_64
path: target/release/deskctl
retention-days: 7
- name: Distribution validation # --- Docker steps ---
run: make dist-validate - uses: docker/setup-buildx-action@v3
if: matrix.target == 'docker'
# --- Release pipeline: update-manifests -> build -> release -> publish --- - uses: docker/login-action@v3
# These stay on ubuntu-latest for artifact upload/download and registry publishing. 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
update-manifests: update-manifests:
name: Update Manifests name: Update Manifests
needs: [changes, validate, integration, distribution] needs: [changes, build]
if: github.event_name != 'pull_request' && needs.changes.outputs.rust == 'true' if: github.event_name != 'pull_request'
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@ -186,11 +212,7 @@ jobs:
- uses: dtolnay/rust-toolchain@stable - uses: dtolnay/rust-toolchain@stable
- uses: actions/setup-node@v4 - name: Update version in Cargo.toml
with:
node-version: 22
- name: Update versions
run: | run: |
CURRENT=$(grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/') CURRENT=$(grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/')
NEW="${{ needs.changes.outputs.version }}" NEW="${{ needs.changes.outputs.version }}"
@ -198,70 +220,26 @@ jobs:
sed -i "0,/^version = \"${CURRENT}\"/s//version = \"${NEW}\"/" Cargo.toml sed -i "0,/^version = \"${CURRENT}\"/s//version = \"${NEW}\"/" Cargo.toml
cargo generate-lockfile cargo generate-lockfile
fi 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 - name: Commit, tag, and push
run: | run: |
git config user.name "github-actions[bot]" git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com" 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 if ! git diff --quiet; then
git add Cargo.toml Cargo.lock
git commit -m "release: ${{ needs.changes.outputs.tag }} [skip ci]" git commit -m "release: ${{ needs.changes.outputs.tag }} [skip ci]"
fi fi
git tag "${{ needs.changes.outputs.tag }}"
git push origin main --tags
build: if ! git rev-parse "${{ needs.changes.outputs.tag }}" >/dev/null 2>&1; then
name: Build Release Asset git tag "${{ needs.changes.outputs.tag }}"
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 fi
echo "Building version $CARGO_VER" git push origin main --tags
- 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: release:
name: Release name: Release
needs: [changes, build, update-manifests] needs: [changes, build, update-manifests]
if: github.event_name != 'pull_request' && needs.changes.outputs.rust == 'true' if: github.event_name != 'pull_request'
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@ -278,87 +256,9 @@ jobs:
chmod +x artifacts/deskctl chmod +x artifacts/deskctl
mv artifacts/deskctl artifacts/deskctl-linux-x86_64 mv artifacts/deskctl artifacts/deskctl-linux-x86_64
cd artifacts && sha256sum deskctl-linux-x86_64 > checksums.txt && cd .. cd artifacts && sha256sum deskctl-linux-x86_64 > checksums.txt && cd ..
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
publish-npm: gh release create "${{ needs.changes.outputs.tag }}" \
name: Publish npm --title "${{ needs.changes.outputs.tag }}" \
needs: [changes, update-manifests, release] --generate-notes \
if: >- artifacts/deskctl-linux-x86_64 \
github.event_name != 'pull_request' artifacts/checksums.txt
&& 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: actions/setup-node@v4
with:
node-version: 22
registry-url: https://registry.npmjs.org
- name: Check if already published
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
- name: Validate npm package
if: steps.published.outputs.npm != 'true'
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
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: steps.published.outputs.crates != 'true'
env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
run: cargo publish --locked

2
.gitignore vendored
View file

@ -5,5 +5,3 @@ secret/
.claude/ .claude/
.codex/ .codex/
openspec/ openspec/
npm/deskctl/vendor/
npm/deskctl/*.tgz

View file

@ -21,7 +21,7 @@ pnpm --dir site install
- `src/` holds production code and unit tests - `src/` holds production code and unit tests
- `tests/` holds integration tests - `tests/` holds integration tests
- `tests/support/` holds shared X11 and daemon helpers for integration coverage - `tests/support/` holds shared X11 and daemon helpers for integration coverage
- `docs/runtime-contract.md` is the stable-vs-best-effort runtime output contract for agent-facing CLI work - `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/`. Keep integration-only helpers out of `src/`.
@ -35,15 +35,10 @@ make lint
make test-unit make test-unit
make test-integration make test-integration
make site-format-check make site-format-check
make cargo-publish-dry-run
make npm-package-check
make nix-flake-check
make dist-validate
make validate make validate
``` ```
`make validate` runs the full Phase 2 validation stack. It requires Linux, `xvfb-run`, and site dependencies to be installed. `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 ## Pre-commit Hooks
@ -65,19 +60,6 @@ The hook config intentionally stays small:
- Site files reuse the existing `site/` Prettier setup - Site files reuse the existing `site/` Prettier setup
- Slower checks stay in CI or `make validate` - Slower checks stay in CI or `make validate`
## Distribution Work
Distribution support currently ships through:
- crate: `deskctl`
- npm package: `deskctl`
- 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 Tests
Integration coverage is Linux/X11-only in this phase. The supported local entrypoint is: Integration coverage is Linux/X11-only in this phase. The supported local entrypoint is:

44
Cargo.lock generated
View file

@ -1,6 +1,6 @@
# This file is automatically @generated by Cargo. # This file is automatically @generated by Cargo.
# It is not intended for manual editing. # It is not intended for manual editing.
version = 3 version = 4
[[package]] [[package]]
name = "ab_glyph" name = "ab_glyph"
@ -241,9 +241,9 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
[[package]] [[package]]
name = "cc" name = "cc"
version = "1.2.58" version = "1.2.57"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1e928d4b69e3077709075a938a05ffbedfa53a84c8f766efbf8220bb1ff60e1" checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423"
dependencies = [ dependencies = [
"find-msvc-tools", "find-msvc-tools",
"jobserver", "jobserver",
@ -400,7 +400,7 @@ checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]] [[package]]
name = "deskctl" name = "deskctl"
version = "0.1.14" version = "0.1.5"
dependencies = [ dependencies = [
"ab_glyph", "ab_glyph",
"anyhow", "anyhow",
@ -911,9 +911,9 @@ dependencies = [
[[package]] [[package]]
name = "js-sys" name = "js-sys"
version = "0.3.92" version = "0.3.91"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc4c90f45aa2e6eacbe8645f77fdea542ac97a494bcd117a67df9ff4d611f995" checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c"
dependencies = [ dependencies = [
"once_cell", "once_cell",
"wasm-bindgen", "wasm-bindgen",
@ -1039,9 +1039,9 @@ dependencies = [
[[package]] [[package]]
name = "mio" name = "mio"
version = "1.2.0" version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc"
dependencies = [ dependencies = [
"libc", "libc",
"wasi", "wasi",
@ -1699,9 +1699,9 @@ dependencies = [
[[package]] [[package]]
name = "simd-adler32" name = "simd-adler32"
version = "0.3.9" version = "0.3.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
[[package]] [[package]]
name = "simd_helpers" name = "simd_helpers"
@ -1861,9 +1861,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]] [[package]]
name = "uuid" name = "uuid"
version = "1.23.0" version = "1.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37"
dependencies = [ dependencies = [
"getrandom 0.4.2", "getrandom 0.4.2",
"js-sys", "js-sys",
@ -1907,9 +1907,9 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen" name = "wasm-bindgen"
version = "0.2.115" version = "0.2.114"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6523d69017b7633e396a89c5efab138161ed5aafcbc8d3e5c5a42ae38f50495a" checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"once_cell", "once_cell",
@ -1920,9 +1920,9 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen-macro" name = "wasm-bindgen-macro"
version = "0.2.115" version = "0.2.114"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e3a6c758eb2f701ed3d052ff5737f5bfe6614326ea7f3bbac7156192dc32e67" checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6"
dependencies = [ dependencies = [
"quote", "quote",
"wasm-bindgen-macro-support", "wasm-bindgen-macro-support",
@ -1930,9 +1930,9 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen-macro-support" name = "wasm-bindgen-macro-support"
version = "0.2.115" version = "0.2.114"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "921de2737904886b52bcbb237301552d05969a6f9c40d261eb0533c8b055fedf" checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3"
dependencies = [ dependencies = [
"bumpalo", "bumpalo",
"proc-macro2", "proc-macro2",
@ -1943,9 +1943,9 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen-shared" name = "wasm-bindgen-shared"
version = "0.2.115" version = "0.2.114"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a93e946af942b58934c604527337bad9ae33ba1d5c6900bbb41c2c07c2364a93" checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16"
dependencies = [ dependencies = [
"unicode-ident", "unicode-ident",
] ]
@ -2297,9 +2297,9 @@ dependencies = [
[[package]] [[package]]
name = "zune-jpeg" name = "zune-jpeg"
version = "0.5.15" version = "0.5.14"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" checksum = "0b7a1c0af6e5d8d1363f4994b7a091ccf963d8b694f7da5b0b9cceb82da2c0a6"
dependencies = [ dependencies = [
"zune-core", "zune-core",
] ]

View file

@ -1,23 +1,10 @@
[package] [package]
name = "deskctl" name = "deskctl"
version = "0.1.14" version = "0.1.5"
edition = "2021" edition = "2021"
description = "X11 desktop control CLI for agents" description = "X11 desktop control CLI for agents"
license = "MIT" license = "MIT"
repository = "https://github.com/harivansh-afk/deskctl" 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] [dependencies]
clap = { version = "4", features = ["derive", "env"] } clap = { version = "4", features = ["derive", "env"] }

View file

@ -1,4 +1,4 @@
.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 .PHONY: fmt fmt-check lint test-unit test-integration site-format-check validate
fmt: fmt:
cargo fmt --all cargo fmt --all
@ -30,34 +30,4 @@ site-format-check:
fi fi
pnpm --dir site format:check 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/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 --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 validate: fmt-check lint test-unit test-integration site-format-check

215
README.md
View file

@ -1,46 +1,213 @@
# deskctl # deskctl
[![npm](https://img.shields.io/npm/v/deskctl?label=npm)](https://www.npmjs.com/package/deskctl)
[![skill](https://img.shields.io/badge/skills.sh-deskctl-111827)](skills/deskctl)
Desktop control cli for AI agents on X11.
https://github.com/user-attachments/assets/e820787e-4d1a-463f-bdcf-a829588778bf
Desktop control CLI for AI agents on Linux X11.
## Install ## Install
```bash ```bash
npm install -g deskctl cargo install deskctl
``` ```
Build a Linux binary with Docker:
```bash ```bash
deskctl doctor docker compose -f docker/docker-compose.yml run --rm build
deskctl snapshot --annotate
``` ```
## Skill This writes `dist/deskctl-linux-x86_64`.
Copy it to an SSH machine where `scp` is unavailable:
```bash ```bash
npx skills add harivansh-afk/deskctl ssh -p 443 deskctl@ssh.agentcomputer.ai 'cat > ~/deskctl && chmod +x ~/deskctl' < dist/deskctl-linux-x86_64
``` ```
## Docs Run it on an X11 session:
- runtime contract: [docs/runtime-contract.md](docs/runtime-contract.md)
- releasing: [docs/releasing.md](docs/releasing.md)
- contributing: [CONTRIBUTING.md](CONTRIBUTING.md)
## Install paths
Nix:
```bash ```bash
nix run github:harivansh-afk/deskctl -- --help DISPLAY=:1 XDG_SESSION_TYPE=x11 ~/deskctl --json snapshot --annotate
nix profile install github:harivansh-afk/deskctl
``` ```
Rust: Local source build requirements:
```bash ```bash
cargo build cargo build
``` ```
At the moment there are no extra native build dependencies beyond a Rust toolchain.
## Quick Start
```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+ (for build)
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-output.md](docs/runtime-output.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-output.md](docs/runtime-output.md) for the exact stable-vs-best-effort breakdown.
## 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)

View file

@ -1,969 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>deskctl - Desktop Control for AI Agents</title>
<style>
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
:root {
/* cozybox light */
--page-bg: #f2f2f2;
--bg: #e7e7e7;
--surface: #dcdcdc;
--surface-2: #e1e1e1;
--border: #c3c7c9;
--text: #282828;
--text-dim: #504945;
--text-muted: #928374;
--selection: #c3c7c9;
--accent: #4261a5;
--green: #427b58;
--red: #c5524a;
--yellow: #d79921;
--orange: #af3a03;
--purple: #8f3f71;
--aqua: #427b58;
--cyan: #3c7678;
--gray: #928374;
--mono: 'Berkeley Mono', 'JetBrains Mono', 'Fira Code', 'SF Mono', Consolas, monospace;
--sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
}
body {
font-family: var(--sans);
background: var(--page-bg);
color: var(--text);
overflow: hidden;
height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.hero {
text-align: center;
margin-bottom: 28px;
z-index: 10;
}
.hero h1 {
font-family: var(--mono);
font-size: 28px;
font-weight: 700;
letter-spacing: -0.5px;
margin-bottom: 4px;
}
.hero p { font-size: 14px; color: var(--text-dim); }
.demo-container {
display: flex;
gap: 16px;
width: 1140px;
max-width: 96vw;
height: 580px;
}
/* ── Desktop ──────────────────────────────────────── */
.desktop-panel {
flex: 1;
position: relative;
border-radius: 12px;
overflow: hidden;
background: var(--bg);
box-shadow: 0 4px 24px rgba(0,0,0,0.08), 0 0 0 1px rgba(0,0,0,0.04);
}
.desktop-titlebar {
height: 30px;
background: var(--surface);
display: flex;
align-items: center;
padding: 0 12px;
gap: 6px;
}
.dot { width: 10px; height: 10px; border-radius: 50%; }
.viewport {
position: relative;
height: calc(100% - 30px);
background: var(--bg);
overflow: hidden;
}
.wallpaper {
position: absolute;
inset: 0;
background:
radial-gradient(ellipse at 25% 35%, rgba(66,97,165,0.04) 0%, transparent 55%),
radial-gradient(ellipse at 75% 65%, rgba(66,123,88,0.03) 0%, transparent 55%),
var(--bg);
}
/* ── Taskbar ──────────────────────────────────────── */
.taskbar {
position: absolute;
bottom: 0; left: 0; right: 0;
height: 28px;
background: var(--surface);
display: flex;
align-items: center;
padding: 0 8px;
gap: 2px;
z-index: 15;
}
.tb-item {
height: 20px;
padding: 0 10px;
font-family: var(--mono);
font-size: 9px;
color: var(--text-dim);
display: flex;
align-items: center;
border-radius: 3px;
opacity: 0;
transform: translateX(-4px);
transition: opacity 0.3s, transform 0.3s, background 0.15s;
}
.tb-item.visible { opacity: 1; transform: translateX(0); }
.tb-item.active { background: rgba(0,0,0,0.06); color: var(--text); }
/* ── Windows ──────────────────────────────────────── */
.win {
position: absolute;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 12px rgba(0,0,0,0.08), 0 0 0 1px rgba(0,0,0,0.04);
transition: box-shadow 0.2s, opacity 0.4s ease, transform 0.4s ease;
opacity: 0;
transform: scale(0.92) translateY(14px);
}
.win.visible { opacity: 1; transform: scale(1) translateY(0); }
.win.focused { box-shadow: 0 4px 20px rgba(0,0,0,0.12), 0 0 0 1px rgba(66,97,165,0.15); z-index: 10; }
.wbar {
height: 26px;
background: var(--surface);
display: flex;
align-items: center;
padding: 0 8px;
gap: 5px;
font-size: 10px;
font-family: var(--mono);
color: var(--text-dim);
}
.wbar .dots { display: flex; gap: 3px; }
.wbar .dots span { width: 7px; height: 7px; border-radius: 50%; }
.wbody {
background: #f8f8f8;
height: calc(100% - 26px);
overflow: hidden;
position: relative;
}
/* ── File Manager ─────────────────────────────────── */
.file-list { padding: 8px; }
.file-row {
display: flex;
align-items: center;
gap: 8px;
padding: 5px 8px;
border-radius: 4px;
font-family: var(--mono);
font-size: 10px;
color: var(--text-dim);
transition: background 0.15s;
}
.file-row.selected { background: var(--selection); color: var(--text); }
.file-row .ficon { font-size: 13px; width: 18px; text-align: center; }
.file-row .fmeta { margin-left: auto; font-size: 8px; color: var(--text-muted); }
.file-preview {
position: absolute;
bottom: 0; left: 0; right: 0;
height: 0;
background: var(--surface);
overflow: hidden;
transition: height 0.3s ease;
font-family: var(--mono);
font-size: 9px;
line-height: 1.5;
color: var(--text-dim);
padding: 0 10px;
}
.file-preview.open { height: 58px; padding: 8px 10px; }
/* ── Stock Chart ──────────────────────────────────── */
.chart-header {
display: flex;
align-items: baseline;
gap: 8px;
padding: 8px 12px 2px;
font-family: var(--mono);
}
.chart-ticker { font-size: 14px; font-weight: 700; color: var(--text); }
.chart-price { font-size: 12px; color: var(--green); }
.chart-change { font-size: 9px; color: var(--green); }
.chart-period { font-size: 8px; color: var(--text-muted); margin-left: auto; }
.chart-area { padding: 4px 12px 8px; height: calc(100% - 60px); }
.chart-area svg { width: 100%; height: 100%; }
.chart-vol {
display: flex;
align-items: flex-end;
gap: 2px;
height: 20px;
padding: 0 12px;
}
.chart-vol div {
flex: 1;
background: var(--border);
border-radius: 1px 1px 0 0;
min-height: 2px;
}
/* ── Google Docs ──────────────────────────────────── */
.gdoc-toolbar {
height: 24px;
background: #f1f3f4;
display: flex;
align-items: center;
padding: 0 8px;
gap: 3px;
}
.gdoc-toolbar .tb { width: 16px; height: 12px; background: #dadce0; border-radius: 2px; }
.gdoc-toolbar .tb.wide { width: 28px; }
.gdoc-toolbar .sep { width: 1px; height: 14px; background: #dadce0; margin: 0 3px; }
.gdoc-page {
background: #ffffff;
margin: 10px auto;
width: 88%;
height: calc(100% - 44px);
border-radius: 2px;
box-shadow: 0 1px 4px rgba(0,0,0,0.06);
padding: 20px 24px;
overflow: hidden;
}
.gdoc-title { font-family: var(--sans); font-size: 16px; font-weight: 700; color: #202124; min-height: 22px; margin-bottom: 4px; }
.gdoc-subtitle { font-family: var(--sans); font-size: 9px; color: #5f6368; margin-bottom: 10px; min-height: 12px; }
.gdoc-body { font-family: var(--sans); font-size: 9px; line-height: 1.6; color: #3c4043; min-height: 14px; }
.gdoc-chart-img {
margin-top: 8px;
width: 100%;
height: 80px;
background: #fafafa;
border: 1px solid #e0e0e0;
border-radius: 4px;
overflow: hidden;
opacity: 0;
transform: scale(0.95);
transition: opacity 0.3s, transform 0.3s;
display: flex;
align-items: center;
justify-content: center;
}
.gdoc-chart-img.visible { opacity: 1; transform: scale(1); }
.gdoc-chart-img svg { width: 95%; height: 80%; }
@keyframes blink { 50% { opacity: 0; } }
/* ── Annotations ──────────────────────────────────── */
.annot {
position: absolute;
border: 2px solid;
border-radius: 4px;
pointer-events: none;
opacity: 0;
transition: opacity 0.3s;
z-index: 20;
}
.annot.visible { opacity: 1; }
.annot-label {
position: absolute;
top: -16px;
left: -2px;
font-family: var(--mono);
font-size: 8px;
font-weight: 700;
padding: 1px 5px;
border-radius: 3px 3px 0 0;
color: #fff;
}
.annot.c1 { border-color: var(--accent); }
.annot.c1 .annot-label { background: var(--accent); }
.annot.c2 { border-color: var(--green); }
.annot.c2 .annot-label { background: var(--green); }
.annot.c3 { border-color: var(--orange); }
.annot.c3 .annot-label { background: var(--orange); }
.flash {
position: absolute;
inset: 0;
background: white;
opacity: 0;
pointer-events: none;
z-index: 50;
transition: opacity 0.05s;
}
.flash.fire { opacity: 0.3; }
/* ── Cursor ───────────────────────────────────────── */
.agent-cursor {
position: absolute;
width: 18px;
height: 22px;
z-index: 100;
pointer-events: none;
filter: drop-shadow(0 1px 2px rgba(0,0,0,0.15));
}
.agent-cursor svg { width: 100%; height: 100%; }
.agent-cursor.clicking { transform: scale(0.85); transition: transform 0.06s ease-out; }
.click-ripple {
position: absolute;
width: 24px; height: 24px;
border-radius: 50%;
border: 2px solid var(--accent);
opacity: 0;
pointer-events: none;
z-index: 99;
transform: translate(-50%, -50%) scale(0.3);
}
.click-ripple.animate { animation: ripple 0.4s ease-out forwards; }
@keyframes ripple {
0% { opacity: 0.6; transform: translate(-50%, -50%) scale(0.3); }
100% { opacity: 0; transform: translate(-50%, -50%) scale(1.5); }
}
/* ── Command Panel (light) ────────────────────────── */
.cmd-panel {
width: 340px;
border-radius: 12px;
overflow: hidden;
background: var(--bg);
box-shadow: 0 4px 24px rgba(0,0,0,0.08), 0 0 0 1px rgba(0,0,0,0.04);
display: flex;
flex-direction: column;
}
.cmd-titlebar {
height: 30px;
background: var(--surface);
display: flex;
align-items: center;
padding: 0 12px;
gap: 6px;
}
.cmd-titlebar .label {
font-family: var(--mono);
font-size: 10px;
color: var(--text-dim);
margin-left: 6px;
}
.cmd-body {
flex: 1;
padding: 12px;
font-family: var(--mono);
font-size: 11px;
line-height: 1.7;
overflow-y: auto;
scrollbar-width: none;
}
.cmd-body::-webkit-scrollbar { display: none; }
.cmd-line {
opacity: 0;
transform: translateY(4px);
transition: opacity 0.25s, transform 0.25s;
margin-bottom: 2px;
}
.cmd-line.visible { opacity: 1; transform: translateY(0); }
.cmd-line .ps { color: var(--green); user-select: none; }
.cmd-line .c { color: var(--text); }
.cmd-line .f { color: var(--orange); }
.cmd-line .s { color: var(--accent); }
.cmd-line .o { color: var(--text-dim); font-size: 10px; padding-left: 2px; }
.cmd-line .ok { color: var(--green); }
.cmd-line .jk { color: var(--purple); }
.cmd-line .jv { color: var(--accent); }
.cmd-line .link { color: var(--accent); text-decoration: underline; }
.cmd-line .agent-msg { color: var(--text); font-size: 10px; line-height: 1.5; padding-left: 2px; }
.cmd-divider {
height: 1px;
background: var(--border);
margin: 8px 0;
opacity: 0;
transition: opacity 0.3s;
}
.cmd-divider.visible { opacity: 1; }
.step-ind {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 4px;
opacity: 0;
transform: translateY(4px);
transition: opacity 0.25s, transform 0.25s;
}
.step-ind.visible { opacity: 1; transform: translateY(0); }
.badge {
font-size: 8px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 2px 5px;
border-radius: 3px;
font-family: var(--mono);
}
.badge.observe { background: rgba(66,97,165,0.12); color: var(--accent); }
.badge.act { background: rgba(66,123,88,0.12); color: var(--green); }
.badge.wait { background: rgba(175,58,3,0.1); color: var(--orange); }
.badge.verify { background: rgba(143,63,113,0.1); color: var(--purple); }
.badge.done { background: rgba(66,123,88,0.15); color: var(--green); }
.step-lbl { font-size: 9px; color: var(--text-muted); }
.caption {
text-align: center;
margin-top: 20px;
z-index: 10;
}
.caption p { font-size: 11px; color: var(--text-muted); font-family: var(--mono); }
.caption .replay-btn {
display: inline-flex;
align-items: center;
gap: 6px;
margin-top: 8px;
padding: 5px 12px;
border-radius: 6px;
border: 1px solid var(--border);
background: #fff;
color: var(--text-dim);
font-family: var(--mono);
font-size: 10px;
cursor: pointer;
transition: color 0.2s, background 0.2s;
}
.caption .replay-btn:hover { background: var(--bg); color: var(--text); }
</style>
</head>
<body>
<div class="hero">
<h1>deskctl</h1>
<p>desktop control CLI for AI agents</p>
</div>
<div class="demo-container">
<div class="desktop-panel">
<div class="desktop-titlebar">
<div class="dot" style="background:#c5524a"></div>
<div class="dot" style="background:#d79921"></div>
<div class="dot" style="background:#427b58"></div>
</div>
<div class="viewport" id="vp">
<div class="wallpaper"></div>
<!-- File Manager -->
<div class="win" id="w-files" style="left:16px; top:16px; width:200px; height:220px;">
<div class="wbar">
<div class="dots"><span style="background:#c5524a"></span><span style="background:#d79921"></span><span style="background:#427b58"></span></div>
<span>Files ~/reports</span>
</div>
<div class="wbody">
<div class="file-list">
<div class="file-row" id="f-notes">
<span class="ficon">&#128221;</span>
<span>task_brief.txt</span>
<span class="fmeta">2.1 KB</span>
</div>
<div class="file-row" id="f-csv">
<span class="ficon">&#128202;</span>
<span>nvda_q1_data.csv</span>
<span class="fmeta">48 KB</span>
</div>
<div class="file-row" id="f-prev">
<span class="ficon">&#128196;</span>
<span>prev_report.pdf</span>
<span class="fmeta">1.2 MB</span>
</div>
<div class="file-row">
<span class="ficon">&#128193;</span>
<span>archive/</span>
<span class="fmeta">--</span>
</div>
</div>
<div class="file-preview" id="file-preview">
<span style="color:#427b58">task:</span> Prepare NVDA Q1 earnings summary<br>
<span style="color:#427b58">source:</span> finance.yahoo.com, local csv<br>
<span style="color:#427b58">output:</span> Google Docs report with chart
</div>
</div>
</div>
<!-- Stock Chart -->
<div class="win" id="w-chart" style="left:140px; top:40px; width:380px; height:260px;">
<div class="wbar">
<div class="dots"><span style="background:#c5524a"></span><span style="background:#d79921"></span><span style="background:#427b58"></span></div>
<span>Chrome - Yahoo Finance</span>
</div>
<div class="wbody">
<div class="chart-header">
<span class="chart-ticker">NVDA</span>
<span class="chart-price">$924.68</span>
<span class="chart-change">+3.42%</span>
<span class="chart-period">1Y</span>
</div>
<div class="chart-area">
<svg viewBox="0 0 360 140" preserveAspectRatio="none">
<defs>
<linearGradient id="cg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#427b58" stop-opacity="0.2"/>
<stop offset="100%" stop-color="#427b58" stop-opacity="0"/>
</linearGradient>
</defs>
<line x1="0" y1="35" x2="360" y2="35" stroke="#dcdcdc" stroke-width="0.5"/>
<line x1="0" y1="70" x2="360" y2="70" stroke="#dcdcdc" stroke-width="0.5"/>
<line x1="0" y1="105" x2="360" y2="105" stroke="#dcdcdc" stroke-width="0.5"/>
<path d="M0,120 L20,115 40,118 60,110 80,105 100,95 120,100 140,85 160,75 180,80 200,65 220,55 240,60 260,45 280,35 300,40 320,28 340,22 360,18 L360,140 L0,140 Z" fill="url(#cg)"/>
<path d="M0,120 L20,115 40,118 60,110 80,105 100,95 120,100 140,85 160,75 180,80 200,65 220,55 240,60 260,45 280,35 300,40 320,28 340,22 360,18" fill="none" stroke="#427b58" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<text x="352" y="33" fill="#928374" font-size="7" font-family="monospace" text-anchor="end">$950</text>
<text x="352" y="68" fill="#928374" font-size="7" font-family="monospace" text-anchor="end">$800</text>
<text x="352" y="103" fill="#928374" font-size="7" font-family="monospace" text-anchor="end">$650</text>
</svg>
</div>
<div class="chart-vol" id="chart-vol"></div>
</div>
</div>
<!-- Google Docs -->
<div class="win" id="w-docs" style="left:80px; top:60px; width:440px; height:340px;">
<div class="wbar">
<div class="dots"><span style="background:#c5524a"></span><span style="background:#d79921"></span><span style="background:#427b58"></span></div>
<span>Chrome - Google Docs</span>
</div>
<div class="wbody" style="background:#f1f3f4">
<div class="gdoc-toolbar">
<div class="tb"></div><div class="tb"></div><div class="tb wide"></div>
<div class="sep"></div>
<div class="tb"></div><div class="tb"></div><div class="tb"></div>
<div class="sep"></div>
<div class="tb wide"></div><div class="tb"></div>
</div>
<div class="gdoc-page">
<div class="gdoc-title" id="doc-title"></div>
<div class="gdoc-subtitle" id="doc-subtitle"></div>
<div class="gdoc-body" id="doc-body"></div>
<div class="gdoc-chart-img" id="doc-chart">
<svg viewBox="0 0 360 80" preserveAspectRatio="none">
<defs>
<linearGradient id="cg2" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#427b58" stop-opacity="0.15"/>
<stop offset="100%" stop-color="#427b58" stop-opacity="0"/>
</linearGradient>
</defs>
<rect width="360" height="80" fill="#fafafa"/>
<path d="M0,65 L20,62 40,64 60,58 80,55 100,48 120,52 140,42 160,36 180,39 200,30 220,24 240,27 260,19 280,14 300,17 320,10 340,7 360,5 L360,80 L0,80 Z" fill="url(#cg2)"/>
<path d="M0,65 L20,62 40,64 60,58 80,55 100,48 120,52 140,42 160,36 180,39 200,30 220,24 240,27 260,19 280,14 300,17 320,10 340,7 360,5" fill="none" stroke="#427b58" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<text x="8" y="12" fill="#928374" font-size="7" font-family="monospace">NVDA 1Y</text>
</svg>
</div>
</div>
</div>
</div>
<!-- Annotations -->
<div class="annot c1" id="a1"><div class="annot-label">@w1</div></div>
<div class="annot c2" id="a2"><div class="annot-label">@w2</div></div>
<div class="annot c3" id="a3"><div class="annot-label">@w3</div></div>
<div class="flash" id="flash"></div>
<div class="agent-cursor" id="cur" style="left:380px; top:260px;">
<svg viewBox="0 0 24 24" fill="none"><path d="M5.5 3.21V20.8c0 .45.54.67.85.35l4.86-4.86a.5.5 0 0 1 .35-.15h6.87a.5.5 0 0 0 .35-.85L6.35 2.86a.5.5 0 0 0-.85.35Z" fill="#282828" stroke="#fff" stroke-width="1"/></svg>
</div>
<div class="click-ripple" id="rip"></div>
<!-- Taskbar -->
<div class="taskbar">
<div class="tb-item" id="tb-files">Files</div>
<div class="tb-item" id="tb-chart">Yahoo Finance</div>
<div class="tb-item" id="tb-docs">Google Docs</div>
</div>
</div>
</div>
<div class="cmd-panel">
<div class="cmd-titlebar">
<div class="dot" style="background:#c5524a"></div>
<div class="dot" style="background:#d79921"></div>
<div class="dot" style="background:#427b58"></div>
<span class="label">agent computer</span>
</div>
<div class="cmd-body" id="cb"></div>
</div>
</div>
<div class="caption">
<p>AI agent controlling a live desktop via deskctl</p>
<button class="replay-btn" id="replay" style="display:none" onclick="run()">&#8634; Replay</button>
</div>
<script>
const $ = s => document.getElementById(s);
const W = ms => new Promise(r => setTimeout(r, ms));
const cur = $('cur'), rip = $('rip'), cb = $('cb');
let cx = 380, cy = 260;
(() => {
const v = $('chart-vol');
[8,12,6,14,10,18,8,15,20,12,7,16,10,22,14,8,18,12,9,16].forEach(h => {
const d = document.createElement('div'); d.style.height = h + 'px'; v.appendChild(d);
});
})();
function move(x, y, dur = 500) {
return new Promise(res => {
const sx = cx, sy = cy, dx = x - sx, dy = y - sy, t0 = performance.now();
(function f(n) {
const t = Math.min((n - t0) / dur, 1), e = 1 - (1 - t) ** 3;
const arc = -Math.sin(t * Math.PI) * Math.min(Math.abs(dy) * 0.25, 25);
cur.style.left = (sx + dx * e) + 'px';
cur.style.top = (sy + dy * e + arc) + 'px';
if (t < 1) requestAnimationFrame(f); else { cx = x; cy = y; res(); }
})(performance.now());
});
}
async function clk() {
cur.classList.add('clicking');
rip.style.left = (cx + 4) + 'px'; rip.style.top = (cy + 4) + 'px';
rip.classList.remove('animate'); void rip.offsetWidth; rip.classList.add('animate');
await W(80); cur.classList.remove('clicking');
}
async function flash() {
const f = $('flash'); f.classList.add('fire'); await W(80); f.classList.remove('fire');
}
function show(id) { $(id).classList.add('visible'); }
function hide(id) { $(id).classList.remove('visible'); }
function tbShow(id) { const el = $(id); el.classList.add('visible'); }
function tbActive(id) {
document.querySelectorAll('.tb-item').forEach(t => t.classList.remove('active'));
if (id) $(id).classList.add('active');
}
function focus(id) {
document.querySelectorAll('.win').forEach(w => { w.classList.remove('focused'); w.style.zIndex = ''; });
if (id) { $(id).classList.add('focused'); $(id).style.zIndex = '10'; }
}
function posAnnot(aid, wid) {
const w = $(wid), a = $(aid);
a.style.left = (parseInt(w.style.left) - 2) + 'px';
a.style.top = (parseInt(w.style.top) + 26) + 'px';
a.style.width = (parseInt(w.style.width) + 4) + 'px';
a.style.height = (parseInt(w.style.height) - 22) + 'px';
}
function hideAnnots() { document.querySelectorAll('.annot').forEach(a => a.classList.remove('visible')); }
function typeEl(el, text, ms = 40) {
return new Promise(async res => {
for (const c of text) { el.textContent += c; await W(ms); }
res();
});
}
function step(type, label) {
const d = document.createElement('div'); d.className = 'step-ind';
d.innerHTML = `<span class="badge ${type}">${type}</span><span class="step-lbl">${label}</span>`;
cb.appendChild(d); void d.offsetWidth; d.classList.add('visible'); cb.scrollTop = cb.scrollHeight;
}
function ln(html) {
const d = document.createElement('div'); d.className = 'cmd-line';
d.innerHTML = `<span class="ps">$ </span>${html}`;
cb.appendChild(d); void d.offsetWidth; d.classList.add('visible'); cb.scrollTop = cb.scrollHeight;
}
function out(html) {
const d = document.createElement('div'); d.className = 'cmd-line';
d.innerHTML = `<span class="o">${html}</span>`;
cb.appendChild(d); void d.offsetWidth; d.classList.add('visible'); cb.scrollTop = cb.scrollHeight;
}
function agentMsg(html) {
const d = document.createElement('div'); d.className = 'cmd-line';
d.innerHTML = `<span class="agent-msg">${html}</span>`;
cb.appendChild(d); void d.offsetWidth; d.classList.add('visible'); cb.scrollTop = cb.scrollHeight;
}
function div() {
const d = document.createElement('div'); d.className = 'cmd-divider';
cb.appendChild(d); void d.offsetWidth; d.classList.add('visible'); cb.scrollTop = cb.scrollHeight;
}
function cm(c, f, s) {
let h = `<span class="c">${c}</span>`;
if (f) h += ` <span class="f">${f}</span>`;
if (s) h += ` <span class="s">${s}</span>`;
return h;
}
async function run() {
$('replay').style.display = 'none';
cb.innerHTML = '';
['w-files','w-chart','w-docs'].forEach(id => { hide(id); $(id).classList.remove('focused'); $(id).style.zIndex = ''; });
document.querySelectorAll('.tb-item').forEach(t => { t.classList.remove('visible','active'); });
hideAnnots();
$('f-notes').classList.remove('selected');
$('f-csv').classList.remove('selected');
$('file-preview').classList.remove('open');
$('doc-title').textContent = '';
$('doc-subtitle').textContent = '';
$('doc-body').textContent = '';
$('doc-chart').classList.remove('visible');
cur.style.left = '380px'; cur.style.top = '260px'; cur.style.opacity = '0';
cx = 380; cy = 260;
await W(500);
cur.style.transition = 'opacity 0.3s'; cur.style.opacity = '1';
await W(400); cur.style.transition = 'none';
// 1: Empty desktop
step('observe', 'Scan desktop');
await W(250);
ln(cm('deskctl snapshot'));
await W(400);
out('<span class="jk">"windows"</span>: <span class="o">[]</span>');
out('<span class="ok">empty desktop</span>');
await W(400); div();
// 2: Launch file manager
step('act', 'Open local files');
await W(250);
ln(cm('deskctl launch', '', 'nautilus ~/reports'));
await W(350);
show('w-files'); focus('w-files');
tbShow('tb-files'); tbActive('tb-files');
await W(300);
out('<span class="ok">launched nautilus (pid 3841)</span>');
await W(300);
step('wait', 'Wait for window');
ln(cm('deskctl wait window', "--selector 'title=Files'", '--timeout 5'));
await W(500);
out('<span class="ok">window ready: "Files ~/reports"</span>');
await W(300); div();
// 3: Read task brief
step('observe', 'Read task brief');
await W(250);
ln(cm('deskctl click', '', "'title=Files'"));
await move(100, 62, 450);
await clk();
$('f-notes').classList.add('selected');
await W(200);
out('<span class="ok">clicked "task_brief.txt"</span>');
await W(200);
ln(cm('deskctl hotkey', '', 'space'));
await W(300);
$('file-preview').classList.add('open');
await W(400);
out('<span class="o">task: Prepare NVDA Q1 earnings summary</span>');
out('<span class="o">source: finance.yahoo.com, local csv</span>');
out('<span class="o">output: Google Docs report with chart</span>');
await W(500); div();
// 4: Launch browser
step('act', 'Research stock data');
await W(250);
ln(cm('deskctl launch', '', 'google-chrome finance.yahoo.com/NVDA'));
await W(400);
show('w-chart'); focus('w-chart');
tbShow('tb-chart'); tbActive('tb-chart');
await W(350);
out('<span class="ok">launched chrome (pid 3912)</span>');
step('wait', 'Wait for page');
ln(cm('deskctl wait window', "--selector 'title=Yahoo'", '--timeout 8'));
await W(600);
out('<span class="ok">window ready: "Yahoo Finance - NVDA"</span>');
await W(300); div();
// 5: Snapshot chart
step('observe', 'Capture chart screenshot');
await W(250);
ln(cm('deskctl snapshot', '--annotate'));
await W(300);
await flash();
posAnnot('a1', 'w-files'); posAnnot('a2', 'w-chart');
show('a1'); show('a2');
await W(200);
out('<span class="jk">"windows"</span>: [');
out('&nbsp;&nbsp;{ <span class="jv">"@w1"</span>: <span class="jv">"Files"</span> }');
out('&nbsp;&nbsp;{ <span class="jv">"@w2"</span>: <span class="jv">"Yahoo Finance"</span> }');
out(']');
out('<span class="ok">screenshot saved: chart_nvda.png</span>');
await W(600);
hideAnnots(); div();
// 6: Open Google Docs
step('act', 'Create report document');
await W(250);
ln(cm('deskctl hotkey', '', 'ctrl t'));
await W(300);
out('<span class="ok">new tab opened</span>');
await W(200);
ln(cm('deskctl type', '', '"docs.google.com/document/new"'));
await W(200);
ln(cm('deskctl press', '', 'enter'));
await W(400);
show('w-docs'); focus('w-docs');
tbShow('tb-docs'); tbActive('tb-docs');
await W(350);
out('<span class="ok">navigated to Google Docs</span>');
step('wait', 'Wait for Docs');
ln(cm('deskctl wait window', "--selector 'title=Google Docs'", '--timeout 8'));
await W(500);
out('<span class="ok">document ready</span>');
await W(300); div();
// 7: Type title
step('act', 'Write report');
await W(250);
await move(310, 140, 450);
await clk();
await W(200);
ln(cm('deskctl type', '', '"NVDA Q1 2025 Earnings Summary"'));
await W(200);
await typeEl($('doc-title'), 'NVDA Q1 2025 Earnings Summary', 35);
out('<span class="ok">typed title</span>');
await W(200);
ln(cm('deskctl press', '', 'enter'));
await W(150);
ln(cm('deskctl type', '', '"Prepared by AI Agent via deskctl"'));
await W(200);
await typeEl($('doc-subtitle'), 'Prepared by AI Agent via deskctl', 28);
await W(200);
ln(cm('deskctl press', '', 'enter enter'));
await W(200); div();
// 8: Type body
step('act', 'Write analysis');
await W(250);
const body = "NVIDIA reported strong Q1 results driven by data center revenue growth of 427% YoY. The stock is up 3.42% today at $924.68. Key drivers include H100/H200 GPU demand from hyperscalers and continued AI infrastructure buildout.";
ln(cm('deskctl type', '', '"NVIDIA reported strong Q1..."'));
await W(200);
await typeEl($('doc-body'), body, 12);
out('<span class="ok">typed analysis (224 chars)</span>');
await W(400); div();
// 9: Paste chart
step('act', 'Insert chart screenshot');
await W(250);
ln(cm('deskctl press', '', 'enter enter'));
await W(200);
ln(cm('deskctl hotkey', '', 'ctrl v'));
await W(400);
$('doc-chart').classList.add('visible');
await W(300);
out('<span class="ok">pasted chart_nvda.png into document</span>');
await W(500); div();
// 10: Final verify
step('verify', 'Verify completed report');
await W(250);
ln(cm('deskctl snapshot', '--annotate'));
await W(300);
await flash();
posAnnot('a1', 'w-files'); posAnnot('a2', 'w-chart'); posAnnot('a3', 'w-docs');
show('a1'); show('a2'); show('a3');
await W(200);
out('<span class="jk">"windows"</span>: [');
out('&nbsp;&nbsp;{ <span class="jv">"@w1"</span>: <span class="jv">"Files"</span>, <span class="jv">"@w2"</span>: <span class="jv">"Yahoo Finance"</span>, <span class="jv">"@w3"</span>: <span class="jv">"Google Docs"</span> }');
out(']');
await W(600);
hideAnnots();
await W(300); div();
// 11: Agent summary (Claude-style)
step('done', 'Task complete');
await W(400);
agentMsg('I\'ve completed the NVDA Q1 earnings report.');
await W(300);
agentMsg('');
await W(100);
agentMsg('Here\'s what I did:');
await W(200);
agentMsg(' - Read task_brief.txt from ~/reports for context');
await W(150);
agentMsg(' - Pulled the NVDA 1Y chart from Yahoo Finance');
await W(150);
agentMsg(' - Created a new Google Doc with title, analysis,');
await W(100);
agentMsg(' and embedded the stock chart screenshot');
await W(300);
agentMsg('');
agentMsg('Document: <span class="link">docs.google.com/d/1xK9m...r4/edit</span>');
// Cursor exits
await W(500);
await move(600, 10, 700);
cur.style.transition = 'opacity 0.5s'; cur.style.opacity = '0';
await W(600);
$('replay').style.display = 'inline-flex';
}
window.addEventListener('load', () => setTimeout(run, 300));
</script>
</body>
</html>

View file

@ -1,110 +0,0 @@
# 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`
- installed command: `deskctl`
## Prerequisites
Before the first live publish on each registry:
- npm ownership for `deskctl`
- 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 jobs (npm and crates.io run in parallel):
- 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
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

View file

@ -1,70 +0,0 @@
# deskctl runtime contract
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

178
docs/runtime-output.md Normal file
View file

@ -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`.

61
flake.lock generated
View file

@ -1,61 +0,0 @@
{
"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
}

View file

@ -1,77 +0,0 @@
{
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
];
};
}
);
}

View file

@ -1,48 +0,0 @@
# deskctl
`deskctl` installs the command for Linux X11 systems.
## Install
```bash
npm install -g deskctl
```
After install, run:
```bash
deskctl --help
```
To upgrade version:
```bash
deskctl upgrade
```
For non-interactive use:
```bash
deskctl upgrade --yes
```
One-shot usage is also supported:
```bash
npx deskctl --help
```
## Runtime Support
- Linux
- X11 session
- currently packaged release asset: `linux-x64`
`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:
```bash
cargo install deskctl
```

View file

@ -1,36 +0,0 @@
#!/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 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();

View file

@ -1,36 +0,0 @@
{
"name": "deskctl",
"version": "0.1.14",
"description": "Installable deskctl 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"
]
}

View file

@ -1,49 +0,0 @@
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 install failed: ${error.message}`);
process.exit(1);
});

View file

@ -1,120 +0,0 @@
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 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
};

View file

@ -1,40 +0,0 @@
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 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", "vendor"))) {
throw new Error("Vendor binary directory resolved unexpectedly.");
}
}
main();

View file

@ -30,7 +30,7 @@ function formatTocText(text: string): string {
<body> <body>
{ {
!isIndex && ( !isIndex && (
<nav class="breadcrumbs"> <nav>
<a class="title" href="/"> <a class="title" href="/">
deskctl deskctl
</a> </a>

View file

@ -0,0 +1,78 @@
---
layout: ../layouts/DocLayout.astro
title: Architecture
toc: true
---
# Architecture
## Client-daemon 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.
Each command opens a new connection to the daemon, sends a single NDJSON request, reads one NDJSON response, and exits.
## Wire protocol
Requests and responses are newline-delimited JSON (NDJSON) over a Unix socket.
**Request:**
```json
{ "id": "r123456", "action": "snapshot", "annotate": true }
```
**Response:**
```json
{"success": true, "data": {"screenshot": "/tmp/deskctl-1234567890.png", "windows": [...]}}
```
Error responses include an `error` field:
```json
{ "success": false, "error": "window not found: @w99" }
```
## Socket location
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`
4. `~/.deskctl/{session}.sock`
PID files are stored alongside the socket.
## Sessions
Multiple isolated daemon instances can run simultaneously using the `--session` flag:
```sh
deskctl --session workspace1 snapshot
deskctl --session workspace2 snapshot
```
Each session has its own socket, PID file, and window ref map.
## Backend design
The core is built around a `DesktopBackend` trait. The current implementation uses `x11rb` for X11 protocol operations and `enigo` for input simulation.
The trait-based design means adding Wayland support is a single trait implementation with no changes to the core, CLI, or daemon code.
## X11 integration
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 |
Falls back to `XQueryTree` if `_NET_CLIENT_LIST_STACKING` is unavailable.

View file

@ -6,101 +6,166 @@ toc: true
# Commands # Commands
The public CLI is intentionally small. Most workflows boil down to grouped ## Snapshot
reads, grouped waits, selector-driven actions, and a few input primitives.
## Observe and inspect Capture a screenshot and get the window tree:
```sh ```sh
deskctl doctor
deskctl upgrade
deskctl snapshot deskctl snapshot
deskctl snapshot --annotate deskctl snapshot --annotate
deskctl list-windows
deskctl screenshot
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
``` ```
`doctor` checks the runtime before daemon startup. `upgrade` checks for a newer 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.
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.
## Wait for state transitions The screenshot is saved to `/tmp/deskctl-{timestamp}.png`.
## Click
Click the center of a window by ref, or click exact coordinates:
```sh ```sh
deskctl wait window --selector 'title=Chromium' --timeout 10
deskctl wait focus --selector 'id=win3' --timeout 5
deskctl --json wait window --selector 'class=chromium' --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 windows
```sh
deskctl launch chromium
deskctl focus @w1
deskctl focus 'title=Chromium'
deskctl click @w1 deskctl click @w1
deskctl click 960,540 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 ## Double click
coordinates where appropriate.
## Keyboard and mouse input ```sh
deskctl dblclick @w1
deskctl dblclick 500,300
```
## Type
Type a string into the focused window:
```sh ```sh
deskctl type "hello world" deskctl type "hello world"
```
## Press
Press a single key:
```sh
deskctl press enter 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 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 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 3
deskctl mouse scroll -5
deskctl mouse scroll 3 --axis horizontal deskctl mouse scroll 3 --axis horizontal
```
## Mouse drag
Drag from one position to another:
```sh
deskctl mouse drag 100 200 500 600 deskctl mouse drag 100 200 500 600
``` ```
Supported key names include `enter`, `tab`, `escape`, `backspace`, `delete`, ## Focus
`space`, arrow keys, paging keys, `f1` through `f12`, and any single
character.
## Selectors Focus a window by ref or by name (case-insensitive substring match):
Prefer explicit selectors when the target matters. They are clearer in logs,
more deterministic for automation, and easier to retry safely.
```sh ```sh
ref=w1 deskctl focus @w1
id=win1 deskctl focus "firefox"
title=Chromium
class=chromium
focused
``` ```
Legacy shorthand is still supported: ## Close
Close a window gracefully:
```sh ```sh
@w1 deskctl close @w2
w1 deskctl close "terminal"
win1
``` ```
Bare strings like `chromium` are fuzzy matches. They resolve when there is one ## Move window
match and fail with candidate windows when there are multiple matches.
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 get-screen-size
```
## Get mouse position
```sh
deskctl get-mouse-position
```
## Screenshot
Take a screenshot without the window tree. Optionally specify a save path:
```sh
deskctl screenshot
deskctl screenshot /tmp/my-screenshot.png
deskctl screenshot --annotate
```
## Launch
Launch an application:
```sh
deskctl launch firefox
deskctl launch code --args /path/to/project
```
## Global options ## Global options
@ -109,6 +174,3 @@ match and fail with candidate windows when there are multiple matches.
| `--json` | | Output as JSON | | `--json` | | Output as JSON |
| `--socket <path>` | `DESKCTL_SOCKET` | Path to daemon Unix socket | | `--socket <path>` | `DESKCTL_SOCKET` | Path to daemon Unix socket |
| `--session <name>` | | Session name for multiple daemons (default: `default`) | | `--session <name>` | | Session name for multiple daemons (default: `default`) |
`deskctl` manages the daemon automatically. Most users never need to think
about it beyond `--session` and `--socket`.

View file

@ -8,33 +8,24 @@ import DocLayout from "../layouts/DocLayout.astro";
<img src="/favicon.svg" alt="" width="40" height="40" /> <img src="/favicon.svg" alt="" width="40" height="40" />
</header> </header>
<p class="tagline">non-interactive desktop control cli for AI agents</p> <p>
Desktop control CLI for AI agents on Linux X11. Compact JSON output for
<p class="lede"> agent loops. Screenshot, click, type, scroll, drag, and manage windows
A thin X11 control primitive for agent loops: diagnose the runtime, observe through a fast client-daemon architecture. 100% native Rust.
the desktop, wait for state transitions, act deterministically, then verify.
</p> </p>
<h2>Start</h2> <h2>Getting started</h2>
<ul> <ul>
<li> <li><a href="/installation">Installation</a></li>
<a href="/installation">Installation</a> <li><a href="/quick-start">Quick start</a></li>
</li>
<li>
<a href="/quick-start">Quick start</a>
</li>
</ul> </ul>
<h2>Reference</h2> <h2>Reference</h2>
<ul> <ul>
<li> <li><a href="/commands">Commands</a></li>
<a href="/commands">Commands</a> <li><a href="/architecture">Architecture</a></li>
</li>
<li>
<a href="/runtime-contract">Runtime contract</a>
</li>
</ul> </ul>
<h2>Links</h2> <h2>Links</h2>
@ -46,8 +37,5 @@ import DocLayout from "../layouts/DocLayout.astro";
<li> <li>
<a href="https://crates.io/crates/deskctl">crates.io</a> <a href="https://crates.io/crates/deskctl">crates.io</a>
</li> </li>
<li>
<a href="https://www.npmjs.com/package/deskctl">npm</a>
</li>
</ul> </ul>
</DocLayout> </DocLayout>

View file

@ -6,71 +6,43 @@ toc: true
# Installation # Installation
Install the public `deskctl` command first, then validate the desktop runtime ## Cargo
with `deskctl doctor` before trying to automate anything.
## Recommended path
```sh ```sh
npm install -g deskctl cargo install deskctl
deskctl doctor
``` ```
`deskctl` is the default install path. It installs the command by ## From source
downloading the matching GitHub Release asset for the supported runtime target.
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
### Nix
```sh
nix run github:harivansh-afk/deskctl -- --help
nix profile install github:harivansh-afk/deskctl
```
### Rust
```sh ```sh
git clone https://github.com/harivansh-afk/deskctl git clone https://github.com/harivansh-afk/deskctl
cd deskctl cd deskctl
cargo build cargo build --release
``` ```
Source builds on Linux require: ## Docker (cross-compile for Linux)
- Rust 1.75+ Build a static Linux binary from any platform:
- `pkg-config`
- X11 development libraries such as `libx11-dev` and `libxtst-dev`
## Runtime requirements
- Linux with an active X11 session
- `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`
The binary itself only depends on the standard Linux glibc runtime.
## Verification
If setup fails for any reason start here:
```sh ```sh
deskctl doctor docker compose -f docker/docker-compose.yml run --rm build
``` ```
`doctor` checks X11 connectivity, window enumeration, screenshot viability, and This writes `dist/deskctl-linux-x86_64`.
daemon/socket health before normal command execution.
## 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
- 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`)
No extra native libraries are needed beyond the standard glibc runtime (`libc`, `libm`, `libgcc_s`).

View file

@ -6,74 +6,50 @@ toc: true
# Quick start # Quick start
The fastest way to use `deskctl` is to follow the same four-step loop : observe, wait, act, verify. ## Core workflow
## 1. Install and diagnose The typical agent loop is: snapshot the desktop, interpret the result, act on it.
```sh ```sh
npm install -g deskctl # 1. see the desktop
deskctl doctor deskctl --json snapshot --annotate
```
Run `deskctl doctor` first. It checks X11 connectivity, basic enumeration, # 2. click a window by its ref
screenshot viability, and socket health before you start driving the desktop. deskctl click @w1
## 2. Observe the desktop # 3. type into the focused window
deskctl type "hello world"
```sh # 4. press a key
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.
## 3. Pick selectors that stay readable
Prefer explicit selectors when you need deterministic targeting:
```sh
ref=w1
id=win1
title=Chromium
class=chromium
focused
```
Legacy refs such as `@w1` still work after `snapshot` or `list-windows`. Bare
strings like `chromium` are fuzzy matches and now fail on ambiguity.
## 4. Wait, act, verify
The core loop is:
```sh
# observe
deskctl snapshot --annotate
# wait
deskctl wait window --selector 'title=Chromium' --timeout 10
# act
deskctl focus 'title=Chromium'
deskctl hotkey ctrl l
deskctl type "https://example.com"
deskctl press enter deskctl press enter
# verify
deskctl wait focus --selector 'title=Chromium' --timeout 5
deskctl snapshot
``` ```
The wait commands return the matched window payload on success, so they compose The `--annotate` flag draws colored bounding boxes and `@wN` labels on the screenshot so agents can visually identify windows.
cleanly into the next action.
## 5. Use `--json` when parsing matters ## Window refs
Every command supports `--json` and uses the same top-level envelope: 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
```
```json ```json
{ {
@ -83,9 +59,9 @@ Every command supports `--json` and uses the same top-level envelope:
"windows": [ "windows": [
{ {
"ref_id": "w1", "ref_id": "w1",
"window_id": "win1", "xcb_id": 12345678,
"title": "Chromium", "title": "Firefox",
"app_name": "chromium", "app_name": "firefox",
"x": 0, "x": 0,
"y": 0, "y": 0,
"width": 1920, "width": 1920,
@ -98,8 +74,14 @@ Every command supports `--json` and uses the same top-level envelope:
} }
``` ```
Use `window_id` for stable targeting inside a live daemon session. The exact ## Daemon lifecycle
text formatting is intentionally compact, but JSON is the parsing contract.
The full stable-vs-best-effort contract lives on the 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.
[runtime contract](/runtime-contract) page.
```sh
# check if the daemon is running
deskctl daemon status
# stop it explicitly
deskctl daemon stop
```

View file

@ -1,177 +0,0 @@
---
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.
## Stable top-level 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 payload
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`.

View file

@ -65,11 +65,6 @@ main {
font-style: italic; font-style: italic;
} }
.lede {
font-size: 1.05rem;
max-width: 42rem;
}
header { header {
display: flex; display: flex;
align-items: center; align-items: center;
@ -122,10 +117,6 @@ a:hover {
text-decoration-thickness: 2px; text-decoration-thickness: 2px;
} }
img {
max-width: 100%;
}
ul, ul,
ol { ol {
padding-left: 1.25em; padding-left: 1.25em;
@ -224,30 +215,30 @@ hr {
} }
} }
.breadcrumbs { nav {
max-width: 50rem; max-width: 50rem;
margin: 0 auto; margin: 0 auto;
padding: 1.5rem clamp(1.25rem, 5vw, 3rem) 0; padding: 1.5rem clamp(1.25rem, 5vw, 3rem) 0;
font-size: 0.9rem; font-size: 0.9rem;
} }
.breadcrumbs a { nav a {
color: inherit; color: inherit;
text-decoration: none; text-decoration: none;
opacity: 0.6; opacity: 0.6;
transition: opacity 0.15s; transition: opacity 0.15s;
} }
.breadcrumbs a:hover { nav a:hover {
opacity: 1; opacity: 1;
} }
.breadcrumbs .title { nav .title {
font-weight: 500; font-weight: 500;
opacity: 1; opacity: 1;
} }
.breadcrumbs .sep { nav .sep {
opacity: 0.3; opacity: 0.3;
margin: 0 0.5em; margin: 0 0.5em;
} }

149
skills/SKILL.md Normal file
View file

@ -0,0 +1,149 @@
---
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`, `/`)

View file

@ -1,60 +0,0 @@
---
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:*), Bash(npm:*), Bash(which:*), Bash(printenv:*), Bash(echo:*)
---
# deskctl
Non-interactive desktop control CLI for Linux X11 agents.
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.
## Quick start
```bash
npm install -g deskctl
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**.
```bash
deskctl snapshot --annotate # observe
deskctl wait window --selector 'title=Chromium' --timeout 10 # wait
deskctl click 'title=Chromium' # act
deskctl snapshot # verify
```
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
ref=w1 # snapshot ref (short-lived)
id=win1 # stable window ID (session-scoped)
title=Chromium # match by title
class=chromium # match by WM class
focused # currently focused window
```
Bare strings like `chromium` do fuzzy matching but fail on ambiguity. Prefer explicit selectors.
## References
- [references/runtime-contract.md](references/runtime-contract.md) - output contract, stable fields, error kinds
- [references/commands.md](references/commands.md) - all available commands
## Workflows
- [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

View file

@ -1,7 +0,0 @@
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

View file

@ -1,66 +0,0 @@
# deskctl commands
All commands support `--json` for machine-parseable output following the
runtime contract.
## Observe
```bash
deskctl doctor
deskctl upgrade
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
```bash
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
`kind` values in `--json` mode.
## Selectors
```bash
ref=w1
id=win1
title=Chromium
class=chromium
focused
```
Legacy shorthand: `@w1`, `w1`, `win1`. Bare strings do fuzzy matching but fail
on ambiguity.
## Act
```bash
deskctl focus 'class=chromium'
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 scroll 3 --axis horizontal
deskctl mouse drag 100 100 500 500
deskctl move-window @w1 100 120
deskctl resize-window @w1 1280 720
deskctl close @w3
deskctl launch chromium
```
The daemon starts automatically on first command. In normal usage you should
not need to manage it directly.

View file

@ -1,73 +0,0 @@
# 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

View file

@ -1,37 +0,0 @@
#!/usr/bin/env bash
# observe-act.sh - main desktop interaction loop
# usage: ./observe-act.sh <selector> [action] [action-args...]
# example: ./observe-act.sh 'title=Chromium' click
# example: ./observe-act.sh 'class=terminal' type "ls -la"
set -euo pipefail
SELECTOR="${1:?usage: observe-act.sh <selector> [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

View file

@ -1,42 +0,0 @@
#!/usr/bin/env bash
# poll-condition.sh - poll the desktop until a condition is met
# usage: ./poll-condition.sh <match-string> [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 <match-string> [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

View file

@ -1,5 +1,4 @@
pub mod connection; pub mod connection;
pub mod upgrade;
use anyhow::Result; use anyhow::Result;
use clap::{Args, Parser, Subcommand}; use clap::{Args, Parser, Subcommand};
@ -8,12 +7,7 @@ use std::path::PathBuf;
use crate::core::protocol::{Request, Response}; use crate::core::protocol::{Request, Response};
#[derive(Parser)] #[derive(Parser)]
#[command( #[command(name = "deskctl", version, about = "Desktop control CLI for AI agents")]
name = "deskctl",
bin_name = "deskctl",
version,
about = "Desktop control CLI for AI agents"
)]
pub struct App { pub struct App {
#[command(flatten)] #[command(flatten)]
pub global: GlobalOpts, pub global: GlobalOpts,
@ -48,13 +42,13 @@ pub enum Command {
/// Click a window ref or coordinates /// Click a window ref or coordinates
#[command(after_help = CLICK_EXAMPLES)] #[command(after_help = CLICK_EXAMPLES)]
Click { Click {
/// Selector (ref=w1, id=win1, title=Chromium, class=chromium, focused) or x,y coordinates /// Selector (ref=w1, id=win1, title=Firefox, class=firefox, focused) or x,y coordinates
selector: String, selector: String,
}, },
/// Double-click a window ref or coordinates /// Double-click a window ref or coordinates
#[command(after_help = DBLCLICK_EXAMPLES)] #[command(after_help = DBLCLICK_EXAMPLES)]
Dblclick { Dblclick {
/// Selector (ref=w1, id=win1, title=Chromium, class=chromium, focused) or x,y coordinates /// Selector (ref=w1, id=win1, title=Firefox, class=firefox, focused) or x,y coordinates
selector: String, selector: String,
}, },
/// Type text into the focused window /// Type text into the focused window
@ -81,19 +75,19 @@ pub enum Command {
/// Focus a window by ref or name /// Focus a window by ref or name
#[command(after_help = FOCUS_EXAMPLES)] #[command(after_help = FOCUS_EXAMPLES)]
Focus { Focus {
/// Selector: ref=w1, id=win1, title=Chromium, class=chromium, focused, or a fuzzy substring /// Selector: ref=w1, id=win1, title=Firefox, class=firefox, focused, or a fuzzy substring
selector: String, selector: String,
}, },
/// Close a window by ref or name /// Close a window by ref or name
#[command(after_help = CLOSE_EXAMPLES)] #[command(after_help = CLOSE_EXAMPLES)]
Close { Close {
/// Selector: ref=w1, id=win1, title=Chromium, class=chromium, focused, or a fuzzy substring /// Selector: ref=w1, id=win1, title=Firefox, class=firefox, focused, or a fuzzy substring
selector: String, selector: String,
}, },
/// Move a window /// Move a window
#[command(after_help = MOVE_WINDOW_EXAMPLES)] #[command(after_help = MOVE_WINDOW_EXAMPLES)]
MoveWindow { MoveWindow {
/// Selector: ref=w1, id=win1, title=Chromium, class=chromium, focused, or a fuzzy substring /// Selector: ref=w1, id=win1, title=Firefox, class=firefox, focused, or a fuzzy substring
selector: String, selector: String,
/// X position /// X position
x: i32, x: i32,
@ -103,7 +97,7 @@ pub enum Command {
/// Resize a window /// Resize a window
#[command(after_help = RESIZE_WINDOW_EXAMPLES)] #[command(after_help = RESIZE_WINDOW_EXAMPLES)]
ResizeWindow { ResizeWindow {
/// Selector: ref=w1, id=win1, title=Chromium, class=chromium, focused, or a fuzzy substring /// Selector: ref=w1, id=win1, title=Firefox, class=firefox, focused, or a fuzzy substring
selector: String, selector: String,
/// Width /// Width
w: u32, w: u32,
@ -122,9 +116,6 @@ pub enum Command {
/// Diagnose X11 runtime, screenshot, and daemon health /// Diagnose X11 runtime, screenshot, and daemon health
#[command(after_help = DOCTOR_EXAMPLES)] #[command(after_help = DOCTOR_EXAMPLES)]
Doctor, Doctor,
/// Upgrade deskctl using the current install channel
#[command(after_help = UPGRADE_EXAMPLES)]
Upgrade(UpgradeOpts),
/// Query runtime state /// Query runtime state
#[command(subcommand)] #[command(subcommand)]
Get(GetCmd), Get(GetCmd),
@ -210,19 +201,19 @@ const SNAPSHOT_EXAMPLES: &str =
const LIST_WINDOWS_EXAMPLES: &str = const LIST_WINDOWS_EXAMPLES: &str =
"Examples:\n deskctl list-windows\n deskctl --json list-windows"; "Examples:\n deskctl list-windows\n deskctl --json list-windows";
const CLICK_EXAMPLES: &str = const CLICK_EXAMPLES: &str =
"Examples:\n deskctl click @w1\n deskctl click 'title=Chromium'\n deskctl click 500,300"; "Examples:\n deskctl click @w1\n deskctl click 'title=Firefox'\n deskctl click 500,300";
const DBLCLICK_EXAMPLES: &str = const DBLCLICK_EXAMPLES: &str =
"Examples:\n deskctl dblclick @w2\n deskctl dblclick 'class=chromium'\n deskctl dblclick 500,300"; "Examples:\n deskctl dblclick @w2\n deskctl dblclick 'class=firefox'\n deskctl dblclick 500,300";
const TYPE_EXAMPLES: &str = const TYPE_EXAMPLES: &str =
"Examples:\n deskctl type \"hello world\"\n deskctl type \"https://example.com\""; "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 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 HOTKEY_EXAMPLES: &str = "Examples:\n deskctl hotkey ctrl l\n deskctl hotkey ctrl shift t";
const FOCUS_EXAMPLES: &str = const FOCUS_EXAMPLES: &str =
"Examples:\n deskctl focus @w1\n deskctl focus 'title=Chromium'\n deskctl focus focused"; "Examples:\n deskctl focus @w1\n deskctl focus 'title=Firefox'\n deskctl focus focused";
const CLOSE_EXAMPLES: &str = const CLOSE_EXAMPLES: &str =
"Examples:\n deskctl close @w3\n deskctl close 'id=win2'\n deskctl close 'class=chromium'"; "Examples:\n deskctl close @w3\n deskctl close 'id=win2'\n deskctl close 'class=firefox'";
const MOVE_WINDOW_EXAMPLES: &str = const MOVE_WINDOW_EXAMPLES: &str =
"Examples:\n deskctl move-window @w1 100 200\n deskctl move-window 'title=Chromium' 0 0"; "Examples:\n deskctl move-window @w1 100 200\n deskctl move-window 'title=Firefox' 0 0";
const RESIZE_WINDOW_EXAMPLES: &str = const RESIZE_WINDOW_EXAMPLES: &str =
"Examples:\n deskctl resize-window @w1 1280 720\n deskctl resize-window 'id=win2' 800 600"; "Examples:\n deskctl resize-window @w1 1280 720\n deskctl resize-window 'id=win2' 800 600";
const GET_MONITORS_EXAMPLES: &str = const GET_MONITORS_EXAMPLES: &str =
@ -235,14 +226,12 @@ const GET_SCREEN_SIZE_EXAMPLES: &str =
const GET_MOUSE_POSITION_EXAMPLES: &str = const GET_MOUSE_POSITION_EXAMPLES: &str =
"Examples:\n deskctl get-mouse-position\n deskctl --json get-mouse-position"; "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 DOCTOR_EXAMPLES: &str = "Examples:\n deskctl doctor\n deskctl --json doctor";
const UPGRADE_EXAMPLES: &str = 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";
"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=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 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 = const SCREENSHOT_EXAMPLES: &str =
"Examples:\n deskctl screenshot\n deskctl screenshot /tmp/screen.png\n deskctl screenshot --annotate"; "Examples:\n deskctl screenshot\n deskctl screenshot /tmp/screen.png\n deskctl screenshot --annotate";
const LAUNCH_EXAMPLES: &str = const LAUNCH_EXAMPLES: &str =
"Examples:\n deskctl launch chromium\n deskctl launch code -- --new-window"; "Examples:\n deskctl launch firefox\n deskctl launch code -- --new-window";
const MOUSE_MOVE_EXAMPLES: &str = const MOUSE_MOVE_EXAMPLES: &str =
"Examples:\n deskctl mouse move 500 300\n deskctl mouse move 0 0"; "Examples:\n deskctl mouse move 500 300\n deskctl mouse move 0 0";
const MOUSE_SCROLL_EXAMPLES: &str = const MOUSE_SCROLL_EXAMPLES: &str =
@ -277,7 +266,7 @@ pub enum WaitCmd {
#[derive(Args)] #[derive(Args)]
pub struct WaitSelectorOpts { pub struct WaitSelectorOpts {
/// Selector: ref=w1, id=win1, title=Chromium, class=chromium, focused, or a fuzzy substring /// Selector: ref=w1, id=win1, title=Firefox, class=firefox, focused, or a fuzzy substring
#[arg(long)] #[arg(long)]
pub selector: String, pub selector: String,
@ -290,13 +279,6 @@ pub struct WaitSelectorOpts {
pub poll_ms: u64, pub poll_ms: u64,
} }
#[derive(Args)]
pub struct UpgradeOpts {
/// Skip confirmation and upgrade non-interactively
#[arg(long)]
pub yes: bool,
}
pub fn run() -> Result<()> { pub fn run() -> Result<()> {
let app = App::parse(); let app = App::parse();
@ -313,22 +295,6 @@ pub fn run() -> Result<()> {
return connection::run_doctor(&app.global); 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 // All other commands need a daemon connection
let request = build_request(&app.command)?; let request = build_request(&app.command)?;
let response = connection::send_command(&app.global, &request)?; let response = connection::send_command(&app.global, &request)?;
@ -392,7 +358,6 @@ fn build_request(cmd: &Command) -> Result<Request> {
Command::GetScreenSize => Request::new("get-screen-size"), Command::GetScreenSize => Request::new("get-screen-size"),
Command::GetMousePosition => Request::new("get-mouse-position"), Command::GetMousePosition => Request::new("get-mouse-position"),
Command::Doctor => unreachable!(), Command::Doctor => unreachable!(),
Command::Upgrade(_) => unreachable!(),
Command::Get(sub) => match sub { Command::Get(sub) => match sub {
GetCmd::ActiveWindow => Request::new("get-active-window"), GetCmd::ActiveWindow => Request::new("get-active-window"),
GetCmd::Monitors => Request::new("get-monitors"), GetCmd::Monitors => Request::new("get-monitors"),
@ -452,7 +417,6 @@ fn render_success_lines(cmd: &Command, data: Option<&serde_json::Value>) -> Resu
Command::Get(GetCmd::Systeminfo) => render_systeminfo_lines(data), Command::Get(GetCmd::Systeminfo) => render_systeminfo_lines(data),
Command::GetScreenSize => vec![render_screen_size_line(data)], Command::GetScreenSize => vec![render_screen_size_line(data)],
Command::GetMousePosition => vec![render_mouse_position_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::Screenshot { annotate, .. } => render_screenshot_lines(data, *annotate),
Command::Click { .. } => vec![render_click_line(data, false)], Command::Click { .. } => vec![render_click_line(data, false)],
Command::Dblclick { .. } => vec![render_click_line(data, true)], Command::Dblclick { .. } => vec![render_click_line(data, true)],
@ -557,41 +521,6 @@ fn render_error_lines(response: &Response) -> Vec<String> {
lines.push("No focused window is available.".to_string()); 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}"));
}
}
_ => {} _ => {}
} }
@ -789,36 +718,6 @@ fn render_screenshot_lines(data: &serde_json::Value, annotate: bool) -> Vec<Stri
lines lines
} }
fn render_upgrade_lines(data: &serde_json::Value) -> Vec<String> {
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 { fn render_click_line(data: &serde_json::Value, double: bool) -> String {
let action = if double { "Double-clicked" } else { "Clicked" }; let action = if double { "Double-clicked" } else { "Clicked" };
let key = if double { "double_clicked" } else { "clicked" }; let key = if double { "double_clicked" } else { "clicked" };
@ -1074,7 +973,7 @@ fn truncate_display(value: &str, max_chars: usize) -> String {
mod tests { mod tests {
use super::{ use super::{
render_error_lines, render_screen_size_line, render_success_lines, target_summary, render_error_lines, render_screen_size_line, render_success_lines, target_summary,
truncate_display, App, Command, Response, UpgradeOpts, truncate_display, App, Command, Response,
}; };
use clap::CommandFactory; use clap::CommandFactory;
use serde_json::json; use serde_json::json;
@ -1089,12 +988,6 @@ mod tests {
assert!(help.contains("deskctl snapshot --annotate")); 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] <COMMAND>"));
}
#[test] #[test]
fn window_listing_text_includes_window_ids() { fn window_listing_text_includes_window_ids() {
let lines = render_success_lines( let lines = render_success_lines(
@ -1103,8 +996,8 @@ mod tests {
"windows": [{ "windows": [{
"ref_id": "w1", "ref_id": "w1",
"window_id": "win1", "window_id": "win1",
"title": "Chromium", "title": "Firefox",
"app_name": "chromium", "app_name": "firefox",
"x": 0, "x": 0,
"y": 0, "y": 0,
"width": 1280, "width": 1280,
@ -1125,37 +1018,37 @@ mod tests {
fn action_text_includes_target_identity() { fn action_text_includes_target_identity() {
let lines = render_success_lines( let lines = render_success_lines(
&Command::Focus { &Command::Focus {
selector: "title=Chromium".to_string(), selector: "title=Firefox".to_string(),
}, },
Some(&json!({ Some(&json!({
"action": "focus", "action": "focus",
"window": "Chromium", "window": "Firefox",
"title": "Chromium", "title": "Firefox",
"ref_id": "w2", "ref_id": "w2",
"window_id": "win7" "window_id": "win7"
})), })),
) )
.unwrap(); .unwrap();
assert_eq!(lines, vec!["Focused @w2 [win7] \"Chromium\""]); assert_eq!(lines, vec!["Focused @w2 [win7] \"Firefox\""]);
} }
#[test] #[test]
fn timeout_errors_render_last_observation() { fn timeout_errors_render_last_observation() {
let lines = render_error_lines(&Response::err_with_data( let lines = render_error_lines(&Response::err_with_data(
"Timed out waiting for focus to match selector: title=Chromium", "Timed out waiting for focus to match selector: title=Firefox",
json!({ json!({
"kind": "timeout", "kind": "timeout",
"wait": "focus", "wait": "focus",
"selector": "title=Chromium", "selector": "title=Firefox",
"timeout_ms": 1000, "timeout_ms": 1000,
"last_observation": { "last_observation": {
"kind": "window_not_focused", "kind": "window_not_focused",
"window": { "window": {
"ref_id": "w1", "ref_id": "w1",
"window_id": "win1", "window_id": "win1",
"title": "Chromium", "title": "Firefox",
"app_name": "chromium", "app_name": "firefox",
"x": 0, "x": 0,
"y": 0, "y": 0,
"width": 1280, "width": 1280,
@ -1167,8 +1060,10 @@ mod tests {
}), }),
)); ));
assert!(lines.iter().any(|line| line assert!(lines
.contains("Timed out after 1000ms waiting for focus selector title=Chromium"))); .iter()
.any(|line| line
.contains("Timed out after 1000ms waiting for focus selector title=Firefox")));
assert!(lines assert!(lines
.iter() .iter()
.any(|line| line.contains("matching window exists but is not focused yet"))); .any(|line| line.contains("matching window exists but is not focused yet")));
@ -1188,9 +1083,9 @@ mod tests {
let summary = target_summary(&json!({ let summary = target_summary(&json!({
"ref_id": "w1", "ref_id": "w1",
"window_id": "win1", "window_id": "win1",
"title": "Chromium" "title": "Firefox"
})); }));
assert_eq!(summary.as_deref(), Some("@w1 [win1] \"Chromium\"")); assert_eq!(summary.as_deref(), Some("@w1 [win1] \"Firefox\""));
} }
#[test] #[test]
@ -1198,22 +1093,4 @@ mod tests {
let input = format!("fire{}fox", '\u{00E9}'); let input = format!("fire{}fox", '\u{00E9}');
assert_eq!(truncate_display(&input, 7), "fire..."); 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)"]
);
}
} }

View file

@ -1,465 +0,0 @@
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::<Vec<_>>()
.join(" ")
}
}
#[derive(Debug)]
struct VersionInfo {
current: String,
latest: String,
}
pub fn run_upgrade(opts: &GlobalOpts, upgrade_opts: &UpgradeOpts) -> Result<Response> {
let current_exe = std::env::current_exe().context("Failed to determine executable path")?;
let install_method = detect_install_method(&current_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<VersionInfo, Response> {
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<String, Response> {
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::<String>(&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<String, Response> {
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<bool> {
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<UpgradePlan> {
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");
}
}

View file

@ -412,8 +412,8 @@ mod tests {
SelectorQuery::WindowId("win4".to_string()) SelectorQuery::WindowId("win4".to_string())
); );
assert_eq!( assert_eq!(
SelectorQuery::parse("title=Chromium"), SelectorQuery::parse("title=Firefox"),
SelectorQuery::Title("Chromium".to_string()) SelectorQuery::Title("Firefox".to_string())
); );
assert_eq!( assert_eq!(
SelectorQuery::parse("class=Navigator"), SelectorQuery::parse("class=Navigator"),
@ -458,11 +458,11 @@ mod tests {
fn fuzzy_resolution_fails_with_candidates_when_ambiguous() { fn fuzzy_resolution_fails_with_candidates_when_ambiguous() {
let mut refs = RefMap::new(); let mut refs = RefMap::new();
refs.rebuild(&[ refs.rebuild(&[
sample_window(1, "Chromium"), sample_window(1, "Firefox"),
BackendWindow { BackendWindow {
native_id: 2, native_id: 2,
title: "Chromium Settings".to_string(), title: "Firefox Settings".to_string(),
app_name: "Chromium".to_string(), app_name: "Firefox".to_string(),
x: 0, x: 0,
y: 0, y: 0,
width: 10, width: 10,
@ -472,7 +472,7 @@ mod tests {
}, },
]); ]);
match refs.resolve("chromium") { match refs.resolve("firefox") {
ResolveResult::Ambiguous { ResolveResult::Ambiguous {
mode, candidates, .. mode, candidates, ..
} => { } => {

View file

@ -1,7 +1,6 @@
mod handler; mod handler;
mod state; mod state;
use std::path::{Path, PathBuf};
use std::sync::Arc; use std::sync::Arc;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
@ -13,29 +12,6 @@ use crate::core::paths::{pid_path_from_env, socket_path_from_env};
use crate::core::session; use crate::core::session;
use state::DaemonState; use state::DaemonState;
struct RuntimePathsGuard {
socket_path: PathBuf,
pid_path: Option<PathBuf>,
}
impl RuntimePathsGuard {
fn new(socket_path: PathBuf, pid_path: Option<PathBuf>) -> 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<()> { pub fn run() -> Result<()> {
// Validate session before starting // Validate session before starting
session::detect_session()?; session::detect_session()?;
@ -49,6 +25,7 @@ pub fn run() -> Result<()> {
async fn async_run() -> Result<()> { async fn async_run() -> Result<()> {
let socket_path = socket_path_from_env().context("DESKCTL_SOCKET_PATH not set")?; let socket_path = socket_path_from_env().context("DESKCTL_SOCKET_PATH not set")?;
let pid_path = pid_path_from_env(); let pid_path = pid_path_from_env();
// Clean up stale socket // Clean up stale socket
@ -56,21 +33,20 @@ async fn async_run() -> Result<()> {
std::fs::remove_file(&socket_path)?; 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 session = std::env::var("DESKCTL_SESSION").unwrap_or_else(|_| "default".to_string());
let state = Arc::new(Mutex::new( let state = Arc::new(Mutex::new(
DaemonState::new(session, socket_path.clone()) DaemonState::new(session, socket_path.clone())
.context("Failed to initialize daemon state")?, .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 = Arc::new(tokio::sync::Notify::new());
let shutdown_clone = shutdown.clone(); let shutdown_clone = shutdown.clone();
@ -99,6 +75,14 @@ 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(()) Ok(())
} }
@ -139,11 +123,3 @@ async fn handle_connection(
Ok(()) 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());
}
}
}

View file

@ -4,7 +4,6 @@ use std::os::unix::net::UnixListener;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::{Command, Output}; use std::process::{Command, Output};
use std::sync::{Mutex, OnceLock}; use std::sync::{Mutex, OnceLock};
use std::thread;
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
use anyhow::{anyhow, bail, Context, Result}; use anyhow::{anyhow, bail, Context, Result};
@ -61,7 +60,8 @@ pub struct FixtureWindow {
impl FixtureWindow { impl FixtureWindow {
pub fn create(title: &str, app_class: &str) -> Result<Self> { pub fn create(title: &str, app_class: &str) -> Result<Self> {
let (conn, screen_num) = connect_to_test_display()?; let (conn, screen_num) =
x11rb::connect(None).context("Failed to connect to the integration test display")?;
let screen = &conn.setup().roots[screen_num]; let screen = &conn.setup().roots[screen_num];
let window = conn.generate_id()?; let window = conn.generate_id()?;
@ -103,26 +103,6 @@ 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 { impl Drop for FixtureWindow {
fn drop(&mut self) { fn drop(&mut self) {
let _ = self.conn.destroy_window(self.window); let _ = self.conn.destroy_window(self.window);
@ -162,10 +142,6 @@ impl TestSession {
.expect("TestSession always has an explicit socket path") .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<()> { pub fn create_stale_socket(&self) -> Result<()> {
let listener = UnixListener::bind(self.socket_path()) let listener = UnixListener::bind(self.socket_path())
.with_context(|| format!("Failed to bind {}", self.socket_path().display()))?; .with_context(|| format!("Failed to bind {}", self.socket_path().display()))?;
@ -211,29 +187,6 @@ impl TestSession {
) )
}) })
} }
pub fn run_daemon<I, K, V>(&self, env: I) -> Result<Output>
where
I: IntoIterator<Item = (K, V)>,
K: AsRef<std::ffi::OsStr>,
V: AsRef<std::ffi::OsStr>,
{
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 { impl Drop for TestSession {
@ -242,9 +195,6 @@ impl Drop for TestSession {
if self.socket_path().exists() { if self.socket_path().exists() {
let _ = std::fs::remove_file(self.socket_path()); 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); let _ = std::fs::remove_dir_all(&self.root);
} }
} }

View file

@ -114,31 +114,6 @@ fn daemon_start_recovers_from_stale_socket() -> Result<()> {
Ok(()) 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] #[test]
fn wait_window_returns_matched_window_payload() -> Result<()> { fn wait_window_returns_matched_window_payload() -> Result<()> {
let _guard = env_lock_guard(); let _guard = env_lock_guard();