diff --git a/.claude/settings.json b/.claude/settings.json
new file mode 100644
index 0000000..475a763
--- /dev/null
+++ b/.claude/settings.json
@@ -0,0 +1,147 @@
+{
+ "hooks": {
+ "SessionStart": [
+ {
+ "hooks": [
+ {
+ "type": "command",
+ "command": "node \"$CLAUDE_PROJECT_DIR/packages/tiny-engineer-claude-code/bin/tiny-engineer-claude-code.js\"",
+ "async": true
+ }
+ ]
+ }
+ ],
+ "SessionEnd": [
+ {
+ "hooks": [
+ {
+ "type": "command",
+ "command": "node \"$CLAUDE_PROJECT_DIR/packages/tiny-engineer-claude-code/bin/tiny-engineer-claude-code.js\"",
+ "async": true
+ }
+ ]
+ }
+ ],
+ "UserPromptSubmit": [
+ {
+ "hooks": [
+ {
+ "type": "command",
+ "command": "node \"$CLAUDE_PROJECT_DIR/packages/tiny-engineer-claude-code/bin/tiny-engineer-claude-code.js\"",
+ "async": true
+ }
+ ]
+ }
+ ],
+ "SubagentStart": [
+ {
+ "hooks": [
+ {
+ "type": "command",
+ "command": "node \"$CLAUDE_PROJECT_DIR/packages/tiny-engineer-claude-code/bin/tiny-engineer-claude-code.js\"",
+ "async": true
+ }
+ ]
+ }
+ ],
+ "PreToolUse": [
+ {
+ "hooks": [
+ {
+ "type": "command",
+ "command": "node \"$CLAUDE_PROJECT_DIR/packages/tiny-engineer-claude-code/bin/tiny-engineer-claude-code.js\"",
+ "async": true
+ }
+ ]
+ }
+ ],
+ "PostToolBatch": [
+ {
+ "hooks": [
+ {
+ "type": "command",
+ "command": "node \"$CLAUDE_PROJECT_DIR/packages/tiny-engineer-claude-code/bin/tiny-engineer-claude-code.js\"",
+ "async": true
+ }
+ ]
+ }
+ ],
+ "PostToolUseFailure": [
+ {
+ "hooks": [
+ {
+ "type": "command",
+ "command": "node \"$CLAUDE_PROJECT_DIR/packages/tiny-engineer-claude-code/bin/tiny-engineer-claude-code.js\"",
+ "async": true
+ }
+ ]
+ }
+ ],
+ "PreCompact": [
+ {
+ "hooks": [
+ {
+ "type": "command",
+ "command": "node \"$CLAUDE_PROJECT_DIR/packages/tiny-engineer-claude-code/bin/tiny-engineer-claude-code.js\"",
+ "async": true
+ }
+ ]
+ }
+ ],
+ "PermissionRequest": [
+ {
+ "hooks": [
+ {
+ "type": "command",
+ "command": "node \"$CLAUDE_PROJECT_DIR/packages/tiny-engineer-claude-code/bin/tiny-engineer-claude-code.js\"",
+ "async": true
+ }
+ ]
+ }
+ ],
+ "PermissionDenied": [
+ {
+ "hooks": [
+ {
+ "type": "command",
+ "command": "node \"$CLAUDE_PROJECT_DIR/packages/tiny-engineer-claude-code/bin/tiny-engineer-claude-code.js\"",
+ "async": true
+ }
+ ]
+ }
+ ],
+ "Notification": [
+ {
+ "hooks": [
+ {
+ "type": "command",
+ "command": "node \"$CLAUDE_PROJECT_DIR/packages/tiny-engineer-claude-code/bin/tiny-engineer-claude-code.js\"",
+ "async": true
+ }
+ ]
+ }
+ ],
+ "Stop": [
+ {
+ "hooks": [
+ {
+ "type": "command",
+ "command": "node \"$CLAUDE_PROJECT_DIR/packages/tiny-engineer-claude-code/bin/tiny-engineer-claude-code.js\"",
+ "async": true
+ }
+ ]
+ }
+ ],
+ "StopFailure": [
+ {
+ "hooks": [
+ {
+ "type": "command",
+ "command": "node \"$CLAUDE_PROJECT_DIR/packages/tiny-engineer-claude-code/bin/tiny-engineer-claude-code.js\"",
+ "async": true
+ }
+ ]
+ }
+ ]
+ }
+}
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 7a4870c..fab9794 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -47,3 +47,5 @@ jobs:
run: npm test --prefix packages/tiny-engineer-cursor
- name: Antigravity package tests
run: npm test --prefix packages/tiny-engineer-antigravity
+ - name: Claude Code package tests
+ run: npm test --prefix packages/tiny-engineer-claude-code
diff --git a/docs/integration.md b/docs/integration.md
index f71b5b7..b57b28f 100644
--- a/docs/integration.md
+++ b/docs/integration.md
@@ -1,16 +1,17 @@
# Integrating Tiny Engineer
-Tiny Engineer is a Wi-Fi desk robot. Drive it from any tool that can make HTTP requests, or use the Cursor-specific helper that maps agent hook events to poses.
+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.
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).
-Three integration paths:
+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` |
```mermaid
flowchart TB
@@ -28,6 +29,11 @@ flowchart TB
AgHook --> AgCli["tiny-engineer-antigravity"]
AgCli --> Post
end
+ subgraph claudeCodePath [Claude Code]
+ CcHook[Claude Code settings.json]
+ CcHook --> CcCli["tiny-engineer-claude-code"]
+ CcCli --> Post
+ end
Post --> Robot[Tiny Engineer on Wi-Fi]
```
@@ -197,12 +203,57 @@ To run globally across all projects on your machine, configure `~/.gemini/config
---
+## 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.
+
+### Smoke test
+
+```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
+```
+
+---
+
## 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.
-- **All three** are fine together: the CLIs are thin clients of the same `/anim` API.
+- **Using Claude Code** → Claude Code CLI + hooks.
+- **All four** are fine together: the CLIs are thin clients of the same `/anim` API.
Prerequisites for any path: flash firmware, join 2.4 GHz Wi-Fi, confirm `http://tiny-engineer.local/health` (or the OLED IP) responds.
@@ -210,4 +261,4 @@ Prerequisites for any path: flash firmware, join 2.4 GHz Wi-Fi, confirm `http://
## Optional extras
-Same `POST /anim` API; not part of the three 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; 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.
diff --git a/packages/tiny-engineer-claude-code/bin/tiny-engineer-claude-code.js b/packages/tiny-engineer-claude-code/bin/tiny-engineer-claude-code.js
new file mode 100644
index 0000000..1d84dca
--- /dev/null
+++ b/packages/tiny-engineer-claude-code/bin/tiny-engineer-claude-code.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-claude-code/package.json b/packages/tiny-engineer-claude-code/package.json
new file mode 100644
index 0000000..03ee90c
--- /dev/null
+++ b/packages/tiny-engineer-claude-code/package.json
@@ -0,0 +1,22 @@
+{
+ "name": "@tiny-engineer/claude-code",
+ "version": "0.1.0",
+ "type": "module",
+ "description": "Claude Code hooks CLI that drives Tiny Engineer robot animations over HTTP",
+ "main": "src/cli.js",
+ "bin": {
+ "tiny-engineer-claude-code": "./bin/tiny-engineer-claude-code.js"
+ },
+ "scripts": {
+ "test": "node --test"
+ },
+ "files": [
+ "bin",
+ "src"
+ ],
+ "engines": {
+ "node": ">=18"
+ },
+ "keywords": ["claude-code", "robot", "tiny-engineer"],
+ "license": "MIT"
+}
diff --git a/packages/tiny-engineer-claude-code/src/cli.js b/packages/tiny-engineer-claude-code/src/cli.js
new file mode 100644
index 0000000..dcf98c9
--- /dev/null
+++ b/packages/tiny-engineer-claude-code/src/cli.js
@@ -0,0 +1,135 @@
+import { DEFAULT_URL, getBaseUrl, getToken, loadDotEnv } from "./env.js";
+import { animationForEvent } from "./map.js";
+import { postAnim } from "./post.js";
+
+function printHelp() {
+ console.log(`Usage: tiny-engineer-claude-code [options]
+
+Claude Code hook helper: read event JSON from stdin, pick an animation, POST to the robot.
+
+Options:
+ --url Robot base URL (default: TINY_ENGINEER_URL, else ${DEFAULT_URL})
+ -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.
+ No token → no Authorization header (device auth disabled).
+ A token from the process env is never sent to a TINY_ENGINEER_URL taken from
+ the project .env; set the URL in the env or with --url instead.
+
+Hook mode (no animation args):
+ Claude Code pipes JSON with hook_event_name (plus tool_name for PreToolUse and
+ source for SessionStart). The CLI maps the event to a pose and POSTs
+ \${url}/anim?name=…. Unknown or unmatched events exit 0 with no request.
+ Nothing is written to stdout, because Claude Code adds SessionStart /
+ UserPromptSubmit stdout to the model's context.
+
+Event map:
+ SessionStart (source startup) → welcome
+ SessionStart (any other or missing source) → wakeup
+ SessionEnd (reason clear/resume) → (skip)
+ SessionEnd (any other reason) → sleep
+ UserPromptSubmit → reading
+ PreToolUse + Read/Grep/Glob/WebFetch/WebSearch → reading
+ PreToolUse + Bash/PowerShell/Edit/Write/NotebookEdit → typing
+ other PreToolUse tools → (skip)
+ SubagentStart → thinking
+ PostToolBatch → thinking
+ PostToolUseFailure → error
+ PreCompact → thinking
+ PermissionRequest → attention
+ PermissionDenied → abort
+ Notification → attention
+ Stop → ring
+ StopFailure → error
+
+Examples:
+ echo '{"hook_event_name":"Stop"}' | tiny-engineer-claude-code
+ echo '{"hook_event_name":"PreToolUse","tool_name":"Read"}' | tiny-engineer-claude-code --url http://192.168.1.10
+`);
+}
+
+/**
+ * @param {string[]} argv
+ * @returns {{ help: boolean, url?: string, error?: string }}
+ */
+export function parseArgs(argv) {
+ let url;
+ let help = false;
+
+ for (let i = 0; i < argv.length; i++) {
+ const arg = argv[i];
+ if (arg === "-h" || arg === "--help") {
+ help = true;
+ continue;
+ }
+ if (arg === "--url") {
+ const next = argv[++i];
+ if (!next) return { help: false, error: "--url requires a value" };
+ url = next;
+ continue;
+ }
+ if (arg.startsWith("--url=")) {
+ url = arg.slice("--url=".length);
+ if (!url) return { help: false, error: "--url requires a value" };
+ continue;
+ }
+ return { help: false, error: `Unknown argument: ${arg}` };
+ }
+
+ return { help, url };
+}
+
+function readStdin() {
+ return new Promise((resolve) => {
+ if (process.stdin.isTTY) {
+ resolve("");
+ return;
+ }
+ const chunks = [];
+ process.stdin.setEncoding("utf8");
+ process.stdin.on("data", (c) => chunks.push(c));
+ process.stdin.on("end", () => resolve(chunks.join("")));
+ process.stdin.on("error", () => resolve(""));
+ });
+}
+
+/**
+ * @param {string[]} argv
+ */
+export async function run(argv) {
+ loadDotEnv();
+
+ const opts = parseArgs(argv);
+ if (opts.error) {
+ console.error(opts.error);
+ process.exitCode = 1;
+ return;
+ }
+ if (opts.help) {
+ printHelp();
+ return;
+ }
+
+ const raw = await readStdin();
+ let event = null;
+ if (raw.trim()) {
+ try {
+ event = JSON.parse(raw);
+ } catch {
+ process.exitCode = 0;
+ return;
+ }
+ }
+
+ const anim = animationForEvent(event);
+ if (anim) {
+ await postAnim(opts.url ?? getBaseUrl(), anim, getToken());
+ // A timed-out fetch leaves its connect attempt open, which keeps Node alive
+ // until undici's 10s connect timeout. Claude Code enforces no timeout on
+ // async hooks, so exit now instead of lingering once per tool call.
+ process.exit(0);
+ }
+ process.exitCode = 0;
+}
diff --git a/packages/tiny-engineer-claude-code/src/env.js b/packages/tiny-engineer-claude-code/src/env.js
new file mode 100644
index 0000000..996b398
--- /dev/null
+++ b/packages/tiny-engineer-claude-code/src/env.js
@@ -0,0 +1,81 @@
+import { readFileSync } from "node:fs";
+import { join } 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 `/.env` into process.env for keys not already set.
+ * Claude Code runs hooks from the session's working directory, which can be a
+ * subdirectory, so prefer the project root it exports.
+ * Missing file / read errors are ignored.
+ * @param {string} [dir]
+ */
+export function loadDotEnv(dir = process.env.CLAUDE_PROJECT_DIR || process.cwd()) {
+ let raw;
+ try {
+ raw = readFileSync(join(dir, ".env"), "utf8");
+ } catch {
+ return;
+ }
+
+ // A token inherited from the process env must not be sent to a URL that the
+ // project's .env picked, so the .env only sets the URL when it isn't.
+ 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;
+ }
+ }
+}
+
+/**
+ * @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-claude-code/src/map.js b/packages/tiny-engineer-claude-code/src/map.js
new file mode 100644
index 0000000..00810c1
--- /dev/null
+++ b/packages/tiny-engineer-claude-code/src/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-claude-code/src/post.js b/packages/tiny-engineer-claude-code/src/post.js
new file mode 100644
index 0000000..ec6d38a
--- /dev/null
+++ b/packages/tiny-engineer-claude-code/src/post.js
@@ -0,0 +1,23 @@
+/**
+ * POST /anim?name=… with a 2s timeout. Errors are swallowed.
+ * @param {string} baseUrl
+ * @param {string} animName
+ * @param {string | null} [token]
+ */
+export async function postAnim(baseUrl, animName, token = null) {
+ const base = baseUrl.replace(/\/+$/, "");
+ const url = `${base}/anim?name=${encodeURIComponent(animName)}`;
+ /** @type {RequestInit} */
+ const init = {
+ method: "POST",
+ signal: AbortSignal.timeout(2000),
+ };
+ if (token) {
+ init.headers = { Authorization: `Bearer ${token}` };
+ }
+ try {
+ await fetch(url, init);
+ } catch {
+ // Robot offline / timeout — never stall the agent.
+ }
+}
diff --git a/packages/tiny-engineer-claude-code/test/env.test.js b/packages/tiny-engineer-claude-code/test/env.test.js
new file mode 100644
index 0000000..8375ef6
--- /dev/null
+++ b/packages/tiny-engineer-claude-code/test/env.test.js
@@ -0,0 +1,56 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { mkdtempSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { DEFAULT_URL, getBaseUrl, getToken, loadDotEnv } from "../src/env.js";
+
+const KEYS = ["TINY_ENGINEER_TOKEN", "TINY_ENGINEER_URL"];
+
+function withEnv(values, fn) {
+ const saved = Object.fromEntries(KEYS.map((key) => [key, process.env[key]]));
+ for (const key of KEYS) delete process.env[key];
+ Object.assign(process.env, values);
+ try {
+ fn();
+ } finally {
+ for (const key of KEYS) {
+ if (saved[key] === undefined) delete process.env[key];
+ else process.env[key] = saved[key];
+ }
+ }
+}
+
+function projectWithDotEnv(contents) {
+ const dir = mkdtempSync(join(tmpdir(), "te-claude-code-env-"));
+ writeFileSync(join(dir, ".env"), contents);
+ return dir;
+}
+
+test("a project .env can set both the token and the URL", () => {
+ const dir = projectWithDotEnv(
+ "TINY_ENGINEER_TOKEN=projtok\nTINY_ENGINEER_URL=http://10.0.0.5/\n"
+ );
+ withEnv({}, () => {
+ loadDotEnv(dir);
+ assert.equal(getToken(), "projtok");
+ assert.equal(getBaseUrl(), "http://10.0.0.5");
+ });
+});
+
+test("a project .env cannot redirect a token inherited from the environment", () => {
+ const dir = projectWithDotEnv("TINY_ENGINEER_URL=http://10.0.0.5\n");
+ withEnv({ TINY_ENGINEER_TOKEN: "envtok" }, () => {
+ loadDotEnv(dir);
+ assert.equal(getToken(), "envtok");
+ assert.equal(getBaseUrl(), DEFAULT_URL);
+ });
+});
+
+test("an inherited URL still wins over the project .env", () => {
+ const dir = projectWithDotEnv("TINY_ENGINEER_URL=http://10.0.0.5\n");
+ withEnv({ TINY_ENGINEER_TOKEN: "envtok", TINY_ENGINEER_URL: "http://10.0.0.9" }, () => {
+ loadDotEnv(dir);
+ assert.equal(getBaseUrl(), "http://10.0.0.9");
+ });
+});
diff --git a/packages/tiny-engineer-claude-code/test/map.test.js b/packages/tiny-engineer-claude-code/test/map.test.js
new file mode 100644
index 0000000..b450afd
--- /dev/null
+++ b/packages/tiny-engineer-claude-code/test/map.test.js
@@ -0,0 +1,118 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { animationForEvent } from "../src/map.js";
+import { parseArgs } from "../src/cli.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("parseArgs reads --url in both forms and rejects unknown args", () => {
+ assert.deepEqual(parseArgs([]), { help: false, url: undefined });
+ assert.equal(parseArgs(["--url", "http://10.0.0.5"]).url, "http://10.0.0.5");
+ assert.equal(parseArgs(["--url=http://10.0.0.5"]).url, "http://10.0.0.5");
+ assert.equal(parseArgs(["-h"]).help, true);
+ assert.match(parseArgs(["--url"]).error, /requires a value/);
+ assert.match(parseArgs(["ring"]).error, /Unknown argument/);
+});