diff --git a/.agents/hooks.json b/.agents/hooks.json index f562ec46..410de905 100644 --- a/.agents/hooks.json +++ b/.agents/hooks.json @@ -2,7 +2,7 @@ "tiny-engineer": { "PreInvocation": [ { - "command": "tiny-engineer-antigravity PreInvocation", + "command": "node packages/tiny-engineer-cli/bin/tiny-engineer.js hook antigravity PreInvocation", "timeout": 3 } ], @@ -11,7 +11,7 @@ "matcher": "*", "hooks": [ { - "command": "tiny-engineer-antigravity PreToolUse", + "command": "node packages/tiny-engineer-cli/bin/tiny-engineer.js hook antigravity PreToolUse", "timeout": 3 } ] @@ -22,7 +22,7 @@ "matcher": "*", "hooks": [ { - "command": "tiny-engineer-antigravity PostToolUse", + "command": "node packages/tiny-engineer-cli/bin/tiny-engineer.js hook antigravity PostToolUse", "timeout": 3 } ] @@ -30,7 +30,7 @@ ], "Stop": [ { - "command": "tiny-engineer-antigravity Stop", + "command": "node packages/tiny-engineer-cli/bin/tiny-engineer.js hook antigravity Stop", "timeout": 3 } ] diff --git a/.claude/settings.json b/.claude/settings.json index 475a7638..39a38f9f 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -5,7 +5,7 @@ "hooks": [ { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR/packages/tiny-engineer-claude-code/bin/tiny-engineer-claude-code.js\"", + "command": "node \"$CLAUDE_PROJECT_DIR/packages/tiny-engineer-cli/bin/tiny-engineer.js\" hook claude-code", "async": true } ] @@ -16,7 +16,7 @@ "hooks": [ { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR/packages/tiny-engineer-claude-code/bin/tiny-engineer-claude-code.js\"", + "command": "node \"$CLAUDE_PROJECT_DIR/packages/tiny-engineer-cli/bin/tiny-engineer.js\" hook claude-code", "async": true } ] @@ -27,7 +27,7 @@ "hooks": [ { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR/packages/tiny-engineer-claude-code/bin/tiny-engineer-claude-code.js\"", + "command": "node \"$CLAUDE_PROJECT_DIR/packages/tiny-engineer-cli/bin/tiny-engineer.js\" hook claude-code", "async": true } ] @@ -38,7 +38,7 @@ "hooks": [ { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR/packages/tiny-engineer-claude-code/bin/tiny-engineer-claude-code.js\"", + "command": "node \"$CLAUDE_PROJECT_DIR/packages/tiny-engineer-cli/bin/tiny-engineer.js\" hook claude-code", "async": true } ] @@ -49,7 +49,7 @@ "hooks": [ { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR/packages/tiny-engineer-claude-code/bin/tiny-engineer-claude-code.js\"", + "command": "node \"$CLAUDE_PROJECT_DIR/packages/tiny-engineer-cli/bin/tiny-engineer.js\" hook claude-code", "async": true } ] @@ -60,7 +60,7 @@ "hooks": [ { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR/packages/tiny-engineer-claude-code/bin/tiny-engineer-claude-code.js\"", + "command": "node \"$CLAUDE_PROJECT_DIR/packages/tiny-engineer-cli/bin/tiny-engineer.js\" hook claude-code", "async": true } ] @@ -71,7 +71,7 @@ "hooks": [ { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR/packages/tiny-engineer-claude-code/bin/tiny-engineer-claude-code.js\"", + "command": "node \"$CLAUDE_PROJECT_DIR/packages/tiny-engineer-cli/bin/tiny-engineer.js\" hook claude-code", "async": true } ] @@ -82,7 +82,7 @@ "hooks": [ { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR/packages/tiny-engineer-claude-code/bin/tiny-engineer-claude-code.js\"", + "command": "node \"$CLAUDE_PROJECT_DIR/packages/tiny-engineer-cli/bin/tiny-engineer.js\" hook claude-code", "async": true } ] @@ -93,7 +93,7 @@ "hooks": [ { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR/packages/tiny-engineer-claude-code/bin/tiny-engineer-claude-code.js\"", + "command": "node \"$CLAUDE_PROJECT_DIR/packages/tiny-engineer-cli/bin/tiny-engineer.js\" hook claude-code", "async": true } ] @@ -104,7 +104,7 @@ "hooks": [ { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR/packages/tiny-engineer-claude-code/bin/tiny-engineer-claude-code.js\"", + "command": "node \"$CLAUDE_PROJECT_DIR/packages/tiny-engineer-cli/bin/tiny-engineer.js\" hook claude-code", "async": true } ] @@ -115,7 +115,7 @@ "hooks": [ { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR/packages/tiny-engineer-claude-code/bin/tiny-engineer-claude-code.js\"", + "command": "node \"$CLAUDE_PROJECT_DIR/packages/tiny-engineer-cli/bin/tiny-engineer.js\" hook claude-code", "async": true } ] @@ -126,7 +126,7 @@ "hooks": [ { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR/packages/tiny-engineer-claude-code/bin/tiny-engineer-claude-code.js\"", + "command": "node \"$CLAUDE_PROJECT_DIR/packages/tiny-engineer-cli/bin/tiny-engineer.js\" hook claude-code", "async": true } ] @@ -137,7 +137,7 @@ "hooks": [ { "type": "command", - "command": "node \"$CLAUDE_PROJECT_DIR/packages/tiny-engineer-claude-code/bin/tiny-engineer-claude-code.js\"", + "command": "node \"$CLAUDE_PROJECT_DIR/packages/tiny-engineer-cli/bin/tiny-engineer.js\" hook claude-code", "async": true } ] diff --git a/.cursor/hooks.json b/.cursor/hooks.json index fb4b703d..c0576864 100644 --- a/.cursor/hooks.json +++ b/.cursor/hooks.json @@ -3,7 +3,7 @@ "hooks": { "sessionStart": [ { - "command": "node packages/tiny-engineer-cursor/bin/tiny-engineer-cursor.js", + "command": "node packages/tiny-engineer-cli/bin/tiny-engineer.js hook cursor", "timeout": 2 }, { @@ -13,7 +13,7 @@ ], "beforeSubmitPrompt": [ { - "command": "node packages/tiny-engineer-cursor/bin/tiny-engineer-cursor.js", + "command": "node packages/tiny-engineer-cli/bin/tiny-engineer.js hook cursor", "timeout": 2 }, { @@ -23,7 +23,7 @@ ], "afterAgentThought": [ { - "command": "node packages/tiny-engineer-cursor/bin/tiny-engineer-cursor.js", + "command": "node packages/tiny-engineer-cli/bin/tiny-engineer.js hook cursor", "timeout": 2 }, { @@ -33,7 +33,7 @@ ], "preCompact": [ { - "command": "node packages/tiny-engineer-cursor/bin/tiny-engineer-cursor.js", + "command": "node packages/tiny-engineer-cli/bin/tiny-engineer.js hook cursor", "timeout": 2 }, { @@ -43,7 +43,7 @@ ], "preToolUse": [ { - "command": "node packages/tiny-engineer-cursor/bin/tiny-engineer-cursor.js", + "command": "node packages/tiny-engineer-cli/bin/tiny-engineer.js hook cursor", "timeout": 2 }, { @@ -53,7 +53,7 @@ ], "beforeReadFile": [ { - "command": "node packages/tiny-engineer-cursor/bin/tiny-engineer-cursor.js", + "command": "node packages/tiny-engineer-cli/bin/tiny-engineer.js hook cursor", "timeout": 2 }, { @@ -63,7 +63,7 @@ ], "beforeShellExecution": [ { - "command": "node packages/tiny-engineer-cursor/bin/tiny-engineer-cursor.js", + "command": "node packages/tiny-engineer-cli/bin/tiny-engineer.js hook cursor", "timeout": 2 }, { @@ -73,7 +73,7 @@ ], "subagentStart": [ { - "command": "node packages/tiny-engineer-cursor/bin/tiny-engineer-cursor.js", + "command": "node packages/tiny-engineer-cli/bin/tiny-engineer.js hook cursor", "timeout": 2 }, { @@ -83,7 +83,7 @@ ], "afterFileEdit": [ { - "command": "node packages/tiny-engineer-cursor/bin/tiny-engineer-cursor.js", + "command": "node packages/tiny-engineer-cli/bin/tiny-engineer.js hook cursor", "timeout": 2 }, { @@ -93,7 +93,7 @@ ], "stop": [ { - "command": "node packages/tiny-engineer-cursor/bin/tiny-engineer-cursor.js", + "command": "node packages/tiny-engineer-cli/bin/tiny-engineer.js hook cursor", "timeout": 2 }, { diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 92d23a6b..aed8890e 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -7,7 +7,7 @@ - [ ] Title is `type(scope): summary` - [ ] Breaking HTTP / pins / NVS / servo defaults / hook CLI called out as `type!:` plus a `BREAKING CHANGE:` footer - [ ] Firmware: `pio run` and/or `pio test -e native` when that code changed -- [ ] Packages: `npm test --prefix packages/tiny-engineer-cursor` / `…/tiny-engineer-antigravity` / `…/tiny-engineer-claude-code` when that package changed +- [ ] Packages: `npm test --prefix packages/tiny-engineer-cli` / `…/tiny-engineer-cursor` / `…/tiny-engineer-antigravity` / `…/tiny-engineer-claude-code` when that package changed - [ ] HTTP: HTML index (`ui/index.html`) + `docs/api.md` (and README / `docs/hardware/testing.md` if the route list changed) - [ ] Settings: layers in `docs/settings.md`; no raw `access_token` in logs - [ ] CAD: `.f3d` + exported `.3mf`; CERN-OHL-S; `AiEmblem.3mf` not used as a branding swap diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7d59e090..e6629aef 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -78,6 +78,8 @@ jobs: npm ci --prefix ui npm run lint --prefix ui npm test --prefix ui + - name: Unified CLI package tests + run: npm test --prefix packages/tiny-engineer-cli - name: Cursor package tests run: npm test --prefix packages/tiny-engineer-cursor - name: Antigravity package tests diff --git a/AGENTS.md b/AGENTS.md index 7e8852e7..f74a9183 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,6 +31,7 @@ pio run -e expression-demo pio test -e native node scripts/expressions/generate.js --check node scripts/expressions/test-assets.js +npm test --prefix packages/tiny-engineer-cli npm test --prefix packages/tiny-engineer-cursor npm test --prefix packages/tiny-engineer-antigravity npm test --prefix packages/tiny-engineer-claude-code diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2d802148..0022119f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -47,6 +47,7 @@ pio run -e expression-demo pio test -e native node scripts/expressions/generate.js --check node scripts/expressions/test-assets.js +npm test --prefix packages/tiny-engineer-cli npm test --prefix packages/tiny-engineer-cursor npm test --prefix packages/tiny-engineer-antigravity npm test --prefix packages/tiny-engineer-claude-code @@ -115,7 +116,7 @@ Robot builds bake `git describe --tags --always --dirty` into `FW_VERSION` via [ - Dev build: `v0.1.0--g` (optional `-dirty`) - No git metadata: `unknown` -A firmware tag does not bump the npm packages. CI (`.github/workflows/ci.yml`) builds with full git history and uploads `bootloader.bin`, `partitions.bin`, `firmware.bin`, and `littlefs.bin` as the `firmware-` Actions artifact on every PR and `main` push. Pushing a tag matching `v*` runs `.github/workflows/release.yml`, which attaches the same four bins as `tiny-engineer--{bootloader,partitions,firmware,littlefs}.bin` plus a stable-name `manifest.json` (offsets from that tag’s `partitions.csv`). Pages deploy (`.github/workflows/pages.yml`) mirrors those assets via `scripts/build_flash_catalog.py` so the web flasher can install same-origin (Release CDN blocks browser CORS). +A firmware tag does not bump the npm packages. Publish the host CLI (`tiny-engineer` on npm) with a separate SemVer and checklist: [docs/cli-release.md](docs/cli-release.md). CI (`.github/workflows/ci.yml`) builds with full git history and uploads `bootloader.bin`, `partitions.bin`, `firmware.bin`, and `littlefs.bin` as the `firmware-` Actions artifact on every PR and `main` push. Pushing a tag matching `v*` runs `.github/workflows/release.yml`, which attaches the same four bins as `tiny-engineer--{bootloader,partitions,firmware,littlefs}.bin` plus a stable-name `manifest.json` (offsets from that tag’s `partitions.csv`). Pages deploy (`.github/workflows/pages.yml`) mirrors those assets via `scripts/build_flash_catalog.py` so the web flasher can install same-origin (Release CDN blocks browser CORS). Public site UI lives under [`web/`](web/) (GitHub Pages via `.github/workflows/pages.yml`). Follow [`web/STYLE.md`](web/STYLE.md) for visual changes. diff --git a/README.md b/README.md index 3b6c454e..04a8b9d3 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ A small robot on your desk. It moves, changes its face, and rings a bell as your Open source. Print the body, plug the ESP32, servos, OLED, and speaker into the [main control board](docs/hardware/main-control-board.md), flash it, and it joins your Wi-Fi. -**Works with** [Claude Code](docs/integration.md#4-claude-code-dedicated-script) · [Cursor](docs/hooks.md) · [Antigravity](docs/integration.md#3-antigravity-cli-dedicated-script) — or Codex, Windsurf, and any script that can POST ([integration guide](docs/integration.md)), including speech through [`POST /play`](docs/api.md#post-play). +**Works with** Claude Code · Cursor · Antigravity — via [`npx -y tiny-engineer setup`](docs/integration.md) — or Codex, Windsurf, and any script that can POST ([integration guide](docs/integration.md) · [HTTP API](docs/api.md)), including speech through [`POST /play`](docs/api.md#post-play). ## Build your own @@ -63,10 +63,8 @@ Your agent (or a hook script) sends poses over HTTP. The **ESP32-C3** is a small **Clients** -- **Cursor** - hooks → `tiny-engineer-cursor` → Wi-Fi -- **Antigravity** - lifecycle hooks → `tiny-engineer-antigravity` → Wi-Fi -- **Claude Code** - project hooks → `tiny-engineer-claude-code` → Wi-Fi -- **Bring your own** - any tool that can `POST` to `/anim` +- **Default** — [`npx -y tiny-engineer`](https://www.npmjs.com/package/tiny-engineer) (`setup` / `hook`) for Cursor, Claude Code, Antigravity → Wi-Fi +- **Advanced** — any tool that can `POST` to `/anim` ([HTTP API](docs/api.md)) Details: [Integrations](docs/integration.md) · [HTTP API](docs/api.md) · [Cursor hooks](docs/hooks.md) diff --git a/docs/README.md b/docs/README.md index 2d105e33..950e1f19 100644 --- a/docs/README.md +++ b/docs/README.md @@ -19,7 +19,7 @@ Pick a path. Depth lives in the linked pages. | **Adapt CAD to a different servo** | [3d/parametric-design.md](3d/parametric-design.md) | | **Add a new CAD / printable part** | [3d/adding-parts.md](3d/adding-parts.md) | | **Optional mods** | [../mods/README.md](../mods/README.md) | -| **Use with an agent** (robot already on Wi-Fi) | [integration.md](integration.md) · Cursor: [hooks.md](hooks.md) | +| **Use with an agent** (robot already on Wi-Fi) | [integration.md](integration.md) (`tiny-engineer` CLI or REST) · Cursor: [hooks.md](hooks.md) | ### Reference and contribute @@ -27,7 +27,8 @@ Pick a path. Depth lives in the linked pages. | --- | --- | | **Contribute a PCB** (KiCad) | [pcb.md](pcb.md) → [`hardware/`](../hardware/README.md) | | **HTTP API / settings** | [api.md](api.md); add a setting: [settings.md](settings.md) | -| **Any IDE / scripts** | [integration.md](integration.md) | +| **CLI setup / REST for any IDE** | [integration.md](integration.md) | +| **Publish npm CLI** (`tiny-engineer`) | [cli-release.md](cli-release.md) | | **Firmware / package tests** | [testing.md](testing.md) | | **AI coding agents (repo rules)** | [AGENTS.md](../AGENTS.md) | | **Contribute / report a vuln** | [CONTRIBUTING.md](../CONTRIBUTING.md); [SECURITY.md](../SECURITY.md) | diff --git a/docs/api.md b/docs/api.md index 1fe892ba..0be12802 100644 --- a/docs/api.md +++ b/docs/api.md @@ -565,7 +565,7 @@ curl -X POST http://tiny-engineer.local/play/thinking -H 'Content-Type: audio/wa { "ok": true, "played_ms": 3000 } ``` -The Claude Code CLI wraps this for agents: `tiny-engineer-claude-code play clip.wav [--name thinking]` ([integration.md](integration.md#4-claude-code-dedicated-script)). +The unified CLI wraps this for agents: `tiny-engineer play clip.wav [--name thinking]` ([integration.md](integration.md#1-unified-cli-default)). Errors arrive before any audio plays: diff --git a/docs/cli-release.md b/docs/cli-release.md new file mode 100644 index 00000000..d0f7e353 --- /dev/null +++ b/docs/cli-release.md @@ -0,0 +1,94 @@ +# Releasing the `tiny-engineer` npm CLI + +The host CLI (`packages/tiny-engineer-cli`, npm package **`tiny-engineer`**) is versioned and published **separately** from firmware GitHub Releases. + +Firmware tags (`v*`) build flash artifacts via `.github/workflows/release.yml`. They do **not** publish npm. See [CONTRIBUTING.md](../CONTRIBUTING.md). + +## Versioning + +| Artefact | Where the version lives | Git tag | +|---|---|---| +| Firmware / web flash | `git describe` → `FW_VERSION` | `v0.2.0` (existing release flow) | +| npm CLI | `packages/tiny-engineer-cli/package.json` → `"version"` | optional `cli-v` | + +Use **SemVer** only for the CLI surface users depend on: + +| Bump | When | +|---|---| +| **patch** | bugfix, docs in the published package, no behavior change for hooks/setup | +| **minor** | new command/flag/IDE integration, backward-compatible | +| **major** | breaking: removed/renamed CLI flag, hook event id, or setup behavior that breaks existing hook configs | + +CLI `0.1.0` need not match firmware `v0.2.0`. Do not bump npm because of CAD, PCB, or firmware-only changes. + +Deprecated per-IDE packages (`tiny-engineer-cursor`, etc.) are **not** published to npm. + +## Prerequisites + +- npm account with 2FA; `npm login` / `npm whoami` locally +- Publish **only** from `packages/tiny-engineer-cli` (not the repo root `package.json`) +- Package name on npm is `tiny-engineer` (bin: `tiny-engineer`) + +## Release checklist + +1. **Land the CLI change on `main`** (or the branch you release from). Tests green: + + ```bash + npm test --prefix packages/tiny-engineer-cli + ``` + +2. **Bump version** in [`packages/tiny-engineer-cli/package.json`](../packages/tiny-engineer-cli/package.json) (and keep `package-lock.json` `name`/`version` in sync if present). Prefer a manual edit in this monorepo over `npm version` at the package root. + +3. **Commit** (conventional commits, scope `integrations`), e.g.: + + ```text + chore(integrations): release tiny-engineer 0.1.1 + ``` + +4. **Dry-run** (optional but recommended): + + ```bash + cd packages/tiny-engineer-cli + npm pack --dry-run + npm publish --dry-run + ``` + + Expect a small tarball (`bin/`, `src/`, `LICENSE`, `README.md`, `package.json`) — no `test/`, no firmware/CAD. + +5. **Publish**: + + ```bash + cd packages/tiny-engineer-cli + npm publish --access public + ``` + + Enter the npm OTP when prompted. + +6. **Verify**: + + ```bash + npm view tiny-engineer version + npx -y tiny-engineer@0.1.1 --help # use the version you just published + ``` + +7. **Optional git tag** (docs / future CI only; not required for npm): + + ```bash + git tag cli-v0.1.1 + git push origin cli-v0.1.1 + ``` + + Do **not** use a bare `v*` tag for CLI — that triggers the firmware release workflow. + +8. **Docs**: user install path is `npx -y tiny-engineer …` ([integration.md](integration.md)). Bump docs only if flags or setup behavior changed. + +## What not to do + +- Do not `npm publish` from the repository root (root wraps CLI + deprecated cursor bin for tarball `npx`). +- Do not bump or publish deprecated per-IDE packages. +- Do not expect a firmware `v*` GitHub Release to update npm. +- Do not invent a second public name; users run `npx tiny-engineer` / `tiny-engineer setup`. + +## Later automation (optional) + +A GitHub Action can publish on push of tags matching `cli-v*` using an npm Automation token (`NPM_TOKEN`). Until that exists, the checklist above is the full process. diff --git a/docs/getting-started.md b/docs/getting-started.md index a73eb7b0..2cb19d52 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -85,7 +85,7 @@ curl -X POST "http://tiny-engineer.local/anim?name=ring" ### 8. Agent hooks -- **Open:** [hooks.md](hooks.md) (Cursor) or [integration.md](integration.md) (Antigravity, Claude Code, raw REST). +- **Open:** [integration.md](integration.md) — `tiny-engineer setup` (default) or REST for custom agents. Cursor details: [hooks.md](hooks.md). - **Until:** optional — an agent event triggers an animation. - Done. @@ -102,6 +102,6 @@ curl -X POST "http://tiny-engineer.local/anim?name=ring" | `.local` slow or fails | OLED IP; `curl -4 http://…` | | OLED shows join AP / `192.168.4.1` | Wi-Fi not saved or STA failed — finish [assembly §20](3d/assembly.md#20-setup-wizard-and-first-boot-on-wi-fi) | | Welcome / ring silent (servos move) | Re-install the same release from [Web flash](https://jamro.github.io/tiny-engineer/flash/) (stock image includes audio). Advanced: [flash.md](flash.md) | -| Hooks never move the robot | Node 18+, hook `timeout` ≥ 30, HTTPS tarball `npx` — see [hooks.md](hooks.md) | +| Hooks never move the robot | Node 18+, `npx -y tiny-engineer doctor`, hook `timeout` ≥ 30 for cold `npx` — see [integration.md](integration.md) / [hooks.md](hooks.md) | | Servos twitch / board resets on motion | Power budget — [hardware/power.md](hardware/power.md) | | Other boot / I2C / audio failures | [hardware/testing.md](hardware/testing.md) | diff --git a/docs/hardware-for-software-engineers/09-how-it-all-fits-together.md b/docs/hardware-for-software-engineers/09-how-it-all-fits-together.md index 05341148..1fb3a51e 100644 --- a/docs/hardware-for-software-engineers/09-how-it-all-fits-together.md +++ b/docs/hardware-for-software-engineers/09-how-it-all-fits-together.md @@ -98,7 +98,7 @@ flowchart TB | Print, PLA/PETG, Fusion | Ch. 06 | [3d_models/README.md](../../3d_models/README.md) | | Supply sizing, brownout | Ch. 07 | [power.md](../hardware/power.md) | | pio, serial, bisect debug | Ch. 08 | [testing.md](../hardware/testing.md) | -| HTTP API, agents | — (after hardware) | [api.md](../api.md), [integration.md](../integration.md), [hooks.md](../hooks.md) | +| HTTP API, agents | — (after hardware) | [integration.md](../integration.md) (`tiny-engineer` CLI), [api.md](../api.md), Cursor detail: [hooks.md](../hooks.md) | --- @@ -112,7 +112,7 @@ Follow the checklist in [getting-started.md](../getting-started.md). Summary: 4. **Assemble** (one run: Head/Hat, centering, joins) → [assembly.md](../3d/assembly.md) §§1–19 5. **Setup wizard** → assembly §20 6. **Prove** (`/health`, `ring`) → [getting-started §7](../getting-started.md#7-prove-it) -7. **Agent hooks** → [hooks.md](../hooks.md) / [integration.md](../integration.md) +7. **Agent hooks** → [integration.md](../integration.md) (`tiny-engineer setup`); Cursor detail: [hooks.md](../hooks.md) **Desk smoke is first:** with boards still on the desk, confirm serial boot, PCA9685 OK, and one servo via `/test/servo` or setup AP **Move all to 90°**. Then one mechanical run. Do not finish the wizard until §20. diff --git a/docs/hooks.md b/docs/hooks.md index 072801b4..a9b4f468 100644 --- a/docs/hooks.md +++ b/docs/hooks.md @@ -1,8 +1,8 @@ # Cursor hooks -Project hooks drive Tiny Engineer poses from Cursor agent events. Config lives in [`.cursor/hooks.json`](../.cursor/hooks.json). CLI source: [`packages/tiny-engineer-cursor/`](../packages/tiny-engineer-cursor/). Root [`package.json`](../package.json) wraps that package for install via GitHub tarball. +Cursor is one IDE supported by the unified **`tiny-engineer` CLI**. Project hooks drive poses from Cursor agent events. Config: [`.cursor/hooks.json`](../.cursor/hooks.json). Install / overview: [`integration.md`](integration.md) (`npx -y tiny-engineer setup cursor`). -For the big picture (REST vs Cursor CLI): [`integration.md`](integration.md). +Published package: [`tiny-engineer` on npm](https://www.npmjs.com/package/tiny-engineer). Hook command: `npx -y tiny-engineer hook cursor`. ## Setup (this repo) @@ -11,30 +11,43 @@ For the big picture (REST vs Cursor CLI): [`integration.md`](integration.md). 3. Enable **Hooks** in Cursor settings if they are off. 4. Use **Node.js 18+**. -Hooks in this repo call the CLI via GitHub tarball `npx` (see [`.cursor/hooks.json`](../.cursor/hooks.json)). For local CLI development without waiting on GitHub, switch the command to: +Hooks in this repo call the **local** unified CLI (so CLI changes apply without waiting on npm): ```text -node packages/tiny-engineer-cursor/bin/tiny-engineer-cursor.js +node packages/tiny-engineer-cli/bin/tiny-engineer.js hook cursor ``` Optional smoke test (robot should ring): ```bash -echo '{"hook_event_name":"stop"}' | node packages/tiny-engineer-cursor/bin/tiny-engineer-cursor.js -npx -y --package=https://github.com/jamro/tiny-engineer/archive/refs/heads/main.tar.gz tiny-engineer-cursor --help +echo '{"hook_event_name":"stop"}' | node packages/tiny-engineer-cli/bin/tiny-engineer.js hook cursor +node packages/tiny-engineer-cli/bin/tiny-engineer.js --help +``` + +Or run the wizard with an explicit local command: + +```bash +node packages/tiny-engineer-cli/bin/tiny-engineer.js setup cursor --yes \ + --command 'node packages/tiny-engineer-cli/bin/tiny-engineer.js' ``` Cursor reloads `.cursor/hooks.json` on save. If a hook never fires, restart Cursor and check the **Hooks** output channel. ## Use in any Cursor project -Robot on Wi-Fi + Node 18+. In the other project’s `.cursor/hooks.json`, use this command for every anim hook (no clone of this repo required): +Robot on Wi-Fi + Node 18+. Easiest: + +```bash +npx -y tiny-engineer setup cursor --yes +``` + +Or hand-edit that project’s `.cursor/hooks.json`. Use this command for every anim hook: ```text -npx -y --package=https://github.com/jamro/tiny-engineer/archive/refs/heads/main.tar.gz tiny-engineer-cursor +npx -y tiny-engineer hook cursor ``` -Use `timeout` **≥ 30** (cold `npx` download can exceed 2s). +Use `timeout` **≥ 30** (cold `npx` can exceed 2s). ```json { @@ -42,61 +55,61 @@ Use `timeout` **≥ 30** (cold `npx` download can exceed 2s). "hooks": { "sessionStart": [ { - "command": "npx -y --package=https://github.com/jamro/tiny-engineer/archive/refs/heads/main.tar.gz tiny-engineer-cursor", + "command": "npx -y tiny-engineer hook cursor", "timeout": 30 } ], "beforeSubmitPrompt": [ { - "command": "npx -y --package=https://github.com/jamro/tiny-engineer/archive/refs/heads/main.tar.gz tiny-engineer-cursor", + "command": "npx -y tiny-engineer hook cursor", "timeout": 30 } ], "afterAgentThought": [ { - "command": "npx -y --package=https://github.com/jamro/tiny-engineer/archive/refs/heads/main.tar.gz tiny-engineer-cursor", + "command": "npx -y tiny-engineer hook cursor", "timeout": 30 } ], "preCompact": [ { - "command": "npx -y --package=https://github.com/jamro/tiny-engineer/archive/refs/heads/main.tar.gz tiny-engineer-cursor", + "command": "npx -y tiny-engineer hook cursor", "timeout": 30 } ], "preToolUse": [ { - "command": "npx -y --package=https://github.com/jamro/tiny-engineer/archive/refs/heads/main.tar.gz tiny-engineer-cursor", + "command": "npx -y tiny-engineer hook cursor", "timeout": 30 } ], "beforeReadFile": [ { - "command": "npx -y --package=https://github.com/jamro/tiny-engineer/archive/refs/heads/main.tar.gz tiny-engineer-cursor", + "command": "npx -y tiny-engineer hook cursor", "timeout": 30 } ], "beforeShellExecution": [ { - "command": "npx -y --package=https://github.com/jamro/tiny-engineer/archive/refs/heads/main.tar.gz tiny-engineer-cursor", + "command": "npx -y tiny-engineer hook cursor", "timeout": 30 } ], "subagentStart": [ { - "command": "npx -y --package=https://github.com/jamro/tiny-engineer/archive/refs/heads/main.tar.gz tiny-engineer-cursor", + "command": "npx -y tiny-engineer hook cursor", "timeout": 30 } ], "afterFileEdit": [ { - "command": "npx -y --package=https://github.com/jamro/tiny-engineer/archive/refs/heads/main.tar.gz tiny-engineer-cursor", + "command": "npx -y tiny-engineer hook cursor", "timeout": 30 } ], "stop": [ { - "command": "npx -y --package=https://github.com/jamro/tiny-engineer/archive/refs/heads/main.tar.gz tiny-engineer-cursor", + "command": "npx -y tiny-engineer hook cursor", "timeout": 30 } ] @@ -109,23 +122,21 @@ Same command every time — no animation args. Cursor pipes event JSON on stdin; Smoke test / help: ```bash -npx -y --package=https://github.com/jamro/tiny-engineer/archive/refs/heads/main.tar.gz tiny-engineer-cursor --help -echo '{"hook_event_name":"stop"}' | npx -y --package=https://github.com/jamro/tiny-engineer/archive/refs/heads/main.tar.gz tiny-engineer-cursor +npx -y tiny-engineer --help +echo '{"hook_event_name":"stop"}' | npx -y tiny-engineer hook cursor ``` Optional robot URL: ```bash -npx -y --package=https://github.com/jamro/tiny-engineer/archive/refs/heads/main.tar.gz tiny-engineer-cursor --url http://192.168.1.10 +npx -y tiny-engineer hook cursor --url http://192.168.1.10 ``` -`-y` skips the install prompt. First run downloads the tarball; later runs use the npx cache. +`-y` skips the install prompt. First run downloads from the npm registry; later runs use the npx cache. ### Why not `npx -y github:jamro/tiny-engineer`? -That shorthand makes npm fetch via **SSH** (`git@github.com:…`). Cursor hooks often have **no SSH agent** → exit 128, silent fail. Also npm 10 needs the **bin name** after `--package=…` (`tiny-engineer-cursor`), and a **2s** hook timeout is too short for cold install. - -HTTPS **tarball** URL avoids SSH and works in hooks. +That shorthand makes npm fetch via **SSH** (`git@github.com:…`). Cursor hooks often have **no SSH agent** → exit 128, silent fail. Use the published package: `npx -y tiny-engineer`. ## What ships in this repo @@ -145,7 +156,7 @@ HTTPS **tarball** URL avoids SSH and works in hooks. | `stop` (`status: aborted`) | `abort` | Agent turn cancelled | | `stop` (`status: error`) | `error` | Agent turn failed | -Each anim hook runs the same command with **no animation args**. Cursor pipes event JSON on stdin; the CLI reads `hook_event_name` (plus `tool_name` for `preToolUse`, `status` for `stop`), maps to a pose in [`packages/tiny-engineer-cursor/src/map.js`](../packages/tiny-engineer-cursor/src/map.js), and `POST`s `/anim?name=…` (2s HTTP timeout, exit 0). Optional `--url` overrides the default `http://tiny-engineer.local`. Events whose `conversation_id` or `generation_id` is `""` are skipped (no POST). Missing IDs still map. +Each anim hook runs the same command with **no animation args**. Cursor pipes event JSON on stdin; the CLI reads `hook_event_name` (plus `tool_name` for `preToolUse`, `status` for `stop`), maps to a pose in [`packages/tiny-engineer-cli/src/integrations/cursor/map.js`](../packages/tiny-engineer-cli/src/integrations/cursor/map.js), and `POST`s `/anim?name=…` (2s HTTP timeout, exit 0). Optional `--url` overrides the default `http://tiny-engineer.local`. Events whose `conversation_id` or `generation_id` is `""` are skipped (no POST). Missing IDs still map. If the robot has an `access_token` set, put the same value in `TINY_ENGINEER_TOKEN` (system/process env, or project-root `.env`). The CLI then sends `Authorization: Bearer `. Without a token, no auth header is sent. @@ -156,5 +167,5 @@ Config: [`.cursor/hooks.json`](../.cursor/hooks.json). Event logging (separate): - Robot offline → hook still exits 0; no agent stall. - Animation API holds each pose ≥1s and keeps only the latest pending switch — see [`api.md`](api.md). - The onboard RGB LED follows the active animation (white for typing/reading/thinking/welcome/ring/wakeup, pulsing red for attention/error/dead, solid red for abort, off for `none`/`sleep`) with 1 s fades between non-pulse states — see [RGB LED](api.md#rgb-led). -- To change the map, edit [`packages/tiny-engineer-cursor/src/map.js`](../packages/tiny-engineer-cursor/src/map.js). -- Root `package.json` is **Cursor CLI only**. Firmware stays PlatformIO; do not put build tooling in that package. +- To change the map, edit [`packages/tiny-engineer-cli/src/integrations/cursor/map.js`](../packages/tiny-engineer-cli/src/integrations/cursor/map.js). +- Custom agents or non-Cursor IDEs: call the [REST API](integration.md#2-rest-api-advanced--custom). diff --git a/docs/integration.md b/docs/integration.md index ea7ad410..213ea5fd 100644 --- a/docs/integration.md +++ b/docs/integration.md @@ -1,47 +1,111 @@ # Integrating Tiny Engineer -Tiny Engineer is a Wi-Fi desk robot. Drive it from any tool that can make HTTP requests, or use one of the dedicated helpers (Cursor, Antigravity, Claude Code) that map agent hook events to poses. +Tiny Engineer is a Wi-Fi desk robot. Prefer the unified **`tiny-engineer` CLI** for Cursor, Claude Code, and Antigravity hooks. For custom agents, other IDEs, or scripts, call the **REST API** directly. Robot must be on the same network. Base URL: `http://tiny-engineer.local` (or the IP shown on the OLED). Full HTTP reference: [`api.md`](api.md). -Four integration paths: - | Path | Best for | How | |---|---|---| -| **REST API** | Any AI IDE, script, CI, custom agent | `POST /anim?name=…` | -| **Cursor CLI** | Cursor project hooks | `npx` → `tiny-engineer-cursor` | -| **Antigravity CLI** | Antigravity CLI lifecycle hooks | `tiny-engineer-antigravity` | -| **Claude Code CLI** | Claude Code project hooks | `tiny-engineer-claude-code` | +| **`tiny-engineer` CLI** (default) | Cursor, Claude Code, Antigravity hooks | `tiny-engineer setup` → `hook ` → `POST /anim` | +| **REST API** (advanced) | Custom agents, other IDEs, CI, one-off scripts | `POST /anim?name=…` ([`api.md`](api.md)) | ```mermaid flowchart TB - subgraph anyIde [Any AI IDE or script] - HookOrScript[Agent event or your code] - HookOrScript --> Post["POST /anim?name=typing|reading|…"] - end - subgraph cursorPath [Cursor] - CursorHook[Cursor hooks.json] - CursorHook --> Npx["npx tiny-engineer-cursor"] - Npx --> Post - end - subgraph antigravityPath [Antigravity] - AgHook[Antigravity hooks.json] - AgHook --> AgCli["tiny-engineer-antigravity"] - AgCli --> Post + subgraph preferred [Preferred] + Setup["tiny-engineer setup"] + Setup --> Hooks[IDE hook config] + Hooks --> Cli["tiny-engineer hook ide"] + Cli --> Anim["POST /anim"] end - subgraph claudeCodePath [Claude Code] - CcHook[Claude Code settings.json] - CcHook --> CcCli["tiny-engineer-claude-code"] - CcCli --> Post + subgraph advanced [Advanced / custom] + Script[Any script or IDE] --> Rest["POST /anim REST"] end - Post --> Robot[Tiny Engineer on Wi-Fi] + Anim --> Robot[Tiny Engineer on Wi-Fi] + Rest --> Robot ``` +Prerequisites: flash firmware, join 2.4 GHz Wi-Fi, confirm `http://tiny-engineer.local/health` (or the OLED IP) responds. + --- -## 1. REST API (any AI IDE) +## 1. Unified CLI (default) + +Package: [`packages/tiny-engineer-cli`](../packages/tiny-engineer-cli/). Bin name: **`tiny-engineer`**. + +Supported IDEs: **Cursor**, **Claude Code**, **Antigravity** — one CLI (`tiny-engineer setup` / `hook `). No separate per-IDE packages for new installs. + +Maps IDE hook events to poses and POSTs `/anim`. Also installs/uninstalls hook entries, checks wiring (`doctor`), and can `anim` / `play` without writing your own HTTP client. + +### Install / run + +Published on npm as [`tiny-engineer`](https://www.npmjs.com/package/tiny-engineer) (no repo clone). + +```bash +npx -y tiny-engineer --help +npx -y tiny-engineer setup +npx -y tiny-engineer setup cursor --yes +npx -y tiny-engineer doctor +``` + +- Cold `npx` can be slow: `setup` writes hook **`timeout` ≥ 30**. +- Optional: `--url http://192.168.x.x` (default `TINY_ENGINEER_URL`, else `http://tiny-engineer.local`). +- Auth: if the device has an `access_token`, set `TINY_ENGINEER_TOKEN` in the process env or a project-root `.env`. The CLI sends `Authorization: Bearer …`. +- Prefer registry `npx` over `npx github:…` (SSH often fails in IDE hooks). + +**This firmware repo (local CLI development):** + +```bash +node packages/tiny-engineer-cli/bin/tiny-engineer.js setup +node packages/tiny-engineer-cli/bin/tiny-engineer.js doctor +``` + +### Setup wizard -Call the board directly. Works with Claude Code, Windsurf, Continue, custom plugins, shell hooks, or anything that can `POST` over HTTP. +```bash +npx -y tiny-engineer setup # interactive: pick IDEs +npx -y tiny-engineer setup cursor --yes +npx -y tiny-engineer setup --all --yes --url http://192.168.1.10 +npx -y tiny-engineer doctor +npx -y tiny-engineer uninstall cursor --yes # remove only Tiny Engineer hook entries +``` + +`setup` merges hook commands into the project (Cursor `.cursor/hooks.json`, Claude Code `.claude/settings.json`, Antigravity `.agents/hooks.json`) using `npx -y tiny-engineer hook `, and can write `TINY_ENGINEER_URL` into `.env`. It does not delete foreign hooks. + +This firmware repo already ships configs pointing at the **local** CLI bin for developing the package. + +### Hook smoke test + +```bash +echo '{"hook_event_name":"stop"}' | npx -y tiny-engineer hook cursor +``` + +Cursor deep dive (event table, sample `hooks.json`): [`hooks.md`](hooks.md). + +### Manual anim / play + +```bash +tiny-engineer anim ring +tiny-engineer play /tmp/clip.wav +tiny-engineer play /tmp/clip.wav --name thinking +``` + +WAV for `/play`: 16-bit mono PCM at 22050 Hz. Details: [`api.md#post-play`](api.md#post-play). + +### Speaking through the robot (skill) + +Install the shared [`tiny-engineer-play`](../skills/tiny-engineer-play/SKILL.md) skill (`curl` + `POST /play`). Same skill works in Claude Code, Cursor, and Antigravity: + +```bash +mkdir -p ~/.claude/skills && ln -s "$PWD/skills/tiny-engineer-play" ~/.claude/skills/ # Claude Code +mkdir -p ~/.cursor/skills && ln -s "$PWD/skills/tiny-engineer-play" ~/.cursor/skills/ # Cursor +mkdir -p ~/.gemini/antigravity/skills && ln -s "$PWD/skills/tiny-engineer-play" ~/.gemini/antigravity/skills/ # Antigravity +``` + +--- + +## 2. REST API (advanced / custom) + +Call the board directly when you are not using the supported IDE hooks, or when you need full control of mapping and timing. Works with any AI IDE, CI, shell hooks, or plugin that can `POST` over HTTP. ### Main call @@ -103,183 +167,24 @@ Health check (no motion): curl http://tiny-engineer.local/health ``` -More routes (tests, servo, web UI): [`api.md`](api.md). - ---- - -## 2. Cursor dedicated script - -For [Cursor](https://cursor.com/) only: a small Node CLI reads Cursor hook JSON on stdin, picks an animation, and POSTs `/anim`. No need to implement the event map yourself. - -### Command - -```bash -npx -y --package=https://github.com/jamro/tiny-engineer/archive/refs/heads/main.tar.gz tiny-engineer-cursor -``` - -- HTTPS **tarball** (not `github:…` SSH shorthand — that often fails in Cursor hooks with no SSH agent). -- Bin name `tiny-engineer-cursor` required after `--package=…`. -- Optional: `--url http://192.168.x.x` (default `http://tiny-engineer.local`). -- Auth: if the device has an `access_token`, set `TINY_ENGINEER_TOKEN` in the process env or a project-root `.env` file (same value). The CLI sends `Authorization: Bearer …`. No token → no header (auth disabled on device). -- `--help` for usage and event map. - -### Smoke test - -```bash -npx -y --package=https://github.com/jamro/tiny-engineer/archive/refs/heads/main.tar.gz tiny-engineer-cursor --help -echo '{"hook_event_name":"stop"}' | npx -y --package=https://github.com/jamro/tiny-engineer/archive/refs/heads/main.tar.gz tiny-engineer-cursor -``` - -### Wire into any Cursor project - -In that project’s `.cursor/hooks.json`, use the same command for each anim hook. Set **`timeout` ≥ 30** (cold `npx` can exceed 2s). Full sample + event table: [`hooks.md`](hooks.md). - -Inside this firmware repo you can instead run the local bin while developing the CLI: - -```bash -node packages/tiny-engineer-cursor/bin/tiny-engineer-cursor.js -``` - ---- - -## 3. Antigravity CLI dedicated script - -For [Google Antigravity](https://github.com/google/antigravity) (`antigravity-cli`): a lightweight Node CLI receives Antigravity lifecycle hook payloads on stdin, maps the agent lifecycle / tool calls to animations, and responds with the expected hook decision JSON while posting `/anim`. - -### Event Mapping -- **`PreInvocation`** (Model thinking) → `thinking` -- **`PreToolUse`** (Reading tools: `view_file`, `grep_search`, `find_by_name`, `list_dir`, etc.) → `reading` -- **`PreToolUse`** (Writing tools: `write_to_file`, `replace_file_content`, `run_command`) → `typing` -- **`PostToolUse`** (Tool error) → `attention` -- **`Stop`** (`model_stop` / completion) → `ring` (rings the physical desk bell!) -- **`Stop`** (`error` / `aborted`) → `error` / `abort` - -### Setup - -Inside this firmware repository, [`.agents/hooks.json`](../.agents/hooks.json) is pre-configured. Run the local bin: `node packages/tiny-engineer-antigravity/bin/tiny-engineer-antigravity.js`. Other machines can `npx -y --package=https://github.com/jamro/tiny-engineer/archive/refs/heads/main.tar.gz tiny-engineer-antigravity` (same tarball pattern as Cursor). - -To run globally across all projects on your machine, configure `~/.gemini/config/hooks.json`: - -```json -{ - "tiny-engineer": { - "PreInvocation": [ - { - "command": "tiny-engineer-antigravity PreInvocation", - "timeout": 3 - } - ], - "PreToolUse": [ - { - "matcher": "*", - "hooks": [ - { - "command": "tiny-engineer-antigravity PreToolUse", - "timeout": 3 - } - ] - } - ], - "PostToolUse": [ - { - "matcher": "*", - "hooks": [ - { - "command": "tiny-engineer-antigravity PostToolUse", - "timeout": 3 - } - ] - } - ], - "Stop": [ - { - "command": "tiny-engineer-antigravity Stop", - "timeout": 3 - } - ] - } -} -``` +Full route list (tests, servo, web UI, `/play`): [`api.md`](api.md). --- -## 4. Claude Code dedicated script - -For [Claude Code](https://code.claude.com/): a small Node CLI reads Claude Code hook JSON on stdin, picks an animation, and POSTs `/anim`. Same shape as the Cursor CLI — one command for every hook, no animation args. - -### Event Mapping -- **`SessionStart`** (`startup`, a fresh launch) → `welcome` -- **`SessionStart`** (`resume`, `clear`, `compact`, `fork`, or no `source`) → `wakeup` -- **`SessionEnd`** → `sleep` (skipped for `clear` and `resume`, where the next `SessionStart` wakes the robot) -- **`UserPromptSubmit`** → `reading` -- **`PreToolUse`** (Reading tools: `Read`, `Grep`, `Glob`, `WebFetch`, `WebSearch`) → `reading` -- **`PreToolUse`** (Writing tools: `Bash`, `PowerShell`, `Edit`, `Write`, `NotebookEdit`) → `typing` -- **`SubagentStart`** → `thinking` -- **`PostToolBatch`** (tool results go back to the model) → `thinking` -- **`PostToolUseFailure`** (a tool call failed) → `error` -- **`PreCompact`** → `thinking` -- **`PermissionRequest`** → `attention` -- **`PermissionDenied`** (auto mode denied a tool call) → `abort` -- **`Notification`** (permission prompt, or waiting for input) → `attention` -- **`Stop`** (turn finished) → `ring` (rings the physical desk bell!) -- **`StopFailure`** → `error` - -To change the map, edit [`packages/tiny-engineer-claude-code/src/map.js`](../packages/tiny-engineer-claude-code/src/map.js). - -### Setup - -Inside this firmware repository, [`.claude/settings.json`](../.claude/settings.json) is pre-configured: open the repo in Claude Code (Node.js 18+) and the hooks run `node packages/tiny-engineer-claude-code/bin/tiny-engineer-claude-code.js`. - -- Every hook is `"async": true`, so Claude Code runs it in the background — an offline robot never stalls a tool call on the 2s HTTP timeout. -- Robot address: `TINY_ENGINEER_URL` or `--url http://192.168.x.x` (default `http://tiny-engineer.local`). -- Auth: if the device has an `access_token`, set `TINY_ENGINEER_TOKEN` in the process env or a project-root `.env` file. The CLI sends `Authorization: Bearer …`. No token → no header (auth disabled on device). When the token comes from the process env, a `TINY_ENGINEER_URL` in the project `.env` is ignored, so a project can't redirect your token; set the URL in the env or with `--url`. -- The CLI never writes to stdout in hook mode — Claude Code adds `SessionStart` / `UserPromptSubmit` hook stdout to the model's context. -- If a hook never fires, run `/hooks` inside Claude Code to confirm the project settings loaded. - -To use it in every project, copy the `hooks` block into `~/.claude/settings.json` and replace `$CLAUDE_PROJECT_DIR/packages/…` with the absolute path to this repo's bin. - -### Speaking through the robot - -The same CLI has a `play` command an agent can run to speak through the robot. It streams a WAV (16-bit mono PCM at 22050 Hz, from any text-to-speech) to [`POST /play`](api.md#post-play) while the robot runs `talking`, prints the robot's reply when the clip ends, and exits 1 with the reason on any error: - -```bash -espeak-ng -w /tmp/raw.wav "Build passed." && ffmpeg -y -loglevel error -i /tmp/raw.wav -ar 22050 -ac 1 -c:a pcm_s16le /tmp/clip.wav -node packages/tiny-engineer-claude-code/bin/tiny-engineer-claude-code.js play /tmp/clip.wav -``` - -`--name thinking` (or `typing`, `reading`, `none`) runs another animation for the clip. - -To let an agent do this on its own, install the shared [`tiny-engineer-play`](../skills/tiny-engineer-play/SKILL.md) skill. It needs only `curl` and works the same in Claude Code, Cursor and Antigravity: - -```bash -mkdir -p ~/.claude/skills && ln -s "$PWD/skills/tiny-engineer-play" ~/.claude/skills/ # Claude Code -mkdir -p ~/.cursor/skills && ln -s "$PWD/skills/tiny-engineer-play" ~/.cursor/skills/ # Cursor -mkdir -p ~/.gemini/antigravity/skills && ln -s "$PWD/skills/tiny-engineer-play" ~/.gemini/antigravity/skills/ # Antigravity -``` - -Then ask the agent something like "have the robot tell me the build passed". - -### Smoke test +## Which to choose? -```bash -node packages/tiny-engineer-claude-code/bin/tiny-engineer-claude-code.js --help -echo '{"hook_event_name":"Stop"}' | node packages/tiny-engineer-claude-code/bin/tiny-engineer-claude-code.js -``` +- **Cursor / Claude Code / Antigravity** → unified CLI (`tiny-engineer setup`). +- **Custom agent, other IDE, CI, or no Node** → REST (`POST /anim`). +- Both paths hit the same firmware API; the CLI is a thin client. --- -## Which to choose? - -- **Building for one IDE / custom agent** → REST. One `POST`, zero Node dependency. -- **Using Cursor and want zero mapping code** → Cursor CLI + hooks. -- **Using Antigravity CLI** → Antigravity CLI + hooks. -- **Using Claude Code** → Claude Code CLI + hooks. -- **All four** are fine together: the CLIs are thin clients of the same `/anim` API. +## Deprecated bins -Prerequisites for any path: flash firmware, join 2.4 GHz Wi-Fi, confirm `http://tiny-engineer.local/health` (or the OLED IP) responds. +The old bins `tiny-engineer-cursor`, `tiny-engineer-antigravity`, and `tiny-engineer-claude-code` still run but are **deprecated**. Prefer `tiny-engineer` above. Migrate with `tiny-engineer setup`, then `tiny-engineer uninstall` to strip leftover hook entries. For custom mapping, use the [REST API](#2-rest-api-advanced--custom). --- ## Optional extras -Same `POST /anim` API; not part of the four paths above. On macOS you can run a small host helper that POSTs `sleep` / `wakeup` when the screen locks or unlocks: [`macos-lock-unlock.md`](macos-lock-unlock.md). The robot does not need it. +Same `POST /anim` API. On macOS you can run a small host helper that POSTs `sleep` / `wakeup` when the screen locks or unlocks: [`macos-lock-unlock.md`](macos-lock-unlock.md). The robot does not need it. diff --git a/docs/testing.md b/docs/testing.md index e717c0e4..a35aa7e1 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -44,6 +44,7 @@ npm run format --prefix ui # apply Prettier Node 18+. No robot. ```bash +npm test --prefix packages/tiny-engineer-cli npm test --prefix packages/tiny-engineer-cursor npm test --prefix packages/tiny-engineer-antigravity npm test --prefix packages/tiny-engineer-claude-code diff --git a/package.json b/package.json index 51988792..ba480d60 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,16 @@ { - "name": "tiny-engineer-cursor", + "name": "tiny-engineer", "version": "0.1.0", - "description": "Cursor hooks CLI that drives Tiny Engineer robot animations over HTTP. Root package wraps packages/tiny-engineer-cursor for HTTPS-tarball / local npx (see docs/hooks.md; avoid npx github:… in hooks).", + "description": "Monorepo root for local/tarball npx. Prefer the published npm package tiny-engineer (packages/tiny-engineer-cli). Deprecated tiny-engineer-cursor bin kept for compat — see docs/integration.md.", "type": "module", "bin": { + "tiny-engineer": "./packages/tiny-engineer-cli/bin/tiny-engineer.js", "tiny-engineer-cursor": "./packages/tiny-engineer-cursor/bin/tiny-engineer-cursor.js" }, "files": [ + "packages/tiny-engineer-cli/bin", + "packages/tiny-engineer-cli/src", + "packages/tiny-engineer-cli/package.json", "packages/tiny-engineer-cursor/bin", "packages/tiny-engineer-cursor/src", "packages/tiny-engineer-cursor/package.json" diff --git a/packages/tiny-engineer-antigravity/package.json b/packages/tiny-engineer-antigravity/package.json index f01ae963..46e3665c 100644 --- a/packages/tiny-engineer-antigravity/package.json +++ b/packages/tiny-engineer-antigravity/package.json @@ -2,7 +2,8 @@ "name": "@tiny-engineer/antigravity", "version": "0.1.0", "type": "module", - "description": "Antigravity CLI lifecycle hook helper for Tiny Engineer robot animations", + "description": "Deprecated: use tiny-engineer-cli (bin: tiny-engineer). Antigravity CLI lifecycle hook helper for Tiny Engineer robot animations", + "deprecated": "Use tiny-engineer-cli (bin: tiny-engineer). See docs/integration.md. For custom agents use the REST API (docs/api.md).", "main": "src/cli.js", "bin": { "tiny-engineer-antigravity": "./bin/tiny-engineer-antigravity.js" diff --git a/packages/tiny-engineer-antigravity/src/cli.js b/packages/tiny-engineer-antigravity/src/cli.js index 675afb64..6e92d20f 100644 --- a/packages/tiny-engineer-antigravity/src/cli.js +++ b/packages/tiny-engineer-antigravity/src/cli.js @@ -35,6 +35,16 @@ function defaultResponseForHook(hookType) { * @param {string[]} argv */ export async function run(argv) { + if (argv.includes("-h") || argv.includes("--help")) { + console.log(`Usage: tiny-engineer-antigravity + +Deprecated: use \`tiny-engineer hook antigravity \` (packages/tiny-engineer-cli). +For custom agents, call the REST API (docs/api.md / docs/integration.md). +`); + process.exitCode = 0; + return; + } + loadDotEnv(); const hookType = argv[2] || "unknown"; diff --git a/packages/tiny-engineer-claude-code/package.json b/packages/tiny-engineer-claude-code/package.json index 03ee90c5..01553156 100644 --- a/packages/tiny-engineer-claude-code/package.json +++ b/packages/tiny-engineer-claude-code/package.json @@ -2,7 +2,8 @@ "name": "@tiny-engineer/claude-code", "version": "0.1.0", "type": "module", - "description": "Claude Code hooks CLI that drives Tiny Engineer robot animations over HTTP", + "description": "Deprecated: use tiny-engineer-cli (bin: tiny-engineer). Claude Code hooks CLI that drives Tiny Engineer robot animations over HTTP", + "deprecated": "Use tiny-engineer-cli (bin: tiny-engineer). See docs/integration.md. For custom agents use the REST API (docs/api.md).", "main": "src/cli.js", "bin": { "tiny-engineer-claude-code": "./bin/tiny-engineer-claude-code.js" diff --git a/packages/tiny-engineer-claude-code/src/cli.js b/packages/tiny-engineer-claude-code/src/cli.js index 43b516fa..ce1f2414 100644 --- a/packages/tiny-engineer-claude-code/src/cli.js +++ b/packages/tiny-engineer-claude-code/src/cli.js @@ -9,6 +9,9 @@ function printHelp() { console.log(`Usage: tiny-engineer-claude-code [options] tiny-engineer-claude-code play [--name ] [--url ] +Deprecated: use \`tiny-engineer hook claude-code\` / \`tiny-engineer play\` (packages/tiny-engineer-cli). +For custom agents, call the REST API (docs/api.md / docs/integration.md). + Claude Code hook helper: read event JSON from stdin, pick an animation, POST to the robot. Options: diff --git a/packages/tiny-engineer-cli/LICENSE b/packages/tiny-engineer-cli/LICENSE new file mode 100644 index 00000000..f959bf3c --- /dev/null +++ b/packages/tiny-engineer-cli/LICENSE @@ -0,0 +1,25 @@ +This MIT License applies to the software portions of this repository as +described in LICENSING.md. Hardware design files under 3d_models/cad/ +and 3d_models/parts/ are licensed separately under CERN-OHL-S-2.0. + +MIT License + +Copyright (c) 2026 Krzysztof Jamroz + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/tiny-engineer-cli/README.md b/packages/tiny-engineer-cli/README.md new file mode 100644 index 00000000..a65cca40 --- /dev/null +++ b/packages/tiny-engineer-cli/README.md @@ -0,0 +1,24 @@ +# tiny-engineer + +Unified CLI for the [Tiny Engineer](https://github.com/jamro/tiny-engineer) desk robot: install IDE hooks, map agent events to poses, and drive animations over HTTP. + +## Install / run + +```bash +npx -y tiny-engineer setup +npx -y tiny-engineer setup cursor --yes +npx -y tiny-engineer doctor +npx -y tiny-engineer anim ring +``` + +Supported IDEs: **Cursor**, **Claude Code**, **Antigravity**. + +## Docs + +- [Integration guide](https://github.com/jamro/tiny-engineer/blob/main/docs/integration.md) (`setup` / hooks) +- [HTTP API](https://github.com/jamro/tiny-engineer/blob/main/docs/api.md) (custom agents / advanced) +- [CLI release process](https://github.com/jamro/tiny-engineer/blob/main/docs/cli-release.md) (maintainers: npm publish, separate from firmware `v*` tags) + +## License + +MIT — see [LICENSE](./LICENSE). diff --git a/packages/tiny-engineer-cli/bin/tiny-engineer.js b/packages/tiny-engineer-cli/bin/tiny-engineer.js new file mode 100755 index 00000000..1d84dca2 --- /dev/null +++ b/packages/tiny-engineer-cli/bin/tiny-engineer.js @@ -0,0 +1,4 @@ +#!/usr/bin/env node +import { run } from "../src/cli.js"; + +await run(process.argv.slice(2)); diff --git a/packages/tiny-engineer-cli/package-lock.json b/packages/tiny-engineer-cli/package-lock.json new file mode 100644 index 00000000..5676cc03 --- /dev/null +++ b/packages/tiny-engineer-cli/package-lock.json @@ -0,0 +1,19 @@ +{ + "name": "tiny-engineer", + "version": "0.1.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "tiny-engineer", + "version": "0.1.1", + "license": "MIT", + "bin": { + "tiny-engineer": "bin/tiny-engineer.js" + }, + "engines": { + "node": ">=18" + } + } + } +} diff --git a/packages/tiny-engineer-cli/package.json b/packages/tiny-engineer-cli/package.json new file mode 100644 index 00000000..47fccdab --- /dev/null +++ b/packages/tiny-engineer-cli/package.json @@ -0,0 +1,43 @@ +{ + "name": "tiny-engineer", + "version": "0.1.1", + "description": "Default Tiny Engineer integration: setup/uninstall IDE hooks, map agent events to poses, anim/play over HTTP. For custom agents use the REST API (docs/api.md).", + "type": "module", + "bin": { + "tiny-engineer": "bin/tiny-engineer.js" + }, + "scripts": { + "test": "node --test" + }, + "files": [ + "bin", + "src", + "LICENSE", + "README.md" + ], + "engines": { + "node": ">=18" + }, + "keywords": [ + "tiny-engineer", + "robot", + "hooks", + "cli", + "cursor", + "claude-code", + "antigravity" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/jamro/tiny-engineer.git", + "directory": "packages/tiny-engineer-cli" + }, + "homepage": "https://github.com/jamro/tiny-engineer/tree/main/packages/tiny-engineer-cli#readme", + "bugs": { + "url": "https://github.com/jamro/tiny-engineer/issues" + }, + "publishConfig": { + "access": "public" + }, + "license": "MIT" +} diff --git a/packages/tiny-engineer-cli/src/cli.js b/packages/tiny-engineer-cli/src/cli.js new file mode 100644 index 00000000..90a8daa1 --- /dev/null +++ b/packages/tiny-engineer-cli/src/cli.js @@ -0,0 +1,267 @@ +import { readFileSync } from "node:fs"; +import { basename } from "node:path"; + +import { DEFAULT_URL, getBaseUrl, getToken, loadDotEnv } from "./config/env.js"; +import { get as getIntegration, list as listIntegrations } from "./integrations/registry.js"; +import { + formatRobotError, + postAnim, + postPlay, + validateAnimName, + validatePlayAnimName, +} from "./robot/client.js"; +import { + parseAnimArgs, + parseCommonArgs, + parsePlayArgs, + stripGlobalFlags, +} from "./util/args.js"; +import { createOutput } from "./util/output.js"; +import { createStyle, detectColor } from "./util/style.js"; +import { readStdin } from "./util/stdin.js"; +import { runDoctor } from "./wizard/doctor.js"; +import { runSetup } from "./wizard/setup.js"; +import { runUninstall } from "./wizard/uninstall.js"; + +function printHelp() { + const integrations = listIntegrations().join(", "); + console.log(`Usage: tiny-engineer [options] + +Unified CLI for the Tiny Engineer desk robot. + +Commands: + setup [ide...] Merge IDE hook configs into the current project + uninstall [ide...] Remove Tiny Engineer hook entries (keeps other hooks) + doctor Check URL, token, hook wiring, and /health + hook Read hook JSON from stdin, map to a pose, POST /anim + anim [--url] POST /anim?name= + play [opts] POST /play (16-bit mono PCM WAV) + +Options (most commands): + --url Robot base URL (default: TINY_ENGINEER_URL, else ${DEFAULT_URL}) + -q, --quiet, --silent Suppress success/info lines; errors still print + -h, --help Show this help + +Auth: + If TINY_ENGINEER_TOKEN is set (process env or project-root .env), requests + send Authorization: Bearer . Must match the device access_token. + +Output: + Colors/icons when stdout/stderr is a TTY (disabled by NO_COLOR or TERM=dumb). + hook File touches on stderr; robot errors swallowed (exit 0). + Antigravity always prints decision JSON on stdout. + anim / play Friendly one-line success; exit 1 on errors. + +Integrations (hook ): + ${integrations || "(none)"} + +Examples: + tiny-engineer setup cursor --yes + tiny-engineer uninstall cursor --yes + tiny-engineer uninstall --all --yes --dry-run + tiny-engineer doctor + tiny-engineer anim ring + echo '{"hook_event_name":"stop"}' | tiny-engineer hook cursor +`); +} + +/** + * @param {import("./util/output.js").Output} out + * @param {string[]} argv + * @param {boolean} quietGlobal + */ +async function runHook(out, argv, quietGlobal) { + const opts = parseCommonArgs(argv); + const quiet = quietGlobal || opts.quiet; + const localOut = quiet === out.quiet ? out : createOutput({ quiet, style: out.style }); + + if (opts.error) { + localOut.error(opts.error); + process.exitCode = 1; + return; + } + if (opts.help) { + printHelp(); + return; + } + + const ide = opts.rest[0]; + const available = listIntegrations().join(", ") || "(none)"; + if (!ide) { + localOut.error(`hook needs an integration id, e.g. tiny-engineer hook cursor (available: ${available})`); + process.exitCode = 1; + return; + } + + const integration = getIntegration(ide); + if (!integration) { + localOut.error(`Unknown integration: ${ide} (available: ${available})`); + process.exitCode = 1; + return; + } + + const stdinText = await readStdin(); + const input = integration.parseHook(opts.rest.slice(1), stdinText); + if (!input) { + process.exitCode = 0; + return; + } + + const anim = integration.mapToAnim(input); + if (anim) { + // Fail-open: ignore result so an offline robot never stalls the agent. + await postAnim(opts.url ?? getBaseUrl(), anim, getToken()); + } + + const fileLines = integration.describeFiles?.(input, localOut.style) ?? []; + for (const line of fileLines) { + localOut.info(line); + } + + integration.respond?.({ animPosted: Boolean(anim), anim, input }); + + // Claude Code async hooks have no timeout; exit so undici connect attempts + // cannot linger ~10s after a timed-out POST. + if (integration.hardExitAfterPost && anim) { + process.exit(0); + } + process.exitCode = 0; +} + +/** + * @param {import("./util/output.js").Output} out + * @param {string[]} argv + * @param {boolean} quietGlobal + */ +async function runAnim(out, argv, quietGlobal) { + const opts = parseAnimArgs(argv); + const quiet = quietGlobal || opts.quiet; + const localOut = quiet === out.quiet ? out : createOutput({ quiet, style: out.style }); + + if (opts.error) { + localOut.error(opts.error); + process.exitCode = 1; + return; + } + if (opts.help) { + printHelp(); + return; + } + + const name = /** @type {string} */ (opts.name); + const invalid = validateAnimName(name); + if (invalid) { + localOut.error(invalid); + process.exitCode = 1; + return; + } + + const baseUrl = opts.url ?? getBaseUrl(); + const result = await postAnim(baseUrl, name, getToken()); + if (!result.ok) { + localOut.error(formatRobotError(result)); + process.exitCode = 1; + return; + } + localOut.ok(`animation ${name} → ${localOut.style.dim(baseUrl)}`); + process.exitCode = 0; +} + +/** + * @param {import("./util/output.js").Output} out + * @param {string[]} argv + * @param {boolean} quietGlobal + */ +async function runPlay(out, argv, quietGlobal) { + const opts = parsePlayArgs(argv); + const quiet = quietGlobal || opts.quiet; + const localOut = quiet === out.quiet ? out : createOutput({ quiet, style: out.style }); + + if (opts.error) { + localOut.error(opts.error); + process.exitCode = 1; + return; + } + + if (opts.name) { + const invalid = validatePlayAnimName(opts.name); + if (invalid) { + localOut.error(invalid); + process.exitCode = 1; + return; + } + } + + let wav; + try { + wav = readFileSync(opts.wavPath); + } catch (err) { + localOut.error(`cannot read ${opts.wavPath}: ${err.message}`); + process.exitCode = 1; + return; + } + + const baseUrl = opts.url ?? getBaseUrl(); + const pose = opts.name ?? "talking"; + const result = await postPlay({ + baseUrl, + wav, + name: opts.name, + token: getToken(), + }); + if (!result.ok) { + localOut.error(formatRobotError(result)); + process.exitCode = 1; + return; + } + localOut.ok( + `played ${basename(/** @type {string} */ (opts.wavPath))} (${pose}) → ${localOut.style.dim(baseUrl)}`, + ); + process.exitCode = 0; +} + +/** + * @param {string[]} argv + */ +export async function run(argv) { + loadDotEnv(); + + const global = stripGlobalFlags(argv); + const style = createStyle({ + color: detectColor(process.stdout) || detectColor(process.stderr), + }); + const out = createOutput({ quiet: global.quiet, style }); + + const args = global.rest; + if (args.length === 0 || args[0] === "-h" || args[0] === "--help") { + printHelp(); + return; + } + + const [command, ...rest] = args; + + switch (command) { + case "setup": + await runSetup(out, rest); + return; + case "uninstall": + await runUninstall(out, rest); + return; + case "doctor": + await runDoctor(out, rest); + return; + case "hook": + await runHook(out, rest, global.quiet); + return; + case "anim": + await runAnim(out, rest, global.quiet); + return; + case "play": + await runPlay(out, rest, global.quiet); + return; + default: + out.error(`Unknown command: ${command}`); + printHelp(); + process.exitCode = 1; + } +} diff --git a/packages/tiny-engineer-cli/src/config/env.js b/packages/tiny-engineer-cli/src/config/env.js new file mode 100644 index 00000000..b4e0f60c --- /dev/null +++ b/packages/tiny-engineer-cli/src/config/env.js @@ -0,0 +1,105 @@ +import { readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join, resolve } from "node:path"; + +const TOKEN_KEY = "TINY_ENGINEER_TOKEN"; +const URL_KEY = "TINY_ENGINEER_URL"; +export const DEFAULT_URL = "http://tiny-engineer.local"; + +/** + * Parse one dotenv line into [key, value] or null. + * @param {string} line + * @returns {[string, string] | null} + */ +function parseLine(line) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) return null; + + const eq = trimmed.indexOf("="); + if (eq <= 0) return null; + + const key = trimmed.slice(0, eq).trim(); + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) return null; + + let value = trimmed.slice(eq + 1).trim(); + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + return [key, value]; +} + +/** + * Load one `.env` file into process.env for keys not already set. + * A token already in the process env must not be sent to a URL from project + * `.env`, so the file skips `TINY_ENGINEER_URL` when a token was inherited. + * Missing file / read errors are ignored. + * @param {string} filePath + */ +export function loadDotEnvFile(filePath) { + let raw; + try { + raw = readFileSync(filePath, "utf8"); + } catch { + return; + } + + const tokenInherited = Boolean(process.env[TOKEN_KEY]?.trim()); + + for (const line of raw.split(/\r?\n/)) { + const parsed = parseLine(line); + if (!parsed) continue; + const [key, value] = parsed; + if (key === URL_KEY && tokenInherited) continue; + if (process.env[key] === undefined) { + process.env[key] = value; + } + } +} + +/** + * Load env from cwd, Claude project dir (if set), and Antigravity global config. + * @param {string} [cwd] + */ +export function loadDotEnv(cwd = process.cwd()) { + const loaded = new Set(); + + /** + * @param {string} dir + */ + function loadDir(dir) { + const abs = resolve(dir); + if (loaded.has(abs)) return; + loaded.add(abs); + loadDotEnvFile(join(abs, ".env")); + } + + loadDir(cwd); + + const claudeRoot = process.env.CLAUDE_PROJECT_DIR?.trim(); + if (claudeRoot) loadDir(claudeRoot); + + loadDotEnvFile(join(homedir(), ".gemini", "config", ".env")); +} + +/** + * @returns {string | null} trimmed TINY_ENGINEER_TOKEN, or null if unset/empty + */ +export function getToken() { + const value = process.env[TOKEN_KEY]; + if (value === undefined) return null; + const trimmed = value.trim(); + return trimmed ? trimmed : null; +} + +/** + * @returns {string} TINY_ENGINEER_URL without trailing slashes, or the default + */ +export function getBaseUrl() { + const value = process.env[URL_KEY]; + if (value === undefined) return DEFAULT_URL; + const trimmed = value.trim().replace(/\/+$/, ""); + return trimmed || DEFAULT_URL; +} diff --git a/packages/tiny-engineer-cli/src/integrations/_template/README.md b/packages/tiny-engineer-cli/src/integrations/_template/README.md new file mode 100644 index 00000000..bedfcbee --- /dev/null +++ b/packages/tiny-engineer-cli/src/integrations/_template/README.md @@ -0,0 +1,34 @@ +# Integration template + +Copy this folder to `integrations//` and register the export in `registry.js`. + +## Required files + +| File | Role | +| --- | --- | +| `index.js` | Export an `Integration`: `id`, `name`, `parseHook`, `mapToAnim` | +| `map.js` | IDE event / tool → animation name | + +Optional: `files.js` + `describeFiles` for stderr file logs; `respond` for stdout contracts; `hardExitAfterPost: true` if the IDE leaves Node hanging after `fetch` (Claude Code). + +## Contract checklist + +1. **`parseHook(argv, stdinText)`** — return `HookInput` or `null` to skip. Antigravity-style hosts that need a stdout reply should never return `null`. +2. **`mapToAnim(input)`** — return pose name or `null`. +3. **`respond(ctx)`** — Cursor/Claude: no-op (stdout must stay empty for Claude). Antigravity: print decision JSON always. +4. **`describeFiles(input, style)`** — stderr lines only; suppressed by `-q`. +5. Unit-test `map.js` under `test/map-.test.js`. + +## Wire-up + +```js +// registry.js +import { myIde } from "./my-ide/index.js"; +byId.set(myIde.id, myIde); +``` + +```bash +tiny-engineer hook +# or, if the host passes the event on argv: +tiny-engineer hook +``` diff --git a/packages/tiny-engineer-cli/src/integrations/antigravity/files.js b/packages/tiny-engineer-cli/src/integrations/antigravity/files.js new file mode 100644 index 00000000..b73d6dcc --- /dev/null +++ b/packages/tiny-engineer-cli/src/integrations/antigravity/files.js @@ -0,0 +1,53 @@ +import { createStyle } from "../../util/style.js"; +import { TYPING_TOOLS } from "./map.js"; + +const PATH_KEYS = ["path", "file_path", "TargetFile", "target_file", "filePath"]; + +/** + * @param {Record} toolCall + * @returns {string | null} + */ +function pathFromToolCall(toolCall) { + for (const key of PATH_KEYS) { + if (typeof toolCall[key] === "string" && toolCall[key]) { + return /** @type {string} */ (toolCall[key]); + } + } + const args = toolCall.args ?? toolCall.arguments; + if (args && typeof args === "object") { + const record = /** @type {Record} */ (args); + for (const key of PATH_KEYS) { + if (typeof record[key] === "string" && record[key]) { + return /** @type {string} */ (record[key]); + } + } + } + return null; +} + +/** + * @param {string} hookType + * @param {Record | null | undefined} payload + * @param {import("../../util/style.js").Style} [style] + * @returns {string[]} + */ +export function formatFileModifications( + hookType, + payload, + style = createStyle({ color: false }), +) { + if (hookType !== "PreToolUse" || !payload || typeof payload !== "object") { + return []; + } + + const toolCall = payload.toolCall; + if (!toolCall || typeof toolCall !== "object") return []; + + const call = /** @type {Record} */ (toolCall); + const name = typeof call.name === "string" ? call.name : ""; + if (!TYPING_TOOLS.has(name)) return []; + + const filePath = pathFromToolCall(call); + if (!filePath) return []; + return [`${style.cyan("✎")} touching: ${filePath}`]; +} diff --git a/packages/tiny-engineer-cli/src/integrations/antigravity/index.js b/packages/tiny-engineer-cli/src/integrations/antigravity/index.js new file mode 100644 index 00000000..ccfa54f1 --- /dev/null +++ b/packages/tiny-engineer-cli/src/integrations/antigravity/index.js @@ -0,0 +1,91 @@ +import { formatFileModifications } from "./files.js"; +import { + applyInstall, + applyUninstall, + configRelativePath, + detect, + planInstall, + planUninstall, +} from "./install.js"; +import { animationForEvent, defaultResponseForHook } from "./map.js"; + +/** + * @param {string} stdinText + * @returns {Record} + */ +function parseStdinJson(stdinText) { + if (!stdinText.trim()) return {}; + try { + const parsed = JSON.parse(stdinText); + return parsed && typeof parsed === "object" ? parsed : {}; + } catch { + return {}; + } +} + +/** @type {import("../types.js").Integration} */ +export const antigravity = { + id: "antigravity", + name: "Antigravity", + + /** + * Hook type on argv (`PreToolUse`, `Stop`, …); payload on stdin. + * Never returns null — Antigravity always needs a decision JSON response. + * @param {string[]} argv + * @param {string} stdinText + */ + parseHook(argv, stdinText) { + const hookType = argv[0] || "unknown"; + const payload = parseStdinJson(stdinText); + const toolCall = payload.toolCall; + const toolName = + toolCall && typeof toolCall === "object" && typeof toolCall.name === "string" + ? toolCall.name + : undefined; + + return { + event: hookType, + tool: toolName, + meta: { raw: payload }, + }; + }, + + /** + * @param {import("../types.js").HookInput} input + */ + mapToAnim(input) { + const raw = + input.meta?.raw && typeof input.meta.raw === "object" + ? /** @type {Record} */ (input.meta.raw) + : {}; + return animationForEvent(input.event, raw); + }, + + /** + * @param {import("../types.js").HookInput} input + * @param {import("../../util/style.js").Style} style + */ + describeFiles(input, style) { + const raw = + input.meta?.raw && typeof input.meta.raw === "object" + ? /** @type {Record} */ (input.meta.raw) + : {}; + return formatFileModifications(input.event, raw, style); + }, + + /** + * Always print Antigravity decision JSON on stdout (not quiet-sensitive). + * @param {import("../types.js").HookRespondContext} ctx + */ + respond(ctx) { + const hookType = ctx.input?.event ?? "unknown"; + console.log(JSON.stringify(defaultResponseForHook(hookType))); + }, + + detect, + configRelativePath, + planInstall, + applyInstall, + planUninstall, + applyUninstall, +}; diff --git a/packages/tiny-engineer-cli/src/integrations/antigravity/install.js b/packages/tiny-engineer-cli/src/integrations/antigravity/install.js new file mode 100644 index 00000000..d9d44ef7 --- /dev/null +++ b/packages/tiny-engineer-cli/src/integrations/antigravity/install.js @@ -0,0 +1,127 @@ +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +export const ANTIGRAVITY_EVENTS = [ + "PreInvocation", + "PreToolUse", + "PostToolUse", + "Stop", +]; + +/** + * @param {string} projectRoot + * @returns {boolean} + */ +export function detect(projectRoot) { + return ( + existsSync(join(projectRoot, ".agents")) || + existsSync(join(projectRoot, ".agents", "hooks.json")) + ); +} + +/** + * @returns {string} + */ +export function configRelativePath() { + return ".agents/hooks.json"; +} + +/** + * @param {string} cliCommand base `node … hook antigravity` + * @param {string} event + * @returns {string} + */ +export function commandForEvent(cliCommand, event) { + return `${cliCommand} ${event}`; +} + +/** + * @param {string} path + * @returns {object | null} + */ +function readConfig(path) { + if (!existsSync(path)) return null; + try { + const parsed = JSON.parse(readFileSync(path, "utf8")); + return parsed && typeof parsed === "object" ? parsed : null; + } catch { + return null; + } +} + +/** + * @param {import("../types.js").InstallContext} ctx + * @returns {import("../types.js").InstallPlan} + */ +export function planInstall(ctx) { + return { + ide: "antigravity", + path: join(ctx.projectRoot, configRelativePath()), + relativePath: configRelativePath(), + events: [...ANTIGRAVITY_EVENTS], + commandPreview: commandForEvent(ctx.cliCommand, "PreToolUse"), + }; +} + +/** + * @param {import("../types.js").InstallContext} ctx + * @param {object | null} existing + * @returns {object} + */ +export function applyInstall(ctx, existing) { + /** @type {Record} */ + const root = + existing && typeof existing === "object" ? structuredClone(existing) : {}; + + /** @type {Record} */ + const block = {}; + + for (const event of ANTIGRAVITY_EVENTS) { + const command = commandForEvent(ctx.cliCommand, event); + if (event === "PreToolUse" || event === "PostToolUse") { + block[event] = [ + { + matcher: "*", + hooks: [{ command, timeout: ctx.timeout }], + }, + ]; + } else { + block[event] = [{ command, timeout: ctx.timeout }]; + } + } + + root["tiny-engineer"] = block; + return root; +} + +/** + * @param {string} projectRoot + * @returns {import("../types.js").UninstallPlan} + */ +export function planUninstall(projectRoot) { + const relativePath = configRelativePath(); + const path = join(projectRoot, relativePath); + const existing = readConfig(path); + const has = Boolean(existing && Object.prototype.hasOwnProperty.call(existing, "tiny-engineer")); + return { + ide: "antigravity", + path, + relativePath, + removeCount: has ? 1 : 0, + detail: has ? 'key "tiny-engineer"' : "nothing to remove", + }; +} + +/** + * @param {object | null} existing + * @returns {object} + */ +export function applyUninstall(existing) { + if (!existing || typeof existing !== "object") { + return {}; + } + /** @type {Record} */ + const root = structuredClone(existing); + delete root["tiny-engineer"]; + return root; +} diff --git a/packages/tiny-engineer-cli/src/integrations/antigravity/map.js b/packages/tiny-engineer-cli/src/integrations/antigravity/map.js new file mode 100644 index 00000000..b6932328 --- /dev/null +++ b/packages/tiny-engineer-cli/src/integrations/antigravity/map.js @@ -0,0 +1,82 @@ +export const READING_TOOLS = new Set([ + "view_file", + "grep_search", + "find_by_name", + "list_dir", + "read_url_content", + "read_resource", + "list_resources", +]); + +export const TYPING_TOOLS = new Set([ + "write_to_file", + "replace_file_content", + "run_command", + "generate_image", + "call_mcp_tool", +]); + +export const THINKING_TOOLS = new Set([ + "ask_question", + "invoke_subagent", + "define_subagent", + "manage_subagents", + "manage_task", + "schedule", +]); + +/** + * Map an Antigravity lifecycle event + payload to a robot animation name. + * @param {string} hookType e.g. "PreInvocation", "PreToolUse", "PostToolUse", "Stop" + * @param {Record} payload Stdin JSON payload from Antigravity + * @returns {string | null} + */ +export function animationForEvent(hookType, payload = {}) { + switch (hookType) { + case "PreInvocation": + return "thinking"; + + case "PreToolUse": { + const toolName = payload?.toolCall?.name; + if (!toolName) return "thinking"; + if (READING_TOOLS.has(toolName)) return "reading"; + if (TYPING_TOOLS.has(toolName)) return "typing"; + if (THINKING_TOOLS.has(toolName)) return "thinking"; + return "thinking"; + } + + case "PostToolUse": { + if (payload?.error) { + return "attention"; + } + return null; + } + + case "Stop": { + const reason = payload?.terminationReason; + if (payload?.error || reason === "error") { + return "error"; + } + if (reason === "aborted" || reason === "user_cancelled") { + return "abort"; + } + // Finished turn normally (model_stop / max_steps_exceeded / idle) -> ring the desk bell! + return "ring"; + } + + default: + return null; + } +} + +/** + * Decision JSON required by Antigravity's hook contract. + * @param {string} hookType + * @returns {Record} + */ +export function defaultResponseForHook(hookType) { + if (hookType === "PreToolUse" || hookType === "Stop") { + return { decision: "allow" }; + } + return {}; +} diff --git a/packages/tiny-engineer-cli/src/integrations/claude-code/files.js b/packages/tiny-engineer-cli/src/integrations/claude-code/files.js new file mode 100644 index 00000000..31231daa --- /dev/null +++ b/packages/tiny-engineer-cli/src/integrations/claude-code/files.js @@ -0,0 +1,29 @@ +import { createStyle } from "../../util/style.js"; + +const PATH_WRITE_TOOLS = new Set(["Edit", "Write", "NotebookEdit"]); + +/** + * @param {Record | null | undefined} raw + * @param {import("../../util/style.js").Style} [style] + * @returns {string[]} + */ +export function formatFileModifications(raw, style = createStyle({ color: false })) { + if (!raw || typeof raw !== "object") return []; + if (raw.hook_event_name !== "PreToolUse") return []; + if (typeof raw.tool_name !== "string" || !PATH_WRITE_TOOLS.has(raw.tool_name)) { + return []; + } + + const toolInput = raw.tool_input; + let filePath = null; + if (typeof raw.file_path === "string") { + filePath = raw.file_path; + } else if (toolInput && typeof toolInput === "object") { + const input = /** @type {Record} */ (toolInput); + if (typeof input.file_path === "string") filePath = input.file_path; + else if (typeof input.path === "string") filePath = input.path; + } + + if (!filePath) return []; + return [`${style.cyan("✎")} touching: ${filePath}`]; +} diff --git a/packages/tiny-engineer-cli/src/integrations/claude-code/index.js b/packages/tiny-engineer-cli/src/integrations/claude-code/index.js new file mode 100644 index 00000000..51868be5 --- /dev/null +++ b/packages/tiny-engineer-cli/src/integrations/claude-code/index.js @@ -0,0 +1,91 @@ +import { formatFileModifications } from "./files.js"; +import { + applyInstall, + applyUninstall, + configRelativePath, + detect, + planInstall, + planUninstall, +} from "./install.js"; +import { animationForEvent } from "./map.js"; + +/** + * @param {string} stdinText + * @returns {Record | null} + */ +function parseStdinJson(stdinText) { + if (!stdinText.trim()) return null; + try { + return JSON.parse(stdinText); + } catch { + return null; + } +} + +/** @type {import("../types.js").Integration} */ +export const claudeCode = { + id: "claude-code", + name: "Claude Code", + hardExitAfterPost: true, + + /** + * @param {string[]} _argv + * @param {string} stdinText + */ + parseHook(_argv, stdinText) { + const event = parseStdinJson(stdinText); + if (!event || typeof event !== "object") return null; + + const name = event.hook_event_name; + if (typeof name !== "string" || !name) return null; + + return { + event: name, + tool: typeof event.tool_name === "string" ? event.tool_name : undefined, + status: typeof event.status === "string" ? event.status : undefined, + meta: { + source: event.source, + reason: event.reason, + raw: event, + }, + }; + }, + + /** + * @param {import("../types.js").HookInput} input + */ + mapToAnim(input) { + const raw = input.meta?.raw; + if (raw && typeof raw === "object") { + return animationForEvent( + /** @type {Parameters[0]} */ (raw), + ); + } + return animationForEvent({ + hook_event_name: input.event, + tool_name: input.tool, + source: typeof input.meta?.source === "string" ? input.meta.source : undefined, + reason: typeof input.meta?.reason === "string" ? input.meta.reason : undefined, + }); + }, + + /** + * @param {import("../types.js").HookInput} input + * @param {import("../../util/style.js").Style} style + */ + describeFiles(input, style) { + const raw = input.meta?.raw; + if (!raw || typeof raw !== "object") return []; + return formatFileModifications(/** @type {Record} */ (raw), style); + }, + + // Claude Code adds SessionStart / UserPromptSubmit stdout to the model context. + respond() {}, + + detect, + configRelativePath, + planInstall, + applyInstall, + planUninstall, + applyUninstall, +}; diff --git a/packages/tiny-engineer-cli/src/integrations/claude-code/install.js b/packages/tiny-engineer-cli/src/integrations/claude-code/install.js new file mode 100644 index 00000000..b6fe921b --- /dev/null +++ b/packages/tiny-engineer-cli/src/integrations/claude-code/install.js @@ -0,0 +1,202 @@ +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +export const CLAUDE_EVENTS = [ + "SessionStart", + "SessionEnd", + "UserPromptSubmit", + "SubagentStart", + "PreToolUse", + "PostToolBatch", + "PostToolUseFailure", + "PreCompact", + "PermissionRequest", + "PermissionDenied", + "Notification", + "Stop", + "StopFailure", +]; + +export const MARKERS = ["hook claude-code", "tiny-engineer-claude-code"]; + +/** + * @param {string} projectRoot + * @returns {boolean} + */ +export function detect(projectRoot) { + return ( + existsSync(join(projectRoot, ".claude")) || + existsSync(join(projectRoot, ".claude", "settings.json")) + ); +} + +/** + * @returns {string} + */ +export function configRelativePath() { + return ".claude/settings.json"; +} + +/** + * @param {string} command + * @returns {boolean} + */ +export function isOurCommand(command) { + if (typeof command !== "string") return false; + return MARKERS.some((m) => command.includes(m)); +} + +/** + * @param {unknown} eventBlock + * @returns {boolean} + */ +function blockHasOurs(eventBlock) { + if (!Array.isArray(eventBlock)) return false; + for (const wrapper of eventBlock) { + const hooks = wrapper?.hooks; + if (!Array.isArray(hooks)) continue; + for (const h of hooks) { + if (isOurCommand(h?.command)) return true; + } + } + return false; +} + +/** + * @param {string} path + * @returns {object | null} + */ +function readConfig(path) { + if (!existsSync(path)) return null; + try { + const parsed = JSON.parse(readFileSync(path, "utf8")); + return parsed && typeof parsed === "object" ? parsed : null; + } catch { + return null; + } +} + +/** + * @param {import("../types.js").InstallContext} ctx + * @returns {import("../types.js").InstallPlan} + */ +export function planInstall(ctx) { + return { + ide: "claude-code", + path: join(ctx.projectRoot, configRelativePath()), + relativePath: configRelativePath(), + events: [...CLAUDE_EVENTS], + commandPreview: ctx.cliCommand, + }; +} + +/** + * @param {import("../types.js").InstallContext} ctx + * @param {object | null} existing + * @returns {object} + */ +export function applyInstall(ctx, existing) { + /** @type {Record} */ + const root = + existing && typeof existing === "object" ? structuredClone(existing) : {}; + + /** @type {Record} */ + const hooks = + root.hooks && typeof root.hooks === "object" + ? /** @type {Record} */ (root.hooks) + : {}; + + const entry = { + hooks: [ + { + type: "command", + command: ctx.cliCommand, + async: true, + }, + ], + }; + + for (const event of CLAUDE_EVENTS) { + if (blockHasOurs(hooks[event])) continue; + const list = Array.isArray(hooks[event]) ? [...hooks[event]] : []; + list.push(structuredClone(entry)); + hooks[event] = list; + } + + root.hooks = hooks; + return root; +} + +/** + * @param {object | null} existing + * @returns {number} + */ +function countOurs(existing) { + if (!existing?.hooks || typeof existing.hooks !== "object") return 0; + let n = 0; + for (const eventBlock of Object.values(existing.hooks)) { + if (!Array.isArray(eventBlock)) continue; + for (const wrapper of eventBlock) { + const hooks = wrapper?.hooks; + if (!Array.isArray(hooks)) continue; + for (const h of hooks) { + if (isOurCommand(h?.command)) n++; + } + } + } + return n; +} + +/** + * @param {string} projectRoot + * @returns {import("../types.js").UninstallPlan} + */ +export function planUninstall(projectRoot) { + const relativePath = configRelativePath(); + const path = join(projectRoot, relativePath); + const existing = readConfig(path); + const removeCount = countOurs(existing); + return { + ide: "claude-code", + path, + relativePath, + removeCount, + detail: removeCount === 1 ? "1 hook entry" : `${removeCount} hook entries`, + }; +} + +/** + * @param {object | null} existing + * @returns {object} + */ +export function applyUninstall(existing) { + if (!existing || typeof existing !== "object") { + return { hooks: {} }; + } + + /** @type {Record} */ + const root = structuredClone(existing); + /** @type {Record} */ + const hooks = + root.hooks && typeof root.hooks === "object" + ? /** @type {Record} */ (root.hooks) + : {}; + + /** @type {Record} */ + const next = {}; + for (const [event, eventBlock] of Object.entries(hooks)) { + if (!Array.isArray(eventBlock)) continue; + /** @type {object[]} */ + const keptWrappers = []; + for (const wrapper of eventBlock) { + if (!wrapper || typeof wrapper !== "object") continue; + const inner = Array.isArray(wrapper.hooks) ? wrapper.hooks : []; + const keptInner = inner.filter((h) => !isOurCommand(h?.command)); + if (keptInner.length === 0) continue; + keptWrappers.push({ ...wrapper, hooks: keptInner }); + } + if (keptWrappers.length > 0) next[event] = keptWrappers; + } + root.hooks = next; + return root; +} diff --git a/packages/tiny-engineer-cli/src/integrations/claude-code/map.js b/packages/tiny-engineer-cli/src/integrations/claude-code/map.js new file mode 100644 index 00000000..00810c14 --- /dev/null +++ b/packages/tiny-engineer-cli/src/integrations/claude-code/map.js @@ -0,0 +1,60 @@ +export const READING_TOOLS = new Set([ + "Read", + "Grep", + "Glob", + "WebFetch", + "WebSearch", +]); + +export const TYPING_TOOLS = new Set([ + "Bash", + "PowerShell", + "Edit", + "Write", + "NotebookEdit", +]); + +const EVENT_ANIM = new Map([ + ["UserPromptSubmit", "reading"], + ["SubagentStart", "thinking"], + ["PostToolBatch", "thinking"], + ["PostToolUseFailure", "error"], + ["PreCompact", "thinking"], + ["PermissionRequest", "attention"], + ["PermissionDenied", "abort"], + ["Notification", "attention"], + ["Stop", "ring"], + ["StopFailure", "error"], +]); + +/** + * Map a Claude Code hook payload to an animation name, or null to skip. + * @param {{ hook_event_name?: string, tool_name?: string, source?: string, reason?: string }} event + * @returns {string | null} + */ +export function animationForEvent(event) { + const name = event?.hook_event_name; + if (!name) return null; + + if (name === "PreToolUse") { + const tool = event.tool_name; + if (READING_TOOLS.has(tool)) return "reading"; + if (TYPING_TOOLS.has(tool)) return "typing"; + return null; + } + + // Only a fresh launch welcomes; resume, /clear, compaction, fork, or an + // unknown source wakes up, so one session never plays welcome twice. + if (name === "SessionStart") { + return event.source === "startup" ? "welcome" : "wakeup"; + } + + // /clear and /resume end one session and start the next at once. The + // SessionStart that follows owns the pose; a sleep posted alongside it makes + // the robot drop the wakeup, so skip it. + if (name === "SessionEnd") { + return event.reason === "clear" || event.reason === "resume" ? null : "sleep"; + } + + return EVENT_ANIM.get(name) ?? null; +} diff --git a/packages/tiny-engineer-cli/src/integrations/cursor/files.js b/packages/tiny-engineer-cli/src/integrations/cursor/files.js new file mode 100644 index 00000000..e94f9476 --- /dev/null +++ b/packages/tiny-engineer-cli/src/integrations/cursor/files.js @@ -0,0 +1,80 @@ +import { createStyle } from "../../util/style.js"; + +const WRITE_TOOLS = new Set([ + "Write", + "StrReplace", + "EditNotebook", + "Delete", +]); + +const TRUNC = 60; + +/** + * @param {unknown} value + * @returns {string} + */ +function collapse(value) { + return String(value ?? "") + .replace(/\r?\n/g, "\\n") + .replace(/\s+/g, " ") + .trim(); +} + +/** + * @param {string} text + * @param {number} [max] + * @returns {string} + */ +function truncate(text, max = TRUNC) { + if (text.length <= max) return text; + return `${text.slice(0, max - 1)}…`; +} + +/** + * Format Cursor hook payload file changes for stderr. + * @param {Record | null | undefined} raw + * @param {import("../../util/style.js").Style} [style] + * @returns {string[]} + */ +export function formatFileModifications(raw, style = createStyle({ color: false })) { + if (!raw || typeof raw !== "object") return []; + + const event = raw.hook_event_name; + /** @type {string[]} */ + const lines = []; + + if (event === "afterFileEdit" || event === "afterTabFileEdit") { + const filePath = typeof raw.file_path === "string" ? raw.file_path : null; + if (!filePath) return []; + + const edits = Array.isArray(raw.edits) ? raw.edits : []; + const n = edits.length; + const label = n === 1 ? "1 edit" : `${n} edits`; + lines.push(`${style.cyan("✎")} modified: ${filePath} (${label})`); + + for (const edit of edits) { + if (!edit || typeof edit !== "object") continue; + const oldS = truncate(collapse(/** @type {{ old_string?: unknown }} */ (edit).old_string)); + const newS = truncate(collapse(/** @type {{ new_string?: unknown }} */ (edit).new_string)); + lines.push( + ` ${style.red("−")} ${style.dim(`"${oldS}"`)} → ${style.green("+")} ${style.green(`"${newS}"`)}`, + ); + } + return lines; + } + + if (event === "preToolUse" && typeof raw.tool_name === "string" && WRITE_TOOLS.has(raw.tool_name)) { + const toolInput = raw.tool_input; + const filePath = + toolInput && + typeof toolInput === "object" && + typeof /** @type {{ file_path?: unknown }} */ (toolInput).file_path === "string" + ? /** @type {{ file_path: string }} */ (toolInput).file_path + : null; + if (filePath) { + lines.push(`${style.cyan("✎")} touching: ${filePath}`); + } + } + + return lines; +} diff --git a/packages/tiny-engineer-cli/src/integrations/cursor/index.js b/packages/tiny-engineer-cli/src/integrations/cursor/index.js new file mode 100644 index 00000000..09005549 --- /dev/null +++ b/packages/tiny-engineer-cli/src/integrations/cursor/index.js @@ -0,0 +1,88 @@ +import { formatFileModifications } from "./files.js"; +import { + applyInstall, + applyUninstall, + configRelativePath, + detect, + planInstall, + planUninstall, +} from "./install.js"; +import { animationForEvent } from "./map.js"; + +/** + * @param {string} stdinText + * @returns {Record | null} + */ +function parseStdinJson(stdinText) { + if (!stdinText.trim()) return null; + try { + return JSON.parse(stdinText); + } catch { + return null; + } +} + +/** @type {import("../types.js").Integration} */ +export const cursor = { + id: "cursor", + name: "Cursor", + + /** + * Cursor pipes JSON with hook_event_name on stdin; argv is unused. + * @param {string[]} _argv + * @param {string} stdinText + */ + parseHook(_argv, stdinText) { + const event = parseStdinJson(stdinText); + if (!event || typeof event !== "object") return null; + + const name = event.hook_event_name; + if (typeof name !== "string" || !name) return null; + + return { + event: name, + tool: typeof event.tool_name === "string" ? event.tool_name : undefined, + status: typeof event.status === "string" ? event.status : undefined, + meta: { + conversation_id: event.conversation_id, + generation_id: event.generation_id, + raw: event, + }, + }; + }, + + /** + * @param {import("../types.js").HookInput} input + */ + mapToAnim(input) { + const raw = input.meta?.raw; + if (raw && typeof raw === "object") { + return animationForEvent(/** @type {Parameters[0]} */ (raw)); + } + return animationForEvent({ + hook_event_name: input.event, + tool_name: input.tool, + status: input.status, + }); + }, + + /** + * @param {import("../types.js").HookInput} input + * @param {import("../../util/style.js").Style} style + */ + describeFiles(input, style) { + const raw = input.meta?.raw; + if (!raw || typeof raw !== "object") return []; + return formatFileModifications(/** @type {Record} */ (raw), style); + }, + + // Cursor has no stdout contract; stay silent. + respond() {}, + + detect, + configRelativePath, + planInstall, + applyInstall, + planUninstall, + applyUninstall, +}; diff --git a/packages/tiny-engineer-cli/src/integrations/cursor/install.js b/packages/tiny-engineer-cli/src/integrations/cursor/install.js new file mode 100644 index 00000000..3321d5f6 --- /dev/null +++ b/packages/tiny-engineer-cli/src/integrations/cursor/install.js @@ -0,0 +1,170 @@ +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +export const CURSOR_EVENTS = [ + "sessionStart", + "beforeSubmitPrompt", + "afterAgentThought", + "preCompact", + "preToolUse", + "beforeReadFile", + "beforeShellExecution", + "subagentStart", + "afterFileEdit", + "stop", +]; + +export const MARKERS = ["hook cursor", "tiny-engineer-cursor"]; + +/** + * @param {string} projectRoot + * @returns {boolean} + */ +export function detect(projectRoot) { + return ( + existsSync(join(projectRoot, ".cursor")) || + existsSync(join(projectRoot, ".cursor", "hooks.json")) + ); +} + +/** + * @returns {string} + */ +export function configRelativePath() { + return ".cursor/hooks.json"; +} + +/** + * @param {string} command + * @returns {boolean} + */ +export function isOurCommand(command) { + if (typeof command !== "string") return false; + return MARKERS.some((m) => command.includes(m)); +} + +/** + * @param {string} path + * @returns {object | null} + */ +function readConfig(path) { + if (!existsSync(path)) return null; + try { + const parsed = JSON.parse(readFileSync(path, "utf8")); + return parsed && typeof parsed === "object" ? parsed : null; + } catch { + return null; + } +} + +/** + * @param {import("../types.js").InstallContext} ctx + * @returns {import("../types.js").InstallPlan} + */ +export function planInstall(ctx) { + return { + ide: "cursor", + path: join(ctx.projectRoot, configRelativePath()), + relativePath: configRelativePath(), + events: [...CURSOR_EVENTS], + commandPreview: ctx.cliCommand, + }; +} + +/** + * @param {import("../types.js").InstallContext} ctx + * @param {object | null} existing + * @returns {object} + */ +export function applyInstall(ctx, existing) { + /** @type {{ version: number, hooks: Record }} */ + const root = { + version: 1, + hooks: {}, + }; + + if (existing && typeof existing === "object") { + if (typeof existing.version === "number") root.version = existing.version; + if (existing.hooks && typeof existing.hooks === "object") { + for (const [event, list] of Object.entries(existing.hooks)) { + root.hooks[event] = Array.isArray(list) ? [...list] : []; + } + } + } + + const entry = { command: ctx.cliCommand, timeout: ctx.timeout }; + + for (const event of CURSOR_EVENTS) { + const list = root.hooks[event] ?? []; + const has = list.some((item) => isOurCommand(/** @type {{ command?: string }} */ (item)?.command)); + if (!has) list.push({ ...entry }); + root.hooks[event] = list; + } + + return root; +} + +/** + * @param {object | null} existing + * @returns {number} + */ +function countOurs(existing) { + if (!existing?.hooks || typeof existing.hooks !== "object") return 0; + let n = 0; + for (const list of Object.values(existing.hooks)) { + if (!Array.isArray(list)) continue; + for (const item of list) { + if (isOurCommand(item?.command)) n++; + } + } + return n; +} + +/** + * @param {string} projectRoot + * @returns {import("../types.js").UninstallPlan} + */ +export function planUninstall(projectRoot) { + const relativePath = configRelativePath(); + const path = join(projectRoot, relativePath); + const existing = readConfig(path); + const removeCount = countOurs(existing); + return { + ide: "cursor", + path, + relativePath, + removeCount, + detail: removeCount === 1 ? "1 hook entry" : `${removeCount} hook entries`, + }; +} + +/** + * @param {object | null} existing + * @returns {object} + */ +export function applyUninstall(existing) { + if (!existing || typeof existing !== "object") { + return { version: 1, hooks: {} }; + } + + /** @type {Record} */ + const root = structuredClone(existing); + /** @type {Record} */ + const hooks = + root.hooks && typeof root.hooks === "object" + ? /** @type {Record} */ (root.hooks) + : {}; + + /** @type {Record} */ + const next = {}; + for (const [event, list] of Object.entries(hooks)) { + if (!Array.isArray(list)) continue; + const kept = list.filter( + (item) => !isOurCommand(/** @type {{ command?: string }} */ (item)?.command), + ); + if (kept.length > 0) next[event] = kept; + } + root.hooks = next; + if (typeof root.version !== "number") root.version = 1; + return root; +} diff --git a/packages/tiny-engineer-cli/src/integrations/cursor/map.js b/packages/tiny-engineer-cli/src/integrations/cursor/map.js new file mode 100644 index 00000000..d9b21d43 --- /dev/null +++ b/packages/tiny-engineer-cli/src/integrations/cursor/map.js @@ -0,0 +1,60 @@ +/** Tools that map to the typing animation (from former hooks.json matcher). */ +export const TYPING_TOOLS = new Set([ + "Write", + "StrReplace", + "EditNotebook", + "Delete", + "Shell", + "Task", + "CallMcpTool", + "TodoWrite", + "GenerateImage", +]); + +export const READING_TOOLS = new Set([ + "Read", + "Grep", + "Glob", + "WebSearch", + "WebFetch", + "AwaitShell", +]); + +const EVENT_ANIM = { + sessionStart: "reading", + beforeSubmitPrompt: "reading", + beforeReadFile: "reading", + afterAgentThought: "thinking", + preCompact: "thinking", + beforeShellExecution: "typing", + subagentStart: "typing", + afterFileEdit: "typing", +}; + +/** + * Map a Cursor hook payload to an animation name, or null to skip. + * Empty conversation_id or generation_id skips the pose (missing IDs still map). + * @param {{ hook_event_name?: string, tool_name?: string, status?: string, conversation_id?: string, generation_id?: string }} event + * @returns {string | null} + */ +export function animationForEvent(event) { + if (event?.conversation_id === "" || event?.generation_id === "") return null; + + const name = event?.hook_event_name; + if (!name) return null; + + if (name === "preToolUse") { + const tool = event.tool_name; + if (READING_TOOLS.has(tool)) return "reading"; + if (TYPING_TOOLS.has(tool)) return "typing"; + return null; + } + + if (name === "stop") { + if (event.status === "aborted") return "abort"; + if (event.status === "error") return "error"; + return "ring"; + } + + return EVENT_ANIM[name] ?? null; +} diff --git a/packages/tiny-engineer-cli/src/integrations/registry.js b/packages/tiny-engineer-cli/src/integrations/registry.js new file mode 100644 index 00000000..20a4669f --- /dev/null +++ b/packages/tiny-engineer-cli/src/integrations/registry.js @@ -0,0 +1,32 @@ +import { antigravity } from "./antigravity/index.js"; +import { claudeCode } from "./claude-code/index.js"; +import { cursor } from "./cursor/index.js"; + +/** @type {Map} */ +const byId = new Map([ + [cursor.id, cursor], + [claudeCode.id, claudeCode], + [antigravity.id, antigravity], +]); + +/** + * @param {string} id + * @returns {import("./types.js").Integration | undefined} + */ +export function get(id) { + return byId.get(id); +} + +/** + * @returns {string[]} + */ +export function list() { + return [...byId.keys()]; +} + +/** + * @returns {import("./types.js").Integration[]} + */ +export function all() { + return [...byId.values()]; +} diff --git a/packages/tiny-engineer-cli/src/integrations/types.js b/packages/tiny-engineer-cli/src/integrations/types.js new file mode 100644 index 00000000..130116e8 --- /dev/null +++ b/packages/tiny-engineer-cli/src/integrations/types.js @@ -0,0 +1,61 @@ +/** + * @typedef {object} HookInput + * @property {string} event Normalized event name (IDE-specific string is fine for now) + * @property {string} [tool] + * @property {string} [status] + * @property {Record} [meta] + */ + +/** + * @typedef {object} HookRespondContext + * @property {boolean} animPosted + * @property {string | null} anim + * @property {HookInput} [input] + */ + +/** + * @typedef {object} InstallContext + * @property {string} projectRoot + * @property {string} cliCommand Base command ending with `hook ` (no event suffix) + * @property {number} timeout + * @property {string} [url] + */ + +/** + * @typedef {object} InstallPlan + * @property {string} ide + * @property {string} path Absolute config path + * @property {string} relativePath + * @property {string[]} events + * @property {string} commandPreview Example command that will be written + */ + +/** + * @typedef {object} UninstallPlan + * @property {string} ide + * @property {string} path Absolute config path + * @property {string} relativePath + * @property {number} removeCount + * @property {string} detail + */ + +/** + * Integration plugin contract. Each IDE implements this shape and registers in registry.js. + * + * @typedef {object} Integration + * @property {string} id CLI id, e.g. "cursor" + * @property {string} name Human label, e.g. "Cursor" + * @property {boolean} [hardExitAfterPost] + * @property {(argv: string[], stdinText: string) => HookInput | null} parseHook + * @property {(input: HookInput) => string | null} mapToAnim + * @property {(input: HookInput, style: import("../util/style.js").Style) => string[]} [describeFiles] + * @property {(ctx: HookRespondContext) => void} [respond] + * @property {(projectRoot: string) => boolean} [detect] + * @property {() => string} [configRelativePath] + * @property {(ctx: InstallContext) => InstallPlan} [planInstall] + * @property {(ctx: InstallContext, existing: object | null) => object} [applyInstall] + * @property {(projectRoot: string) => UninstallPlan} [planUninstall] + * @property {(existing: object | null) => object} [applyUninstall] + */ + +export {}; diff --git a/packages/tiny-engineer-cli/src/robot/client.js b/packages/tiny-engineer-cli/src/robot/client.js new file mode 100644 index 00000000..5c95a73a --- /dev/null +++ b/packages/tiny-engineer-cli/src/robot/client.js @@ -0,0 +1,169 @@ +/** Allowed POST /anim?name= values (firmware allow-list). */ +export const ANIM_NAMES = new Set([ + "none", + "typing", + "reading", + "thinking", + "talking", + "ring", + "welcome", + "attention", + "error", + "abort", + "dead", + "wakeup", + "sleep", +]); + +/** Allowed POST /play/ path segments. */ +export const PLAY_ANIM_NAMES = new Set([ + "talking", + "typing", + "reading", + "thinking", + "none", +]); + +/** + * @typedef {{ ok: boolean, status?: number, body?: string, error?: string }} RobotResult + */ + +/** + * @param {string} name + * @returns {string | null} error message, or null if valid + */ +export function validateAnimName(name) { + if (ANIM_NAMES.has(name)) return null; + const allowed = [...ANIM_NAMES].join(", "); + return `unknown animation: ${name} (allowed: ${allowed})`; +} + +/** + * @param {string} name + * @returns {string | null} error message, or null if valid + */ +export function validatePlayAnimName(name) { + if (PLAY_ANIM_NAMES.has(name)) return null; + const allowed = [...PLAY_ANIM_NAMES].join(", "); + return `unknown play animation: ${name} (allowed: ${allowed})`; +} + +/** + * @param {RobotResult} result + * @returns {string} + */ +export function formatRobotError(result) { + if (result.error) return result.error; + if (result.status !== undefined) return `HTTP ${result.status}`; + return "robot request failed"; +} + +/** + * @param {string} body + * @param {number} status + * @returns {string} + */ +function errorFromBody(body, status) { + try { + const json = JSON.parse(body); + if (typeof json?.error === "string" && json.error) return json.error; + } catch { + // not JSON + } + const trimmed = body?.trim(); + if (trimmed) return trimmed; + return `HTTP ${status}`; +} + +/** + * @param {unknown} err + * @returns {string} + */ +function unreachableMessage(err) { + const detail = + err && typeof err === "object" && "cause" in err && err.cause instanceof Error + ? err.cause.message + : err instanceof Error + ? err.message + : String(err); + return `robot unreachable: ${detail}`; +} + +/** + * fetch() against the robot: joins base URL and path and adds the Bearer token when set. + * @param {string} baseUrl + * @param {string} path + * @param {RequestInit} init + * @param {string | null} [token] + */ +export function robotFetch(baseUrl, path, init, token = null) { + const headers = { ...init.headers }; + if (token) headers.Authorization = `Bearer ${token}`; + return fetch(`${baseUrl.replace(/\/+$/, "")}${path}`, { ...init, headers }); +} + +/** + * POST /anim?name=…. Never throws — returns a RobotResult. + * @param {string} baseUrl + * @param {string} animName + * @param {string | null} [token] + * @returns {Promise} + */ +export async function postAnim(baseUrl, animName, token = null) { + try { + const response = await robotFetch( + baseUrl, + `/anim?name=${encodeURIComponent(animName)}`, + { method: "POST", signal: AbortSignal.timeout(2000) }, + token, + ); + const body = await response.text(); + if (response.ok) { + return { ok: true, status: response.status, body }; + } + return { + ok: false, + status: response.status, + body, + error: errorFromBody(body, response.status), + }; + } catch (err) { + return { ok: false, error: unreachableMessage(err) }; + } +} + +// The robot answers once the clip has finished playing. +const PLAY_TIMEOUT_MS = 10 * 60 * 1000; + +/** + * POST a WAV to /play (or /play/). Never throws — returns a RobotResult. + * @param {{ baseUrl: string, wav: Uint8Array, name?: string, token?: string | null }} request + * @returns {Promise} + */ +export async function postPlay({ baseUrl, wav, name, token }) { + try { + const response = await robotFetch( + baseUrl, + name ? `/play/${encodeURIComponent(name)}` : "/play", + { + method: "POST", + headers: { "Content-Type": "audio/wav" }, + body: wav, + signal: AbortSignal.timeout(PLAY_TIMEOUT_MS), + }, + token, + ); + const body = await response.text(); + if (response.ok) { + return { ok: true, status: response.status, body }; + } + return { + ok: false, + status: response.status, + body, + error: errorFromBody(body, response.status), + }; + } catch (err) { + return { ok: false, error: unreachableMessage(err) }; + } +} diff --git a/packages/tiny-engineer-cli/src/util/args.js b/packages/tiny-engineer-cli/src/util/args.js new file mode 100644 index 00000000..64c90fe7 --- /dev/null +++ b/packages/tiny-engineer-cli/src/util/args.js @@ -0,0 +1,146 @@ +/** + * @param {string} arg + * @returns {boolean} + */ +export function isQuietFlag(arg) { + return arg === "-q" || arg === "--quiet" || arg === "--silent"; +} + +/** + * Strip leading global flags before the command name. + * @param {string[]} argv + * @returns {{ quiet: boolean, rest: string[], error?: string }} + */ +export function stripGlobalFlags(argv) { + let quiet = false; + let i = 0; + + while (i < argv.length) { + const arg = argv[i]; + if (isQuietFlag(arg)) { + quiet = true; + i++; + continue; + } + break; + } + + return { quiet, rest: argv.slice(i) }; +} + +/** + * Parse shared flags from argv. Flags may appear before or after positionals + * (e.g. `hook cursor -q`). Non-flag tokens go to `rest` in order. + * @param {string[]} argv + * @returns {{ help: boolean, quiet: boolean, url?: string, rest: string[], error?: string }} + */ +export function parseCommonArgs(argv) { + let help = false; + let quiet = false; + /** @type {string | undefined} */ + let url; + /** @type {string[]} */ + const rest = []; + + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === "-h" || arg === "--help") { + help = true; + continue; + } + if (isQuietFlag(arg)) { + quiet = true; + continue; + } + if (arg === "--url") { + const next = argv[++i]; + if (!next) return { help, quiet, url, rest, error: "--url requires a value" }; + url = next; + continue; + } + if (arg.startsWith("--url=")) { + url = arg.slice("--url=".length); + if (!url) return { help, quiet, url, rest, error: "--url requires a value" }; + continue; + } + if (arg.startsWith("-")) { + return { help, quiet, url, rest, error: `Unknown argument: ${arg}` }; + } + rest.push(arg); + } + + return { help, quiet, url, rest }; +} + +/** + * @param {string[]} argv arguments after `play` + * @returns {{ wavPath?: string, name?: string, url?: string, quiet: boolean, error?: string }} + */ +export function parsePlayArgs(argv) { + /** @type {{ wavPath?: string, name?: string, url?: string, quiet: boolean }} */ + const opts = { quiet: false }; + + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + + if (isQuietFlag(arg)) { + opts.quiet = true; + continue; + } + if (arg === "--name" || arg === "--url") { + const value = argv[++i]; + if (!value || value.startsWith("-")) return { quiet: opts.quiet, error: `${arg} requires a value` }; + opts[arg.slice(2)] = value; + continue; + } + if (arg.startsWith("-") || opts.wavPath) { + return { quiet: opts.quiet, error: `Unknown argument: ${arg}` }; + } + opts.wavPath = arg; + } + + if (!opts.wavPath) return { quiet: opts.quiet, error: "play needs a WAV file" }; + return opts; +} + +/** + * @param {string[]} argv arguments after `anim` + * @returns {{ name?: string, url?: string, help: boolean, quiet: boolean, error?: string }} + */ +export function parseAnimArgs(argv) { + let help = false; + let quiet = false; + /** @type {string | undefined} */ + let url; + /** @type {string | undefined} */ + let name; + + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === "-h" || arg === "--help") { + help = true; + continue; + } + if (isQuietFlag(arg)) { + quiet = true; + continue; + } + if (arg === "--url") { + const next = argv[++i]; + if (!next) return { help, quiet, error: "--url requires a value" }; + url = next; + continue; + } + if (arg.startsWith("--url=")) { + url = arg.slice("--url=".length); + if (!url) return { help, quiet, error: "--url requires a value" }; + continue; + } + if (arg.startsWith("-")) return { help, quiet, error: `Unknown argument: ${arg}` }; + if (name) return { help, quiet, error: `Unexpected argument: ${arg}` }; + name = arg; + } + + if (!help && !name) return { help, quiet, error: "anim needs a pose name" }; + return { help, quiet, name, url }; +} diff --git a/packages/tiny-engineer-cli/src/util/output.js b/packages/tiny-engineer-cli/src/util/output.js new file mode 100644 index 00000000..e15891ba --- /dev/null +++ b/packages/tiny-engineer-cli/src/util/output.js @@ -0,0 +1,43 @@ +import { createStyle, detectColor } from "./style.js"; + +/** + * @typedef {import("./style.js").Style} Style + * + * @typedef {{ + * ok: (message: string) => void, + * info: (message: string) => void, + * error: (message: string) => void, + * style: Style, + * quiet: boolean, + * }} Output + */ + +/** + * @param {{ + * quiet?: boolean, + * style?: Style, + * log?: (...args: unknown[]) => void, + * error?: (...args: unknown[]) => void, + * }} [opts] + * @returns {Output} + */ +export function createOutput({ + quiet = false, + style = createStyle({ color: detectColor(process.stdout) }), + log = console.log.bind(console), + error = console.error.bind(console), +} = {}) { + return { + quiet, + style, + ok(message) { + if (!quiet) log(`${style.green("✓")} ${message}`); + }, + info(message) { + if (!quiet) error(message); + }, + error(message) { + error(`${style.red("✗")} error: ${message}`); + }, + }; +} diff --git a/packages/tiny-engineer-cli/src/util/stdin.js b/packages/tiny-engineer-cli/src/util/stdin.js new file mode 100644 index 00000000..87b5ce2b --- /dev/null +++ b/packages/tiny-engineer-cli/src/util/stdin.js @@ -0,0 +1,43 @@ +/** Max wait for non-TTY stdin when the pipe never emits `end` (hook JSON is tiny). */ +const STDIN_TIMEOUT_MS = 2000; + +/** + * Read stdin (or `stream`) to a string. Empty string when TTY or on error. + * Resolves with data collected so far if the pipe stays open past `timeoutMs`. + * + * @param {{ timeoutMs?: number, stream?: NodeJS.ReadableStream & { isTTY?: boolean } }} [opts] + * @returns {Promise} + */ +export function readStdin({ timeoutMs = STDIN_TIMEOUT_MS, stream = process.stdin } = {}) { + return new Promise((resolve) => { + if (stream.isTTY) { + resolve(""); + return; + } + + const chunks = []; + let settled = false; + /** @type {ReturnType | undefined} */ + let timer; + + const onData = (c) => chunks.push(c); + const onEnd = () => finish(chunks.join("")); + const onError = () => finish(""); + + function finish(value) { + if (settled) return; + settled = true; + clearTimeout(timer); + stream.off("data", onData); + stream.off("end", onEnd); + stream.off("error", onError); + resolve(value); + } + + timer = setTimeout(() => finish(chunks.join("")), timeoutMs); + stream.setEncoding("utf8"); + stream.on("data", onData); + stream.on("end", onEnd); + stream.on("error", onError); + }); +} diff --git a/packages/tiny-engineer-cli/src/util/style.js b/packages/tiny-engineer-cli/src/util/style.js new file mode 100644 index 00000000..326cefb9 --- /dev/null +++ b/packages/tiny-engineer-cli/src/util/style.js @@ -0,0 +1,46 @@ +/** + * @typedef {{ + * green: (s: string) => string, + * red: (s: string) => string, + * cyan: (s: string) => string, + * dim: (s: string) => string, + * bold: (s: string) => string, + * enabled: boolean, + * }} Style + */ + +/** + * @param {NodeJS.WriteStream} [stream] + * @returns {boolean} + */ +export function detectColor(stream = process.stdout) { + if (process.env.NO_COLOR !== undefined) return false; + if (process.env.TERM === "dumb") return false; + return Boolean(stream?.isTTY); +} + +/** + * @param {{ color?: boolean }} [opts] + * @returns {Style} + */ +export function createStyle({ color } = {}) { + const enabled = color ?? detectColor(); + + /** + * @param {number} code + * @returns {(s: string) => string} + */ + function wrap(code) { + if (!enabled) return (s) => s; + return (s) => `\x1b[${code}m${s}\x1b[0m`; + } + + return { + enabled, + green: wrap(32), + red: wrap(31), + cyan: wrap(36), + dim: wrap(2), + bold: wrap(1), + }; +} diff --git a/packages/tiny-engineer-cli/src/wizard/cliPath.js b/packages/tiny-engineer-cli/src/wizard/cliPath.js new file mode 100644 index 00000000..d3f51827 --- /dev/null +++ b/packages/tiny-engineer-cli/src/wizard/cliPath.js @@ -0,0 +1,29 @@ +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +/** Default hook command for any project (npm registry). */ +export const NPX_CLI = "npx -y tiny-engineer"; + +/** + * Absolute path to this package's `bin/tiny-engineer.js` (local checkout / CLI development). + * @returns {string} + */ +export function resolveCliBinPath() { + const here = dirname(fileURLToPath(import.meta.url)); + return join(here, "..", "..", "bin", "tiny-engineer.js"); +} + +/** + * Shell command that runs `hook `. + * Default: `npx -y tiny-engineer hook ` (published package). + * Pass `binPath` for a local `node …/tiny-engineer.js hook ` override. + * @param {string} ideId + * @param {string} [binPath] + * @returns {string} + */ +export function hookCommandFor(ideId, binPath) { + if (binPath) { + return `node ${JSON.stringify(binPath)} hook ${ideId}`; + } + return `${NPX_CLI} hook ${ideId}`; +} diff --git a/packages/tiny-engineer-cli/src/wizard/doctor.js b/packages/tiny-engineer-cli/src/wizard/doctor.js new file mode 100644 index 00000000..64380ecd --- /dev/null +++ b/packages/tiny-engineer-cli/src/wizard/doctor.js @@ -0,0 +1,109 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { DEFAULT_URL, getBaseUrl, getToken } from "../config/env.js"; +import { all as allIntegrations } from "../integrations/registry.js"; +import { robotFetch } from "../robot/client.js"; +import { readJsonFile } from "./merge.js"; + +/** + * @param {unknown} value + * @returns {string} + */ +function collectCommands(value) { + if (typeof value === "string") return value; + if (Array.isArray(value)) return value.map(collectCommands).join("\n"); + if (value && typeof value === "object") { + return Object.values(value).map(collectCommands).join("\n"); + } + return ""; +} + +/** + * @param {string} projectRoot + * @param {string} ideId + * @param {string} relativePath + * @returns {boolean} + */ +export function configHasHook(projectRoot, ideId, relativePath) { + const data = readJsonFile(join(projectRoot, relativePath)); + if (!data) return false; + const blob = collectCommands(data); + return blob.includes(`hook ${ideId}`) || blob.includes(`tiny-engineer-${ideId === "claude-code" ? "claude-code" : ideId === "cursor" ? "cursor" : "antigravity"}`); +} + +/** + * @param {string} baseUrl + * @returns {Promise<{ ok: boolean, detail: string }>} + */ +export async function checkHealth(baseUrl) { + try { + const res = await robotFetch( + baseUrl, + "/health", + { method: "GET", signal: AbortSignal.timeout(2000) }, + getToken(), + ); + const body = await res.text(); + if (res.ok) return { ok: true, detail: body.trim() || `HTTP ${res.status}` }; + return { ok: false, detail: body.trim() || `HTTP ${res.status}` }; + } catch (err) { + const detail = + err instanceof Error ? err.message : String(err); + return { ok: false, detail }; + } +} + +/** + * @param {import("../util/output.js").Output} out + * @param {string[]} argv + * @param {{ projectRoot?: string }} [opts] + */ +export async function runDoctor(out, argv, { projectRoot = process.cwd() } = {}) { + for (const arg of argv) { + if (arg === "-h" || arg === "--help") { + console.log(`Usage: tiny-engineer doctor + +Report robot URL/token, detected IDE configs, and /health. +`); + return; + } + if (arg.startsWith("-")) { + out.error(`Unknown argument: ${arg}`); + process.exitCode = 1; + return; + } + } + + const url = getBaseUrl() || DEFAULT_URL; + const token = getToken(); + + out.ok(`url: ${url}`); + out.info(`token: ${token ? "set" : "not set"}`); + + for (const integration of allIntegrations()) { + const detected = Boolean(integration.detect?.(projectRoot)); + const rel = integration.configRelativePath?.() ?? ""; + const wired = rel ? configHasHook(projectRoot, integration.id, rel) : false; + const mark = wired ? "hooks ok" : detected ? "detected, hooks missing" : "not detected"; + out.info(`${integration.id}: ${mark}${rel ? ` (${rel})` : ""}`); + } + + // Surface .env presence without printing secrets. + try { + readFileSync(join(projectRoot, ".env"), "utf8"); + out.info(".env: present"); + } catch { + out.info(".env: absent"); + } + + const health = await checkHealth(url); + if (health.ok) { + out.ok(`health: ok (${health.detail.slice(0, 80)})`); + } else { + out.error(`health: ${health.detail}`); + // Non-fatal for doctor — still exit 0 unless user expects otherwise. + // Plan: print ok/fail (not fatal). + } + process.exitCode = 0; +} diff --git a/packages/tiny-engineer-cli/src/wizard/merge.js b/packages/tiny-engineer-cli/src/wizard/merge.js new file mode 100644 index 00000000..7bf596b8 --- /dev/null +++ b/packages/tiny-engineer-cli/src/wizard/merge.js @@ -0,0 +1,91 @@ +import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname } from "node:path"; + +/** + * @param {string} path + * @returns {object | null} + */ +export function readJsonFile(path) { + if (!existsSync(path)) return null; + try { + const raw = readFileSync(path, "utf8"); + const parsed = JSON.parse(raw); + return parsed && typeof parsed === "object" ? parsed : null; + } catch { + return null; + } +} + +/** + * Write JSON; create parent dirs; backup once to `path.bak` if file exists and bak does not. + * @param {string} path + * @param {object} data + */ +export function writeJsonFile(path, data) { + mkdirSync(dirname(path), { recursive: true }); + if (existsSync(path) && !existsSync(`${path}.bak`)) { + copyFileSync(path, `${path}.bak`); + } + writeFileSync(path, `${JSON.stringify(data, null, 2)}\n`, "utf8"); +} + +/** + * Merge TINY_ENGINEER_URL into a `.env` file. + * @param {string} envPath + * @param {string} url + * @param {{ force?: boolean }} [opts] + * @returns {"created" | "updated" | "skipped"} + */ +export function mergeEnvUrl(envPath, url, { force = false } = {}) { + mkdirSync(dirname(envPath), { recursive: true }); + let raw = ""; + if (existsSync(envPath)) { + raw = readFileSync(envPath, "utf8"); + } + + const lines = raw ? raw.split(/\r?\n/) : []; + let found = false; + const next = lines.map((line) => { + if (!/^\s*TINY_ENGINEER_URL\s*=/.test(line)) return line; + found = true; + if (!force) return line; + return `TINY_ENGINEER_URL=${url}`; + }); + + if (found && !force) return "skipped"; + + if (!found) { + if (next.length && next[next.length - 1] !== "") next.push(""); + next.push(`TINY_ENGINEER_URL=${url}`); + writeFileSync(envPath, `${next.join("\n").replace(/\n+$/, "\n")}`, "utf8"); + return raw ? "updated" : "created"; + } + + writeFileSync(envPath, `${next.join("\n").replace(/\n+$/, "\n")}`, "utf8"); + return "updated"; +} + +const ENV_TE_LINE = /^\s*TINY_ENGINEER_(URL|TOKEN)\s*=/; + +/** + * Remove TINY_ENGINEER_URL / TINY_ENGINEER_TOKEN lines from `.env`. + * Leaves the file if other content remains; does not delete a non-empty file. + * @param {string} envPath + * @returns {"removed" | "absent" | "unchanged"} + */ +export function stripEnvTinyEngineer(envPath) { + if (!existsSync(envPath)) return "absent"; + const raw = readFileSync(envPath, "utf8"); + const lines = raw.split(/\r?\n/); + const kept = lines.filter((line) => !ENV_TE_LINE.test(line)); + if (kept.length === lines.length) return "unchanged"; + + // Trim trailing empty lines but keep a final newline when content remains. + while (kept.length > 0 && kept[kept.length - 1] === "") kept.pop(); + if (kept.length === 0) { + writeFileSync(envPath, "", "utf8"); + } else { + writeFileSync(envPath, `${kept.join("\n")}\n`, "utf8"); + } + return "removed"; +} diff --git a/packages/tiny-engineer-cli/src/wizard/prompt.js b/packages/tiny-engineer-cli/src/wizard/prompt.js new file mode 100644 index 00000000..43eef6a2 --- /dev/null +++ b/packages/tiny-engineer-cli/src/wizard/prompt.js @@ -0,0 +1,56 @@ +import { createInterface } from "node:readline/promises"; +import { stdin as input, stdout as output } from "node:process"; + +/** + * @param {string} question + * @param {string} [defaultValue] + * @returns {Promise} + */ +export async function ask(question, defaultValue = "") { + const rl = createInterface({ input, output }); + try { + const hint = defaultValue ? ` [${defaultValue}]` : ""; + const answer = (await rl.question(`${question}${hint}: `)).trim(); + return answer || defaultValue; + } finally { + rl.close(); + } +} + +/** + * @param {string} question + * @param {boolean} [defaultYes] + * @returns {Promise} + */ +export async function confirm(question, defaultYes = true) { + const suffix = defaultYes ? "Y/n" : "y/N"; + const answer = (await ask(`${question} (${suffix})`, defaultYes ? "y" : "n")).toLowerCase(); + if (!answer) return defaultYes; + return answer === "y" || answer === "yes"; +} + +/** + * Numbered multi-select. Empty enter selects all. + * @param {string} title + * @param {{ id: string, label: string }[]} options + * @returns {Promise} selected ids + */ +export async function multiSelect(title, options) { + if (options.length === 0) return []; + console.log(title); + options.forEach((opt, i) => { + console.log(` ${i + 1}) ${opt.label} (${opt.id})`); + }); + const raw = await ask("Select numbers (comma-separated, Enter = all)", ""); + if (!raw) return options.map((o) => o.id); + + /** @type {Set} */ + const selected = new Set(); + for (const part of raw.split(/[,\s]+/)) { + const n = Number(part); + if (Number.isInteger(n) && n >= 1 && n <= options.length) { + selected.add(options[n - 1].id); + } + } + return selected.size > 0 ? [...selected] : options.map((o) => o.id); +} diff --git a/packages/tiny-engineer-cli/src/wizard/setup.js b/packages/tiny-engineer-cli/src/wizard/setup.js new file mode 100644 index 00000000..eaeef932 --- /dev/null +++ b/packages/tiny-engineer-cli/src/wizard/setup.js @@ -0,0 +1,274 @@ +import { existsSync } from "node:fs"; +import { join } from "node:path"; + +import { DEFAULT_URL, getBaseUrl } from "../config/env.js"; +import { all as allIntegrations, get as getIntegration } from "../integrations/registry.js"; +import { hookCommandFor } from "./cliPath.js"; +import { mergeEnvUrl, readJsonFile, writeJsonFile } from "./merge.js"; +import { ask, confirm, multiSelect } from "./prompt.js"; + +/** Cold `npx` can exceed a few seconds; keep hook timeout comfortable. */ +const DEFAULT_TIMEOUT = 30; + +/** + * @param {string[]} argv + * @returns {{ + * help: boolean, + * yes: boolean, + * all: boolean, + * dryRun: boolean, + * forceUrl: boolean, + * url?: string, + * command?: string, + * ides: string[], + * error?: string, + * }} + */ +export function parseSetupArgs(argv) { + let help = false; + let yes = false; + let all = false; + let dryRun = false; + let forceUrl = false; + /** @type {string | undefined} */ + let url; + /** @type {string | undefined} */ + let command; + /** @type {string[]} */ + const ides = []; + + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === "-h" || arg === "--help") { + help = true; + continue; + } + if (arg === "--yes" || arg === "-y") { + yes = true; + continue; + } + if (arg === "--all") { + all = true; + continue; + } + if (arg === "--dry-run") { + dryRun = true; + continue; + } + if (arg === "--force-url") { + forceUrl = true; + continue; + } + if (arg === "--url") { + const next = argv[++i]; + if (!next) return { help, yes, all, dryRun, forceUrl, ides, error: "--url requires a value" }; + url = next; + continue; + } + if (arg.startsWith("--url=")) { + url = arg.slice("--url=".length); + if (!url) return { help, yes, all, dryRun, forceUrl, ides, error: "--url requires a value" }; + continue; + } + if (arg === "--command") { + const next = argv[++i]; + if (!next) return { help, yes, all, dryRun, forceUrl, ides, error: "--command requires a value" }; + command = next; + continue; + } + if (arg.startsWith("--command=")) { + command = arg.slice("--command=".length); + if (!command) return { help, yes, all, dryRun, forceUrl, ides, error: "--command requires a value" }; + continue; + } + if (arg.startsWith("-")) { + return { help, yes, all, dryRun, forceUrl, ides, error: `Unknown argument: ${arg}` }; + } + ides.push(arg); + } + + return { help, yes, all, dryRun, forceUrl, url, command, ides }; +} + +/** + * @param {string} projectRoot + * @returns {{ id: string, name: string, detected: boolean }[]} + */ +export function detectIntegrations(projectRoot) { + return allIntegrations().map((integration) => ({ + id: integration.id, + name: integration.name, + detected: Boolean(integration.detect?.(projectRoot)), + })); +} + +/** + * @param {import("../util/output.js").Output} out + * @param {string[]} argv + * @param {{ projectRoot?: string }} [opts] + */ +export async function runSetup(out, argv, { projectRoot = process.cwd() } = {}) { + const args = parseSetupArgs(argv); + if (args.error) { + out.error(args.error); + process.exitCode = 1; + return; + } + if (args.help) { + printSetupHelp(); + return; + } + + if (args.yes && !args.all && args.ides.length === 0) { + out.error("setup --yes requires --all or at least one ide id (cursor, claude-code, antigravity)"); + process.exitCode = 1; + return; + } + + for (const id of args.ides) { + if (!getIntegration(id)) { + out.error(`Unknown integration: ${id} (available: ${allIntegrations().map((i) => i.id).join(", ")})`); + process.exitCode = 1; + return; + } + } + + const detected = detectIntegrations(projectRoot); + /** @type {string[]} */ + let selected; + + if (args.all) { + selected = allIntegrations().map((i) => i.id); + } else if (args.ides.length > 0) { + selected = args.ides; + } else if (args.yes) { + selected = []; + } else { + const detectedOpts = detected.filter((d) => d.detected); + const choices = + detectedOpts.length > 0 + ? detectedOpts.map((d) => ({ id: d.id, label: d.name })) + : detected.map((d) => ({ id: d.id, label: d.name })); + selected = await multiSelect("Install Tiny Engineer hooks for:", choices); + } + + if (selected.length === 0) { + out.error("no integrations selected"); + process.exitCode = 1; + return; + } + + let url = args.url; + if (!args.yes) { + url = await ask("Robot base URL", url || getBaseUrl() || DEFAULT_URL); + } else { + url = url || getBaseUrl(); + } + + if (!args.yes) { + const ok = await confirm(`Write hook configs for: ${selected.join(", ")}?`, true); + if (!ok) { + out.info("setup cancelled"); + process.exitCode = 0; + return; + } + } + + /** @type {import("../integrations/types.js").InstallPlan[]} */ + const plans = []; + + for (const id of selected) { + const integration = getIntegration(id); + if (!integration?.planInstall || !integration.applyInstall) { + out.error(`integration ${id} does not support setup`); + process.exitCode = 1; + return; + } + + const cliCommand = args.command + ? `${args.command} hook ${id}` + : hookCommandFor(id); + + const ctx = { + projectRoot, + cliCommand, + timeout: DEFAULT_TIMEOUT, + url, + }; + plans.push(integration.planInstall(ctx)); + } + + if (args.dryRun) { + out.ok("dry-run — no files written"); + for (const plan of plans) { + out.info(`${plan.relativePath}`); + out.info(` events: ${plan.events.join(", ")}`); + out.info(` command: ${plan.commandPreview}`); + } + if (args.url || url) { + out.info(`.env TINY_ENGINEER_URL=${url}${args.forceUrl ? " (force)" : " (if unset)"}`); + } + process.exitCode = 0; + return; + } + + for (const id of selected) { + const integration = getIntegration(id); + const cliCommand = args.command + ? `${args.command} hook ${id}` + : hookCommandFor(id); + const ctx = { + projectRoot, + cliCommand, + timeout: DEFAULT_TIMEOUT, + url, + }; + const rel = integration.configRelativePath(); + const abs = join(projectRoot, rel); + const existing = readJsonFile(abs); + if (existing === null && existsSync(abs)) { + out.error(`${rel} is not valid JSON; fix it before running setup`); + process.exitCode = 1; + return; + } + const merged = integration.applyInstall(ctx, existing); + writeJsonFile(abs, merged); + out.ok(`wrote ${rel}`); + } + + if (url) { + const envPath = join(projectRoot, ".env"); + const result = mergeEnvUrl(envPath, url, { force: args.forceUrl }); + if (result === "skipped") { + out.info(".env TINY_ENGINEER_URL already set (use --force-url to replace)"); + } else { + out.ok(`.env TINY_ENGINEER_URL ${result}`); + } + } + + out.ok("setup complete — run: tiny-engineer doctor"); + process.exitCode = 0; +} + +function printSetupHelp() { + console.log(`Usage: tiny-engineer setup [ide...] [options] + +Merge Tiny Engineer hook commands into the current project. + +Options: + --all Install all integrations (cursor, claude-code, antigravity) + --yes, -y Non-interactive (requires --all or ide args) + --dry-run Print plan without writing files + --url Robot URL; also merge into project .env when unset + --force-url Overwrite existing TINY_ENGINEER_URL in .env + --command Override CLI launcher (default: npx -y tiny-engineer) + -h, --help Show this help + +Examples: + npx -y tiny-engineer setup + npx -y tiny-engineer setup cursor --yes + npx -y tiny-engineer setup --all --yes --url http://192.168.1.10 + tiny-engineer setup --dry-run + tiny-engineer setup cursor --yes --command 'node packages/tiny-engineer-cli/bin/tiny-engineer.js' +`); +} diff --git a/packages/tiny-engineer-cli/src/wizard/uninstall.js b/packages/tiny-engineer-cli/src/wizard/uninstall.js new file mode 100644 index 00000000..4c2cf935 --- /dev/null +++ b/packages/tiny-engineer-cli/src/wizard/uninstall.js @@ -0,0 +1,217 @@ +import { join } from "node:path"; + +import { all as allIntegrations, get as getIntegration } from "../integrations/registry.js"; +import { readJsonFile, stripEnvTinyEngineer, writeJsonFile } from "./merge.js"; +import { confirm, multiSelect } from "./prompt.js"; + +/** + * @param {string[]} argv + * @returns {{ + * help: boolean, + * yes: boolean, + * all: boolean, + * dryRun: boolean, + * purgeEnv: boolean, + * ides: string[], + * error?: string, + * }} + */ +export function parseUninstallArgs(argv) { + let help = false; + let yes = false; + let all = false; + let dryRun = false; + let purgeEnv = false; + /** @type {string[]} */ + const ides = []; + + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === "-h" || arg === "--help") { + help = true; + continue; + } + if (arg === "--yes" || arg === "-y") { + yes = true; + continue; + } + if (arg === "--all") { + all = true; + continue; + } + if (arg === "--dry-run") { + dryRun = true; + continue; + } + if (arg === "--purge-env") { + purgeEnv = true; + continue; + } + if (arg.startsWith("-")) { + return { help, yes, all, dryRun, purgeEnv, ides, error: `Unknown argument: ${arg}` }; + } + ides.push(arg); + } + + return { help, yes, all, dryRun, purgeEnv, ides }; +} + +/** + * @param {import("../util/output.js").Output} out + * @param {string[]} argv + * @param {{ projectRoot?: string }} [opts] + */ +export async function runUninstall(out, argv, { projectRoot = process.cwd() } = {}) { + const args = parseUninstallArgs(argv); + if (args.error) { + out.error(args.error); + process.exitCode = 1; + return; + } + if (args.help) { + printUninstallHelp(); + return; + } + + if (args.yes && !args.all && args.ides.length === 0) { + out.error("uninstall --yes requires --all or at least one ide id"); + process.exitCode = 1; + return; + } + + for (const id of args.ides) { + if (!getIntegration(id)) { + out.error( + `Unknown integration: ${id} (available: ${allIntegrations().map((i) => i.id).join(", ")})`, + ); + process.exitCode = 1; + return; + } + } + + /** @type {string[]} */ + let selected; + if (args.all) { + selected = allIntegrations().map((i) => i.id); + } else if (args.ides.length > 0) { + selected = args.ides; + } else { + const withHooks = allIntegrations() + .filter((i) => i.planUninstall) + .map((i) => { + const plan = i.planUninstall(projectRoot); + return { id: i.id, name: i.name, removeCount: plan.removeCount }; + }) + .filter((p) => p.removeCount > 0); + + if (withHooks.length === 0) { + out.info("no Tiny Engineer hooks found in this project"); + if (args.purgeEnv) { + // still allow interactive purge? skip without --yes path for env-only + } + selected = []; + } else { + selected = await multiSelect( + "Remove Tiny Engineer hooks for:", + withHooks.map((p) => ({ + id: p.id, + label: `${p.name} (${p.removeCount})`, + })), + ); + } + } + + /** @type {import("../integrations/types.js").UninstallPlan[]} */ + const plans = []; + for (const id of selected) { + const integration = getIntegration(id); + if (!integration?.planUninstall || !integration.applyUninstall) { + out.error(`integration ${id} does not support uninstall`); + process.exitCode = 1; + return; + } + plans.push(integration.planUninstall(projectRoot)); + } + + const total = plans.reduce((n, p) => n + p.removeCount, 0); + + if (args.dryRun) { + out.ok("dry-run — no files written"); + if (plans.length === 0) { + out.info("no integrations selected"); + } + for (const plan of plans) { + out.info(`${plan.relativePath}: remove ${plan.detail}`); + } + if (args.purgeEnv) { + out.info(".env: strip TINY_ENGINEER_URL / TINY_ENGINEER_TOKEN"); + } + process.exitCode = 0; + return; + } + + if (total === 0 && !args.purgeEnv) { + out.ok("nothing to remove"); + process.exitCode = 0; + return; + } + + if (!args.yes) { + const labels = + plans.length > 0 + ? plans.map((p) => `${p.ide} (${p.removeCount})`).join(", ") + : "(hooks none)"; + const envNote = args.purgeEnv ? " + purge .env Tiny Engineer keys" : ""; + const ok = await confirm(`Remove ${labels}${envNote}?`, true); + if (!ok) { + out.info("uninstall cancelled"); + process.exitCode = 0; + return; + } + } + + for (const plan of plans) { + if (plan.removeCount === 0) { + out.info(`${plan.relativePath}: nothing to remove`); + continue; + } + const integration = getIntegration(plan.ide); + const existing = readJsonFile(plan.path); + const next = integration.applyUninstall(existing); + writeJsonFile(plan.path, next); + out.ok(`updated ${plan.relativePath} (removed ${plan.detail})`); + } + + if (args.purgeEnv) { + const envPath = join(projectRoot, ".env"); + const result = stripEnvTinyEngineer(envPath); + if (result === "removed") out.ok(".env: removed TINY_ENGINEER_* keys"); + else if (result === "absent") out.info(".env: absent"); + else out.info(".env: no TINY_ENGINEER_* keys"); + } + + out.ok("uninstall complete"); + process.exitCode = 0; +} + +function printUninstallHelp() { + console.log(`Usage: tiny-engineer uninstall [ide...] [options] + +Remove Tiny Engineer hook entries from the current project. +Only commands matching Tiny Engineer markers are removed; other hooks stay. +Config files are never deleted. + +Options: + --all Target all integrations + --yes, -y Non-interactive (requires --all or ide args) + --dry-run Print plan without writing files + --purge-env Also remove TINY_ENGINEER_URL / TINY_ENGINEER_TOKEN from .env + -h, --help Show this help + +Examples: + tiny-engineer uninstall + tiny-engineer uninstall cursor --yes + tiny-engineer uninstall --all --yes --dry-run + tiny-engineer uninstall --all --yes --purge-env +`); +} diff --git a/packages/tiny-engineer-cli/test/args.test.js b/packages/tiny-engineer-cli/test/args.test.js new file mode 100644 index 00000000..fdc91ee2 --- /dev/null +++ b/packages/tiny-engineer-cli/test/args.test.js @@ -0,0 +1,79 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + parseAnimArgs, + parseCommonArgs, + parsePlayArgs, + stripGlobalFlags, +} from "../src/util/args.js"; + +test("parseCommonArgs extracts --url and rest", () => { + assert.deepEqual(parseCommonArgs(["--url", "http://x", "cursor"]), { + help: false, + quiet: false, + url: "http://x", + rest: ["cursor"], + }); + assert.deepEqual(parseCommonArgs(["--url=http://y", "cursor", "extra"]), { + help: false, + quiet: false, + url: "http://y", + rest: ["cursor", "extra"], + }); +}); + +test("parseCommonArgs help and missing --url value", () => { + assert.equal(parseCommonArgs(["-h"]).help, true); + assert.equal(parseCommonArgs(["--url"]).error, "--url requires a value"); +}); + +test("parseCommonArgs accepts quiet before or after ide id", () => { + assert.equal(parseCommonArgs(["cursor", "-q"]).quiet, true); + assert.deepEqual(parseCommonArgs(["cursor", "-q"]).rest, ["cursor"]); + assert.equal(parseCommonArgs(["-q", "cursor"]).quiet, true); + assert.deepEqual(parseCommonArgs(["-q", "cursor"]).rest, ["cursor"]); + assert.equal(parseCommonArgs(["--silent", "--url", "http://x", "cursor"]).quiet, true); +}); + +test("parseAnimArgs requires name", () => { + assert.equal(parseAnimArgs([]).error, "anim needs a pose name"); + assert.deepEqual(parseAnimArgs(["ring", "--url", "http://x"]), { + help: false, + quiet: false, + name: "ring", + url: "http://x", + }); +}); + +test("parseAnimArgs accepts -q after name", () => { + assert.equal(parseAnimArgs(["ring", "-q"]).quiet, true); + assert.equal(parseAnimArgs(["--quiet", "ring"]).quiet, true); +}); + +test("parsePlayArgs requires wav path", () => { + assert.equal(parsePlayArgs([]).error, "play needs a WAV file"); + assert.deepEqual(parsePlayArgs(["clip.wav", "--name", "thinking"]), { + wavPath: "clip.wav", + name: "thinking", + quiet: false, + }); +}); + +test("parsePlayArgs accepts --silent", () => { + assert.equal(parsePlayArgs(["clip.wav", "--silent"]).quiet, true); +}); + +test("stripGlobalFlags pulls leading quiet", () => { + assert.deepEqual(stripGlobalFlags(["-q", "anim", "ring"]), { + quiet: true, + rest: ["anim", "ring"], + }); + assert.deepEqual(stripGlobalFlags(["--quiet", "--silent", "play", "a.wav"]), { + quiet: true, + rest: ["play", "a.wav"], + }); + assert.deepEqual(stripGlobalFlags(["anim", "ring"]), { + quiet: false, + rest: ["anim", "ring"], + }); +}); diff --git a/packages/tiny-engineer-cli/test/client.test.js b/packages/tiny-engineer-cli/test/client.test.js new file mode 100644 index 00000000..decb62ae --- /dev/null +++ b/packages/tiny-engineer-cli/test/client.test.js @@ -0,0 +1,113 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + ANIM_NAMES, + formatRobotError, + PLAY_ANIM_NAMES, + postAnim, + postPlay, + validateAnimName, + validatePlayAnimName, +} from "../src/robot/client.js"; + +/** @type {typeof fetch | undefined} */ +let originalFetch; + +test.beforeEach(() => { + originalFetch = globalThis.fetch; +}); + +test.afterEach(() => { + globalThis.fetch = originalFetch; +}); + +/** + * @param {number} status + * @param {string} body + */ +function mockFetchResponse(status, body) { + globalThis.fetch = async () => + /** @type {Response} */ ({ + ok: status >= 200 && status < 300, + status, + text: async () => body, + }); +} + +test("validateAnimName accepts firmware names", () => { + assert.equal(validateAnimName("ring"), null); + assert.equal(validateAnimName("typing"), null); + assert.ok(ANIM_NAMES.has("sleep")); +}); + +test("validateAnimName rejects unknown names", () => { + const err = validateAnimName("foobar"); + assert.ok(err?.startsWith("unknown animation: foobar")); + assert.ok(err?.includes("ring")); +}); + +test("validatePlayAnimName accepts play path names", () => { + assert.equal(validatePlayAnimName("talking"), null); + assert.equal(validatePlayAnimName("none"), null); + assert.ok(PLAY_ANIM_NAMES.has("thinking")); +}); + +test("validatePlayAnimName rejects ring", () => { + const err = validatePlayAnimName("ring"); + assert.ok(err?.startsWith("unknown play animation: ring")); +}); + +test("postAnim 200 → ok", async () => { + mockFetchResponse(200, '{"ok":true,"animation":"ring"}'); + const result = await postAnim("http://robot.test", "ring"); + assert.equal(result.ok, true); + assert.equal(result.status, 200); + assert.equal(result.body, '{"ok":true,"animation":"ring"}'); +}); + +test("postAnim 400 unknown animation → ok false with parsed error", async () => { + mockFetchResponse(400, '{"ok":false,"error":"unknown animation"}'); + const result = await postAnim("http://robot.test", "nope"); + assert.equal(result.ok, false); + assert.equal(result.status, 400); + assert.equal(result.error, "unknown animation"); + assert.equal(formatRobotError(result), "unknown animation"); +}); + +test("postAnim fetch throw → unreachable", async () => { + globalThis.fetch = async () => { + throw new Error("connect ECONNREFUSED"); + }; + const result = await postAnim("http://robot.test", "ring"); + assert.equal(result.ok, false); + assert.ok(result.error?.startsWith("robot unreachable:")); + assert.ok(result.error?.includes("ECONNREFUSED")); +}); + +test("postPlay 415 → ok false with body error", async () => { + mockFetchResponse(415, '{"ok":false,"error":"expected 16-bit mono PCM at 22050 Hz"}'); + const result = await postPlay({ + baseUrl: "http://robot.test", + wav: new Uint8Array([1, 2, 3]), + }); + assert.equal(result.ok, false); + assert.equal(result.error, "expected 16-bit mono PCM at 22050 Hz"); +}); + +test("postPlay network error → unreachable", async () => { + globalThis.fetch = async () => { + throw new Error("timed out"); + }; + const result = await postPlay({ + baseUrl: "http://robot.test", + wav: new Uint8Array([1]), + name: "talking", + }); + assert.equal(result.ok, false); + assert.equal(formatRobotError(result), "robot unreachable: timed out"); +}); + +test("formatRobotError falls back to HTTP status", () => { + assert.equal(formatRobotError({ ok: false, status: 503 }), "HTTP 503"); + assert.equal(formatRobotError({ ok: false }), "robot request failed"); +}); diff --git a/packages/tiny-engineer-cli/test/cursor-files.test.js b/packages/tiny-engineer-cli/test/cursor-files.test.js new file mode 100644 index 00000000..aa0ad69a --- /dev/null +++ b/packages/tiny-engineer-cli/test/cursor-files.test.js @@ -0,0 +1,101 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { formatFileModifications } from "../src/integrations/cursor/files.js"; +import { createStyle } from "../src/util/style.js"; + +const plain = createStyle({ color: false }); + +test("afterFileEdit formats path and edits", () => { + const lines = formatFileModifications( + { + hook_event_name: "afterFileEdit", + file_path: "/proj/src/a.js", + edits: [{ old_string: "x", new_string: "y" }], + }, + plain, + ); + assert.equal(lines[0], "✎ modified: /proj/src/a.js (1 edit)"); + assert.equal(lines[1], ' − "x" → + "y"'); +}); + +test("afterFileEdit with no edits still headers", () => { + const lines = formatFileModifications( + { hook_event_name: "afterFileEdit", file_path: "/f.ts", edits: [] }, + plain, + ); + assert.deepEqual(lines, ["✎ modified: /f.ts (0 edits)"]); +}); + +test("missing file_path yields nothing", () => { + assert.deepEqual( + formatFileModifications({ hook_event_name: "afterFileEdit" }, plain), + [], + ); +}); + +test("truncates long edit sides", () => { + const long = "a".repeat(80); + const lines = formatFileModifications( + { + hook_event_name: "afterTabFileEdit", + file_path: "/x", + edits: [{ old_string: long, new_string: "b" }], + }, + plain, + ); + assert.ok(lines[1].includes("…")); + assert.ok(lines[1].length < 200); +}); + +test("collapses newlines in edits", () => { + const lines = formatFileModifications( + { + hook_event_name: "afterFileEdit", + file_path: "/x", + edits: [{ old_string: "a\nb", new_string: "c\nd" }], + }, + plain, + ); + assert.equal(lines[1], ' − "a\\nb" → + "c\\nd"'); +}); + +test("preToolUse Write reports touching path", () => { + const lines = formatFileModifications( + { + hook_event_name: "preToolUse", + tool_name: "Write", + tool_input: { file_path: "/proj/new.js" }, + }, + plain, + ); + assert.deepEqual(lines, ["✎ touching: /proj/new.js"]); +}); + +test("preToolUse Read reports nothing", () => { + assert.deepEqual( + formatFileModifications( + { + hook_event_name: "preToolUse", + tool_name: "Read", + tool_input: { file_path: "/proj/a.js" }, + }, + plain, + ), + [], + ); +}); + +test("color style wraps icons", () => { + const style = createStyle({ color: true }); + const lines = formatFileModifications( + { + hook_event_name: "afterFileEdit", + file_path: "/a", + edits: [{ old_string: "1", new_string: "2" }], + }, + style, + ); + assert.ok(lines[0].includes("\x1b[36m")); + assert.ok(lines[1].includes("\x1b[31m")); + assert.ok(lines[1].includes("\x1b[32m")); +}); diff --git a/packages/tiny-engineer-cli/test/doctor.test.js b/packages/tiny-engineer-cli/test/doctor.test.js new file mode 100644 index 00000000..8508f249 --- /dev/null +++ b/packages/tiny-engineer-cli/test/doctor.test.js @@ -0,0 +1,81 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createOutput } from "../src/util/output.js"; +import { createStyle } from "../src/util/style.js"; +import { checkHealth, configHasHook, runDoctor } from "../src/wizard/doctor.js"; + +test("configHasHook finds hook cursor command", () => { + const dir = mkdtempSync(join(tmpdir(), "te-doc-")); + try { + mkdirSync(join(dir, ".cursor"), { recursive: true }); + writeFileSync( + join(dir, ".cursor", "hooks.json"), + JSON.stringify({ + version: 1, + hooks: { stop: [{ command: 'node "/x" hook cursor', timeout: 5 }] }, + }), + "utf8", + ); + assert.equal(configHasHook(dir, "cursor", ".cursor/hooks.json"), true); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("checkHealth returns ok on 200", async () => { + const original = globalThis.fetch; + globalThis.fetch = async () => + /** @type {Response} */ ({ + ok: true, + status: 200, + text: async () => '{"ok":true}', + }); + try { + const result = await checkHealth("http://robot.test"); + assert.equal(result.ok, true); + } finally { + globalThis.fetch = original; + } +}); + +test("checkHealth returns fail on throw", async () => { + const original = globalThis.fetch; + globalThis.fetch = async () => { + throw new Error("offline"); + }; + try { + const result = await checkHealth("http://robot.test"); + assert.equal(result.ok, false); + assert.ok(result.detail.includes("offline")); + } finally { + globalThis.fetch = original; + } +}); + +test("runDoctor prints without throwing", async () => { + const dir = mkdtempSync(join(tmpdir(), "te-doc-")); + const original = globalThis.fetch; + globalThis.fetch = async () => { + throw new Error("offline"); + }; + /** @type {string[]} */ + const lines = []; + const out = createOutput({ + quiet: false, + style: createStyle({ color: false }), + log: (m) => lines.push(String(m)), + error: (m) => lines.push(String(m)), + }); + try { + await runDoctor(out, [], { projectRoot: dir }); + assert.ok(lines.some((l) => l.includes("url:"))); + assert.ok(lines.some((l) => l.includes("cursor:"))); + } finally { + globalThis.fetch = original; + rmSync(dir, { recursive: true, force: true }); + process.exitCode = 0; + } +}); diff --git a/packages/tiny-engineer-cli/test/install-antigravity.test.js b/packages/tiny-engineer-cli/test/install-antigravity.test.js new file mode 100644 index 00000000..bd9f5d02 --- /dev/null +++ b/packages/tiny-engineer-cli/test/install-antigravity.test.js @@ -0,0 +1,29 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { applyInstall, commandForEvent } from "../src/integrations/antigravity/install.js"; + +const ctx = { + projectRoot: "/proj", + cliCommand: 'node "/x/bin/tiny-engineer.js" hook antigravity', + timeout: 5, +}; + +test("commandForEvent appends hook type", () => { + assert.equal( + commandForEvent(ctx.cliCommand, "PreToolUse"), + `${ctx.cliCommand} PreToolUse`, + ); +}); + +test("applyInstall writes tiny-engineer block with matcher hooks", () => { + const once = applyInstall(ctx, { other: true }); + assert.equal(once.other, true); + const block = once["tiny-engineer"]; + assert.ok(block.PreInvocation[0].command.endsWith("PreInvocation")); + assert.equal(block.PreToolUse[0].matcher, "*"); + assert.ok(block.PreToolUse[0].hooks[0].command.endsWith("PreToolUse")); + assert.equal(block.Stop[0].timeout, 5); + + const twice = applyInstall(ctx, once); + assert.equal(twice["tiny-engineer"].Stop.length, 1); +}); diff --git a/packages/tiny-engineer-cli/test/install-claude-code.test.js b/packages/tiny-engineer-cli/test/install-claude-code.test.js new file mode 100644 index 00000000..e3df6da5 --- /dev/null +++ b/packages/tiny-engineer-cli/test/install-claude-code.test.js @@ -0,0 +1,27 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { applyInstall } from "../src/integrations/claude-code/install.js"; + +const ctx = { + projectRoot: "/proj", + cliCommand: 'node "/x/bin/tiny-engineer.js" hook claude-code', + timeout: 5, +}; + +test("applyInstall writes async command shape and is idempotent", () => { + const once = applyInstall(ctx, null); + const stop = once.hooks.Stop; + assert.ok(Array.isArray(stop)); + assert.equal(stop[0].hooks[0].type, "command"); + assert.equal(stop[0].hooks[0].async, true); + assert.ok(stop[0].hooks[0].command.includes("hook claude-code")); + + const twice = applyInstall(ctx, once); + assert.equal(twice.hooks.Stop.length, 1); +}); + +test("applyInstall keeps unrelated settings keys", () => { + const once = applyInstall(ctx, { permissions: { allow: ["Bash"] }, hooks: {} }); + assert.deepEqual(once.permissions, { allow: ["Bash"] }); + assert.ok(once.hooks.SessionStart); +}); diff --git a/packages/tiny-engineer-cli/test/install-cursor.test.js b/packages/tiny-engineer-cli/test/install-cursor.test.js new file mode 100644 index 00000000..dd5962e8 --- /dev/null +++ b/packages/tiny-engineer-cli/test/install-cursor.test.js @@ -0,0 +1,35 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { applyInstall, planInstall } from "../src/integrations/cursor/install.js"; + +const ctx = { + projectRoot: "/proj", + cliCommand: 'node "/proj/bin/tiny-engineer.js" hook cursor', + timeout: 5, +}; + +test("planInstall lists cursor events", () => { + const plan = planInstall(ctx); + assert.equal(plan.ide, "cursor"); + assert.ok(plan.events.includes("stop")); + assert.ok(plan.events.includes("afterFileEdit")); + assert.equal(plan.commandPreview, ctx.cliCommand); +}); + +test("applyInstall preserves foreign hooks and is idempotent", () => { + const existing = { + version: 1, + hooks: { + sessionStart: [{ command: ".cursor/hooks/log-event.sh sessionStart", timeout: 2 }], + }, + }; + const once = applyInstall(ctx, existing); + assert.equal(once.hooks.sessionStart.length, 2); + assert.ok(once.hooks.sessionStart.some((h) => h.command.includes("log-event"))); + assert.ok(once.hooks.sessionStart.some((h) => h.command.includes("hook cursor"))); + assert.equal(once.hooks.stop[0].timeout, 5); + + const twice = applyInstall(ctx, once); + assert.equal(twice.hooks.sessionStart.length, 2); + assert.equal(twice.hooks.stop.length, 1); +}); diff --git a/packages/tiny-engineer-cli/test/map-antigravity.test.js b/packages/tiny-engineer-cli/test/map-antigravity.test.js new file mode 100644 index 00000000..605b097c --- /dev/null +++ b/packages/tiny-engineer-cli/test/map-antigravity.test.js @@ -0,0 +1,77 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { formatFileModifications } from "../src/integrations/antigravity/files.js"; +import { animationForEvent } from "../src/integrations/antigravity/map.js"; +import { createStyle } from "../src/util/style.js"; + +test("PreInvocation maps to thinking", () => { + assert.equal(animationForEvent("PreInvocation"), "thinking"); +}); + +test("PreToolUse maps reading tools to reading", () => { + assert.equal( + animationForEvent("PreToolUse", { toolCall: { name: "view_file" } }), + "reading" + ); + assert.equal( + animationForEvent("PreToolUse", { toolCall: { name: "grep_search" } }), + "reading" + ); +}); + +test("PreToolUse maps typing tools to typing", () => { + assert.equal( + animationForEvent("PreToolUse", { toolCall: { name: "write_to_file" } }), + "typing" + ); + assert.equal( + animationForEvent("PreToolUse", { toolCall: { name: "replace_file_content" } }), + "typing" + ); + assert.equal( + animationForEvent("PreToolUse", { toolCall: { name: "run_command" } }), + "typing" + ); +}); + +test("PostToolUse maps error to attention", () => { + assert.equal( + animationForEvent("PostToolUse", { error: "command failed" }), + "attention" + ); + assert.equal(animationForEvent("PostToolUse", {}), null); +}); + +test("Stop maps model_stop to ring", () => { + assert.equal( + animationForEvent("Stop", { terminationReason: "model_stop" }), + "ring" + ); +}); + +test("Stop maps error to error", () => { + assert.equal( + animationForEvent("Stop", { terminationReason: "error" }), + "error" + ); + assert.equal( + animationForEvent("Stop", { error: "fatal failure" }), + "error" + ); +}); + +test("Stop maps abort to abort", () => { + assert.equal( + animationForEvent("Stop", { terminationReason: "aborted" }), + "abort" + ); +}); + +test("describeFiles reports write_to_file path", () => { + const lines = formatFileModifications( + "PreToolUse", + { toolCall: { name: "write_to_file", path: "/proj/b.ts" } }, + createStyle({ color: false }), + ); + assert.deepEqual(lines, ["✎ touching: /proj/b.ts"]); +}); diff --git a/packages/tiny-engineer-cli/test/map-claude-code.test.js b/packages/tiny-engineer-cli/test/map-claude-code.test.js new file mode 100644 index 00000000..dce7f116 --- /dev/null +++ b/packages/tiny-engineer-cli/test/map-claude-code.test.js @@ -0,0 +1,122 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { formatFileModifications } from "../src/integrations/claude-code/files.js"; +import { animationForEvent } from "../src/integrations/claude-code/map.js"; +import { createStyle } from "../src/util/style.js"; + +test("lifecycle events map to their poses", () => { + assert.equal(animationForEvent({ hook_event_name: "SessionEnd" }), "sleep"); + assert.equal( + animationForEvent({ hook_event_name: "SessionEnd", reason: "prompt_input_exit" }), + "sleep" + ); + for (const reason of ["clear", "resume"]) { + assert.equal( + animationForEvent({ hook_event_name: "SessionEnd", reason }), + null, + reason + ); + } + assert.equal( + animationForEvent({ hook_event_name: "UserPromptSubmit" }), + "reading" + ); + assert.equal( + animationForEvent({ hook_event_name: "SubagentStart" }), + "thinking" + ); + assert.equal( + animationForEvent({ hook_event_name: "PostToolBatch" }), + "thinking" + ); + assert.equal( + animationForEvent({ hook_event_name: "PostToolUseFailure" }), + "error" + ); + assert.equal(animationForEvent({ hook_event_name: "PreCompact" }), "thinking"); + assert.equal( + animationForEvent({ hook_event_name: "PermissionRequest" }), + "attention" + ); + assert.equal( + animationForEvent({ hook_event_name: "PermissionDenied" }), + "abort" + ); + assert.equal( + animationForEvent({ + hook_event_name: "Notification", + notification_type: "permission_prompt", + }), + "attention" + ); + assert.equal(animationForEvent({ hook_event_name: "Stop" }), "ring"); + assert.equal(animationForEvent({ hook_event_name: "StopFailure" }), "error"); +}); + +test("SessionStart welcomes only a fresh launch; every other start wakes up", () => { + assert.equal( + animationForEvent({ hook_event_name: "SessionStart", source: "startup" }), + "welcome" + ); + for (const source of ["resume", "clear", "compact", "fork", "other", undefined]) { + assert.equal( + animationForEvent({ hook_event_name: "SessionStart", source }), + "wakeup", + String(source) + ); + } +}); + +test("PreToolUse maps reading tools to reading", () => { + for (const tool of ["Read", "Grep", "Glob", "WebFetch", "WebSearch"]) { + assert.equal( + animationForEvent({ hook_event_name: "PreToolUse", tool_name: tool }), + "reading", + tool + ); + } +}); + +test("PreToolUse maps typing tools to typing", () => { + for (const tool of ["Bash", "PowerShell", "Edit", "Write", "NotebookEdit"]) { + assert.equal( + animationForEvent({ hook_event_name: "PreToolUse", tool_name: tool }), + "typing", + tool + ); + } +}); + +test("PreToolUse skips unknown or missing tools", () => { + assert.equal( + animationForEvent({ hook_event_name: "PreToolUse", tool_name: "AskUserQuestion" }), + null + ); + assert.equal( + animationForEvent({ hook_event_name: "PreToolUse", tool_name: "mcp__x__y" }), + null + ); + assert.equal(animationForEvent({ hook_event_name: "PreToolUse" }), null); +}); + +test("skips missing, unknown, or Cursor-cased hook_event_name", () => { + assert.equal(animationForEvent(undefined), null); + assert.equal(animationForEvent(null), null); + assert.equal(animationForEvent({}), null); + assert.equal(animationForEvent({ hook_event_name: "" }), null); + assert.equal(animationForEvent({ hook_event_name: "PostToolUse" }), null); + assert.equal(animationForEvent({ hook_event_name: "stop" }), null); + assert.equal(animationForEvent({ hook_event_name: "constructor" }), null); +}); + +test("describeFiles reports Write tool_input path", () => { + const lines = formatFileModifications( + { + hook_event_name: "PreToolUse", + tool_name: "Write", + tool_input: { file_path: "/proj/a.js" }, + }, + createStyle({ color: false }), + ); + assert.deepEqual(lines, ["✎ touching: /proj/a.js"]); +}); diff --git a/packages/tiny-engineer-cli/test/map-cursor.test.js b/packages/tiny-engineer-cli/test/map-cursor.test.js new file mode 100644 index 00000000..863d9920 --- /dev/null +++ b/packages/tiny-engineer-cli/test/map-cursor.test.js @@ -0,0 +1,143 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { animationForEvent } from "../src/integrations/cursor/map.js"; +import { cursor } from "../src/integrations/cursor/index.js"; + +test("EVENT_ANIM maps reading events to reading", () => { + assert.equal( + animationForEvent({ hook_event_name: "sessionStart" }), + "reading" + ); + assert.equal( + animationForEvent({ hook_event_name: "beforeSubmitPrompt" }), + "reading" + ); + assert.equal( + animationForEvent({ hook_event_name: "beforeReadFile" }), + "reading" + ); +}); + +test("EVENT_ANIM maps thinking events to thinking", () => { + assert.equal( + animationForEvent({ hook_event_name: "afterAgentThought" }), + "thinking" + ); + assert.equal( + animationForEvent({ hook_event_name: "preCompact" }), + "thinking" + ); +}); + +test("EVENT_ANIM maps typing events to typing", () => { + assert.equal( + animationForEvent({ hook_event_name: "beforeShellExecution" }), + "typing" + ); + assert.equal( + animationForEvent({ hook_event_name: "subagentStart" }), + "typing" + ); + assert.equal( + animationForEvent({ hook_event_name: "afterFileEdit" }), + "typing" + ); +}); + +test("preToolUse maps reading tools to reading", () => { + assert.equal( + animationForEvent({ hook_event_name: "preToolUse", tool_name: "Read" }), + "reading" + ); + assert.equal( + animationForEvent({ hook_event_name: "preToolUse", tool_name: "Grep" }), + "reading" + ); +}); + +test("preToolUse maps typing tools to typing", () => { + assert.equal( + animationForEvent({ hook_event_name: "preToolUse", tool_name: "Write" }), + "typing" + ); + assert.equal( + animationForEvent({ hook_event_name: "preToolUse", tool_name: "StrReplace" }), + "typing" + ); + assert.equal( + animationForEvent({ hook_event_name: "preToolUse", tool_name: "Shell" }), + "typing" + ); +}); + +test("preToolUse skips unknown tools", () => { + assert.equal( + animationForEvent({ + hook_event_name: "preToolUse", + tool_name: "AskQuestion", + }), + null + ); +}); + +test("stop maps aborted to abort", () => { + assert.equal( + animationForEvent({ hook_event_name: "stop", status: "aborted" }), + "abort" + ); +}); + +test("stop maps error to error", () => { + assert.equal( + animationForEvent({ hook_event_name: "stop", status: "error" }), + "error" + ); +}); + +test("stop maps completed or missing status to ring", () => { + assert.equal( + animationForEvent({ hook_event_name: "stop", status: "completed" }), + "ring" + ); + assert.equal(animationForEvent({ hook_event_name: "stop" }), "ring"); +}); + +test("skips empty conversation_id or generation_id", () => { + assert.equal( + animationForEvent({ hook_event_name: "stop", conversation_id: "" }), + null + ); + assert.equal( + animationForEvent({ hook_event_name: "stop", generation_id: "" }), + null + ); + assert.equal( + animationForEvent({ + hook_event_name: "stop", + conversation_id: "conv", + generation_id: "gen", + }), + "ring" + ); +}); + +test("skips missing or unknown hook_event_name", () => { + assert.equal(animationForEvent(undefined), null); + assert.equal(animationForEvent({}), null); + assert.equal(animationForEvent({ hook_event_name: "" }), null); + assert.equal( + animationForEvent({ hook_event_name: "afterFileEditFoo" }), + null + ); +}); + +test("cursor.parseHook + mapToAnim round-trip stop → ring", () => { + const input = cursor.parseHook([], '{"hook_event_name":"stop","status":"completed"}'); + assert.ok(input); + assert.equal(cursor.mapToAnim(input), "ring"); +}); + +test("cursor.parseHook returns null on bad JSON", () => { + assert.equal(cursor.parseHook([], "{"), null); + assert.equal(cursor.parseHook([], ""), null); +}); diff --git a/packages/tiny-engineer-cli/test/output.test.js b/packages/tiny-engineer-cli/test/output.test.js new file mode 100644 index 00000000..32c4808e --- /dev/null +++ b/packages/tiny-engineer-cli/test/output.test.js @@ -0,0 +1,59 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createOutput } from "../src/util/output.js"; +import { createStyle } from "../src/util/style.js"; + +test("ok prints with checkmark; quiet suppresses", () => { + /** @type {string[]} */ + const logs = []; + /** @type {string[]} */ + const errs = []; + const style = createStyle({ color: false }); + const out = createOutput({ + quiet: false, + style, + log: (m) => logs.push(String(m)), + error: (m) => errs.push(String(m)), + }); + + out.ok("animation ring → http://x"); + assert.deepEqual(logs, ["✓ animation ring → http://x"]); + + const quiet = createOutput({ + quiet: true, + style, + log: (m) => logs.push(String(m)), + error: (m) => errs.push(String(m)), + }); + quiet.ok("hidden"); + quiet.info("also hidden"); + assert.deepEqual(logs, ["✓ animation ring → http://x"]); + assert.deepEqual(errs, []); +}); + +test("error always prints with icon and prefix", () => { + /** @type {string[]} */ + const errs = []; + const out = createOutput({ + quiet: true, + style: createStyle({ color: false }), + log: () => {}, + error: (m) => errs.push(String(m)), + }); + out.error("unknown animation: foobar"); + assert.deepEqual(errs, ["✗ error: unknown animation: foobar"]); +}); + +test("info goes to stderr writer and is quiet-sensitive", () => { + /** @type {string[]} */ + const errs = []; + const style = createStyle({ color: false }); + const out = createOutput({ + quiet: false, + style, + log: () => {}, + error: (m) => errs.push(String(m)), + }); + out.info("✎ modified: a.js (1 edit)"); + assert.deepEqual(errs, ["✎ modified: a.js (1 edit)"]); +}); diff --git a/packages/tiny-engineer-cli/test/registry.test.js b/packages/tiny-engineer-cli/test/registry.test.js new file mode 100644 index 00000000..0ab367c9 --- /dev/null +++ b/packages/tiny-engineer-cli/test/registry.test.js @@ -0,0 +1,34 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { all, get, list } from "../src/integrations/registry.js"; + +test("list includes cursor, claude-code, antigravity", () => { + assert.deepEqual(list(), ["cursor", "claude-code", "antigravity"]); +}); + +test("get returns each registered integration", () => { + for (const id of ["cursor", "claude-code", "antigravity"]) { + const integration = get(id); + assert.ok(integration, id); + assert.equal(integration.id, id); + assert.equal(typeof integration.parseHook, "function"); + assert.equal(typeof integration.mapToAnim, "function"); + } +}); + +test("claude-code sets hardExitAfterPost", () => { + assert.equal(get("claude-code")?.hardExitAfterPost, true); +}); + +test("get returns undefined for unknown id", () => { + assert.equal(get("windsurf"), undefined); +}); + +test("all returns registered integrations", () => { + const integrations = all(); + assert.equal(integrations.length, 3); + assert.deepEqual( + integrations.map((i) => i.id), + ["cursor", "claude-code", "antigravity"], + ); +}); diff --git a/packages/tiny-engineer-cli/test/respond-antigravity.test.js b/packages/tiny-engineer-cli/test/respond-antigravity.test.js new file mode 100644 index 00000000..27992e18 --- /dev/null +++ b/packages/tiny-engineer-cli/test/respond-antigravity.test.js @@ -0,0 +1,30 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { antigravity } from "../src/integrations/antigravity/index.js"; +import { defaultResponseForHook } from "../src/integrations/antigravity/map.js"; + +test("defaultResponseForHook allows PreToolUse and Stop", () => { + assert.deepEqual(defaultResponseForHook("PreToolUse"), { decision: "allow" }); + assert.deepEqual(defaultResponseForHook("Stop"), { decision: "allow" }); + assert.deepEqual(defaultResponseForHook("PreInvocation"), {}); +}); + +test("antigravity.parseHook never returns null", () => { + const input = antigravity.parseHook(["PreToolUse"], "{"); + assert.ok(input); + assert.equal(input.event, "PreToolUse"); +}); + +test("antigravity.respond prints decision JSON", () => { + /** @type {string[]} */ + const lines = []; + const original = console.log; + console.log = (msg) => lines.push(String(msg)); + try { + const input = antigravity.parseHook(["Stop"], "{}"); + antigravity.respond({ animPosted: true, anim: "ring", input }); + assert.deepEqual(lines, ['{"decision":"allow"}']); + } finally { + console.log = original; + } +}); diff --git a/packages/tiny-engineer-cli/test/setup-args.test.js b/packages/tiny-engineer-cli/test/setup-args.test.js new file mode 100644 index 00000000..26a7d3a1 --- /dev/null +++ b/packages/tiny-engineer-cli/test/setup-args.test.js @@ -0,0 +1,129 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync, mkdirSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createOutput } from "../src/util/output.js"; +import { createStyle } from "../src/util/style.js"; +import { mergeEnvUrl } from "../src/wizard/merge.js"; +import { parseSetupArgs, runSetup } from "../src/wizard/setup.js"; + +test("parseSetupArgs --yes without ide fails at run time; parser ok", () => { + const args = parseSetupArgs(["--yes"]); + assert.equal(args.yes, true); + assert.equal(args.ides.length, 0); +}); + +test("parseSetupArgs reads flags", () => { + const args = parseSetupArgs([ + "cursor", + "--yes", + "--dry-run", + "--url", + "http://x", + "--force-url", + ]); + assert.deepEqual(args.ides, ["cursor"]); + assert.equal(args.yes, true); + assert.equal(args.dryRun, true); + assert.equal(args.url, "http://x"); + assert.equal(args.forceUrl, true); +}); + +test("runSetup --yes without ide exits 1", async () => { + /** @type {string[]} */ + const errs = []; + const out = createOutput({ + quiet: false, + style: createStyle({ color: false }), + log: () => {}, + error: (m) => errs.push(String(m)), + }); + await runSetup(out, ["--yes"]); + assert.equal(process.exitCode, 1); + assert.ok(errs.some((e) => e.includes("--yes requires"))); + process.exitCode = 0; +}); + +test("runSetup --dry-run does not write files", async () => { + const dir = mkdtempSync(join(tmpdir(), "te-setup-")); + /** @type {string[]} */ + const infos = []; + const out = createOutput({ + quiet: false, + style: createStyle({ color: false }), + log: () => {}, + error: (m) => infos.push(String(m)), + }); + try { + await runSetup(out, ["cursor", "--yes", "--dry-run"], { projectRoot: dir }); + assert.equal(process.exitCode, 0); + assert.throws(() => readFileSync(join(dir, ".cursor", "hooks.json"))); + assert.ok(infos.some((l) => l.includes(".cursor/hooks.json"))); + } finally { + rmSync(dir, { recursive: true, force: true }); + process.exitCode = 0; + } +}); + +test("runSetup --yes writes cursor hooks", async () => { + const dir = mkdtempSync(join(tmpdir(), "te-setup-")); + mkdirSync(join(dir, ".cursor")); + const out = createOutput({ + quiet: true, + style: createStyle({ color: false }), + log: () => {}, + error: () => {}, + }); + try { + await runSetup(out, ["cursor", "--yes", "--url", "http://robot.test"], { + projectRoot: dir, + }); + const hooks = JSON.parse(readFileSync(join(dir, ".cursor", "hooks.json"), "utf8")); + assert.equal(hooks.hooks.stop[0].command, "npx -y tiny-engineer hook cursor"); + assert.equal(hooks.hooks.stop[0].timeout, 30); + const env = readFileSync(join(dir, ".env"), "utf8"); + assert.ok(env.includes("TINY_ENGINEER_URL=http://robot.test")); + } finally { + rmSync(dir, { recursive: true, force: true }); + process.exitCode = 0; + } +}); + +test("runSetup aborts when hooks.json is invalid JSON", async () => { + const dir = mkdtempSync(join(tmpdir(), "te-setup-")); + mkdirSync(join(dir, ".cursor"), { recursive: true }); + const hooksPath = join(dir, ".cursor", "hooks.json"); + writeFileSync(hooksPath, "{", "utf8"); + /** @type {string[]} */ + const errs = []; + const out = createOutput({ + quiet: false, + style: createStyle({ color: false }), + log: () => {}, + error: (m) => errs.push(String(m)), + }); + try { + await runSetup(out, ["cursor", "--yes"], { projectRoot: dir }); + assert.equal(process.exitCode, 1); + assert.equal(readFileSync(hooksPath, "utf8"), "{"); + assert.ok(errs.some((e) => e.includes("not valid JSON"))); + } finally { + rmSync(dir, { recursive: true, force: true }); + process.exitCode = 0; + } +}); + +test("mergeEnvUrl respects force flag", () => { + const dir = mkdtempSync(join(tmpdir(), "te-env-")); + const envPath = join(dir, ".env"); + try { + writeFileSync(envPath, "TINY_ENGINEER_URL=http://old\n", "utf8"); + assert.equal(mergeEnvUrl(envPath, "http://new"), "skipped"); + assert.ok(readFileSync(envPath, "utf8").includes("http://old")); + assert.equal(mergeEnvUrl(envPath, "http://new", { force: true }), "updated"); + assert.ok(readFileSync(envPath, "utf8").includes("http://new")); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/packages/tiny-engineer-cli/test/stdin.test.js b/packages/tiny-engineer-cli/test/stdin.test.js new file mode 100644 index 00000000..d8a39691 --- /dev/null +++ b/packages/tiny-engineer-cli/test/stdin.test.js @@ -0,0 +1,35 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { PassThrough } from "node:stream"; +import { readStdin } from "../src/util/stdin.js"; + +test("readStdin resolves with chunks when pipe never ends", async () => { + const stream = new PassThrough(); + const pending = readStdin({ timeoutMs: 50, stream }); + stream.write('{"a":1}'); + const text = await pending; + assert.equal(text, '{"a":1}'); + assert.equal(stream.listenerCount("data"), 0); + assert.equal(stream.listenerCount("end"), 0); + assert.equal(stream.listenerCount("error"), 0); + stream.destroy(); +}); + +test("readStdin resolves on end before timeout", async () => { + const stream = new PassThrough(); + const pending = readStdin({ timeoutMs: 5000, stream }); + stream.write('{"hook_event_name":"stop"}'); + stream.end(); + const text = await pending; + assert.equal(text, '{"hook_event_name":"stop"}'); + assert.equal(stream.listenerCount("data"), 0); + assert.equal(stream.listenerCount("end"), 0); + assert.equal(stream.listenerCount("error"), 0); +}); + +test("readStdin returns empty string for TTY", async () => { + const stream = new PassThrough(); + stream.isTTY = true; + const text = await readStdin({ timeoutMs: 50, stream }); + assert.equal(text, ""); +}); diff --git a/packages/tiny-engineer-cli/test/style.test.js b/packages/tiny-engineer-cli/test/style.test.js new file mode 100644 index 00000000..b4d5bc29 --- /dev/null +++ b/packages/tiny-engineer-cli/test/style.test.js @@ -0,0 +1,21 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createStyle } from "../src/util/style.js"; + +test("createStyle color false leaves strings plain", () => { + const style = createStyle({ color: false }); + assert.equal(style.enabled, false); + assert.equal(style.green("ok"), "ok"); + assert.equal(style.red("no"), "no"); + assert.equal(style.cyan("x"), "x"); + assert.equal(style.dim("d"), "d"); +}); + +test("createStyle color true wraps with ANSI", () => { + const style = createStyle({ color: true }); + assert.equal(style.enabled, true); + assert.equal(style.green("ok"), "\x1b[32mok\x1b[0m"); + assert.equal(style.red("no"), "\x1b[31mno\x1b[0m"); + assert.equal(style.cyan("x"), "\x1b[36mx\x1b[0m"); + assert.ok(style.dim("d").includes("\x1b[2m")); +}); diff --git a/packages/tiny-engineer-cli/test/uninstall-antigravity.test.js b/packages/tiny-engineer-cli/test/uninstall-antigravity.test.js new file mode 100644 index 00000000..cb544b19 --- /dev/null +++ b/packages/tiny-engineer-cli/test/uninstall-antigravity.test.js @@ -0,0 +1,18 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { applyUninstall } from "../src/integrations/antigravity/install.js"; + +test("applyUninstall removes tiny-engineer key only", () => { + const existing = { + other: { keep: true }, + "tiny-engineer": { + Stop: [{ command: "node x hook antigravity Stop", timeout: 5 }], + }, + }; + const once = applyUninstall(existing); + assert.deepEqual(once.other, { keep: true }); + assert.equal(once["tiny-engineer"], undefined); + + const twice = applyUninstall(once); + assert.deepEqual(twice.other, { keep: true }); +}); diff --git a/packages/tiny-engineer-cli/test/uninstall-args.test.js b/packages/tiny-engineer-cli/test/uninstall-args.test.js new file mode 100644 index 00000000..6d7bf810 --- /dev/null +++ b/packages/tiny-engineer-cli/test/uninstall-args.test.js @@ -0,0 +1,115 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createOutput } from "../src/util/output.js"; +import { createStyle } from "../src/util/style.js"; +import { stripEnvTinyEngineer } from "../src/wizard/merge.js"; +import { parseUninstallArgs, runUninstall } from "../src/wizard/uninstall.js"; + +test("parseUninstallArgs reads flags", () => { + const args = parseUninstallArgs(["cursor", "--yes", "--dry-run", "--purge-env"]); + assert.deepEqual(args.ides, ["cursor"]); + assert.equal(args.yes, true); + assert.equal(args.dryRun, true); + assert.equal(args.purgeEnv, true); +}); + +test("runUninstall --yes without ide exits 1", async () => { + /** @type {string[]} */ + const errs = []; + const out = createOutput({ + quiet: false, + style: createStyle({ color: false }), + log: () => {}, + error: (m) => errs.push(String(m)), + }); + await runUninstall(out, ["--yes"]); + assert.equal(process.exitCode, 1); + assert.ok(errs.some((e) => e.includes("--yes requires"))); + process.exitCode = 0; +}); + +test("runUninstall --dry-run does not write", async () => { + const dir = mkdtempSync(join(tmpdir(), "te-un-")); + mkdirSync(join(dir, ".cursor"), { recursive: true }); + const hooksPath = join(dir, ".cursor", "hooks.json"); + writeFileSync( + hooksPath, + JSON.stringify({ + version: 1, + hooks: { stop: [{ command: 'node "/x" hook cursor', timeout: 5 }] }, + }), + "utf8", + ); + const before = readFileSync(hooksPath, "utf8"); + const out = createOutput({ + quiet: true, + style: createStyle({ color: false }), + log: () => {}, + error: () => {}, + }); + try { + await runUninstall(out, ["cursor", "--yes", "--dry-run"], { projectRoot: dir }); + assert.equal(process.exitCode, 0); + assert.equal(readFileSync(hooksPath, "utf8"), before); + } finally { + rmSync(dir, { recursive: true, force: true }); + process.exitCode = 0; + } +}); + +test("runUninstall --yes removes cursor hooks", async () => { + const dir = mkdtempSync(join(tmpdir(), "te-un-")); + mkdirSync(join(dir, ".cursor"), { recursive: true }); + const hooksPath = join(dir, ".cursor", "hooks.json"); + writeFileSync( + hooksPath, + JSON.stringify({ + version: 1, + hooks: { + sessionStart: [ + { command: "log.sh", timeout: 2 }, + { command: 'node "/x" hook cursor', timeout: 5 }, + ], + }, + }), + "utf8", + ); + const out = createOutput({ + quiet: true, + style: createStyle({ color: false }), + log: () => {}, + error: () => {}, + }); + try { + await runUninstall(out, ["cursor", "--yes"], { projectRoot: dir }); + const hooks = JSON.parse(readFileSync(hooksPath, "utf8")); + assert.equal(hooks.hooks.sessionStart.length, 1); + assert.equal(hooks.hooks.sessionStart[0].command, "log.sh"); + } finally { + rmSync(dir, { recursive: true, force: true }); + process.exitCode = 0; + } +}); + +test("stripEnvTinyEngineer removes only TE keys", () => { + const dir = mkdtempSync(join(tmpdir(), "te-env-")); + const envPath = join(dir, ".env"); + try { + writeFileSync( + envPath, + "FOO=1\nTINY_ENGINEER_URL=http://x\nTINY_ENGINEER_TOKEN=secret\nBAR=2\n", + "utf8", + ); + assert.equal(stripEnvTinyEngineer(envPath), "removed"); + const next = readFileSync(envPath, "utf8"); + assert.ok(next.includes("FOO=1")); + assert.ok(next.includes("BAR=2")); + assert.ok(!next.includes("TINY_ENGINEER")); + assert.equal(stripEnvTinyEngineer(envPath), "unchanged"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/packages/tiny-engineer-cli/test/uninstall-claude-code.test.js b/packages/tiny-engineer-cli/test/uninstall-claude-code.test.js new file mode 100644 index 00000000..23d63814 --- /dev/null +++ b/packages/tiny-engineer-cli/test/uninstall-claude-code.test.js @@ -0,0 +1,35 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { applyUninstall } from "../src/integrations/claude-code/install.js"; + +test("applyUninstall keeps permissions and strips our Stop hook", () => { + const existing = { + permissions: { allow: ["Bash"] }, + hooks: { + Stop: [ + { + hooks: [ + { + type: "command", + command: 'node "/x" hook claude-code', + async: true, + }, + { + type: "command", + command: "echo other", + async: true, + }, + ], + }, + ], + }, + }; + const once = applyUninstall(existing); + assert.deepEqual(once.permissions, { allow: ["Bash"] }); + assert.equal(once.hooks.Stop.length, 1); + assert.equal(once.hooks.Stop[0].hooks.length, 1); + assert.equal(once.hooks.Stop[0].hooks[0].command, "echo other"); + + const twice = applyUninstall(once); + assert.equal(twice.hooks.Stop[0].hooks.length, 1); +}); diff --git a/packages/tiny-engineer-cli/test/uninstall-cursor.test.js b/packages/tiny-engineer-cli/test/uninstall-cursor.test.js new file mode 100644 index 00000000..f47c7fb6 --- /dev/null +++ b/packages/tiny-engineer-cli/test/uninstall-cursor.test.js @@ -0,0 +1,43 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { applyUninstall, planUninstall } from "../src/integrations/cursor/install.js"; + +test("applyUninstall keeps foreign hooks and removes ours", () => { + const existing = { + version: 1, + hooks: { + sessionStart: [ + { command: ".cursor/hooks/log-event.sh sessionStart", timeout: 2 }, + { command: 'node "/x" hook cursor', timeout: 5 }, + ], + stop: [{ command: 'node "/x" hook cursor', timeout: 5 }], + }, + }; + const once = applyUninstall(existing); + assert.equal(once.hooks.sessionStart.length, 1); + assert.ok(once.hooks.sessionStart[0].command.includes("log-event")); + assert.equal(once.hooks.stop, undefined); + + const twice = applyUninstall(once); + assert.deepEqual(twice.hooks.sessionStart, once.hooks.sessionStart); +}); + +test("planUninstall counts our entries", () => { + // planUninstall reads from disk; count via applyUninstall input shape using temp is in args test. + const existing = { + hooks: { + stop: [ + { command: "tiny-engineer-cursor" }, + { command: "other" }, + ], + }, + }; + const after = applyUninstall(existing); + assert.equal(after.hooks.stop.length, 1); + assert.equal(after.hooks.stop[0].command, "other"); +}); + +test("planUninstall missing file is zero", () => { + const plan = planUninstall("/tmp/te-uninstall-missing-cursor-root-xyz"); + assert.equal(plan.removeCount, 0); +}); diff --git a/packages/tiny-engineer-cursor/package.json b/packages/tiny-engineer-cursor/package.json index 2bef9182..6de84621 100644 --- a/packages/tiny-engineer-cursor/package.json +++ b/packages/tiny-engineer-cursor/package.json @@ -1,7 +1,8 @@ { "name": "tiny-engineer-cursor", "version": "0.1.0", - "description": "Cursor hooks CLI that drives Tiny Engineer robot animations over HTTP", + "description": "Deprecated: use tiny-engineer-cli (bin: tiny-engineer). Cursor hooks CLI that drives Tiny Engineer robot animations over HTTP", + "deprecated": "Use tiny-engineer-cli (bin: tiny-engineer). See docs/integration.md. For custom agents use the REST API (docs/api.md).", "type": "module", "bin": { "tiny-engineer-cursor": "./bin/tiny-engineer-cursor.js" diff --git a/packages/tiny-engineer-cursor/src/cli.js b/packages/tiny-engineer-cursor/src/cli.js index 9dbc26f4..b91820d9 100644 --- a/packages/tiny-engineer-cursor/src/cli.js +++ b/packages/tiny-engineer-cursor/src/cli.js @@ -7,6 +7,9 @@ const DEFAULT_URL = "http://tiny-engineer.local"; function printHelp() { console.log(`Usage: tiny-engineer-cursor [options] +Deprecated: use \`tiny-engineer hook cursor\` (packages/tiny-engineer-cli). +For custom agents, call the REST API (docs/api.md / docs/integration.md). + Cursor hook helper: read event JSON from stdin, pick an animation, POST to the robot. Options: