From 06e964cfdfe2729c716d8074bee7c017f5067465 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 15:42:47 +0000 Subject: [PATCH 01/16] test: cover the noun-verb CLI surface (#96) Adds red coverage for sessions/tools/events as noun-verb commands, updates every existing CLI test (integration, e2e, snapshot, unit) to the new forms, and adds two new suites: create-cli.test.ts (a boolean flag ahead of a positional must not swallow it) and noun-verb-cli.integration.test.ts (a removed top-level word names its replacement; a bare or unknown verb names its noun's verbs; events ls stays reserved for #95). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014wk7yigkPHWoWLy9nvocyt --- .../__snapshots__/output.test.ts.snap | 2 +- .../src/__tests__/call-timeouts.test.ts | 4 +- .../src/__tests__/cli-v2.integration.test.ts | 126 ++++++++++-------- .../src/__tests__/cli.integration.test.ts | 49 ++++--- .../src/__tests__/command-options.test.ts | 40 ++---- .../appduct/src/__tests__/create-cli.test.ts | 64 +++++++++ .../src/__tests__/e2e/churn.e2e.test.ts | 8 +- .../src/__tests__/e2e/cold-start.e2e.test.ts | 12 +- .../__tests__/e2e/daemon-restart.e2e.test.ts | 2 +- .../e2e/daemon-version-mismatch.e2e.test.ts | 22 +-- .../src/__tests__/e2e/events.e2e.test.ts | 10 +- packages/appduct/src/__tests__/e2e/harness.ts | 6 +- .../src/__tests__/e2e/hostility.e2e.test.ts | 2 +- .../__tests__/e2e/invoke-cancel.e2e.test.ts | 6 +- .../__tests__/e2e/multi-device.e2e.test.ts | 14 +- .../__tests__/e2e/policy-audit.e2e.test.ts | 8 +- .../src/__tests__/events.integration.test.ts | 17 +-- .../__tests__/exit-codes.integration.test.ts | 28 ++-- .../global-flags.integration.test.ts | 18 +-- .../__tests__/link-open.integration.test.ts | 4 +- .../__tests__/mcp-command.integration.test.ts | 2 +- .../noun-verb-cli.integration.test.ts | 104 +++++++++++++++ packages/appduct/src/__tests__/output.test.ts | 60 ++++----- .../scheme-discovery.integration.test.ts | 4 +- packages/appduct/src/__tests__/scheme.test.ts | 2 +- packages/appduct/src/cli/routes/events.ts | 55 -------- packages/appduct/src/cli/routes/invoke.ts | 53 -------- packages/appduct/src/cli/routes/link.ts | 42 ------ packages/appduct/src/cli/routes/ls.ts | 18 --- packages/appduct/src/cli/routes/revoke.ts | 24 ---- packages/appduct/src/cli/routes/tools.ts | 61 --------- 31 files changed, 387 insertions(+), 480 deletions(-) create mode 100644 packages/appduct/src/__tests__/create-cli.test.ts create mode 100644 packages/appduct/src/__tests__/noun-verb-cli.integration.test.ts delete mode 100644 packages/appduct/src/cli/routes/events.ts delete mode 100644 packages/appduct/src/cli/routes/invoke.ts delete mode 100644 packages/appduct/src/cli/routes/link.ts delete mode 100644 packages/appduct/src/cli/routes/ls.ts delete mode 100644 packages/appduct/src/cli/routes/revoke.ts delete mode 100644 packages/appduct/src/cli/routes/tools.ts diff --git a/packages/appduct/src/__tests__/__snapshots__/output.test.ts.snap b/packages/appduct/src/__tests__/__snapshots__/output.test.ts.snap index c3d3cd2..9836e82 100644 --- a/packages/appduct/src/__tests__/__snapshots__/output.test.ts.snap +++ b/packages/appduct/src/__tests__/__snapshots__/output.test.ts.snap @@ -134,6 +134,6 @@ exports[`output rendering > tools list output stays structured 1`] = ` echo(...) -> ... Echo a payload on the connected device. -Run \`appduct tools \` for a tool's full schema. +Run \`appduct tools describe \` for a tool's full schema. " `; diff --git a/packages/appduct/src/__tests__/call-timeouts.test.ts b/packages/appduct/src/__tests__/call-timeouts.test.ts index cfd3167..e77ecfa 100644 --- a/packages/appduct/src/__tests__/call-timeouts.test.ts +++ b/packages/appduct/src/__tests__/call-timeouts.test.ts @@ -1,7 +1,7 @@ /** * `daemon/calls.ts`'s timeout arithmetic (issue #25). `clampTimeout` decides the deadline the * daemon actually enforces for a `tools.call`; `deriveCallTransportTimeoutMs` is what every caller - * (the MCP server, `appduct invoke`) sizes its own socket watchdog off, so that the daemon's + * (the MCP server, `appduct tools call`) sizes its own socket watchdog off, so that the daemon's * `tool_timeout` always wins the race and the caller reports the real error type. */ @@ -60,7 +60,7 @@ describe("deriveCallTransportTimeoutMs", () => { }); /** - * `appduct invoke` and `AppClient.call` differ from the MCP server: they pass the *caller's* + * `appduct tools call` and `AppClient.call` differ from the MCP server: they pass the *caller's* * timeout, so `undefined` there means "the daemon will fall back to the tool's own declared * deadline", which neither knows. Sizing their watchdog off the 10 s default would make the caller * the thing that fails a long tool call. diff --git a/packages/appduct/src/__tests__/cli-v2.integration.test.ts b/packages/appduct/src/__tests__/cli-v2.integration.test.ts index de6724f..b0d7ee7 100644 --- a/packages/appduct/src/__tests__/cli-v2.integration.test.ts +++ b/packages/appduct/src/__tests__/cli-v2.integration.test.ts @@ -1,10 +1,11 @@ /** - * The v2 command table of §10 works end-to-end against a real daemon in an - * integration test (temp state dir): keygen -> daemon auto-spawn via `ls` -> link -> fake app client - * claims -> `ls` shows the alias ACTIVE -> `tools`/`invoke` round-trip -> `revoke`. Every command - * here runs as a real CLI subprocess (`bin.ts`) against a real daemon it auto-spawns, driven by a - * scripted fake app client (`ws`) — the same harness pattern as - * `tool-invocation.integration.test.ts`, just through the CLI instead of raw UDS RPC. + * The noun-verb command table of §10 (issue #96) works end-to-end against a real daemon in an + * integration test (temp state dir): keygen -> daemon auto-spawn via `sessions ls` -> `sessions + * link` -> fake app client claims -> `sessions ls` shows the alias ACTIVE -> `tools + * ls`/`describe`/`call` round-trip -> `sessions revoke`. Every command here runs as a real CLI + * subprocess (`bin.ts`) against a real daemon it auto-spawns, driven by a scripted fake app client + * (`ws`) — the same harness pattern as `tool-invocation.integration.test.ts`, just through the CLI + * instead of raw UDS RPC. */ import path from "node:path"; @@ -131,7 +132,7 @@ const nextMessage = (socket: WebSocket): Promise> => { describe("appduct CLI v2: end-to-end command table", () => { test( - "keygen -> ls auto-spawns -> link -> claim -> ls ACTIVE -> tools/invoke round-trip -> revoke", + "keygen -> sessions ls auto-spawns -> sessions link -> claim -> sessions ls ACTIVE -> tools ls/describe/call round-trip -> sessions revoke", async () => { const stateDir = await makeTempStateDir(); @@ -146,7 +147,7 @@ describe("appduct CLI v2: end-to-end command table", () => { expect(keygenAgain.error?.type).toBe("usage_error"); // ls: no sessions yet, and this is the call that auto-spawns the daemon. - const firstLs = await runCliJson(["ls"], stateDir); + const firstLs = await runCliJson(["sessions", "ls"], stateDir); expect(firstLs.ok).toBe(true); expect(firstLs.data).toEqual([]); @@ -159,7 +160,7 @@ describe("appduct CLI v2: end-to-end command table", () => { expect(port).toBeGreaterThan(0); // link: mint a pending session and decode its deep link. - const linkResult = await runCliJson(["link", "--ttl", "60"], stateDir); + const linkResult = await runCliJson(["sessions", "link", "--ttl", "60"], stateDir); expect(linkResult.ok).toBe(true); const linkData = linkResult.data as { sessionId: string; deepLink: string; endpoint: { port: number } }; expect(linkData.deepLink.startsWith("appduct-e2e:///?appduct=")).toBe(true); @@ -206,7 +207,7 @@ describe("appduct CLI v2: end-to-end command table", () => { await new Promise((resolve) => setTimeout(resolve, 100)); // ls: the claimed session shows up ACTIVE with its device metadata and tool count. - const secondLs = await runCliJson(["ls"], stateDir); + const secondLs = await runCliJson(["sessions", "ls"], stateDir); expect(secondLs.ok).toBe(true); const sessions = secondLs.data as Array<{ alias: string; state: string; toolCount: number }>; expect(sessions).toHaveLength(1); @@ -215,19 +216,32 @@ describe("appduct CLI v2: end-to-end command table", () => { expect(sessions[0]!.toolCount).toBe(1); // tools: list, then detail by name. - const toolsList = await runCliJson(["tools", alias], stateDir); + const toolsList = await runCliJson(["tools", "ls", alias], stateDir); expect(toolsList.ok).toBe(true); const toolsListData = toolsList.data as { tools: Array<{ name: string }>; total: number }; expect(toolsListData.tools.map((tool) => tool.name)).toEqual(["echo"]); expect(toolsListData.total).toBe(1); - const toolsDetail = await runCliJson(["tools", alias, "echo"], stateDir); + const toolsDetail = await runCliJson(["tools", "describe", alias, "echo"], stateDir); expect(toolsDetail.ok).toBe(true); expect((toolsDetail.data as { name: string; input_schema?: unknown }).input_schema).toEqual({ type: "object", properties: { text: { type: "string" } }, }); + // `describe` with no selector resolves against the one active session (issue #96: no more + // probing a single positional as "selector or name" — the verb says which it is). + const toolsDetailImplicit = await runCliJson(["tools", "describe", "echo"], stateDir); + expect(toolsDetailImplicit.ok).toBe(true); + expect((toolsDetailImplicit.data as { name: string }).name).toBe("echo"); + + // A name that matches nothing is a clean "not registered" error, not "unknown session" — + // there is no selector here for the daemon to have misread the name as. + const toolsDetailMissing = await runCliJson(["tools", "describe", "does-not-exist"], stateDir); + expect(toolsDetailMissing.ok).toBe(false); + expect(toolsDetailMissing.error?.type).toBe("usage_error"); + expect(toolsDetailMissing.error?.message).toBe('Tool "does-not-exist" is not registered.'); + // invoke: round-trip through the fake app. appSocket.on("message", (data) => { const msg = JSON.parse(data.toString("utf8")) as Record; @@ -244,23 +258,23 @@ describe("appduct CLI v2: end-to-end command table", () => { }); const invokeResult = await runCliJson( - ["invoke", alias, "echo", "--input", JSON.stringify({ text: "hello" })], + ["tools", "call", alias, "echo", "--input", JSON.stringify({ text: "hello" })], stateDir, ); expect(invokeResult.ok).toBe(true); expect(invokeResult.data).toEqual({ echoed: "hello" }); // invoke a nonexistent tool: the wire error type is preserved verbatim. - const invokeMissing = await runCliJson(["invoke", alias, "does-not-exist", "--input", "{}"], stateDir); + const invokeMissing = await runCliJson(["tools", "call", alias, "does-not-exist", "--input", "{}"], stateDir); expect(invokeMissing.ok).toBe(false); expect(invokeMissing.error?.type).toBe("tool_not_found"); // revoke: the session disappears from ls. - const revokeResult = await runCliJson(["revoke", alias], stateDir); + const revokeResult = await runCliJson(["sessions", "revoke", alias], stateDir); expect(revokeResult.ok).toBe(true); expect(revokeResult.data).toEqual({ ok: true }); - const finalLs = await runCliJson(["ls"], stateDir); + const finalLs = await runCliJson(["sessions", "ls"], stateDir); expect(finalLs.ok).toBe(true); expect(finalLs.data).toEqual([]); @@ -285,7 +299,7 @@ describe("appduct CLI v2: end-to-end command table", () => { expect(port).toBeGreaterThan(0); const claimOne = async (deviceModel: string): Promise<{ socket: WebSocket; alias: string }> => { - const linkResult = await runCliJson(["link", "--ttl", "60"], stateDir); + const linkResult = await runCliJson(["sessions", "link", "--ttl", "60"], stateDir); const linkData = linkResult.data as { deepLink: string }; const linkPayload = linkData.deepLink .slice(linkData.deepLink.indexOf("appduct=") + "appduct=".length) @@ -309,7 +323,7 @@ describe("appduct CLI v2: end-to-end command table", () => { const first = await claimOne("Pixel 8"); const second = await claimOne("Pixel 8"); - const toolsResult = await runCliJson(["tools"], stateDir); + const toolsResult = await runCliJson(["tools", "ls"], stateDir); expect(toolsResult.ok).toBe(false); expect(toolsResult.error?.type).toBe("ambiguous_session"); expect(toolsResult.error?.message).toContain(first.alias); @@ -336,7 +350,7 @@ describe("appduct CLI v2: end-to-end command table", () => { const port = (status.data as { daemon: { wss_port: number } }).daemon.wss_port; expect(port).toBeGreaterThan(0); - const linkResult = await runCliJson(["link", "--ttl", "60"], stateDir); + const linkResult = await runCliJson(["sessions", "link", "--ttl", "60"], stateDir); const linkData = linkResult.data as { deepLink: string }; const linkPayload = linkData.deepLink .slice(linkData.deepLink.indexOf("appduct=") + "appduct=".length) @@ -373,14 +387,14 @@ describe("appduct CLI v2: end-to-end command table", () => { await new Promise((resolve) => setTimeout(resolve, 100)); // Unpaged: `{ tools, total }`, total tools registered. - const unpaged = await runCliJson(["tools", alias], stateDir); + const unpaged = await runCliJson(["tools", "ls", alias], stateDir); expect(unpaged.ok).toBe(true); const unpagedData = unpaged.data as { tools: Array<{ name: string }>; total: number }; expect(unpagedData.tools).toHaveLength(30); expect(unpagedData.total).toBe(30); // --filter narrows both the page and `total`. - const filtered = await runCliJson(["tools", alias, "--filter", "tool_1"], stateDir); + const filtered = await runCliJson(["tools", "ls", alias, "--filter", "tool_1"], stateDir); expect(filtered.ok).toBe(true); const filteredData = filtered.data as { tools: Array<{ name: string }>; total: number }; // tool_10..tool_19 (substring match on the name), still name-sorted. @@ -388,43 +402,43 @@ describe("appduct CLI v2: end-to-end command table", () => { expect(filteredData.tools.map((tool) => tool.name)).toEqual(toolNames.slice(10, 20)); // --limit/--offset returns the right slice of the sorted, unfiltered registry. - const paged = await runCliJson(["tools", alias, "--limit", "5", "--offset", "5"], stateDir); + const paged = await runCliJson(["tools", "ls", alias, "--limit", "5", "--offset", "5"], stateDir); expect(paged.ok).toBe(true); const pagedData = paged.data as { tools: Array<{ name: string }>; total: number }; expect(pagedData.tools.map((tool) => tool.name)).toEqual(toolNames.slice(5, 10)); expect(pagedData.total).toBe(30); // A name lookup still resolves even though it would fall outside a small page. - const detailUnderPaging = await runCliJson(["tools", alias, "tool_29", "--limit", "1"], stateDir); + const detailUnderPaging = await runCliJson(["tools", "describe", alias, "tool_29", "--limit", "1"], stateDir); expect(detailUnderPaging.ok).toBe(false); expect(detailUnderPaging.error?.type).toBe("usage_error"); - const detail = await runCliJson(["tools", alias, "tool_29"], stateDir); + const detail = await runCliJson(["tools", "describe", alias, "tool_29"], stateDir); expect(detail.ok).toBe(true); expect((detail.data as { name: string }).name).toBe("tool_29"); - // The single-arg form that resolves to a tool name follows the same rule as ` `. - const probeUnderPaging = await runCliJson(["tools", "tool_29", "--limit", "1"], stateDir); - expect(probeUnderPaging.ok).toBe(false); - expect(probeUnderPaging.error?.type).toBe("usage_error"); + // `describe`'s listing flags are rejected next to a `` the same as an explicit ` `. + const describeWithLimit = await runCliJson(["tools", "describe", "tool_29", "--limit", "1"], stateDir); + expect(describeWithLimit.ok).toBe(false); + expect(describeWithLimit.error?.type).toBe("usage_error"); // A numeric-looking filter is matched as text, verbatim (cac alone would turn "07" into 7). - const numericFilter = await runCliJson(["tools", alias, "--filter", "07"], stateDir); + const numericFilter = await runCliJson(["tools", "ls", alias, "--filter", "07"], stateDir); expect(numericFilter.ok).toBe(true); const numericFilterData = numericFilter.data as { tools: Array<{ name: string }>; total: number; filter: string }; expect(numericFilterData.tools.map((tool) => tool.name)).toEqual(["tool_07"]); expect(numericFilterData.filter).toBe("07"); // An offset past the end is an empty page of a non-empty registry, not "No tools registered". - const pastEnd = await runCliHuman(["tools", alias, "--offset", "100"], stateDir); + const pastEnd = await runCliHuman(["tools", "ls", alias, "--offset", "100"], stateDir); expect(pastEnd.stdout).toContain("No tools at offset 100; 30 matching tools in total."); expect(pastEnd.stdout).not.toContain("No tools registered"); // Human output: a signature line per tool, and the "Showing" line once the page truncates. - const human = await runCliHuman(["tools", alias, "--limit", "5"], stateDir); + const human = await runCliHuman(["tools", "ls", alias, "--limit", "5"], stateDir); expect(human.stdout).toContain("tool_00(value: string)"); expect(human.stdout).toContain("Showing 5 of 30 tools (offset 0)."); - expect(human.stdout).toContain("Run `appduct tools ` for a tool's full schema."); + expect(human.stdout).toContain("Run `appduct tools describe ` for a tool's full schema."); socket.close(); @@ -444,7 +458,7 @@ describe("appduct CLI v2: end-to-end command table", () => { daemonPids.push((status.data as { daemon: { pid: number } }).daemon.pid); const port = (status.data as { daemon: { wss_port: number } }).daemon.wss_port; - const linkResult = await runCliJson(["link", "--ttl", "60"], stateDir); + const linkResult = await runCliJson(["sessions", "link", "--ttl", "60"], stateDir); const linkData = linkResult.data as { deepLink: string }; const linkPayload = linkData.deepLink .slice(linkData.deepLink.indexOf("appduct=") + "appduct=".length) @@ -482,7 +496,7 @@ describe("appduct CLI v2: end-to-end command table", () => { await new Promise((resolve) => setTimeout(resolve, 100)); // --json: `group` on each entry, `groups` on the listing (whole registry, parent first). - const json = await runCliJson(["tools", alias], stateDir); + const json = await runCliJson(["tools", "ls", alias], stateDir); expect(json.ok).toBe(true); const jsonData = json.data as { tools: Array<{ name: string; group?: string }>; @@ -500,7 +514,7 @@ describe("appduct CLI v2: end-to-end command table", () => { ]); // Human listing: group headings, a subgroup as an indented sub-heading, ungrouped last. - const human = await runCliHuman(["tools", alias], stateDir); + const human = await runCliHuman(["tools", "ls", alias], stateDir); const lines = stripAnsi(human.stdout).split("\n"); const indexOfLine = (line: string) => lines.indexOf(line); expect(indexOfLine(" cart")).toBeGreaterThan(-1); @@ -515,14 +529,14 @@ describe("appduct CLI v2: end-to-end command table", () => { expect(human.stdout).not.toContain("Showing"); // Truncated: the footer names the top-level groups (never subgroups) to narrow to. - const truncated = await runCliHuman(["tools", alias, "--limit", "5"], stateDir); + const truncated = await runCliHuman(["tools", "ls", alias, "--limit", "5"], stateDir); expect(truncated.stdout).toContain( "Showing 5 of 30 tools (offset 0). Narrow with --group (groups: cart 12, checkout 8, flags 6) " + "or --filter , or page with --offset .", ); // --group : includes the subgroup; `total` is the group's size. - const parent = await runCliJson(["tools", alias, "--group", "checkout"], stateDir); + const parent = await runCliJson(["tools", "ls", alias, "--group", "checkout"], stateDir); const parentData = parent.data as { tools: Array<{ name: string }>; total: number; group: string }; expect(parentData.total).toBe(8); expect(parentData.group).toBe("checkout"); @@ -538,44 +552,44 @@ describe("appduct CLI v2: end-to-end command table", () => { ]); // --group /: exactly the subgroup, flat (no headings), under its own title. - const sub = await runCliHuman(["tools", alias, "--group", "checkout/payment"], stateDir); + const sub = await runCliHuman(["tools", "ls", alias, "--group", "checkout/payment"], stateDir); expect(stripAnsi(sub.stdout)).toContain("Tools in group checkout/payment"); expect(sub.stdout).toContain(" payment_0()"); expect(sub.stdout).not.toContain("checkout_0"); expect(sub.stdout).not.toContain("(ungrouped)"); // An empty group (matching is case-sensitive) is not an empty registry. - const unknownGroup = await runCliHuman(["tools", alias, "--group", "Cart"], stateDir); + const unknownGroup = await runCliHuman(["tools", "ls", alias, "--group", "Cart"], stateDir); expect(stripAnsi(unknownGroup.stdout)).toContain( - 'Tools in group Cart\n No tools in group "Cart". Run `appduct tools --groups` to see the session\'s groups.', + 'Tools in group Cart\n No tools in group "Cart". Run `appduct tools ls --groups` to see the session\'s groups.', ); // --group combines with --filter and paging; the footer drops the group hint once narrowed. - const combined = await runCliHuman(["tools", alias, "--group", "cart", "--filter", "cart_1", "--limit", "1"], stateDir); + const combined = await runCliHuman(["tools", "ls", alias, "--group", "cart", "--filter", "cart_1", "--limit", "1"], stateDir); expect(combined.stdout).toContain("Showing 1 of 2 tools (offset 0). Narrow with --filter or page with --offset ."); // --groups: groups and counts only, subgroups indented under their parent. - const groupsHuman = await runCliHuman(["tools", alias, "--groups"], stateDir); + const groupsHuman = await runCliHuman(["tools", "ls", alias, "--groups"], stateDir); expect(stripAnsi(groupsHuman.stdout)).toContain("Groups\n cart 12\n checkout 8\n checkout/payment 3\n flags 6\n (ungrouped) 4\n"); expect(groupsHuman.stdout).not.toContain("cart_00"); - const groupsJson = await runCliJson(["tools", alias, "--groups"], stateDir); + const groupsJson = await runCliJson(["tools", "ls", alias, "--groups"], stateDir); expect(groupsJson.data).toEqual({ groups: jsonData.groups, total: 30 }); // Usage errors: a listing flag with a tool name, --groups with a narrowing flag, a bad group. for (const args of [ - ["tools", alias, "cart_00", "--group", "cart"], - ["tools", "cart_00", "--group", "cart"], - ["tools", alias, "cart_00", "--groups"], - ["tools", alias, "--groups", "--group", "cart"], - ["tools", alias, "--groups", "--filter", "x"], - ["tools", alias, "--groups", "--full"], - ["tools", alias, "--groups", "--limit", "2"], - ["tools", alias, "--groups", "--offset", "1"], - // The single-arg probe: `cart_00` resolves to a tool, so `--groups` is a listing flag on a lookup. - ["tools", "cart_00", "--groups"], - ["tools", alias, "--group", "a/b/c"], - ["tools", alias, "--group", "checkout/"], + ["tools", "describe", alias, "cart_00", "--group", "cart"], + ["tools", "describe", "cart_00", "--group", "cart"], + ["tools", "describe", alias, "cart_00", "--groups"], + ["tools", "ls", alias, "--groups", "--group", "cart"], + ["tools", "ls", alias, "--groups", "--filter", "x"], + ["tools", "ls", alias, "--groups", "--full"], + ["tools", "ls", alias, "--groups", "--limit", "2"], + ["tools", "ls", alias, "--groups", "--offset", "1"], + // `describe cart_00 --groups`: a listing flag on a lookup, unambiguous now. + ["tools", "describe", "cart_00", "--groups"], + ["tools", "ls", alias, "--group", "a/b/c"], + ["tools", "ls", alias, "--group", "checkout/"], ]) { const result = await runCliJson(args, stateDir); expect(result.ok, args.join(" ")).toBe(false); @@ -584,7 +598,7 @@ describe("appduct CLI v2: end-to-end command table", () => { // `--groups checkout` (a value on a boolean flag) points at `--group` instead of failing as // an unknown session. - const groupsWithValue = await runCliJson(["tools", "--groups", "checkout"], stateDir); + const groupsWithValue = await runCliJson(["tools", "ls", "--groups", "checkout"], stateDir); expect(groupsWithValue.ok).toBe(false); expect(groupsWithValue.error?.type).toBe("usage_error"); expect(groupsWithValue.error?.message).toContain('use "--group checkout"'); diff --git a/packages/appduct/src/__tests__/cli.integration.test.ts b/packages/appduct/src/__tests__/cli.integration.test.ts index 7306ff5..d52ef5e 100644 --- a/packages/appduct/src/__tests__/cli.integration.test.ts +++ b/packages/appduct/src/__tests__/cli.integration.test.ts @@ -31,7 +31,7 @@ describe("CLI integration", () => { } }); - test("help lists exactly the v2 command surface (ARCHITECTURE.md §10)", () => { + test("help lists exactly the noun-verb command surface (ARCHITECTURE.md §10, issue #96)", () => { const command = runCliBinary(["--help"]); expect(command.exitCode).toBe(0); @@ -47,18 +47,16 @@ describe("CLI integration", () => { .filter((line) => line.trim().length > 0) .map((line) => line.trim().split(/\s{2,}/u)[0]); - // Exactly the v2 command surface (ARCHITECTURE.md §10, plus `mcp` from §9) — v1's - // `host`/`connect`/`session` commands must never resurface here. + // Exactly the noun-verb command surface (ARCHITECTURE.md §10, plus `mcp` from §9): `ls`, + // `revoke`, `link` and `invoke` no longer exist as top-level commands (issue #96), and v1's + // `host`/`connect`/`session` commands must never resurface here either. expect(new Set(commandNames)).toEqual( new Set([ "init", "keygen", - "link", - "ls", - "tools [selector] [name]", - "invoke [selector] [tool]", - "events [selector]", - "revoke [selector]", + "sessions [...args]", + "tools [...args]", + "events [...args]", "mcp", "doctor ", "daemon [action]", @@ -94,22 +92,24 @@ describe("CLI integration", () => { expect(keygenHelp).toContain("--out"); expect(keygenHelp).toContain("--force"); - const linkHelp = helpFor("link"); - expect(linkHelp).toContain("--ttl"); - expect(linkHelp).toContain("--qr"); - expect(linkHelp).toContain("--scheme"); - expect(linkHelp).toContain("--open"); - expect(linkHelp).toContain("--device"); - expect(linkHelp).toContain("--app-id"); - expect(linkHelp).toContain("--relaunch"); - expect(linkHelp).toContain("ios-device"); + // `sessions` declares every flag any of its verbs uses (`link`'s, since `ls`/`revoke` take + // none), so `--help` on the noun lists them regardless of which verb is typed (issue #96). + const sessionsHelp = helpFor("sessions"); + expect(sessionsHelp).toContain("--ttl"); + expect(sessionsHelp).toContain("--qr"); + expect(sessionsHelp).toContain("--scheme"); + expect(sessionsHelp).toContain("--open"); + expect(sessionsHelp).toContain("--device"); + expect(sessionsHelp).toContain("--app-id"); + expect(sessionsHelp).toContain("--relaunch"); + expect(sessionsHelp).toContain("ios-device"); // `--app-id` has to survive cac's camelCasing all the way into `handleLinkCommand`, and a // flag that quietly parsed to `undefined` would look identical to one that was never passed: - // `link --open ios-sim --app-id ...` would then mint and deliver instead of erroring. The - // validation runs before any daemon contact, so this needs no state dir beyond an empty one. + // `sessions link --open ios-sim --app-id ...` would then mint and deliver instead of erroring. + // The validation runs before any daemon contact, so this needs no state dir beyond an empty one. const misplacedAppId = runCliBinary( - ["link", "--open", "ios-sim", "--app-id", "com.example.playground", "--json"], + ["sessions", "link", "--open", "ios-sim", "--app-id", "com.example.playground", "--json"], { stateDir: path.join(tmpdir(), "appduct-app-id-flag-nonexistent") }, ); expect(misplacedAppId.exitCode).not.toBe(0); @@ -118,12 +118,11 @@ describe("CLI integration", () => { /--app-id.{0,4} only applies with .{0,4}--open android.{0,4} or .{0,4}--open ios-device/u, ); + // `tools` declares every flag any of its verbs uses (`ls`'s and `call`'s). const toolsHelp = helpFor("tools"); expect(toolsHelp).toContain("--full"); - - const invokeHelp = helpFor("invoke"); - expect(invokeHelp).toContain("--input"); - expect(invokeHelp).toContain("--timeout"); + expect(toolsHelp).toContain("--input"); + expect(toolsHelp).toContain("--timeout"); const eventsHelp = helpFor("events"); expect(eventsHelp).toContain("--follow"); diff --git a/packages/appduct/src/__tests__/command-options.test.ts b/packages/appduct/src/__tests__/command-options.test.ts index ce2bc15..f66cc61 100644 --- a/packages/appduct/src/__tests__/command-options.test.ts +++ b/packages/appduct/src/__tests__/command-options.test.ts @@ -6,7 +6,6 @@ import { parsePositiveIntegerOption, readTextOption, splitOptionalSelector, - splitOptionalSelectorAndTarget, splitSelectorAndRequiredTarget, } from "../cli/command-options.js"; @@ -97,58 +96,37 @@ describe("parseJsonInputOption", () => { }); }); -describe("splitSelectorAndRequiredTarget (invoke [selector] )", () => { +describe("splitSelectorAndRequiredTarget (tools call [selector] / tools describe [selector] / events since [selector] )", () => { test("one arg: no selector, target is the sole arg", () => { - expect(splitSelectorAndRequiredTarget(["echo"], "invoke")).toEqual({ target: "echo" }); + expect(splitSelectorAndRequiredTarget(["echo"], "tools call")).toEqual({ target: "echo" }); }); test("two args: selector then target", () => { - expect(splitSelectorAndRequiredTarget(["pixel-8", "echo"], "invoke")).toEqual({ + expect(splitSelectorAndRequiredTarget(["pixel-8", "echo"], "tools call")).toEqual({ selector: "pixel-8", target: "echo", }); }); test("zero args: usage error (target is required)", () => { - expect(() => splitSelectorAndRequiredTarget([], "invoke")).toThrow(/Usage/u); + expect(() => splitSelectorAndRequiredTarget([], "tools call")).toThrow(/Usage/u); }); test("three or more args: usage error", () => { - expect(() => splitSelectorAndRequiredTarget(["a", "b", "c"], "invoke")).toThrow(/Usage/u); + expect(() => splitSelectorAndRequiredTarget(["a", "b", "c"], "tools call")).toThrow(/Usage/u); }); }); -describe("splitOptionalSelector (revoke [selector] / events [selector])", () => { +describe("splitOptionalSelector (sessions revoke [selector] / events tail [selector])", () => { test("zero args", () => { - expect(splitOptionalSelector([], "revoke")).toEqual({ selector: undefined }); + expect(splitOptionalSelector([], "sessions revoke")).toEqual({ selector: undefined }); }); test("one arg", () => { - expect(splitOptionalSelector(["pixel-8"], "revoke")).toEqual({ selector: "pixel-8" }); + expect(splitOptionalSelector(["pixel-8"], "sessions revoke")).toEqual({ selector: "pixel-8" }); }); test("two or more args: usage error", () => { - expect(() => splitOptionalSelector(["a", "b"], "revoke")).toThrow(/Usage/u); - }); -}); - -describe("splitOptionalSelectorAndTarget (tools [selector] [name])", () => { - test("zero args", () => { - expect(splitOptionalSelectorAndTarget([], "tools")).toEqual({}); - }); - - test("one arg: ambiguous, comes back as selectorOrTarget", () => { - expect(splitOptionalSelectorAndTarget(["echo"], "tools")).toEqual({ selectorOrTarget: "echo" }); - }); - - test("two args: selector then target, unambiguous", () => { - expect(splitOptionalSelectorAndTarget(["pixel-8", "echo"], "tools")).toEqual({ - selector: "pixel-8", - target: "echo", - }); - }); - - test("three or more args: usage error", () => { - expect(() => splitOptionalSelectorAndTarget(["a", "b", "c"], "tools")).toThrow(/Usage/u); + expect(() => splitOptionalSelector(["a", "b"], "sessions revoke")).toThrow(/Usage/u); }); }); diff --git a/packages/appduct/src/__tests__/create-cli.test.ts b/packages/appduct/src/__tests__/create-cli.test.ts new file mode 100644 index 0000000..057edbd --- /dev/null +++ b/packages/appduct/src/__tests__/create-cli.test.ts @@ -0,0 +1,64 @@ +/** + * `cli/create-cli.ts` (issue #96, criterion 5): cac matches only the noun and builds its + * boolean/string flag table from that noun's own declared options, so every verb's flags have to + * be declared at the noun level or a boolean flag ahead of a positional would swallow it as that + * flag's value. Checked directly against `cac`'s parse result — no daemon, no subprocess. + */ + +import { describe, expect, test } from "vitest"; + +import { createCli } from "../cli/create-cli.js"; + +const parse = (argv: string[]) => { + const cli = createCli(); + cli.parse(["node", "appduct", ...argv], { run: false }); + + return { matched: cli.matchedCommandName, args: cli.args as string[], options: cli.options as Record }; +}; + +describe("a boolean flag ahead of a positional does not swallow it", () => { + test('"tools ls --full " parses --full as a boolean and keeps the selector', () => { + const result = parse(["tools", "ls", "--full", "my-selector"]); + + expect(result.matched).toBe("tools"); + expect(result.args).toEqual(["ls", "my-selector"]); + expect(result.options.full).toBe(true); + }); + + test('"sessions link --qr --open ios-sim" parses both flags and keeps their values', () => { + const result = parse(["sessions", "link", "--qr", "--open", "ios-sim"]); + + expect(result.matched).toBe("sessions"); + expect(result.args).toEqual(["link"]); + expect(result.options.qr).toBe(true); + expect(result.options.open).toBe("ios-sim"); + }); +}); + +describe("each noun declares every option its verbs use", () => { + test('"tools call" flags (--input/--timeout) parse under the "tools" command', () => { + const result = parse(["tools", "call", "my-selector", "echo", "--input", "{}", "--timeout", "500"]); + + expect(result.matched).toBe("tools"); + expect(result.args).toEqual(["call", "my-selector", "echo"]); + expect(result.options.input).toBe("{}"); + expect(result.options.timeout).toBe(500); + }); + + test('"sessions ls"/"sessions revoke" take no flags of their own but still match the "sessions" command', () => { + const ls = parse(["sessions", "ls", "my-selector"]); + expect(ls.matched).toBe("sessions"); + expect(ls.args).toEqual(["ls", "my-selector"]); + + const revoke = parse(["sessions", "revoke", "my-selector"]); + expect(revoke.matched).toBe("sessions"); + expect(revoke.args).toEqual(["revoke", "my-selector"]); + }); + + test('"events since " keeps the cursor as a positional, not a flag value', () => { + const result = parse(["events", "since", "my-selector", "42"]); + + expect(result.matched).toBe("events"); + expect(result.args).toEqual(["since", "my-selector", "42"]); + }); +}); diff --git a/packages/appduct/src/__tests__/e2e/churn.e2e.test.ts b/packages/appduct/src/__tests__/e2e/churn.e2e.test.ts index 69ee84b..644cd8c 100644 --- a/packages/appduct/src/__tests__/e2e/churn.e2e.test.ts +++ b/packages/appduct/src/__tests__/e2e/churn.e2e.test.ts @@ -52,12 +52,12 @@ describe("e2e: churn", () => { app.dropSocket(); await suspended; - const suspendedLs = await runCliJson>(["ls"], stateDir); + const suspendedLs = await runCliJson>(["sessions", "ls"], stateDir); expect(suspendedLs.ok).toBe(true); expect(suspendedLs.data).toEqual([expect.objectContaining({ alias, state: "suspended" })]); // A call against a SUSPENDED session fails fast with session_suspended, not a timeout. - const failedInvoke = await runCliJson(["invoke", alias, "echo", "--input", "{}"], stateDir); + const failedInvoke = await runCliJson(["tools", "call", alias, "echo", "--input", "{}"], stateDir); expect(failedInvoke.ok).toBe(false); expect(failedInvoke.error?.type).toBe("session_suspended"); @@ -70,7 +70,7 @@ describe("e2e: churn", () => { await events.waitFor("tools_changed"); app.answerCalls(() => ({ result: "ok-after-resume" })); - const resumedInvoke = await runCliJson(["invoke", alias, "echo", "--input", "{}"], stateDir); + const resumedInvoke = await runCliJson(["tools", "call", alias, "echo", "--input", "{}"], stateDir); expect(resumedInvoke.ok).toBe(true); expect(resumedInvoke.data).toBe("ok-after-resume"); @@ -82,7 +82,7 @@ describe("e2e: churn", () => { const expired = events.waitFor("session_expired"); await expired; - const afterExpiryLs = await runCliJson(["ls"], stateDir); + const afterExpiryLs = await runCliJson(["sessions", "ls"], stateDir); expect(afterExpiryLs.ok).toBe(true); expect(afterExpiryLs.data).toEqual([]); diff --git a/packages/appduct/src/__tests__/e2e/cold-start.e2e.test.ts b/packages/appduct/src/__tests__/e2e/cold-start.e2e.test.ts index 006bc03..2200cce 100644 --- a/packages/appduct/src/__tests__/e2e/cold-start.e2e.test.ts +++ b/packages/appduct/src/__tests__/e2e/cold-start.e2e.test.ts @@ -47,7 +47,7 @@ describe("e2e: cold start", () => { // link: mints a pending session. This is the first command to touch the daemon, so it is the // one that auto-spawns it (ARCHITECTURE.md §4). const linkResult = await runCliJson<{ sessionId: string; deepLink: string; endpoint: { port: number } }>( - ["link", "--ttl", "60"], + ["sessions", "link", "--ttl", "60"], stateDir, ); expect(linkResult.ok).toBe(true); @@ -82,7 +82,7 @@ describe("e2e: cold start", () => { await toolsChanged; // ls: the claimed session shows up ACTIVE with its device metadata and tool count. - const lsResult = await runCliJson>(["ls"], stateDir); + const lsResult = await runCliJson>(["sessions", "ls"], stateDir); expect(lsResult.ok).toBe(true); expect(lsResult.data).toHaveLength(1); expect(lsResult.data![0]!.alias).toBe(alias); @@ -90,7 +90,7 @@ describe("e2e: cold start", () => { expect(lsResult.data![0]!.toolCount).toBe(1); // tools: list, then detail by name. - const toolsList = await runCliJson<{ tools: Array<{ name: string }>; total: number }>(["tools", alias], stateDir); + const toolsList = await runCliJson<{ tools: Array<{ name: string }>; total: number }>(["tools", "ls", alias], stateDir); expect(toolsList.ok).toBe(true); expect(toolsList.data!.tools.map((tool) => tool.name)).toEqual(["echo"]); expect(toolsList.data!.total).toBe(1); @@ -99,7 +99,7 @@ describe("e2e: cold start", () => { app.answerCalls((call) => ({ result: { echoed: (call.args as Record).text } })); const invokeResult = await runCliJson( - ["invoke", alias, "echo", "--input", JSON.stringify({ text: "hello" })], + ["tools", "call", alias, "echo", "--input", JSON.stringify({ text: "hello" })], stateDir, ); expect(invokeResult.ok).toBe(true); @@ -107,12 +107,12 @@ describe("e2e: cold start", () => { // revoke: the session disappears from ls, and the app's socket is closed with code 1000. const revokeClosed = app.waitForClose(); - const revokeResult = await runCliJson(["revoke", alias], stateDir); + const revokeResult = await runCliJson(["sessions", "revoke", alias], stateDir); expect(revokeResult.ok).toBe(true); const closeInfo = await revokeClosed; expect(closeInfo.code).toBe(1000); - const finalLs = await runCliJson(["ls"], stateDir); + const finalLs = await runCliJson(["sessions", "ls"], stateDir); expect(finalLs.ok).toBe(true); expect(finalLs.data).toEqual([]); diff --git a/packages/appduct/src/__tests__/e2e/daemon-restart.e2e.test.ts b/packages/appduct/src/__tests__/e2e/daemon-restart.e2e.test.ts index a5f6d6f..6e9b866 100644 --- a/packages/appduct/src/__tests__/e2e/daemon-restart.e2e.test.ts +++ b/packages/appduct/src/__tests__/e2e/daemon-restart.e2e.test.ts @@ -52,7 +52,7 @@ describe("e2e: daemon restart", () => { // The next command has nothing listening at the stale socket path: it must auto-spawn a fresh // daemon rather than fail (ARCHITECTURE.md §4: "a stale socket file with a dead pid is // unlinked before spawning"). - const lsResult = await runCliJson(["ls"], stateDir); + const lsResult = await runCliJson(["sessions", "ls"], stateDir); expect(lsResult.ok).toBe(true); // The old session does not survive daemon death (documented behavior, not a bug). expect(lsResult.data).toEqual([]); diff --git a/packages/appduct/src/__tests__/e2e/daemon-version-mismatch.e2e.test.ts b/packages/appduct/src/__tests__/e2e/daemon-version-mismatch.e2e.test.ts index e7da687..f9ce67c 100644 --- a/packages/appduct/src/__tests__/e2e/daemon-version-mismatch.e2e.test.ts +++ b/packages/appduct/src/__tests__/e2e/daemon-version-mismatch.e2e.test.ts @@ -64,7 +64,7 @@ const readStatus = async (stateDir: string): Promise => { /** * One raw JSON-RPC request over the control socket. Used only to mint a link for the live-session - * scenario: `appduct link` is itself one of the commands that runs the version check, so using + * scenario: `appduct sessions link` is itself one of the commands that runs the version check, so using * it as *setup* would restart the very daemon the test needs to keep stale. Every scenario action * still goes through a real CLI subprocess or the fake app client. */ @@ -162,7 +162,7 @@ describe("e2e: daemon/CLI version drift", () => { const { stateDir } = await makeTempStateDir(); const stalePid = await startStaleDaemon(stateDir); - const lsResult = await runCliJson(["ls"], stateDir); + const lsResult = await runCliJson(["sessions", "ls"], stateDir); expect(lsResult.ok).toBe(true); expect(lsResult.data).toEqual([]); @@ -199,7 +199,7 @@ describe("e2e: daemon/CLI version drift", () => { await app.claim(link, { model: "Pixel 8" }); try { - const lsResult = await runCliJson(["ls"], stateDir); + const lsResult = await runCliJson(["sessions", "ls"], stateDir); expect(lsResult.ok).toBe(false); expect(lsResult.exitCode).toBe(70); @@ -243,7 +243,7 @@ describe("e2e: daemon/CLI version drift", () => { await app.claim(link, { model: "Pixel 8" }); const socketClosed = app.waitForClose(); - const lsResult = await runCliJson(["ls", "--daemon-restart"], stateDir); + const lsResult = await runCliJson(["sessions", "ls", "--daemon-restart"], stateDir); expect(lsResult.ok).toBe(true); // The session went with the old daemon — the documented cost the default refuses to pay. expect(lsResult.data).toEqual([]); @@ -307,7 +307,7 @@ describe("e2e: forcing a version-drift restart", () => { await app.claim(link, { model: "Pixel 8" }); // The env form exists for exactly this: an MCP launch config passes no CLI flags. - const lsResult = await runCliJson(["ls"], stateDir, { + const lsResult = await runCliJson(["sessions", "ls"], stateDir, { APPDUCT_DAEMON_RESTART: "1", }); @@ -344,7 +344,7 @@ describe("e2e: forcing a version-drift restart", () => { // Config says "always restart"; the flag says "not this time". A flag that parsed cleanly // and then did nothing would be the worst outcome for a knob that decides whether the // operator's connected device survives the next command. - const lsResult = await runCliJson(["ls", "--no-daemon-restart"], stateDir); + const lsResult = await runCliJson(["sessions", "ls", "--no-daemon-restart"], stateDir); expect(lsResult.ok).toBe(false); expect(lsResult.error?.type).toBe("connection_error"); @@ -370,7 +370,7 @@ describe("e2e: forcing a version-drift restart", () => { // session, so nothing in `sessions` protects it — the daemon reports it separately. await mintLinkWithoutCli(stateDir); - const lsResult = await runCliJson(["ls"], stateDir); + const lsResult = await runCliJson(["sessions", "ls"], stateDir); expect(lsResult.ok).toBe(false); expect(lsResult.error?.type).toBe("connection_error"); @@ -407,14 +407,14 @@ describe("e2e: a daemon newer than the CLI", () => { const newerPid = await startStaleDaemon(stateDir, NEWER_VERSION); // Human mode: the operator gets told, and the daemon keeps serving. - const human = await runCliCapturing(["ls"], stateDir); + const human = await runCliCapturing(["sessions", "ls"], stateDir); expect(human.exitCode).toBe(0); expect(human.stderr).toContain(NEWER_VERSION); expect(human.stderr).toContain(CLI_VERSION); // `--json` promises one machine-readable object and nothing else; a bare line of prose on // stderr would corrupt a script that captures both streams. - const machine = await runCliCapturing(["ls", "--json"], stateDir); + const machine = await runCliCapturing(["sessions", "ls", "--json"], stateDir); expect(machine.exitCode).toBe(0); expect(machine.stderr).toBe(""); expect(JSON.parse(machine.stdout).ok).toBe(true); @@ -438,7 +438,7 @@ describe("e2e: an expired link no longer blocks an upgrade", () => { await mintLinkWithoutCli(stateDir); // While the link is claimable it is live state, and the restart is refused. - const blocked = await runCliJson(["ls"], stateDir); + const blocked = await runCliJson(["sessions", "ls"], stateDir); expect(blocked.ok).toBe(false); expect(blocked.error?.details).toMatchObject({ pending_link_count: 1 }); @@ -449,7 +449,7 @@ describe("e2e: an expired link no longer blocks an upgrade", () => { { timeoutMs: 10_000, intervalMs: 100, description: "the minted link to pass its TTL" }, ); - const lsResult = await runCliJson(["ls"], stateDir); + const lsResult = await runCliJson(["sessions", "ls"], stateDir); expect(lsResult.ok).toBe(true); const status = await readStatus(stateDir); diff --git a/packages/appduct/src/__tests__/e2e/events.e2e.test.ts b/packages/appduct/src/__tests__/e2e/events.e2e.test.ts index e508f5f..624c3ee 100644 --- a/packages/appduct/src/__tests__/e2e/events.e2e.test.ts +++ b/packages/appduct/src/__tests__/e2e/events.e2e.test.ts @@ -1,5 +1,5 @@ /** - * E2E scenario: events. `appduct events --json` streams NDJSON; this drives the full session + * E2E scenario: events. `appduct events tail --json` streams NDJSON; this drives the full session * lifecycle (claim, tool registration, an app event, a tool call, a second app event) through a real * CLI subprocess and fake app client and asserts the subscriber prints the app's own events only. */ @@ -23,7 +23,7 @@ afterEach(cleanupAfterEach); type CapturedLine = { kind: string; sessionId?: string; alias?: string; ts: string; data: unknown }; -/** Reads NDJSON lines from a spawned `events --json` subprocess's stdout as they arrive. */ +/** Reads NDJSON lines from a spawned `events tail --json` subprocess's stdout as they arrive. */ const collectLines = (proc: ReturnType): { lines: CapturedLine[]; stop: () => void } => { const lines: CapturedLine[] = []; let buffered = ""; @@ -69,7 +69,7 @@ const waitForAppEventLines = async (lines: CapturedLine[], count: number, timeou } }; -describe("e2e: events --json", () => { +describe("e2e: events tail --json", () => { test( "prints only the app's own events across claim, tool registration and a tool call", async () => { @@ -80,7 +80,7 @@ describe("e2e: events --json", () => { const port = await daemonWssPort(stateDir); const pinnedKeys = await fetchPinnedKeys(stateDir); - const eventsProcess = spawnCli(["events", "--json"], stateDir); + const eventsProcess = spawnCli(["events", "tail", "--json"], stateDir); const { lines, stop } = collectLines(eventsProcess); // The subscriber must be attached before the claim fires, or the lifecycle events this test @@ -99,7 +99,7 @@ describe("e2e: events --json", () => { await waitForAppEventLines(lines, 1); app.answerCalls(() => ({ result: "ok" })); - const invokeResult = await runCliJson(["invoke", alias, "echo", "--input", "{}"], stateDir); + const invokeResult = await runCliJson(["tools", "call", alias, "echo", "--input", "{}"], stateDir); expect(invokeResult.ok).toBe(true); app.emitEvent("second", { n: 2 }); diff --git a/packages/appduct/src/__tests__/e2e/harness.ts b/packages/appduct/src/__tests__/e2e/harness.ts index a82a779..7f6b309 100644 --- a/packages/appduct/src/__tests__/e2e/harness.ts +++ b/packages/appduct/src/__tests__/e2e/harness.ts @@ -224,12 +224,12 @@ export const decodeDeepLink = (deepLink: string): DecodedLink => { return { sessionId: decoded.sessionId, token: decoded.token, port: decoded.port }; }; -/** Mints a link through a real `appduct link` CLI subprocess and decodes it. */ +/** Mints a link through a real `appduct sessions link` CLI subprocess and decodes it. */ export const mintLink = async ( stateDir: string, options: { ttlSeconds?: number } = {}, ): Promise => { - const args = ["link"]; + const args = ["sessions", "link"]; if (options.ttlSeconds !== undefined) { args.push("--ttl", String(options.ttlSeconds)); @@ -238,7 +238,7 @@ export const mintLink = async ( const result = await runCliJson<{ deepLink: string }>(args, stateDir); if (!result.ok || !result.data) { - throw new Error(`"link" failed: ${JSON.stringify(result)}`); + throw new Error(`"sessions link" failed: ${JSON.stringify(result)}`); } return decodeDeepLink(result.data.deepLink); diff --git a/packages/appduct/src/__tests__/e2e/hostility.e2e.test.ts b/packages/appduct/src/__tests__/e2e/hostility.e2e.test.ts index 1b79ea3..a5348b7 100644 --- a/packages/appduct/src/__tests__/e2e/hostility.e2e.test.ts +++ b/packages/appduct/src/__tests__/e2e/hostility.e2e.test.ts @@ -114,7 +114,7 @@ describe("e2e: hostility", () => { // The ACTIVE session, untouched by any of the hostile connections, keeps invoking successfully. app.answerCalls((call) => ({ result: { echoed: (call.args as Record).text } })); const invokeResult = await runCliJson( - ["invoke", alias, "echo", "--input", JSON.stringify({ text: "still-alive" })], + ["tools", "call", alias, "echo", "--input", JSON.stringify({ text: "still-alive" })], stateDir, ); expect(invokeResult.ok).toBe(true); diff --git a/packages/appduct/src/__tests__/e2e/invoke-cancel.e2e.test.ts b/packages/appduct/src/__tests__/e2e/invoke-cancel.e2e.test.ts index fffea2c..a0a37b6 100644 --- a/packages/appduct/src/__tests__/e2e/invoke-cancel.e2e.test.ts +++ b/packages/appduct/src/__tests__/e2e/invoke-cancel.e2e.test.ts @@ -1,5 +1,5 @@ /** - * E2E scenario: `appduct invoke` + SIGINT (issue #9). SIGINT during an in-flight `tools.call` + * E2E scenario: `appduct tools call` + SIGINT (issue #9). SIGINT during an in-flight `tools.call` * must cancel the call rather than leave it running unowned in the app — this drives a real CLI * subprocess against a real daemon and fake app-client, the same harness as `events.e2e.test.ts`. */ @@ -20,7 +20,7 @@ import { afterEach(cleanupAfterEach); -describe("e2e: appduct invoke + SIGINT", () => { +describe("e2e: appduct tools call + SIGINT", () => { test( "SIGINT cancels the in-flight call: the app receives tool_cancel and the CLI exits non-zero as tool_cancelled", async () => { @@ -43,7 +43,7 @@ describe("e2e: appduct invoke + SIGINT", () => { const gotToolCall = app.waitForToolCall(); const gotToolCancel = app.waitForToolCancel(); - const invokeProcess = spawnCli(["invoke", alias, "slow", "--input", "{}", "--json"], stateDir); + const invokeProcess = spawnCli(["tools", "call", alias, "slow", "--input", "{}", "--json"], stateDir); // Drain stderr so the child never blocks on a full pipe buffer; stdout is collected below. invokeProcess.stderr.resume(); const stdoutChunks: Buffer[] = []; diff --git a/packages/appduct/src/__tests__/e2e/multi-device.e2e.test.ts b/packages/appduct/src/__tests__/e2e/multi-device.e2e.test.ts index 5c48304..578a496 100644 --- a/packages/appduct/src/__tests__/e2e/multi-device.e2e.test.ts +++ b/packages/appduct/src/__tests__/e2e/multi-device.e2e.test.ts @@ -49,12 +49,12 @@ describe("e2e: multi-device", () => { const deviceB = await claimDevice("Pixel 8"); expect(deviceA.alias).not.toBe(deviceB.alias); - const lsResult = await runCliJson>(["ls"], stateDir); + const lsResult = await runCliJson>(["sessions", "ls"], stateDir); expect(lsResult.ok).toBe(true); expect(lsResult.data!.map((session) => session.alias).sort()).toEqual([deviceA.alias, deviceB.alias].sort()); // No selector, two live sessions: ambiguous_session, listing both aliases. - const ambiguous = await runCliJson(["invoke", "echo", "--input", "{}"], stateDir); + const ambiguous = await runCliJson(["tools", "call", "echo", "--input", "{}"], stateDir); expect(ambiguous.ok).toBe(false); expect(ambiguous.error?.type).toBe("ambiguous_session"); expect(ambiguous.error?.message).toContain(deviceA.alias); @@ -64,26 +64,26 @@ describe("e2e: multi-device", () => { deviceA.app.answerCalls(() => ({ result: "from-a" })); deviceB.app.answerCalls(() => ({ result: "from-b" })); - const invokeA = await runCliJson(["invoke", deviceA.alias, "echo", "--input", "{}"], stateDir); + const invokeA = await runCliJson(["tools", "call", deviceA.alias, "echo", "--input", "{}"], stateDir); expect(invokeA.ok).toBe(true); expect(invokeA.data).toBe("from-a"); - const invokeB = await runCliJson(["invoke", deviceB.alias, "echo", "--input", "{}"], stateDir); + const invokeB = await runCliJson(["tools", "call", deviceB.alias, "echo", "--input", "{}"], stateDir); expect(invokeB.ok).toBe(true); expect(invokeB.data).toBe("from-b"); // revoke device A: its socket closes, but device B stays untouched and invokable. const revokeAClosed = deviceA.app.waitForClose(); - const revokeResult = await runCliJson(["revoke", deviceA.alias], stateDir); + const revokeResult = await runCliJson(["sessions", "revoke", deviceA.alias], stateDir); expect(revokeResult.ok).toBe(true); const closeInfo = await revokeAClosed; expect(closeInfo.code).toBe(1000); - const afterRevokeLs = await runCliJson>(["ls"], stateDir); + const afterRevokeLs = await runCliJson>(["sessions", "ls"], stateDir); expect(afterRevokeLs.ok).toBe(true); expect(afterRevokeLs.data).toEqual([expect.objectContaining({ alias: deviceB.alias, state: "active" })]); - const stillWorks = await runCliJson(["invoke", deviceB.alias, "echo", "--input", "{}"], stateDir); + const stillWorks = await runCliJson(["tools", "call", deviceB.alias, "echo", "--input", "{}"], stateDir); expect(stillWorks.ok).toBe(true); expect(stillWorks.data).toBe("from-b"); diff --git a/packages/appduct/src/__tests__/e2e/policy-audit.e2e.test.ts b/packages/appduct/src/__tests__/e2e/policy-audit.e2e.test.ts index 6b473f7..d2c4d3e 100644 --- a/packages/appduct/src/__tests__/e2e/policy-audit.e2e.test.ts +++ b/packages/appduct/src/__tests__/e2e/policy-audit.e2e.test.ts @@ -1,7 +1,7 @@ /** * Policy/audit: destructive-deny + audit line assertions, driven through the real CLI subprocess * (a separate unit suite exercises the same policy/audit engine directly against the daemon's UDS - * RPC; this drives the identical policy decision through `appduct invoke`). + * RPC; this drives the identical policy decision through `appduct tools call`). */ import { readFile } from "node:fs/promises"; @@ -48,7 +48,7 @@ const waitForBothAuditRecords = async (stateDir: string): Promise describe("e2e: policy and audit", () => { test( - "a destructive-hinted tool is denied by policy via `appduct invoke`, and every attempt is audited without raw args", + "a destructive-hinted tool is denied by policy via `appduct tools call`, and every attempt is audited without raw args", async () => { const { stateDir } = await makeTempStateDir({ policy: { destructive: "deny" } }); await ensureDaemon(stateDir); @@ -75,12 +75,12 @@ describe("e2e: policy and audit", () => { const sentinelSecret = "sentinel-secret-should-never-appear-in-audit-log"; const okInvoke = await runCliJson( - ["invoke", alias, "echo", "--input", JSON.stringify({ secret: sentinelSecret })], + ["tools", "call", alias, "echo", "--input", JSON.stringify({ secret: sentinelSecret })], stateDir, ); expect(okInvoke.ok).toBe(true); - const deniedInvoke = await runCliJson(["invoke", alias, "deleteAll", "--input", "{}"], stateDir); + const deniedInvoke = await runCliJson(["tools", "call", alias, "deleteAll", "--input", "{}"], stateDir); expect(deniedInvoke.ok).toBe(false); expect(deniedInvoke.error?.type).toBe("policy_denied"); // The hint names the config file the operator would edit to change this (ARCHITECTURE.md §12). diff --git a/packages/appduct/src/__tests__/events.integration.test.ts b/packages/appduct/src/__tests__/events.integration.test.ts index 5b3e13c..793bbbf 100644 --- a/packages/appduct/src/__tests__/events.integration.test.ts +++ b/packages/appduct/src/__tests__/events.integration.test.ts @@ -1,8 +1,9 @@ /** - * `appduct events --json` (ARCHITECTURE.md §10): spawns the CLI as a subprocess and asserts + * `appduct events tail --json` (ARCHITECTURE.md §10): spawns the CLI as a subprocess and asserts * line-delimited parseability. Drives a real daemon (auto-spawned by the first - * CLI call) and a real `events` subprocess, asserts each stdout line is independently parseable - * NDJSON carrying an app event, then confirms Ctrl-C (SIGINT) ends the stream cleanly (exit 0). + * CLI call) and a real `events tail` subprocess, asserts each stdout line is independently + * parseable NDJSON carrying an app event, then confirms Ctrl-C (SIGINT) ends the stream cleanly + * (exit 0). */ import { afterEach, describe, expect, test } from "vitest"; @@ -80,7 +81,7 @@ const nextMessage = (socket: WebSocket): Promise> => { const claimAppOverCli = async ( stateDir: string, ): Promise<{ socket: WebSocket; alias: string; sessionId: string }> => { - const linkResult = runCliJson(["link", "--ttl", "30", "--scheme", "appduct-events-since-test"], stateDir); + const linkResult = runCliJson(["sessions", "link", "--ttl", "30", "--scheme", "appduct-events-since-test"], stateDir); expect(linkResult.ok).toBe(true); const payload = (linkResult.data.deepLink as string).split("appduct=")[1]!.split("&")[0]!; @@ -110,7 +111,7 @@ const claimAppOverCli = async ( return { socket, alias: ack.alias as string, sessionId: decoded.sessionId }; }; -describe("appduct events --json", () => { +describe("appduct events tail --json", () => { test("streams only app_event lines as NDJSON and exits 0 on SIGINT", async () => { const { stateDir } = await makeTempStateDir(); @@ -119,7 +120,7 @@ describe("appduct events --json", () => { expect(status.ok).toBe(true); daemonPids.push(status.data.daemon.pid); - const eventsProcess = spawnCliBinary(["events", "--json"], { stateDir }); + const eventsProcess = spawnCliBinary(["events", "tail", "--json"], { stateDir }); const lines: string[] = []; let buffered = ""; @@ -189,7 +190,7 @@ describe("appduct events --json", () => { // Attach the data listener before the process can exit — the child's stdout write and its // `process.exit()` race the parent's own read otherwise, and a `for await` started only once // the child has already exited can end up seeing nothing. - const sinceProcess = spawnCliBinary(["events", alias, "--since", "0", "--json"], { stateDir }); + const sinceProcess = spawnCliBinary(["events", "since", alias, "0", "--json"], { stateDir }); let stdout = ""; sinceProcess.stdout.on("data", (chunk: Buffer) => { stdout += chunk.toString("utf8"); @@ -212,7 +213,7 @@ describe("appduct events --json", () => { // The session's claim is not printed, only the app's own event. expect(eventLines.map((line) => JSON.parse(line).kind)).toEqual(["app_event"]); - const drainedProcess = spawnCliBinary(["events", alias, "--since", String(cursorLine.cursor), "--json"], { stateDir }); + const drainedProcess = spawnCliBinary(["events", "since", alias, String(cursorLine.cursor), "--json"], { stateDir }); let drainedStdout = ""; drainedProcess.stdout.on("data", (chunk: Buffer) => { drainedStdout += chunk.toString("utf8"); diff --git a/packages/appduct/src/__tests__/exit-codes.integration.test.ts b/packages/appduct/src/__tests__/exit-codes.integration.test.ts index 8bbd555..10d493d 100644 --- a/packages/appduct/src/__tests__/exit-codes.integration.test.ts +++ b/packages/appduct/src/__tests__/exit-codes.integration.test.ts @@ -1,7 +1,7 @@ /** - * An `exit-codes` test suite for the v2 commands: the v1 suite's convention of asserting exact - * codes per failure class is worth reproducing. Each case here is a real CLI subprocess against a - * real daemon, asserting both the exit code and the JSON error's `type`. + * An `exit-codes` test suite for the noun-verb commands (issue #96): the v1 suite's convention of + * asserting exact codes per failure class is worth reproducing. Each case here is a real CLI + * subprocess against a real daemon, asserting both the exit code and the JSON error's `type`. */ import path from "node:path"; @@ -65,7 +65,7 @@ const runCli = (args: string[], stateDir: string) => { }; }; -describe("exit codes: v2 command surface", () => { +describe("exit codes: noun-verb command surface", () => { test("usage_error (64): an unknown command", async () => { const stateDir = await makeTempStateDir(); const { exitCode, payload } = runCli(["not-a-real-command"], stateDir); @@ -74,9 +74,9 @@ describe("exit codes: v2 command surface", () => { expect(payload.error.type).toBe("usage_error"); }); - test("usage_error (64): events --since combined with --follow", async () => { + test("usage_error (64): events since with a non-numeric cursor", async () => { const stateDir = await makeTempStateDir(); - const { exitCode, payload } = runCli(["events", "--since", "0", "--follow"], stateDir); + const { exitCode, payload } = runCli(["events", "since", "not-a-number"], stateDir); expect(exitCode).toBe(64); expect(payload.error.type).toBe("usage_error"); @@ -94,9 +94,9 @@ describe("exit codes: v2 command surface", () => { expect(second.payload.error.type).toBe("usage_error"); }); - test("validation_error (65): invoke --input is not valid JSON", async () => { + test("validation_error (65): tools call --input is not valid JSON", async () => { const stateDir = await makeTempStateDir(); - const { exitCode, payload } = runCli(["invoke", "some-tool", "--input", "{not-json"], stateDir); + const { exitCode, payload } = runCli(["tools", "call", "some-tool", "--input", "{not-json"], stateDir); expect(exitCode).toBe(65); expect(payload.error.type).toBe("validation_error"); @@ -112,14 +112,14 @@ describe("exit codes: v2 command surface", () => { test("session_error (71): no_session when no device has ever connected", async () => { const stateDir = await makeTempStateDir(); - const { exitCode, payload } = runCli(["ls"], stateDir); + const { exitCode, payload } = runCli(["sessions", "ls"], stateDir); expect(exitCode).toBe(0); expect(payload.data).toEqual([]); const status = runCli(["daemon", "status"], stateDir); daemonPids.push(status.payload.data.daemon.pid); - const revokeResult = runCli(["revoke"], stateDir); + const revokeResult = runCli(["sessions", "revoke"], stateDir); expect(revokeResult.exitCode).toBe(71); expect(revokeResult.payload.error.type).toBe("no_session"); }); @@ -129,13 +129,13 @@ describe("exit codes: v2 command surface", () => { const status = runCli(["daemon", "status"], stateDir); daemonPids.push(status.payload.data.daemon.pid); - const { exitCode, payload } = runCli(["invoke", "no-such-alias", "some-tool", "--input", "{}"], stateDir); + const { exitCode, payload } = runCli(["tools", "call", "no-such-alias", "some-tool", "--input", "{}"], stateDir); expect(exitCode).toBe(71); expect(payload.error.type).toBe("unknown_session"); }); - test("tool_error (72): invoke a name not registered on a real, claimed session", async () => { + test("tool_error (72): call a name not registered on a real, claimed session", async () => { const stateDir = await makeTempStateDir(); const status = runCli(["daemon", "status"], stateDir); @@ -145,7 +145,7 @@ describe("exit codes: v2 command surface", () => { const port = status.payload.data.daemon.wss_port as number; expect(port).toBeGreaterThan(0); - const linkResult = runCli(["link", "--scheme", "appduct-exit-codes"], stateDir); + const linkResult = runCli(["sessions", "link", "--scheme", "appduct-exit-codes"], stateDir); expect(linkResult.exitCode).toBe(0); // The deep link is `:///?appduct=&pin=` (commit 9c73849 added @@ -188,7 +188,7 @@ describe("exit codes: v2 command surface", () => { const alias = ack.alias as string; const { exitCode, payload: invokeError } = runCli( - ["invoke", alias, "does-not-exist", "--input", "{}"], + ["tools", "call", alias, "does-not-exist", "--input", "{}"], stateDir, ); diff --git a/packages/appduct/src/__tests__/global-flags.integration.test.ts b/packages/appduct/src/__tests__/global-flags.integration.test.ts index 8f604b3..9a70e25 100644 --- a/packages/appduct/src/__tests__/global-flags.integration.test.ts +++ b/packages/appduct/src/__tests__/global-flags.integration.test.ts @@ -60,15 +60,15 @@ describe("global flags (no daemon required)", () => { */ describe("route argument errors render as usage errors (no daemon required)", () => { test.each([ - [["tools", "--limit", "0"], /"--limit" must be a positive integer/u], - [["tools", "--limit", "-1"], /"--limit" must be a positive integer/u], - [["tools", "--offset", "abc"], /"--offset" must be a non-negative integer/u], - [["tools", "--filter"], /"--filter" requires a value/u], - [["tools", "a", "b", "c"], /Usage/u], - [["invoke"], /Usage/u], - [["revoke", "a", "b"], /Usage/u], - [["events", "a", "b"], /Usage/u], - [["events", "--since", "-1"], /"--since" must be a non-negative integer/u], + [["tools", "ls", "--limit", "0"], /"--limit" must be a positive integer/u], + [["tools", "ls", "--limit", "-1"], /"--limit" must be a positive integer/u], + [["tools", "ls", "--offset", "abc"], /"--offset" must be a non-negative integer/u], + [["tools", "ls", "--filter"], /"--filter" requires a value/u], + [["tools", "ls", "a", "b", "c"], /Usage/u], + [["tools", "call"], /Usage/u], + [["sessions", "revoke", "a", "b"], /Usage/u], + [["events", "tail", "a", "b"], /Usage/u], + [["events", "since", "a", "-1"], /"" must be a non-negative integer/u], ])("%j", async (argv, message) => { const result = await runCliWithCapture([...argv, "--json", "--state-dir", "/nonexistent-appduct-state"]); diff --git a/packages/appduct/src/__tests__/link-open.integration.test.ts b/packages/appduct/src/__tests__/link-open.integration.test.ts index 189fec1..c67917b 100644 --- a/packages/appduct/src/__tests__/link-open.integration.test.ts +++ b/packages/appduct/src/__tests__/link-open.integration.test.ts @@ -1,5 +1,5 @@ /** - * `appduct link --open android|ios-sim|ios-device` (ARCHITECTURE.md §8 delivery path + * `appduct sessions link --open android|ios-sim|ios-device` (ARCHITECTURE.md §8 delivery path * 1). Two layers are tested here against a real daemon over its actual UDS control socket (no * mocking the RPC layer, matching the pattern in session-engine.integration.test.ts): * @@ -480,7 +480,7 @@ describe("client link({ target: \"ios-device\" })", () => { const exec = devicectlExec(calls, [{ udid: "00008030-AAAA", name: "My iPhone" }]); // `appduct/client`'s `link()` is what a test's globalSetup calls; it must not drift from - // `appduct link` (both go through `mintLink`, and this is the test that says so). + // `appduct sessions link` (both go through `mintLink`, and this is the test that says so). const { link } = await import("../client/bootstrap.js"); const result = await link({ stateDir, target: "ios-device", appId: APP_ID, exec, autoSpawn: false }); diff --git a/packages/appduct/src/__tests__/mcp-command.integration.test.ts b/packages/appduct/src/__tests__/mcp-command.integration.test.ts index b74e57a..a099096 100644 --- a/packages/appduct/src/__tests__/mcp-command.integration.test.ts +++ b/packages/appduct/src/__tests__/mcp-command.integration.test.ts @@ -215,7 +215,7 @@ describe("appduct mcp command", () => { }); // The server must still start without a scheme (it is useful for proxying a session paired by - // QR or `appduct link`); only `appduct_connect` fails, and it names where to put one. + // QR or `appduct sessions link`); only `appduct_connect` fails, and it names where to put one. test("starts without any scheme and fails appduct_connect with the locations tried", async () => { const { stateDir } = await startTestDaemon(); const { client } = await startMcpCommandWithClient(stateDir); diff --git a/packages/appduct/src/__tests__/noun-verb-cli.integration.test.ts b/packages/appduct/src/__tests__/noun-verb-cli.integration.test.ts new file mode 100644 index 0000000..21c2d83 --- /dev/null +++ b/packages/appduct/src/__tests__/noun-verb-cli.integration.test.ts @@ -0,0 +1,104 @@ +/** + * Issue #96: the CLI is unified as noun-verb commands (`sessions`, `tools`, `events`), with no + * aliases for the removed bare-verb forms. Every usage-error case here is resolved by the router + * before any daemon contact, so it runs in-process (`runCliWithCapture`) with no state dir and no + * daemon, exactly like `global-flags.integration.test.ts`. The `--help` cases spawn the real + * binary (`runCliBinary`) instead: cac's own `--help` handler prints through `console.log` + * directly, bypassing the writer `runCliWithCapture` injects. + */ + +import { describe, expect, test } from "vitest"; + +import { runCliBinary, runCliWithCapture } from "./fixtures.js"; + +describe("removed top-level commands name their replacement (criterion 2)", () => { + test.each([ + ["ls", "sessions ls"], + ["revoke", "sessions revoke"], + ["link", "sessions link"], + ["invoke", "tools call"], + ])('"appduct %s" exits 64 naming "appduct %s"', async (removed, replacement) => { + const result = await runCliWithCapture([removed, "--json"]); + + expect(result.exitCode).toBe(64); + const parsed = JSON.parse(result.stdout); + expect(parsed.error.type).toBe("usage_error"); + expect(parsed.error.message).toBe(`Unknown command "${removed}"; use "appduct ${replacement}".`); + }); + + test("an unrelated unknown command is not treated as a removed one", async () => { + const result = await runCliWithCapture(["bogus-command", "--json"]); + + expect(result.exitCode).toBe(64); + const parsed = JSON.parse(result.stdout); + expect(parsed.error.message).toBe('Unknown command "bogus-command".'); + }); +}); + +describe("a bare noun with no verb, or an unknown verb, names its verbs (criterion 3)", () => { + test.each([ + ["sessions", "ls, revoke, or link"], + ["tools", "ls, describe, or call"], + ["events", "tail or since"], + ])('"appduct %s" alone exits 64 naming its verbs', async (noun, verbList) => { + const result = await runCliWithCapture([noun, "--json"]); + + expect(result.exitCode).toBe(64); + const parsed = JSON.parse(result.stdout); + expect(parsed.error.type).toBe("usage_error"); + expect(parsed.error.message).toBe(`The ${noun} command requires a verb: ${verbList} (got none).`); + }); + + test.each([ + ["sessions", "ls, revoke, or link"], + ["tools", "ls, describe, or call"], + ["events", "tail or since"], + ])('"appduct %s bogus-verb" exits 64 naming its verbs', async (noun, verbList) => { + const result = await runCliWithCapture([noun, "bogus-verb", "--json"]); + + expect(result.exitCode).toBe(64); + const parsed = JSON.parse(result.stdout); + expect(parsed.error.type).toBe("usage_error"); + expect(parsed.error.message).toBe(`The ${noun} command requires a verb: ${verbList} (got "bogus-verb").`); + }); + + test.each(["sessions", "tools", "events"])('"appduct %s --help" exits 0', (noun) => { + const result = runCliBinary([noun, "--help"]); + + expect(result.exitCode).toBe(0); + }); + + // cac's per-command `--help` shows usage and flags but never a command's own description + // (checked against `appduct daemon --help`, the existing model, which doesn't print its verbs + // either) — a description only surfaces in the *global* `--help`'s "Commands" section, so that + // is where each noun's verbs actually get printed, exactly like `daemon`'s already does. + test.each([ + ["sessions", ["ls", "revoke", "link"]], + ["tools", ["ls", "describe", "call"]], + ["events", ["tail", "since"]], + ])('"appduct --help" prints %s\'s verbs in its description', (noun, verbs) => { + const result = runCliBinary(["--help"]); + + expect(result.exitCode).toBe(0); + + const commandsSection = result.stdout.split(/\n\s*\n/u).find((block) => block.startsWith("Commands:")); + expect(commandsSection).toBeDefined(); + const nounLine = commandsSection!.split("\n").find((line) => line.trim().startsWith(`${noun} `)); + expect(nounLine, commandsSection).toBeDefined(); + + for (const verb of verbs) { + expect(nounLine).toContain(verb); + } + }); +}); + +describe("events ls is reserved for #95, until then it is just an unknown verb (criterion 4)", () => { + test('"appduct events ls" exits 64 naming tail and since', async () => { + const result = await runCliWithCapture(["events", "ls", "--json"]); + + expect(result.exitCode).toBe(64); + const parsed = JSON.parse(result.stdout); + expect(parsed.error.type).toBe("usage_error"); + expect(parsed.error.message).toBe('The events command requires a verb: tail or since (got "ls").'); + }); +}); diff --git a/packages/appduct/src/__tests__/output.test.ts b/packages/appduct/src/__tests__/output.test.ts index 4528ce3..51a010f 100644 --- a/packages/appduct/src/__tests__/output.test.ts +++ b/packages/appduct/src/__tests__/output.test.ts @@ -34,7 +34,7 @@ describe("output rendering", () => { }, }, { - command: "tools", + command: "tools ls", flags: flags(), }, ); @@ -84,7 +84,7 @@ describe("output rendering", () => { total: 2, }, }, - { command: "tools", flags: flags() }, + { command: "tools ls", flags: flags() }, ); expect(rendered.stdout).toBe( @@ -95,7 +95,7 @@ describe("output rendering", () => { ' set_flag(name: "dark_mode" | "new_checkout", enabled: bool) [prompt]', " Toggle a feature flag.", "", - "Run `appduct tools ` for a tool's full schema.", + "Run `appduct tools describe ` for a tool's full schema.", "", ].join("\n"), ); @@ -112,7 +112,7 @@ describe("output rendering", () => { limit: 1, }, }, - { command: "tools", flags: flags() }, + { command: "tools ls", flags: flags() }, ).stdout ?? ""; expect(rendered).toContain( @@ -135,7 +135,7 @@ describe("output rendering", () => { groups: [topLevel[0], { group: "g00/sub", total: 1 }, ...topLevel.slice(1)], }, }, - { command: "tools", flags: flags() }, + { command: "tools ls", flags: flags() }, ).stdout ?? ""; expect(rendered).toContain( @@ -148,7 +148,7 @@ describe("output rendering", () => { test("tools list output with a filter and no matches says so", () => { const rendered = renderResult( { ok: true, data: { tools: [], total: 0, filter: "nope" } }, - { command: "tools", flags: flags() }, + { command: "tools ls", flags: flags() }, ).stdout ?? ""; expect(rendered).toContain('No tools match "nope".'); @@ -157,11 +157,11 @@ describe("output rendering", () => { test("tools list output with no tools and no filter keeps the original message", () => { const rendered = renderResult( { ok: true, data: { tools: [], total: 0 } }, - { command: "tools", flags: flags() }, + { command: "tools ls", flags: flags() }, ).stdout ?? ""; expect(rendered).toContain("No tools registered."); - expect(rendered).not.toContain("Run `appduct tools `"); + expect(rendered).not.toContain("Run `appduct tools describe `"); }); test("a description longer than 120 characters is cut with a trailing ellipsis", () => { @@ -172,7 +172,7 @@ describe("output rendering", () => { ok: true, data: { tools: [{ name: "verbose", description: longDescription, policy: "allow" }], total: 1 }, }, - { command: "tools", flags: flags() }, + { command: "tools ls", flags: flags() }, ).stdout ?? ""; const descriptionLine = rendered.split("\n").find((line) => line.startsWith(" A.")); @@ -185,7 +185,7 @@ describe("output rendering", () => { const rendered = renderResult( { ok: true, data: { tools: [{ name: "ping", description: "Health check.", policy: "allow" }], total: 1 } }, - { command: "tools", flags: flags() }, + { command: "tools ls", flags: flags() }, ).stdout ?? ""; expect(rendered).toContain(" ping()"); @@ -208,13 +208,13 @@ describe("output rendering", () => { offset: 0, }, }, - { command: "tools", flags: flags(), full: true }, + { command: "tools ls", flags: flags(), full: true }, ).stdout ?? ""; expect(rendered).toContain("Tool: echo"); expect(rendered).toMatch(/Signature\s+echo\(text\?: string\)/); expect(rendered).toContain("Showing 1 of 3 tools (offset 0)."); - expect(rendered).not.toContain("Run `appduct tools `"); + expect(rendered).not.toContain("Run `appduct tools describe `"); }); test("tools detail shows a declared timeout_ms, and no timeout line when the tool declares none", () => { @@ -228,7 +228,7 @@ describe("output rendering", () => { ...(timeoutMs !== undefined ? { timeout_ms: timeoutMs } : {}), }, }, - { command: "tools", flags: flags() }, + { command: "tools describe", flags: flags() }, ).stdout; expect(renderDetail(60_000)).toContain("Timeout (ms) 60000"); @@ -249,7 +249,7 @@ describe("output rendering", () => { group, }, }, - { command: "tools", flags: flags() }, + { command: "tools describe", flags: flags() }, ).stdout; expect(renderDetail("checkout/payment")).toMatch(/Group\s+checkout\/payment/); @@ -269,7 +269,7 @@ describe("output rendering", () => { }, }, { - command: "tools", + command: "tools describe", flags: flags(), }, ); @@ -290,7 +290,7 @@ describe("output rendering", () => { }, }, { - command: "link", + command: "sessions link", flags: flags(), }, ); @@ -311,7 +311,7 @@ describe("output rendering", () => { }, }, { - command: "link", + command: "sessions link", flags: flags({ json: true }), qr: true, }, @@ -346,7 +346,7 @@ describe("output rendering", () => { ], }, { - command: "ls", + command: "sessions ls", flags: flags(), now: FIXED_NOW, }, @@ -362,7 +362,7 @@ describe("output rendering", () => { data: [], }, { - command: "ls", + command: "sessions ls", flags: flags(), now: FIXED_NOW, }, @@ -396,7 +396,7 @@ describe("output rendering", () => { data: { echoed: "hello" }, }, { - command: "invoke", + command: "tools call", flags: flags(), }, ); @@ -410,11 +410,11 @@ describe("output rendering", () => { data: { echoed: { nested: { value: true } } }, }; - const compact = renderResult(result, { command: "invoke", flags: flags() }).stdout ?? ""; + const compact = renderResult(result, { command: "tools call", flags: flags() }).stdout ?? ""; expect(compact).toContain('{"nested":{"value":true}}'); expect(compact).toBe("Result\n{\"echoed\":{\"nested\":{\"value\":true}}}\n"); - const pretty = renderResult(result, { command: "invoke", flags: flags({ pretty: true }) }).stdout ?? ""; + const pretty = renderResult(result, { command: "tools call", flags: flags({ pretty: true }) }).stdout ?? ""; expect(pretty).toContain('"nested": {'); expect(pretty).toContain('"value": true'); }); @@ -432,7 +432,7 @@ describe("output rendering", () => { }, }, { - command: "invoke", + command: "tools call", flags: flags(), }, ); @@ -450,7 +450,7 @@ describe("output rendering", () => { }, }, { - command: "invoke", + command: "tools call", flags: flags({ json: true }), }, ); @@ -461,11 +461,11 @@ describe("output rendering", () => { }); test("the Meta block renders (human and --json) exactly when the result carries meta", () => { - const meta = { command: "invoke", timestamp: FIXED_NOW.toISOString(), duration_ms: 4 }; + const meta = { command: "tools call", timestamp: FIXED_NOW.toISOString(), duration_ms: 4 }; const human = renderResult( { ok: true, data: { echoed: "hi" }, meta }, - { command: "invoke", flags: flags() }, + { command: "tools call", flags: flags() }, ).stdout; expect(human).toContain("Meta"); expect(human).toContain(`Command: ${meta.command}`); @@ -474,13 +474,13 @@ describe("output rendering", () => { const humanNoMeta = renderResult( { ok: true, data: { echoed: "hi" } }, - { command: "invoke", flags: flags() }, + { command: "tools call", flags: flags() }, ).stdout; expect(humanNoMeta).not.toContain("Meta"); const json = renderResult( { ok: true, data: { echoed: "hi" }, meta }, - { command: "invoke", flags: flags({ json: true }) }, + { command: "tools call", flags: flags({ json: true }) }, ).stdout ?? ""; expect(JSON.parse(json).meta).toEqual(meta); }); @@ -535,11 +535,11 @@ describe("output rendering", () => { test("--json output is a single line by default, and indented under --pretty", () => { const result = { ok: true as const, data: { a: 1, b: { c: 2 } } }; - const compact = renderResult(result, { command: "invoke", flags: flags({ json: true }) }).stdout ?? ""; + const compact = renderResult(result, { command: "tools call", flags: flags({ json: true }) }).stdout ?? ""; expect(compact.replace(/\n$/u, "").split("\n")).toHaveLength(1); expect(JSON.parse(compact)).toEqual(result); - const pretty = renderResult(result, { command: "invoke", flags: flags({ json: true, pretty: true }) }).stdout ?? ""; + const pretty = renderResult(result, { command: "tools call", flags: flags({ json: true, pretty: true }) }).stdout ?? ""; expect(pretty.split("\n").length).toBeGreaterThan(1); expect(JSON.parse(pretty)).toEqual(result); }); diff --git a/packages/appduct/src/__tests__/scheme-discovery.integration.test.ts b/packages/appduct/src/__tests__/scheme-discovery.integration.test.ts index 95dcf79..0c13bb9 100644 --- a/packages/appduct/src/__tests__/scheme-discovery.integration.test.ts +++ b/packages/appduct/src/__tests__/scheme-discovery.integration.test.ts @@ -67,7 +67,7 @@ const mint = async (stateDir: string, cwd: string, scheme?: string) => { ); }; -describe("appduct link scheme discovery", () => { +describe("appduct sessions link scheme discovery", () => { test("mints from an app.json alone, with no scheme configured anywhere", async () => { const stateDir = await startSchemelessDaemon(); const appRoot = await makeAppRoot("myapp"); @@ -90,7 +90,7 @@ describe("appduct link scheme discovery", () => { const nested = path.join(appRoot, "src", "screens"); await mkdir(nested, { recursive: true }); - // Run from a subdirectory: the walk-up is what makes `appduct link` work anywhere in a repo. + // Run from a subdirectory: the walk-up is what makes `appduct sessions link` work anywhere in a repo. const result = await mint(stateDir, nested); expect(result.ok && result.data.deepLink).toMatch(/^from-project-config:\/\/\/\?appduct=/u); diff --git a/packages/appduct/src/__tests__/scheme.test.ts b/packages/appduct/src/__tests__/scheme.test.ts index 46e1fdd..807ea85 100644 --- a/packages/appduct/src/__tests__/scheme.test.ts +++ b/packages/appduct/src/__tests__/scheme.test.ts @@ -1,6 +1,6 @@ /** * Deep-link scheme resolution (`scheme.ts`, issue #29): `app.json` discovery, the project-config - * walk-up, and the precedence order shared by `appduct link`, `appduct mcp`, + * walk-up, and the precedence order shared by `appduct sessions link`, `appduct mcp`, * `appduct/client`'s `link()` and `appduct_connect`. */ diff --git a/packages/appduct/src/cli/routes/events.ts b/packages/appduct/src/cli/routes/events.ts deleted file mode 100644 index b792bb4..0000000 --- a/packages/appduct/src/cli/routes/events.ts +++ /dev/null @@ -1,55 +0,0 @@ -/** Route for `appduct events` — loaded by `cli/dispatch.ts`'s router only when it runs. */ - -import type { EventNotification } from "@appduct/shared"; - -import type { Route } from "../router.js"; - -import { handleEventsCommand } from "../../commands/events.js"; -import { usageError } from "../../errors.js"; -import { renderEventLine, renderEventsCursorLine } from "../../output.js"; -import { parseNonNegativeIntegerOption, splitOptionalSelector } from "../command-options.js"; -import { commandName } from "../router.js"; -import { executeHostedCommand } from "../runner.js"; -import { guarded } from "../version-guard.js"; - -export const route: Route = async (context) => { - const { options, stateDir, env } = context; - const follow = Boolean(options.follow); - - return executeHostedCommand( - commandName(context), - // All argument parsing happens inside the handler (not the route body) so - // `executeHostedCommand`'s own try/catch renders a bad argument as a normal usage_error - // instead of an uncaught rejection, and before the version check so a typo never waits on - // the daemon. - () => { - const { selector } = splitOptionalSelector(context.args, "events [selector]"); - const since = parseNonNegativeIntegerOption(options.since, "--since"); - - if (since !== undefined && follow) { - throw usageError('"--since" is a one-shot pull and cannot be combined with "--follow".'); - } - - return guarded(context)(() => - handleEventsCommand( - { selector, since }, - { - stateDir, - onEvent: (event: EventNotification) => { - env.stdout.write(`${renderEventLine(event, env.flags)}\n`); - }, - onCursor: (cursor) => { - env.stdout.write(`${renderEventsCursorLine(cursor, env.flags)}\n`); - }, - }, - ), - )(); - }, - env, - { - kind: "interactive", - onEvent: () => {}, - dispose: () => {}, - }, - ); -}; diff --git a/packages/appduct/src/cli/routes/invoke.ts b/packages/appduct/src/cli/routes/invoke.ts deleted file mode 100644 index 068312e..0000000 --- a/packages/appduct/src/cli/routes/invoke.ts +++ /dev/null @@ -1,53 +0,0 @@ -/** Route for `appduct invoke` — loaded by `cli/dispatch.ts`'s router only when it runs. */ - -import type { Route } from "../router.js"; - -import { handleInvokeCommand } from "../../commands/invoke.js"; -import { - parseJsonInputOption, - parsePositiveIntegerOption, - splitSelectorAndRequiredTarget, -} from "../command-options.js"; -import { commandName } from "../router.js"; -import { executeCommand } from "../runner.js"; -import { guarded } from "../version-guard.js"; - -export const route: Route = async (context) => { - const { options, stateDir } = context; - - // SIGINT cancels the in-flight tools.call rather than leaving it running unowned in the app - // (issue #9) — the listener is torn down once the command settles either way. - const cancelController = new AbortController(); - const onSigint = (): void => cancelController.abort(); - process.once("SIGINT", onSigint); - - try { - return await executeCommand( - commandName(context), - // Positionals are split inside the handler so a missing `` renders through the runner - // as a usage error instead of escaping the route as an uncaught rejection. - () => { - const { selector, target: tool } = splitSelectorAndRequiredTarget( - context.args, - "invoke [selector] --input ''", - ); - - return guarded(context)(() => - handleInvokeCommand( - { - selector, - tool, - args: parseJsonInputOption(typeof options.input === "string" ? options.input : undefined), - timeoutMs: parsePositiveIntegerOption(options.timeout, "--timeout"), - }, - { stateDir }, - cancelController.signal, - ), - )(); - }, - context.env, - ); - } finally { - process.off("SIGINT", onSigint); - } -}; diff --git a/packages/appduct/src/cli/routes/link.ts b/packages/appduct/src/cli/routes/link.ts deleted file mode 100644 index f4834b9..0000000 --- a/packages/appduct/src/cli/routes/link.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** Route for `appduct link` — loaded by `cli/dispatch.ts`'s router only when it runs. */ - -import type { Route } from "../router.js"; - -import { handleLinkCommand } from "../../commands/link.js"; -import { parsePositiveIntegerOption } from "../command-options.js"; -import { commandName } from "../router.js"; -import { executeCommand } from "../runner.js"; -import { guarded } from "../version-guard.js"; - -export const route: Route = async (context) => { - const { options, stateDir } = context; - - return executeCommand( - commandName(context), - guarded(context)(() => - handleLinkCommand( - { - ttlSeconds: parsePositiveIntegerOption(options.ttl, "--ttl"), - scheme: typeof options.scheme === "string" ? options.scheme : undefined, - open: typeof options.open === "string" ? options.open : undefined, - device: typeof options.device === "string" ? options.device : undefined, - // cac camelCases `--app-id`; the dashed spelling is kept as a fallback so a - // parser change can't silently drop the flag. - appId: - typeof options.appId === "string" - ? options.appId - : typeof options["app-id"] === "string" - ? options["app-id"] - : undefined, - // Left `undefined` when absent rather than coerced to `false`, so that - // "--relaunch only applies with --open ios-device" fires on the flag actually being - // passed and not on every `link` invocation. - relaunch: options.relaunch === true ? true : undefined, - }, - { stateDir }, - ), - ), - context.env, - { qr: Boolean(options.qr) }, - ); -}; diff --git a/packages/appduct/src/cli/routes/ls.ts b/packages/appduct/src/cli/routes/ls.ts deleted file mode 100644 index 85dc47c..0000000 --- a/packages/appduct/src/cli/routes/ls.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** Route for `appduct ls` — loaded by `cli/dispatch.ts`'s router only when it runs. */ - -import type { Route } from "../router.js"; - -import { handleLsCommand } from "../../commands/ls.js"; -import { commandName } from "../router.js"; -import { executeCommand } from "../runner.js"; -import { guarded } from "../version-guard.js"; - -export const route: Route = async (context) => { - const { stateDir } = context; - - return executeCommand( - commandName(context), - guarded(context)(() => handleLsCommand({ stateDir })), - context.env, - ); -}; diff --git a/packages/appduct/src/cli/routes/revoke.ts b/packages/appduct/src/cli/routes/revoke.ts deleted file mode 100644 index 4719d4b..0000000 --- a/packages/appduct/src/cli/routes/revoke.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** Route for `appduct revoke` — loaded by `cli/dispatch.ts`'s router only when it runs. */ - -import type { Route } from "../router.js"; - -import { handleRevokeCommand } from "../../commands/revoke.js"; -import { splitOptionalSelector } from "../command-options.js"; -import { commandName } from "../router.js"; -import { executeCommand } from "../runner.js"; -import { guarded } from "../version-guard.js"; - -export const route: Route = async (context) => { - const { stateDir } = context; - - return executeCommand( - commandName(context), - // Parsed inside the handler so a usage error renders through the runner instead of escaping - // the route as an uncaught rejection. - () => { - const { selector } = splitOptionalSelector(context.args, "revoke [selector]"); - return guarded(context)(() => handleRevokeCommand({ selector }, { stateDir }))(); - }, - context.env, - ); -}; diff --git a/packages/appduct/src/cli/routes/tools.ts b/packages/appduct/src/cli/routes/tools.ts deleted file mode 100644 index 35e7feb..0000000 --- a/packages/appduct/src/cli/routes/tools.ts +++ /dev/null @@ -1,61 +0,0 @@ -/** Route for `appduct tools` — loaded by `cli/dispatch.ts`'s router only when it runs. */ - -import { isValidToolGroup } from "@appduct/shared"; - -import type { Route } from "../router.js"; - -import { handleToolsCommand } from "../../commands/tools.js"; -import { usageError } from "../../errors.js"; -import { - parseNonNegativeIntegerOption, - parsePositiveIntegerOption, - readTextOption, - splitOptionalSelectorAndTarget, -} from "../command-options.js"; -import { commandName } from "../router.js"; -import { executeCommand } from "../runner.js"; -import { guarded } from "../version-guard.js"; - -export const route: Route = async (context) => { - const { options, stateDir } = context; - - return executeCommand( - commandName(context), - // Argument parsing runs inside the handler so a usage error renders through the runner (exit - // 64, `--json` envelope) instead of escaping the route as an uncaught rejection, and before - // the version check so a typo never waits on (or restarts) the daemon. - () => { - const { selector, target, selectorOrTarget } = splitOptionalSelectorAndTarget( - context.args, - "tools [selector] [name]", - ); - const limit = parsePositiveIntegerOption(options.limit, "--limit"); - const offset = parseNonNegativeIntegerOption(options.offset, "--offset"); - const filter = readTextOption(context.argv, options.filter, "--filter"); - const group = readTextOption(context.argv, options.group, "--group"); - - // Checked here, not only by the daemon, so a malformed group is a usage error (exit 64) - // that never waits on the daemon — the same rule the daemon's `tools.list` applies. - if (group !== undefined && !isValidToolGroup(group)) { - throw usageError( - `"--group" must be a group name like "checkout" or "checkout/payment" (one or two "/"-separated segments of [a-zA-Z0-9_-], at most 64 characters each); got ${JSON.stringify(group)}.`, - ); - } - - const groups = options.groups === undefined ? undefined : Boolean(options.groups); - - if (groups && options.full) { - throw usageError('"--groups" lists groups only and cannot be combined with "--full".'); - } - - return guarded(context)(() => - handleToolsCommand( - { selector: selector ?? selectorOrTarget, name: target, group, groups, filter, limit, offset }, - { stateDir }, - ), - )(); - }, - context.env, - { full: Boolean(options.full) }, - ); -}; From 8bae42ff23596c88f853f23f05d8ab53c806edd1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 15:43:18 +0000 Subject: [PATCH 02/16] feat(cli): unify sessions/tools/events as noun-verb commands (#96) Every command is now appduct [selector] [args], the shape daemon run|start|stop|status already used: - routes/sessions/, routes/tools/ and routes/events/ are routers exactly like routes/daemon/, one route module per verb (ls, revoke, link; ls, describe, call; tail, since). - create-cli.ts's sessions/tools/events commands each declare every flag any of their verbs use, so cac's boolean/string flag table never lets a flag swallow a positional (tools ls --full ). - dispatch.ts's root router drops ls, revoke, link and invoke entirely (no aliases, pre-1.0); a removed word or any other unmatched command is a usage error, and REMOVED_COMMANDS additionally names the replacement for the four that used to exist. - command-options.ts's splitOptionalSelectorAndTarget is deleted: the old ambiguity it resolved (is a single positional the selector, or the name with the selector omitted?) no longer exists once ls and describe are separate commands, so commands/tools.ts's handleToolsCommand drops the probing branch that used to guess between them. - events since takes the cursor as a required positional (splitSelectorAndRequiredTarget), not the removed --since flag. - output.ts's renderSuccessData switches on the new command names, and its human hints (and commands/init.ts's) point at the new forms. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014wk7yigkPHWoWLy9nvocyt --- packages/appduct/src/cli/command-options.ts | 25 -------- packages/appduct/src/cli/create-cli.ts | 52 +++++++-------- packages/appduct/src/cli/dispatch.ts | 41 +++++++++--- packages/appduct/src/cli/result-types.ts | 19 +++--- .../appduct/src/cli/routes/events/index.ts | 26 ++++++++ .../appduct/src/cli/routes/events/since.ts | 54 ++++++++++++++++ .../appduct/src/cli/routes/events/tail.ts | 47 ++++++++++++++ .../appduct/src/cli/routes/sessions/index.ts | 25 ++++++++ .../appduct/src/cli/routes/sessions/link.ts | 43 +++++++++++++ .../appduct/src/cli/routes/sessions/ls.ts | 19 ++++++ .../appduct/src/cli/routes/sessions/revoke.ts | 25 ++++++++ packages/appduct/src/cli/routes/tools/call.ts | 54 ++++++++++++++++ .../appduct/src/cli/routes/tools/describe.ts | 36 +++++++++++ .../appduct/src/cli/routes/tools/index.ts | 24 +++++++ packages/appduct/src/cli/routes/tools/ls.ts | 35 ++++++++++ .../src/cli/routes/tools/shared-options.ts | 41 ++++++++++++ packages/appduct/src/client/bootstrap.ts | 2 +- packages/appduct/src/client/index.ts | 2 +- packages/appduct/src/commands/events.ts | 22 ++++--- packages/appduct/src/commands/init.ts | 6 +- packages/appduct/src/commands/invoke.ts | 2 +- packages/appduct/src/commands/link.ts | 2 +- packages/appduct/src/commands/ls.ts | 2 +- packages/appduct/src/commands/mcp.ts | 4 +- packages/appduct/src/commands/revoke.ts | 2 +- packages/appduct/src/commands/tools.ts | 64 +++++-------------- packages/appduct/src/daemon/config.ts | 2 +- packages/appduct/src/daemon/links.ts | 2 +- packages/appduct/src/link.ts | 2 +- packages/appduct/src/mcp/app-tools.ts | 5 +- packages/appduct/src/mcp/connect-tool.ts | 2 +- packages/appduct/src/native-scheme.ts | 2 +- packages/appduct/src/output.ts | 55 ++++++++-------- packages/appduct/src/rpc/client.ts | 2 +- packages/appduct/src/scheme.ts | 6 +- packages/react-native/src/Appduct.types.ts | 4 +- packages/shared/src/domains/rpc.ts | 2 +- packages/shared/src/domains/tool-signature.ts | 4 +- 38 files changed, 580 insertions(+), 182 deletions(-) create mode 100644 packages/appduct/src/cli/routes/events/index.ts create mode 100644 packages/appduct/src/cli/routes/events/since.ts create mode 100644 packages/appduct/src/cli/routes/events/tail.ts create mode 100644 packages/appduct/src/cli/routes/sessions/index.ts create mode 100644 packages/appduct/src/cli/routes/sessions/link.ts create mode 100644 packages/appduct/src/cli/routes/sessions/ls.ts create mode 100644 packages/appduct/src/cli/routes/sessions/revoke.ts create mode 100644 packages/appduct/src/cli/routes/tools/call.ts create mode 100644 packages/appduct/src/cli/routes/tools/describe.ts create mode 100644 packages/appduct/src/cli/routes/tools/index.ts create mode 100644 packages/appduct/src/cli/routes/tools/ls.ts create mode 100644 packages/appduct/src/cli/routes/tools/shared-options.ts diff --git a/packages/appduct/src/cli/command-options.ts b/packages/appduct/src/cli/command-options.ts index 587999b..a1bc914 100644 --- a/packages/appduct/src/cli/command-options.ts +++ b/packages/appduct/src/cli/command-options.ts @@ -119,31 +119,6 @@ export const splitOptionalSelector = (args: readonly string[], commandUsage: str return { selector: args[0] }; }; -/** - * Splits the positional args of a command shaped ` [selector] [target]` (`tools [selector] - * [name]`): with 2 args, `(selector, target)`; with 1, the single arg is ambiguous between "the - * selector" and "the target with the selector omitted" — the caller resolves that (see - * `commands/tools.ts`), so it comes back unlabeled here as `selectorOrTarget`. - */ -export const splitOptionalSelectorAndTarget = ( - args: readonly string[], - commandUsage: string, -): { selector?: string; target?: string; selectorOrTarget?: string } => { - if (args.length > 2) { - throw usageError(`Usage: ${commandUsage} (too many arguments).`); - } - - if (args.length === 2) { - return { selector: args[0], target: args[1] }; - } - - if (args.length === 1) { - return { selectorOrTarget: args[0] }; - } - - return {}; -}; - /** * Parses the JSON payload for `invoke --input`; never throws a raw `SyntaxError` at the CLI * boundary. A missing flag is a usage error (nothing was given); a present-but-unparseable or diff --git a/packages/appduct/src/cli/create-cli.ts b/packages/appduct/src/cli/create-cli.ts index 81369fd..daa20a1 100644 --- a/packages/appduct/src/cli/create-cli.ts +++ b/packages/appduct/src/cli/create-cli.ts @@ -42,55 +42,51 @@ export const createCli = () => { .option("--out ", "Destination path (default: /key.pem).") .option("--force", "Overwrite an existing key at the destination path."); + // cac only matches a command's first word against argv[0] and builds its boolean/string table + // from that command's own declared options (ARCHITECTURE.md §10 "CLI surface"), so each noun + // below declares every option any of its verbs uses — otherwise a boolean flag ahead of a + // positional (`sessions link --qr --open ios-sim`) would swallow it as that flag's value. cli - .command("link", "Mint a pending session and print its deep link.") - .option("--ttl ", "Link time-to-live in seconds (default: from config.json).") - .option("--qr", "Also render the deep link as a terminal QR code.") + .command("sessions [...args]", "Manage Appduct sessions: ls, revoke, or link.") + .option("--ttl ", "link: time-to-live in seconds (default: from config.json).") + .option("--qr", "link: also render the deep link as a terminal QR code.") .option( "--scheme ", - "Deep-link URI scheme (also: APPDUCT_SCHEME; default: .appduct/config.json, then " + + "link: deep-link URI scheme (also: APPDUCT_SCHEME; default: .appduct/config.json, then " + "app.json's \"expo.scheme\", then the Android/iOS project files in ).", ) .option( "--open ", - "Deliver the link automatically via adb/simctl/devicectl (android|ios-sim|ios-device; ios-device is experimental).", + "link: deliver the link automatically via adb/simctl/devicectl (android|ios-sim|ios-device; ios-device is experimental).", ) - .option("--device ", "adb serial, simulator udid or paired-device udid to target when --open is ambiguous.") + .option("--device ", "link: adb serial, simulator udid or paired-device udid to target when --open is ambiguous.") .option( "--app-id ", - "Installed app id for --open android/ios-device (default: .appduct/config.json's \"appId.\").", + "link: installed app id for --open android/ios-device (default: .appduct/config.json's \"appId.\").", ) .option( "--relaunch", - "With --open ios-device, terminate a running instance first (try this if delivery to an already-running app does nothing).", + "link: with --open ios-device, terminate a running instance first (try this if delivery to an already-running app does nothing).", ); - cli.command("ls", "List Appduct sessions."); - - cli - .command("tools [selector] [name]", "List a session's tools, or show one tool's full schema.") - .option("--full", "Render full schemas/annotations for every listed tool.") - .option("--group ", "Only tools in this group (\"checkout\" includes \"checkout/payment\").") - .option("--groups", "List the session's groups with tool counts instead of its tools.") - .option("--filter ", "Only tools whose name or description contains this text (case-insensitive).") - .option("--limit ", "Show at most n tools.") - .option("--offset ", "Skip the first n tools of the sorted list."); - cli - .command("invoke [selector] [tool]", "Call a tool on a session.") - .option("--input ", "Tool input arguments as a JSON object.") + .command("tools [...args]", "List a session's tools, describe one, or call one: ls, describe, or call.") + .option("--full", "ls: render full schemas/annotations for every listed tool.") + .option("--group ", "ls: only tools in this group (\"checkout\" includes \"checkout/payment\").") + .option("--groups", "ls: list the session's groups with tool counts instead of its tools.") + .option("--filter ", "ls: only tools whose name or description contains this text (case-insensitive).") + .option("--limit ", "ls: show at most n tools.") + .option("--offset ", "ls: skip the first n tools of the sorted list.") + .option("--input ", "call: tool input arguments as a JSON object.") .option( "--timeout ", - "Call timeout in milliseconds. Shortens the deadline; it cannot extend one past the app's " + - "own timer, which is the tool's declared timeoutMs (else 10000).", + "call: call timeout in milliseconds. Shortens the deadline; it cannot extend one past the " + + "app's own timer, which is the tool's declared timeoutMs (else 10000).", ); cli - .command("events [selector]", "Stream the events the app posts until interrupted.") - .option("--follow", "Accepted for script readability; the default behavior already follows.") - .option("--since ", "One-shot: print the app events retained since this cursor instead of streaming live."); - - cli.command("revoke [selector]", "Revoke a session."); + .command("events [...args]", "Stream the app's events, or replay them since a cursor: tail or since.") + .option("--follow", "tail: accepted for script readability; the default behavior already follows."); cli .command("mcp", "Start a stdio MCP server that gives MCP clients access to connected apps' tools.") diff --git a/packages/appduct/src/cli/dispatch.ts b/packages/appduct/src/cli/dispatch.ts index 3a035fc..4516af3 100644 --- a/packages/appduct/src/cli/dispatch.ts +++ b/packages/appduct/src/cli/dispatch.ts @@ -28,26 +28,46 @@ const isEnvTruthy = (value: string | undefined): boolean => { return value === "1" || value?.toLowerCase() === "true"; }; +/** + * Issue #96's clean break: `ls`, `revoke`, `link` and `invoke` no longer exist as top-level + * commands, each replaced by a verb under `sessions`/`tools`. There are no aliases (we are + * pre-1.0) — a removed word is an ordinary usage error, just one that names its replacement + * instead of only saying "unknown command", so an agent working from an old prompt corrects + * itself in one step. + */ +const REMOVED_COMMANDS: Readonly> = { + ls: "sessions ls", + revoke: "sessions revoke", + link: "sessions link", + invoke: "tools call", +}; + +const rootUnknownCommandError = (word: string | undefined): Error => { + if (word !== undefined && Object.hasOwn(REMOVED_COMMANDS, word)) { + return usageError(`Unknown command "${word}"; use "appduct ${REMOVED_COMMANDS[word]}".`); + } + + return unknownCommandError(word); +}; + /** * One entry per command `create-cli.ts` registers. Each loader is a dynamic `import()` so the * route — and its command handler, and that handler's dependencies — is only evaluated when the - * command runs. `daemon` is a router of its own (`routes/daemon/index.ts`), one level down. + * command runs. `daemon`, `sessions`, `tools` and `events` are routers of their own + * (`routes//index.ts`), one level down. */ const rootRouter = createRouter( { init: () => import("./routes/init.js"), keygen: () => import("./routes/keygen.js"), - link: () => import("./routes/link.js"), - ls: () => import("./routes/ls.js"), - tools: () => import("./routes/tools.js"), - invoke: () => import("./routes/invoke.js"), - revoke: () => import("./routes/revoke.js"), - events: () => import("./routes/events.js"), + sessions: () => import("./routes/sessions/index.js"), + tools: () => import("./routes/tools/index.js"), + events: () => import("./routes/events/index.js"), mcp: () => import("./routes/mcp.js"), doctor: () => import("./routes/doctor.js"), daemon: () => import("./routes/daemon/index.js"), }, - { unknown: unknownCommandError }, + { unknown: rootUnknownCommandError }, ); export const runCli = async (argv: string[], options: RunCliOptions = {}): Promise => { @@ -93,7 +113,10 @@ export const runCli = async (argv: string[], options: RunCliOptions = {}): Promi return executeCommand( "cli", () => { - throw usageError(`Unknown command "${parsedArgs[0]}".`); + // A removed top-level word (`ls`, `revoke`, `link`, `invoke` — issue #96) never matches + // any `cac`-registered command at all, so it lands here rather than in `rootRouter`'s own + // `unknown` handler; the same replacement map applies either way. + throw rootUnknownCommandError(parsedArgs[0]); }, { flags, diff --git a/packages/appduct/src/cli/result-types.ts b/packages/appduct/src/cli/result-types.ts index dd85a85..c3c43dd 100644 --- a/packages/appduct/src/cli/result-types.ts +++ b/packages/appduct/src/cli/result-types.ts @@ -146,10 +146,11 @@ export type LinkCommandData = { target?: OpenTarget; }; -/** `appduct ls`: `sessions.list` passthrough, verbatim (ARCHITECTURE.md §10: "--json passthrough"). */ +/** `appduct sessions ls`: `sessions.list` passthrough, verbatim (ARCHITECTURE.md §10: "--json + * passthrough"). */ export type LsCommandData = SessionSummary[]; -/** `appduct tools`'s listing form: the daemon's `tools.list` result, plus the `--group`/`--filter`/ +/** `appduct tools ls`'s listing form: the daemon's `tools.list` result, plus the `--group`/`--filter`/ * `--limit`/`--offset` inputs that produced it (only the ones actually given — echoed so `--json` and the * human renderer's "Showing n of total" line can report what was asked for without threading the * CLI options through separately). */ @@ -160,26 +161,28 @@ export type ToolsListing = ToolsListResult & { filter?: string; }; -/** `appduct tools --groups`: the daemon's `groups` summary alone, plus the registry's size. */ +/** `appduct tools ls --groups`: the daemon's `groups` summary alone, plus the registry's size. */ export type ToolGroupsListing = { groups: ToolGroupSummary[]; /** Every tool in the session, grouped or not. */ total: number; }; -/** `appduct tools`: a listing, a groups summary (`--groups`), or a single entry when a tool name - * resolved to a detail lookup. The detail form is the daemon's own listing entry, picked out of - * `tools.list` — not a registration, so its `group` is `null` for an ungrouped tool. */ +/** `appduct tools ls`/`appduct tools describe`: a listing, a groups summary (`--groups`), or a + * single entry when `describe`'s `` resolved to a tool. The detail form is the daemon's own + * listing entry, picked out of `tools.list` — not a registration, so its `group` is `null` for an + * ungrouped tool. */ export type ToolsCommandData = ToolsListing | ToolGroupsListing | ToolsListEntry; -/** `appduct invoke`: the tool's raw result payload, printed as-is. */ +/** `appduct tools call`: the tool's raw result payload, printed as-is. */ export type InvokeCommandData = unknown; export type RevokeCommandData = { ok: true; }; -/** A single `appduct events` line, in both human and NDJSON (`--json`) rendering. */ +/** A single `appduct events tail`/`appduct events since` line, in both human and NDJSON + * (`--json`) rendering. */ export type EventsCommandLine = EventNotification; export type DaemonRunCommandData = { diff --git a/packages/appduct/src/cli/routes/events/index.ts b/packages/appduct/src/cli/routes/events/index.ts new file mode 100644 index 0000000..a858e46 --- /dev/null +++ b/packages/appduct/src/cli/routes/events/index.ts @@ -0,0 +1,26 @@ +/** + * Route for `appduct events` — itself a router over the events verbs, so `events tail` loads only + * `tail.ts` (issue #96). cac matches only the noun (`cli/create-cli.ts`), so the verb arrives here + * as the first positional rather than as a cac command of its own. + * + * `ls` is reserved for the catalog issue #95 adds; until it lands, `events ls` falls through to + * `unknown` below like any other unrecognized verb, naming `tail`/`since` as the ones that exist. + */ + +import { usageError } from "../../../errors.js"; +import { createRouter } from "../../router.js"; + +export const route = createRouter( + { + tail: () => import("./tail.js"), + since: () => import("./since.js"), + }, + { + unknown: (verb) => + usageError( + `The events command requires a verb: tail or since (got ${ + verb === undefined ? "none" : `"${verb}"` + }).`, + ), + }, +); diff --git a/packages/appduct/src/cli/routes/events/since.ts b/packages/appduct/src/cli/routes/events/since.ts new file mode 100644 index 0000000..bf9fa8c --- /dev/null +++ b/packages/appduct/src/cli/routes/events/since.ts @@ -0,0 +1,54 @@ +/** Route for `appduct events since` — loaded by `routes/events/index.ts`'s router only when it + * runs (issue #96; replaces the removed `appduct events --since ` one-shot form). The + * cursor is now a required positional rather than a flag, split out via + * `splitSelectorAndRequiredTarget` exactly like `tools describe`/`tools call`. */ + +import type { EventNotification } from "@appduct/shared"; + +import type { Route } from "../../router.js"; + +import { handleEventsCommand } from "../../../commands/events.js"; +import { renderEventLine, renderEventsCursorLine } from "../../../output.js"; +import { parseNonNegativeIntegerOption, splitSelectorAndRequiredTarget } from "../../command-options.js"; +import { commandName } from "../../router.js"; +import { executeHostedCommand } from "../../runner.js"; +import { guarded } from "../../version-guard.js"; + +export const route: Route = async (context) => { + const { stateDir, env } = context; + + return executeHostedCommand( + commandName(context), + // Argument parsing runs inside the handler (not the route body) so `executeHostedCommand`'s + // own try/catch renders a bad argument as a normal usage_error instead of an uncaught + // rejection, and before the version check so a typo never waits on the daemon. + () => { + const { selector, target: cursorArg } = splitSelectorAndRequiredTarget( + context.args, + "events since [selector] ", + ); + const since = parseNonNegativeIntegerOption(cursorArg, ""); + + return guarded(context)(() => + handleEventsCommand( + { selector, since }, + { + stateDir, + onEvent: (event: EventNotification) => { + env.stdout.write(`${renderEventLine(event, env.flags)}\n`); + }, + onCursor: (cursor) => { + env.stdout.write(`${renderEventsCursorLine(cursor, env.flags)}\n`); + }, + }, + ), + )(); + }, + env, + { + kind: "interactive", + onEvent: () => {}, + dispose: () => {}, + }, + ); +}; diff --git a/packages/appduct/src/cli/routes/events/tail.ts b/packages/appduct/src/cli/routes/events/tail.ts new file mode 100644 index 0000000..d2cb066 --- /dev/null +++ b/packages/appduct/src/cli/routes/events/tail.ts @@ -0,0 +1,47 @@ +/** Route for `appduct events tail` — loaded by `routes/events/index.ts`'s router only when it + * runs (issue #96; replaces the removed `appduct events` streaming form). `--follow` is accepted + * (declared at the `events` noun level in `create-cli.ts`) but never read here: the default + * behavior already follows, so the flag exists only for script readability. */ + +import type { EventNotification } from "@appduct/shared"; + +import type { Route } from "../../router.js"; + +import { handleEventsCommand } from "../../../commands/events.js"; +import { renderEventLine } from "../../../output.js"; +import { splitOptionalSelector } from "../../command-options.js"; +import { commandName } from "../../router.js"; +import { executeHostedCommand } from "../../runner.js"; +import { guarded } from "../../version-guard.js"; + +export const route: Route = async (context) => { + const { stateDir, env } = context; + + return executeHostedCommand( + commandName(context), + // Argument parsing runs inside the handler (not the route body) so `executeHostedCommand`'s + // own try/catch renders a bad argument as a normal usage_error instead of an uncaught + // rejection, and before the version check so a typo never waits on the daemon. + () => { + const { selector } = splitOptionalSelector(context.args, "events tail [selector]"); + + return guarded(context)(() => + handleEventsCommand( + { selector }, + { + stateDir, + onEvent: (event: EventNotification) => { + env.stdout.write(`${renderEventLine(event, env.flags)}\n`); + }, + }, + ), + )(); + }, + env, + { + kind: "interactive", + onEvent: () => {}, + dispose: () => {}, + }, + ); +}; diff --git a/packages/appduct/src/cli/routes/sessions/index.ts b/packages/appduct/src/cli/routes/sessions/index.ts new file mode 100644 index 0000000..de060d9 --- /dev/null +++ b/packages/appduct/src/cli/routes/sessions/index.ts @@ -0,0 +1,25 @@ +/** + * Route for `appduct sessions` — itself a router over the session verbs, so `sessions ls` loads + * only `ls.ts` (issue #96). cac matches only the noun (`cli/create-cli.ts`), so the verb arrives + * here as the first positional rather than as a cac command of its own — the same shape + * `routes/daemon/index.ts` already uses for `daemon status`. + */ + +import { usageError } from "../../../errors.js"; +import { createRouter } from "../../router.js"; + +export const route = createRouter( + { + ls: () => import("./ls.js"), + revoke: () => import("./revoke.js"), + link: () => import("./link.js"), + }, + { + unknown: (verb) => + usageError( + `The sessions command requires a verb: ls, revoke, or link (got ${ + verb === undefined ? "none" : `"${verb}"` + }).`, + ), + }, +); diff --git a/packages/appduct/src/cli/routes/sessions/link.ts b/packages/appduct/src/cli/routes/sessions/link.ts new file mode 100644 index 0000000..6f87c99 --- /dev/null +++ b/packages/appduct/src/cli/routes/sessions/link.ts @@ -0,0 +1,43 @@ +/** Route for `appduct sessions link` — loaded by `routes/sessions/index.ts`'s router only when it + * runs (issue #96; replaces the removed `appduct link`). */ + +import type { Route } from "../../router.js"; + +import { handleLinkCommand } from "../../../commands/link.js"; +import { parsePositiveIntegerOption } from "../../command-options.js"; +import { commandName } from "../../router.js"; +import { executeCommand } from "../../runner.js"; +import { guarded } from "../../version-guard.js"; + +export const route: Route = async (context) => { + const { options, stateDir } = context; + + return executeCommand( + commandName(context), + guarded(context)(() => + handleLinkCommand( + { + ttlSeconds: parsePositiveIntegerOption(options.ttl, "--ttl"), + scheme: typeof options.scheme === "string" ? options.scheme : undefined, + open: typeof options.open === "string" ? options.open : undefined, + device: typeof options.device === "string" ? options.device : undefined, + // cac camelCases `--app-id`; the dashed spelling is kept as a fallback so a + // parser change can't silently drop the flag. + appId: + typeof options.appId === "string" + ? options.appId + : typeof options["app-id"] === "string" + ? options["app-id"] + : undefined, + // Left `undefined` when absent rather than coerced to `false`, so that + // "--relaunch only applies with --open ios-device" fires on the flag actually being + // passed and not on every `link` invocation. + relaunch: options.relaunch === true ? true : undefined, + }, + { stateDir }, + ), + ), + context.env, + { qr: Boolean(options.qr) }, + ); +}; diff --git a/packages/appduct/src/cli/routes/sessions/ls.ts b/packages/appduct/src/cli/routes/sessions/ls.ts new file mode 100644 index 0000000..8306ef7 --- /dev/null +++ b/packages/appduct/src/cli/routes/sessions/ls.ts @@ -0,0 +1,19 @@ +/** Route for `appduct sessions ls` — loaded by `routes/sessions/index.ts`'s router only when it + * runs (issue #96; replaces the removed `appduct ls`). */ + +import type { Route } from "../../router.js"; + +import { handleLsCommand } from "../../../commands/ls.js"; +import { commandName } from "../../router.js"; +import { executeCommand } from "../../runner.js"; +import { guarded } from "../../version-guard.js"; + +export const route: Route = async (context) => { + const { stateDir } = context; + + return executeCommand( + commandName(context), + guarded(context)(() => handleLsCommand({ stateDir })), + context.env, + ); +}; diff --git a/packages/appduct/src/cli/routes/sessions/revoke.ts b/packages/appduct/src/cli/routes/sessions/revoke.ts new file mode 100644 index 0000000..9bf3011 --- /dev/null +++ b/packages/appduct/src/cli/routes/sessions/revoke.ts @@ -0,0 +1,25 @@ +/** Route for `appduct sessions revoke` — loaded by `routes/sessions/index.ts`'s router only when + * it runs (issue #96; replaces the removed `appduct revoke`). */ + +import type { Route } from "../../router.js"; + +import { handleRevokeCommand } from "../../../commands/revoke.js"; +import { splitOptionalSelector } from "../../command-options.js"; +import { commandName } from "../../router.js"; +import { executeCommand } from "../../runner.js"; +import { guarded } from "../../version-guard.js"; + +export const route: Route = async (context) => { + const { stateDir } = context; + + return executeCommand( + commandName(context), + // Parsed inside the handler so a usage error renders through the runner instead of escaping + // the route as an uncaught rejection. + () => { + const { selector } = splitOptionalSelector(context.args, "sessions revoke [selector]"); + return guarded(context)(() => handleRevokeCommand({ selector }, { stateDir }))(); + }, + context.env, + ); +}; diff --git a/packages/appduct/src/cli/routes/tools/call.ts b/packages/appduct/src/cli/routes/tools/call.ts new file mode 100644 index 0000000..1c7e9ea --- /dev/null +++ b/packages/appduct/src/cli/routes/tools/call.ts @@ -0,0 +1,54 @@ +/** Route for `appduct tools call` — loaded by `routes/tools/index.ts`'s router only when it runs + * (issue #96; replaces the removed `appduct invoke`). */ + +import type { Route } from "../../router.js"; + +import { handleInvokeCommand } from "../../../commands/invoke.js"; +import { + parseJsonInputOption, + parsePositiveIntegerOption, + splitSelectorAndRequiredTarget, +} from "../../command-options.js"; +import { commandName } from "../../router.js"; +import { executeCommand } from "../../runner.js"; +import { guarded } from "../../version-guard.js"; + +export const route: Route = async (context) => { + const { options, stateDir } = context; + + // SIGINT cancels the in-flight tools.call rather than leaving it running unowned in the app + // (issue #9) — the listener is torn down once the command settles either way. + const cancelController = new AbortController(); + const onSigint = (): void => cancelController.abort(); + process.once("SIGINT", onSigint); + + try { + return await executeCommand( + commandName(context), + // Positionals are split inside the handler so a missing `` renders through the runner + // as a usage error instead of escaping the route as an uncaught rejection. + () => { + const { selector, target: tool } = splitSelectorAndRequiredTarget( + context.args, + "tools call [selector] --input ''", + ); + + return guarded(context)(() => + handleInvokeCommand( + { + selector, + tool, + args: parseJsonInputOption(typeof options.input === "string" ? options.input : undefined), + timeoutMs: parsePositiveIntegerOption(options.timeout, "--timeout"), + }, + { stateDir }, + cancelController.signal, + ), + )(); + }, + context.env, + ); + } finally { + process.off("SIGINT", onSigint); + } +}; diff --git a/packages/appduct/src/cli/routes/tools/describe.ts b/packages/appduct/src/cli/routes/tools/describe.ts new file mode 100644 index 0000000..cce2bf2 --- /dev/null +++ b/packages/appduct/src/cli/routes/tools/describe.ts @@ -0,0 +1,36 @@ +/** Route for `appduct tools describe` — loaded by `routes/tools/index.ts`'s router only when it + * runs (issue #96; replaces the removed `appduct tools ` / `appduct tools [selector] ` + * detail form). Unlike the old single-positional form, `` is never ambiguous with a + * selector: `splitSelectorAndRequiredTarget` always takes the last positional as the name. */ + +import type { Route } from "../../router.js"; + +import { handleToolsCommand } from "../../../commands/tools.js"; +import { splitSelectorAndRequiredTarget } from "../../command-options.js"; +import { commandName } from "../../router.js"; +import { executeCommand } from "../../runner.js"; +import { guarded } from "../../version-guard.js"; +import { parseToolsListingFlags } from "./shared-options.js"; + +export const route: Route = async (context) => { + const { stateDir } = context; + + return executeCommand( + commandName(context), + // Argument parsing runs inside the handler so a usage error renders through the runner + // instead of escaping the route as an uncaught rejection. + () => { + const { selector, target: name } = splitSelectorAndRequiredTarget( + context.args, + "tools describe [selector] ", + ); + // Parsed (and validated) even though `describe` never *uses* them, so a listing flag next + // to a `` still gets `commands/tools.ts`'s "only apply to a listing" usage error + // instead of being silently ignored. + const listing = parseToolsListingFlags(context); + + return guarded(context)(() => handleToolsCommand({ selector, name, ...listing }, { stateDir }))(); + }, + context.env, + ); +}; diff --git a/packages/appduct/src/cli/routes/tools/index.ts b/packages/appduct/src/cli/routes/tools/index.ts new file mode 100644 index 0000000..7c51b97 --- /dev/null +++ b/packages/appduct/src/cli/routes/tools/index.ts @@ -0,0 +1,24 @@ +/** + * Route for `appduct tools` — itself a router over the tools verbs, so `tools ls` loads only + * `ls.ts` (issue #96). cac matches only the noun (`cli/create-cli.ts`), so the verb arrives here + * as the first positional rather than as a cac command of its own. + */ + +import { usageError } from "../../../errors.js"; +import { createRouter } from "../../router.js"; + +export const route = createRouter( + { + ls: () => import("./ls.js"), + describe: () => import("./describe.js"), + call: () => import("./call.js"), + }, + { + unknown: (verb) => + usageError( + `The tools command requires a verb: ls, describe, or call (got ${ + verb === undefined ? "none" : `"${verb}"` + }).`, + ), + }, +); diff --git a/packages/appduct/src/cli/routes/tools/ls.ts b/packages/appduct/src/cli/routes/tools/ls.ts new file mode 100644 index 0000000..bfe599a --- /dev/null +++ b/packages/appduct/src/cli/routes/tools/ls.ts @@ -0,0 +1,35 @@ +/** Route for `appduct tools ls` — loaded by `routes/tools/index.ts`'s router only when it runs + * (issue #96; replaces the removed `appduct tools [selector]` listing form). */ + +import type { Route } from "../../router.js"; + +import { handleToolsCommand } from "../../../commands/tools.js"; +import { usageError } from "../../../errors.js"; +import { splitOptionalSelector } from "../../command-options.js"; +import { commandName } from "../../router.js"; +import { executeCommand } from "../../runner.js"; +import { guarded } from "../../version-guard.js"; +import { parseToolsListingFlags } from "./shared-options.js"; + +export const route: Route = async (context) => { + const { options, stateDir } = context; + + return executeCommand( + commandName(context), + // Argument parsing runs inside the handler so a usage error renders through the runner (exit + // 64, `--json` envelope) instead of escaping the route as an uncaught rejection, and before + // the version check so a typo never waits on (or restarts) the daemon. + () => { + const { selector } = splitOptionalSelector(context.args, "tools ls [selector]"); + const listing = parseToolsListingFlags(context); + + if (listing.groups && options.full) { + throw usageError('"--groups" lists groups only and cannot be combined with "--full".'); + } + + return guarded(context)(() => handleToolsCommand({ selector, ...listing }, { stateDir }))(); + }, + context.env, + { full: Boolean(options.full) }, + ); +}; diff --git a/packages/appduct/src/cli/routes/tools/shared-options.ts b/packages/appduct/src/cli/routes/tools/shared-options.ts new file mode 100644 index 0000000..c45442a --- /dev/null +++ b/packages/appduct/src/cli/routes/tools/shared-options.ts @@ -0,0 +1,41 @@ +/** + * The listing-only flags (`--group`/`--groups`/`--filter`/`--limit`/`--offset`) shared by + * `tools ls`, which acts on them, and `tools describe`, which only needs them parsed so it can + * reject one next to a `` with the same usage error it has always gotten + * (`commands/tools.ts`'s `hasListingOnlyOptions`). Kept out of `index.ts` — nothing outside this + * directory imports it — since a route module, not the noun's router, is the caller. + */ + +import { isValidToolGroup } from "@appduct/shared"; + +import { usageError } from "../../../errors.js"; +import { parseNonNegativeIntegerOption, parsePositiveIntegerOption, readTextOption } from "../../command-options.js"; +import type { RouteContext } from "../../router.js"; + +export type ToolsListingFlags = { + group?: string; + groups?: boolean; + filter?: string; + limit?: number; + offset?: number; +}; + +export const parseToolsListingFlags = (context: RouteContext): ToolsListingFlags => { + const { options } = context; + const limit = parsePositiveIntegerOption(options.limit, "--limit"); + const offset = parseNonNegativeIntegerOption(options.offset, "--offset"); + const filter = readTextOption(context.argv, options.filter, "--filter"); + const group = readTextOption(context.argv, options.group, "--group"); + + // Checked here, not only by the daemon, so a malformed group is a usage error (exit 64) that + // never waits on the daemon — the same rule the daemon's `tools.list` applies. + if (group !== undefined && !isValidToolGroup(group)) { + throw usageError( + `"--group" must be a group name like "checkout" or "checkout/payment" (one or two "/"-separated segments of [a-zA-Z0-9_-], at most 64 characters each); got ${JSON.stringify(group)}.`, + ); + } + + const groups = options.groups === undefined ? undefined : Boolean(options.groups); + + return { group, groups, filter, limit, offset }; +}; diff --git a/packages/appduct/src/client/bootstrap.ts b/packages/appduct/src/client/bootstrap.ts index 726c6a9..153a6fc 100644 --- a/packages/appduct/src/client/bootstrap.ts +++ b/packages/appduct/src/client/bootstrap.ts @@ -1,6 +1,6 @@ /** * `link()`/`waitForSession()` (issue #8): the bootstrap half of the client, so a test's - * `globalSetup` can pair a simulator/emulator without shelling out to `appduct link --open`. + * `globalSetup` can pair a simulator/emulator without shelling out to `appduct sessions link --open`. * `link()` delegates to the same `mintLink` core `commands/link.ts` uses (`../link.js`) so the * deep-link shape can't drift between the CLI and this package; `waitForSession()` mirrors * `mcp/connect-tool.ts`'s `handleWaitForSessionTool`. diff --git a/packages/appduct/src/client/index.ts b/packages/appduct/src/client/index.ts index 57eb54f..07ce4e3 100644 --- a/packages/appduct/src/client/index.ts +++ b/packages/appduct/src/client/index.ts @@ -1,7 +1,7 @@ /** * `appduct/client` (issue #8): a first-class programmatic client for test runners — a thin typed * wrapper over the same daemon RPC the CLI and MCP server use, so a Jest/Vitest/Detox spec can - * `connect()`/`call()` instead of spawning `appduct invoke ... --json` and parsing stdout. + * `connect()`/`call()` instead of spawning `appduct tools call ... --json` and parsing stdout. * * ```ts * import { connect } from "appduct/client"; diff --git a/packages/appduct/src/commands/events.ts b/packages/appduct/src/commands/events.ts index 3c5b71a..04c93e3 100644 --- a/packages/appduct/src/commands/events.ts +++ b/packages/appduct/src/commands/events.ts @@ -1,15 +1,17 @@ /** - * `appduct events` (ARCHITECTURE.md §10): subscribes to `events.subscribe` for `app_event` only - * over a persistent connection and streams the app's own events until interrupted (Ctrl-C) or the - * connection drops. Appduct's lifecycle and tool-call kinds are not printed (issue #98). This is - * the one command whose output isn't a single rendered `CliResult` — each event is written as it - * arrives (NDJSON under `--json`, a human line otherwise) — so it plugs into `executeHostedCommand` - * with a live reporter that suppresses the default one-shot bootstrap render (`cli/runner.ts`). + * `appduct events tail` (ARCHITECTURE.md §10): subscribes to `events.subscribe` for `app_event` + * only over a persistent connection and streams the app's own events until interrupted (Ctrl-C) or + * the connection drops. Appduct's lifecycle and tool-call kinds are not printed (issue #98). This + * is the one command whose output isn't a single rendered `CliResult` — each event is written as + * it arrives (NDJSON under `--json`, a human line otherwise) — so it plugs into + * `executeHostedCommand` with a live reporter that suppresses the default one-shot bootstrap + * render (`cli/runner.ts`). * - * `--since ` (issue #6) switches to a one-shot mode: a single `events.since` pull instead - * of a live subscription. It falls out of the same `EventsHostedResult` shape (each retained event - * is written through `onEvent` exactly like a live one) so the CLI plumbing needs no forking — the - * "hosted command" here just completes immediately instead of running until interrupted. + * `appduct events since ` (issue #6) is a one-shot mode: a single `events.since` pull + * instead of a live subscription, selected here by `since !== undefined`. It falls out of the same + * `EventsHostedResult` shape (each retained event is written through `onEvent` exactly like a live + * one) so the CLI plumbing needs no forking — the "hosted command" here just completes immediately + * instead of running until interrupted. */ import { RPC_METHODS, type EventNotification, type EventsSinceResult, type EventsSubscribeResult } from "@appduct/shared"; diff --git a/packages/appduct/src/commands/init.ts b/packages/appduct/src/commands/init.ts index 6c0e010..8569ef9 100644 --- a/packages/appduct/src/commands/init.ts +++ b/packages/appduct/src/commands/init.ts @@ -134,7 +134,7 @@ const readExistingConfig = async (path: string): Promise * * A hand-edited `"scheme": "myapp://"` must not be adopted and echoed back into `mcpServerEntry` * as though it were usable — every consumer of that entry would compose an unopenable link, and - * `appduct link` would reject the very value `init` just blessed. + * `appduct sessions link` would reject the very value `init` just blessed. * * But it is only fatal when the run would go on to *keep* that value: `replaceable` says a * `--scheme` or `--force` is about to overwrite it anyway, and refusing then would make the @@ -456,13 +456,13 @@ export const handleInitCommand = async ( `Add the Appduct MCP server entry to your agent's MCP config. "--scheme ${scheme}" keeps ` + `that entry self-contained; ${SCHEME_ENV_VAR} and this ${PROJECT_CONFIG_RELATIVE_PATH} ` + "work too.", - "With the app running, pair a device: `appduct link --open ios-sim` (or `--open android`).", + "With the app running, pair a device: `appduct sessions link --open ios-sim` (or `--open android`).", ...(origin === undefined ? [] : [`Scheme "${scheme}" was read from ${origin}.`]), ...(iosAppId === undefined && androidAppId === undefined ? [ "Delivering to a physical iPhone (`--open ios-device`) or an Android device/emulator " + "(`--open android`) needs the installed app's id: run `appduct init --ios-app-id " + - " --android-app-id `, or pass --app-id on `appduct link`.", + " --android-app-id `, or pass --app-id on `appduct sessions link`.", ] : []), `This file is safe to commit — it holds only "scheme" and "appId". Do not point --state-dir ` + diff --git a/packages/appduct/src/commands/invoke.ts b/packages/appduct/src/commands/invoke.ts index 74bea83..d9352ff 100644 --- a/packages/appduct/src/commands/invoke.ts +++ b/packages/appduct/src/commands/invoke.ts @@ -1,5 +1,5 @@ /** - * `appduct invoke` (ARCHITECTURE.md §10): `tools.call` round trip. Failures propagate as-is + * `appduct tools call` (ARCHITECTURE.md §10): `tools.call` round trip. Failures propagate as-is * (`DaemonRpcError`, preserving `error.data.type` verbatim) — `errors.ts`'s `toCliError` is what * renders `error.type` as the app's wire error type end-to-end, not this module's job. */ diff --git a/packages/appduct/src/commands/link.ts b/packages/appduct/src/commands/link.ts index 31e1d04..b8df545 100644 --- a/packages/appduct/src/commands/link.ts +++ b/packages/appduct/src/commands/link.ts @@ -1,5 +1,5 @@ /** - * `appduct link` (ARCHITECTURE.md §10, §8): CLI-flag validation (`--open`/`--device`'s + * `appduct sessions link` (ARCHITECTURE.md §10, §8): CLI-flag validation (`--open`/`--device`'s * CLI-specific error wording) around the shared `mintLink` core (`../link.ts`), which both this * command and `appduct/client`'s `link()` use so the deep-link shape can't drift between them. */ diff --git a/packages/appduct/src/commands/ls.ts b/packages/appduct/src/commands/ls.ts index a7c61a6..3df597f 100644 --- a/packages/appduct/src/commands/ls.ts +++ b/packages/appduct/src/commands/ls.ts @@ -1,4 +1,4 @@ -/** `appduct ls` (ARCHITECTURE.md §10): `sessions.list` passthrough. */ +/** `appduct sessions ls` (ARCHITECTURE.md §10): `sessions.list` passthrough. */ import { RPC_METHODS, type SessionsListResult } from "@appduct/shared"; diff --git a/packages/appduct/src/commands/mcp.ts b/packages/appduct/src/commands/mcp.ts index 34ca0bd..2b940f6 100644 --- a/packages/appduct/src/commands/mcp.ts +++ b/packages/appduct/src/commands/mcp.ts @@ -57,9 +57,9 @@ export type McpHostedResult = { /** * Resolves the deep-link scheme for the server, *never* throwing. * - * Unlike `appduct link`, an unresolved (or unresolvable) scheme must not stop `appduct mcp` + * Unlike `appduct sessions link`, an unresolved (or unresolvable) scheme must not stop `appduct mcp` * from starting: the server is still fully useful for proxying an app's tools to a session that was - * paired some other way (a QR scan, `appduct link` in another terminal), and an MCP client that + * paired some other way (a QR scan, `appduct sessions link` in another terminal), and an MCP client that * cannot start its server gets a much worse failure than one whose `appduct_connect` call * returns a clear `invalid_request`. So a scheme problem here is downgraded to an extra entry in * the `tried` list, which `appduct_connect` renders if and when it is actually called. diff --git a/packages/appduct/src/commands/revoke.ts b/packages/appduct/src/commands/revoke.ts index f1cf7f0..20a20a7 100644 --- a/packages/appduct/src/commands/revoke.ts +++ b/packages/appduct/src/commands/revoke.ts @@ -1,4 +1,4 @@ -/** `appduct revoke` (ARCHITECTURE.md §10): revokes a session, optionally by selector. */ +/** `appduct sessions revoke` (ARCHITECTURE.md §10): revokes a session, optionally by selector. */ import { RPC_METHODS } from "@appduct/shared"; diff --git a/packages/appduct/src/commands/tools.ts b/packages/appduct/src/commands/tools.ts index 7086a34..918bd4a 100644 --- a/packages/appduct/src/commands/tools.ts +++ b/packages/appduct/src/commands/tools.ts @@ -1,19 +1,18 @@ /** - * `appduct tools` (ARCHITECTURE.md §10): `tools [selector] [--full] [--group ] [--filter - * ] [--limit ] [--offset ]` lists tools for a session; `tools [selector] --groups` - * lists only the session's groups with their tool counts; `tools [selector] ` shows one - * tool's full schema/annotations. + * `appduct tools ls`/`appduct tools describe` (ARCHITECTURE.md §10, issue #96): `ls [selector] + * [--full] [--group ] [--filter ] [--limit ] [--offset ]` lists tools for a + * session; `ls [selector] --groups` lists only the session's groups with their tool counts; + * `describe [selector] ` shows one tool's full schema/annotations. * - * The command table gives both forms a leading optional `[selector]`, which makes a single - * positional argument inherently ambiguous (is it the selector, or the tool name in `tools ` - * with the selector omitted?). This resolves it the same way a human reading the table would: - * first try the arg as a tool name in the implicit-selector session's registry; if no such tool - * exists there (or the implicit selector doesn't resolve, e.g. `ambiguous_session`), fall back to - * treating it as a selector and list that session's tools instead. + * The two verbs (`cli/routes/tools/ls.ts` and `cli/routes/tools/describe.ts`) split `[selector]` + * from `` unambiguously before this handler ever runs — `describe`'s `` is always the + * last positional, required, never a candidate selector — so there is no probing here: a listing + * request (`options.name === undefined`) always lists, a lookup (`options.name !== undefined`) + * always looks up, on the given selector or the implicit session if none was given. * - * `--group`/`--filter`/`--limit`/`--offset` only ever reach the daemon on a *listing* request: the detail - * path (an explicit ``, or the ambiguous single-arg probe above) always asks for the whole, - * unpaged registry, so a name lookup can never miss a tool that paging would have left off a page. + * `--group`/`--filter`/`--limit`/`--offset` only ever reach the daemon on a *listing* request: the + * detail path always asks for the whole, unpaged registry, so a name lookup can never miss a tool + * that paging would have left off a page. */ import { RPC_METHODS, type ToolsListEntry, type ToolsListResult } from "@appduct/shared"; @@ -177,13 +176,15 @@ export const handleToolsCommand = async ( throw listingOnlyError(); } - if (options.selector !== undefined && options.name !== undefined) { + if (options.name !== undefined) { const result = await listTools(options.selector, context); const tool = findTool(result.tools, options.name); if (!tool) { throw usageError( - `Tool "${options.name}" is not registered on session "${options.selector}".`, + options.selector !== undefined + ? `Tool "${options.name}" is not registered on session "${options.selector}".` + : `Tool "${options.name}" is not registered.`, { available: result.tools.map((entry) => entry.name) }, ); } @@ -191,36 +192,5 @@ export const handleToolsCommand = async ( return { ok: true, data: tool }; } - if (options.selector !== undefined) { - // A single positional arg: try it as the implicit session's tool name first. - let implicitTools: ToolsListResult | undefined; - - try { - implicitTools = await listTools(undefined, context); - } catch (error) { - if (!(error instanceof DaemonRpcError)) { - throw error; - } - } - - if (implicitTools) { - const tool = findTool(implicitTools.tools, options.selector); - - if (tool) { - // The same rule as an explicit ` `: silently dropping the listing flags - // here would make `tools --limit 5` behave differently from `tools `. - if (hasListingOnlyOptions(options)) { - throw listingOnlyError(); - } - - return { ok: true, data: tool }; - } - } - - // Not a tool name on the implicit session (or there is no implicit session): treat the arg as - // a selector and list that session's tools instead. - return { ok: true, data: await listOrGroups(options.selector, options, context) }; - } - - return { ok: true, data: await listOrGroups(undefined, options, context) }; + return { ok: true, data: await listOrGroups(options.selector, options, context) }; }; diff --git a/packages/appduct/src/daemon/config.ts b/packages/appduct/src/daemon/config.ts index 1a77218..833e0c9 100644 --- a/packages/appduct/src/daemon/config.ts +++ b/packages/appduct/src/daemon/config.ts @@ -63,7 +63,7 @@ export type AppductConfig = { /** Operator override for advertised-address detection (daemon/address.ts); undefined = auto-detect. */ advertisedIp?: string; /** - * Deep-link URI scheme used to compose `appduct link`'s output (ARCHITECTURE.md §10: "taken + * Deep-link URI scheme used to compose `appduct sessions link`'s output (ARCHITECTURE.md §10: "taken * from the flag, else `config.json`, else the CLI errors with a clear message"). Not part of the * `config.json` shape enumerated in ARCHITECTURE.md §3 (which only covers daemon-side settings), * but `config.json` is explicitly "all fields optional" there and this is the natural home for a diff --git a/packages/appduct/src/daemon/links.ts b/packages/appduct/src/daemon/links.ts index 48c4cba..e7cf874 100644 --- a/packages/appduct/src/daemon/links.ts +++ b/packages/appduct/src/daemon/links.ts @@ -64,7 +64,7 @@ export type PendingLinkRegistry = { const generateSessionId = (): string => { // Re-rolled if it starts with "-": v1 ids starting with "-" broke CLI flag parsing - // (e.g. `appduct tools -abc123...` was parsed as a flag, not a selector). + // (e.g. `appduct tools ls -abc123...` was parsed as a flag, not a selector). let candidate = randomBytes(SESSION_ID_BYTES).toString("base64url"); while (candidate.startsWith("-")) { diff --git a/packages/appduct/src/link.ts b/packages/appduct/src/link.ts index d1a64b7..181f5b4 100644 --- a/packages/appduct/src/link.ts +++ b/packages/appduct/src/link.ts @@ -2,7 +2,7 @@ * Shared core of `link.create` + deep-link composition + optional emulator/simulator delivery * (ARCHITECTURE.md §8, §10): mints a pending session via `link.create`, then composes * `:///?appduct=&pin=` and optionally delivers it to a booted - * Android emulator/device or iOS simulator. Used by `commands/link.ts` (`appduct link`) and + * Android emulator/device or iOS simulator. Used by `commands/link.ts` (`appduct sessions link`) and * `client/bootstrap.ts` (`appduct/client`'s `link()`) so this shape — scheme resolution, the * `pin` query param, the `127.0.0.1` emulator/simulator address override (which the experimental * `ios-device` target deliberately opts out of) — can't drift between the CLI and the programmatic diff --git a/packages/appduct/src/mcp/app-tools.ts b/packages/appduct/src/mcp/app-tools.ts index b538734..9709127 100644 --- a/packages/appduct/src/mcp/app-tools.ts +++ b/packages/appduct/src/mcp/app-tools.ts @@ -1,8 +1,9 @@ /** * The built-in `appduct_list_tools` / `appduct_describe_tool` / `appduct_call_tool` MCP tools * (ARCHITECTURE.md §9). The app's own tools are never listed as MCP tools. An agent reaches them - * the way the CLI does: compact signatures first (`appduct tools`), one full schema on demand - * (`appduct tools `), then a call by name (`appduct invoke`). A registry of hundreds of tools + * the way the CLI does: compact signatures first (`appduct tools ls`), one full schema on demand + * (`appduct tools describe `), then a call by name (`appduct tools call`). A registry of + * hundreds of tools * therefore costs a client three fixed tool definitions, and `tools/list` never changes while an * agent works. * diff --git a/packages/appduct/src/mcp/connect-tool.ts b/packages/appduct/src/mcp/connect-tool.ts index d599549..e7d894d 100644 --- a/packages/appduct/src/mcp/connect-tool.ts +++ b/packages/appduct/src/mcp/connect-tool.ts @@ -208,7 +208,7 @@ const asOptionalPositiveNumber = (value: unknown, field: string): number | undef export type ConnectToolDeps = { call: DaemonCall; /** The scheme composing the deep link (`:///?appduct=`), resolved once at - * server startup against the shared order in `scheme.ts` — the same one `appduct link` uses, + * server startup against the shared order in `scheme.ts` — the same one `appduct sessions link` uses, * so an app root with an `app.json` needs no configuration at all. */ scheme?: string; /** Every location `scheme.ts` consulted, named in the failure below so an agent can tell its diff --git a/packages/appduct/src/native-scheme.ts b/packages/appduct/src/native-scheme.ts index 4b3d9ea..4800f0e 100644 --- a/packages/appduct/src/native-scheme.ts +++ b/packages/appduct/src/native-scheme.ts @@ -25,7 +25,7 @@ * **Disagreement is not guessed away.** If more than one probe below resolves to a *different* * scheme (e.g. a monorepo whose `android/` and `ios/` declare different values), discovery throws * a usage error naming every conflicting source instead of picking one — silently choosing would - * mean `appduct link` opens the wrong app on whichever platform lost. + * mean `appduct sessions link` opens the wrong app on whichever platform lost. */ import { readdir, readFile, stat } from "node:fs/promises"; diff --git a/packages/appduct/src/output.ts b/packages/appduct/src/output.ts index 50b2465..07cda9d 100644 --- a/packages/appduct/src/output.ts +++ b/packages/appduct/src/output.ts @@ -41,9 +41,11 @@ export type RenderOptions = { * (which is only attached under `--verbose`). Defaults to the wall clock when omitted, matching * the pre-`--verbose` fallback behavior for a caller that doesn't care (e.g. non-`ls` tests). */ now?: Date; - /** `link`-only: also render the deep link as terminal QR art (never affects `--json` output). */ + /** `sessions link`-only: also render the deep link as terminal QR art (never affects `--json` + * output). */ qr?: boolean; - /** `tools`-only: render full schemas/annotations for every listed tool, not just name+description. */ + /** `tools ls`-only: render full schemas/annotations for every listed tool, not just + * name+description. */ full?: boolean; }; @@ -152,14 +154,9 @@ const renderLsData = (colors: ColorPalette, data: LsCommandData, now: Date): str ]; }; -/** `renderToolsData` distinguishes the listing form of `ToolsCommandData` (`ToolsListing`, which - * carries `tools`/`total`) from the bare single-tool detail form purely by shape — a single entry - * never has a `tools` array of its own, so this never misclassifies either one. */ -const isToolsListing = (data: ToolsCommandData): data is ToolsListing => { - return typeof data === "object" && data !== null && Array.isArray((data as ToolsListing).tools); -}; - -/** `--groups`' form: a `groups` array and no `tools` array (a listing carries both). */ +/** `--groups`' form: a `groups` array and no `tools` array (a listing carries both). Distinguishes + * `tools ls`'s two shapes — `tools describe`'s bare detail never reaches this (`renderToolDetail` + * is called directly for it by `renderSuccessData`'s `"tools describe"` case). */ const isToolGroupsListing = (data: ToolsCommandData): data is ToolGroupsListing => { return ( typeof data === "object" && @@ -197,7 +194,7 @@ const renderEmptyToolsLine = (data: ToolsListing): string => { // Never "No tools registered" for an empty group: the registry may well have tools, just not // in this group (a typo, or the wrong case — matching is case-sensitive). const match = data.filter === undefined ? "" : ` match ${JSON.stringify(data.filter)}`; - return ` No tools in group ${JSON.stringify(data.group)}${match}. Run \`appduct tools --groups\` to see the session's groups.`; + return ` No tools in group ${JSON.stringify(data.group)}${match}. Run \`appduct tools ls --groups\` to see the session's groups.`; } return data.filter === undefined ? " No tools registered." : ` No tools match ${JSON.stringify(data.filter)}.`; @@ -330,7 +327,7 @@ const renderToolSummaryTable = (colors: ColorPalette, data: ToolsListing): strin : data.tools.flatMap((tool) => renderToolSummaryLines(tool, " "))), ...renderTruncationLine(data), "", - "Run `appduct tools ` for a tool's full schema.", + "Run `appduct tools describe ` for a tool's full schema.", ]; }; @@ -374,7 +371,7 @@ const renderToolsFullListing = (colors: ColorPalette, data: ToolsListing, flags: ]; }; -/** `appduct tools --groups`: every group with its tool count, subgroups indented under their +/** `appduct tools ls --groups`: every group with its tool count, subgroups indented under their * parent, ungrouped last — in the daemon's `groups` order, which already puts a parent right * before its subgroups. */ const renderToolGroups = (colors: ColorPalette, data: ToolGroupsListing): string[] => { @@ -398,13 +395,16 @@ const renderToolGroups = (colors: ColorPalette, data: ToolGroupsListing): string ...data.groups.map((entry) => ` ${label(entry).padEnd(width)} ${String(entry.total).padStart(countWidth)}`), "", `${data.total} tool${data.total === 1 ? "" : "s"} in total. ` + - (hasAnyGroup(data.groups) ? "Run `appduct tools --group ` to list one group's tools." : "No tool declares a group."), + (hasAnyGroup(data.groups) ? "Run `appduct tools ls --group ` to list one group's tools." : "No tool declares a group."), ]; }; -const renderToolsData = ( +/** `appduct tools ls`'s two shapes: a `--groups` summary, or a listing (compact or `--full`). Never + * called with a bare tool detail — that is `tools describe`'s own shape (`renderToolDetail`, + * called directly by `renderSuccessData`'s `"tools describe"` case). */ +const renderToolsListData = ( colors: ColorPalette, - data: ToolsCommandData, + data: ToolsListing | ToolGroupsListing, flags: GlobalFlags, full?: boolean, ): string[] => { @@ -412,10 +412,6 @@ const renderToolsData = ( return renderToolGroups(colors, data); } - if (!isToolsListing(data)) { - return renderToolDetail(colors, data, flags); - } - return full ? renderToolsFullListing(colors, data, flags) : renderToolSummaryTable(colors, data); }; @@ -645,15 +641,17 @@ const renderSuccessData = (colors: ColorPalette, command: string, data: unknown, return renderInitData(colors, data as InitCommandData, flags); case "keygen": return renderKeygenData(colors, data as KeygenCommandData, flags); - case "link": + case "sessions link": return renderLinkData(colors, data as LinkCommandData, flags, options.qr); - case "ls": + case "sessions ls": return renderLsData(colors, data as LsCommandData, options.now ?? new Date()); - case "tools": - return renderToolsData(colors, data as ToolsCommandData, flags, options.full); - case "invoke": + case "tools ls": + return renderToolsListData(colors, data as ToolsListing | ToolGroupsListing, flags, options.full); + case "tools describe": + return renderToolDetail(colors, data as ListedToolDescriptor, flags); + case "tools call": return renderInvokeData(colors, data as InvokeCommandData, flags); - case "revoke": + case "sessions revoke": return renderRevokeData(colors, data as RevokeCommandData); case "daemon run": return renderDaemonRunData(colors, data as DaemonRunCommandData, flags); @@ -726,7 +724,8 @@ export const renderResult = ( }; }; -/** Renders one `appduct events` line: NDJSON under `--json`, a compact human line otherwise. */ +/** Renders one `appduct events tail`/`appduct events since` line: NDJSON under `--json`, a + * compact human line otherwise. */ export const renderEventLine = (event: EventNotification, flags: GlobalFlags): string => { if (flags.json) { // NDJSON is one object per line by contract (a streaming consumer reads it line-by-line, and @@ -742,7 +741,7 @@ export const renderEventLine = (event: EventNotification, flags: GlobalFlags): s return `${colors.dim(timestamp)} ${colors.green(event.kind)}${target ? ` ${target}` : ""}${dataSuffix}`; }; -/** Renders the trailing cursor line for `appduct events --since` (issue #6): NDJSON under +/** Renders the trailing cursor line for `appduct events since` (issue #6): NDJSON under * `--json` so a scripted caller can parse the resume point without maxing `seq` over the printed * events (impossible when the response is empty), a human note otherwise. */ export const renderEventsCursorLine = (cursor: number, flags: GlobalFlags): string => { diff --git a/packages/appduct/src/rpc/client.ts b/packages/appduct/src/rpc/client.ts index 07f9527..9867481 100644 --- a/packages/appduct/src/rpc/client.ts +++ b/packages/appduct/src/rpc/client.ts @@ -1045,7 +1045,7 @@ export type DaemonStream = { /** Subscribes to server→client `"event"` notifications; returns an unsubscribe function. */ onNotification: (callback: (payload: unknown) => void) => () => void; /** Fires once when the underlying socket closes (daemon gone, stop(), etc.); returns an - * unsubscribe function. Lets long-lived stream consumers (e.g. `appduct events`) end + * unsubscribe function. Lets long-lived stream consumers (e.g. `appduct events tail`) end * gracefully instead of hanging once the connection is no longer usable. */ onClose: (callback: () => void) => () => void; close: () => void; diff --git a/packages/appduct/src/scheme.ts b/packages/appduct/src/scheme.ts index 74a909f..6d08982 100644 --- a/packages/appduct/src/scheme.ts +++ b/packages/appduct/src/scheme.ts @@ -1,7 +1,7 @@ /** * Deep-link scheme resolution (ARCHITECTURE.md §10, issue #29). * - * A scheme is needed to compose `:///?appduct=` in `appduct link`, + * A scheme is needed to compose `:///?appduct=` in `appduct sessions link`, * `appduct/client`'s `link()` and the MCP `appduct_connect` tool. Before this module the only * source was `/config.json` — a single global file, which meant two apps with different * schemes on one machine required hand-editing it on every switch. @@ -257,7 +257,7 @@ export const globalConfigDirs = (options: ProjectConfigLookupOptions = {}): Set< * documented precedence: * * - `/.appduct`, the default state dir. Essentially every project lives somewhere under - * the home directory, so without this a plain `appduct link` in any repo would pick up the + * the home directory, so without this a plain `appduct sessions link` in any repo would pick up the * global `config.json` as if it were the project's — and with `--state-dir` pointing elsewhere it * would shadow the state dir the operator explicitly chose. * - `stateDirRoot`, the state dir actually in use, for the same reason when it is not the default. @@ -512,7 +512,7 @@ export const resolveScheme = async (options: ResolveSchemeOptions = {}): Promise /** * The shared "no scheme anywhere" message. Every caller renders the same body so the locations - * listed (and the fixes suggested) can't drift between `appduct link`, `appduct mcp` and + * listed (and the fixes suggested) can't drift between `appduct sessions link`, `appduct mcp` and * `appduct_connect`. */ export const describeMissingScheme = (tried: string[]): string => { diff --git a/packages/react-native/src/Appduct.types.ts b/packages/react-native/src/Appduct.types.ts index e1dff61..6d56698 100644 --- a/packages/react-native/src/Appduct.types.ts +++ b/packages/react-native/src/Appduct.types.ts @@ -308,7 +308,7 @@ export type AppductToolDefinition< * "timeout")`; a later result from the same invocation is ignored. * * Declared here it also travels on the tool descriptor and becomes the daemon's default deadline - * for this tool, so a caller that passes no timeout of its own (an MCP agent, `appduct invoke` + * for this tool, so a caller that passes no timeout of its own (an MCP agent, `appduct tools call` * with no `--timeout`) gets the same budget instead of the daemon's 10 s. Must be a positive * integer to make that trip — anything else stays app-side only, with a dev warning. The * client-wide `defaultToolTimeoutMs` is deliberately never sent. @@ -317,7 +317,7 @@ export type AppductToolDefinition< /** * The group this tool belongs to: a top-level group (`"checkout"`) or a subgroup * (`"checkout/payment"`) — one or two `/`-separated segments, each `[a-zA-Z0-9_-]{1,64}`. - * Agents list a large app's tools one group at a time (`appduct tools --groups`, then + * Agents list a large app's tools one group at a time (`appduct tools ls --groups`, then * `--group checkout`, which includes `checkout/*`). Optional; an ungrouped tool is listed under * `(ungrouped)`. A malformed group makes registration throw, like a malformed `name`. * `createToolGroup("checkout")` binds it for a whole feature module. diff --git a/packages/shared/src/domains/rpc.ts b/packages/shared/src/domains/rpc.ts index af14703..b537480 100644 --- a/packages/shared/src/domains/rpc.ts +++ b/packages/shared/src/domains/rpc.ts @@ -299,7 +299,7 @@ export type EventNotification = { /** Pulls the app events (`app_event` only) retained in the daemon's per-session ring buffer * (ARCHITECTURE.md §5) — the request/response counterpart to `events.subscribe`'s push model, for - * callers (MCP tools, a scripted `appduct events --since`) that ask "what did the app report?" + * callers (MCP tools, a scripted `appduct events since`) that ask "what did the app report?" * after the fact instead of listening live. */ export type EventsSinceParams = { /** Session id or alias; omitted selects the sole active/suspended session (same default as diff --git a/packages/shared/src/domains/tool-signature.ts b/packages/shared/src/domains/tool-signature.ts index 5ce1e03..b2b1e1e 100644 --- a/packages/shared/src/domains/tool-signature.ts +++ b/packages/shared/src/domains/tool-signature.ts @@ -2,7 +2,7 @@ * Renders a `ToolDescriptor` into a one-line call signature, e.g. * `seed_cart(items: int, sku?: string, clear?: bool = true) -> { added: int, cartId: string }`. * - * This exists so `appduct tools` can list hundreds of tools cheaply for an agent to read: a + * This exists so `appduct tools ls` can list hundreds of tools cheaply for an agent to read: a * signature plus the description's first line says far more per line than a bare name, without * the cost of printing every tool's full schema (`--full`/`tools ` remain the source of * truth for that). @@ -280,7 +280,7 @@ export const renderToolSignature = ( export const MAX_TOOL_SUMMARY_LENGTH = 120; /** - * A tool description's first line, for a listing (`appduct tools`, `appduct_list_tools`): any line + * A tool description's first line, for a listing (`appduct tools ls`, `appduct_list_tools`): any line * break ends it (`\r` alone included), remaining control characters are dropped since the text is * app-supplied and may be printed straight to a terminal, and it is capped at * {@link MAX_TOOL_SUMMARY_LENGTH} code points, never cut through a surrogate pair. From 5a6e4db52869e9c5852533b366be8090d552d02a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 15:43:39 +0000 Subject: [PATCH 03/16] docs: switch to the noun-verb CLI forms everywhere (#96) Every reference to a removed command (ls, revoke, link, tools , invoke, events --since) now reads as its noun-verb replacement: the appduct package README, ARCHITECTURE.md/PROTOCOL.md/SECURITY.md/TOOLS.md, the react-native/native/playground READMEs, the website's CLI/agents/ install/quick-start pages, and the shipped skill (skills/appduct/). Adds the Unreleased changelog entry for the breaking rename. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014wk7yigkPHWoWLy9nvocyt --- CHANGELOG.md | 4 ++ README.md | 2 +- docs/ARCHITECTURE.md | 33 ++++++--- docs/PROTOCOL.md | 18 ++--- docs/SECURITY.md | 6 +- docs/TOOLS.md | 16 ++--- packages/appduct/README.md | 70 ++++++++++--------- packages/native/android/README.md | 10 +-- packages/native/ios/README.md | 14 ++-- packages/react-native/README.md | 14 ++-- playground-native/android/README.md | 8 +-- playground-native/ios/README.md | 10 +-- playground/README.md | 2 +- playground/app/(tabs)/index.tsx | 4 +- playground/app/(tabs)/status.tsx | 4 +- playground/app/_layout.tsx | 2 +- skills/appduct/SKILL.md | 52 +++++++------- skills/appduct/references/cli.md | 47 +++++++------ skills/appduct/references/setup.md | 10 +-- skills/appduct/references/writing-tools.md | 4 +- website/src/content/docs/guides/agents.mdx | 18 ++--- website/src/content/docs/guides/cli.mdx | 44 ++++++------ .../src/content/docs/guides/writing-tools.mdx | 6 +- website/src/content/docs/install/android.mdx | 12 ++-- website/src/content/docs/install/ios.mdx | 14 ++-- .../src/content/docs/install/react-native.mdx | 12 ++-- .../content/docs/reference/architecture.md | 8 +-- website/src/content/docs/reference/cli.md | 40 +++++++---- .../docs/reference/react-native-api.md | 2 +- website/src/content/docs/start/playground.mdx | 14 ++-- .../src/content/docs/start/quick-start.mdx | 16 ++--- 31 files changed, 279 insertions(+), 237 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4dc2a8d..f5a728a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ section into a versioned heading. ## Unreleased +- **Breaking: the CLI commands are now noun-verb, with no aliases.** `ls`, `revoke`, `link`, + `tools` and `invoke` are gone — use `sessions ls|revoke|link`, `tools ls|describe|call`, and + `events tail|since ` instead; a removed command's error names its replacement. + ## 0.12.0 (2026-09-24) - **Breaking: `appduct events`, `appduct_events` and `appduct_wait_for_event` show only the events diff --git a/README.md b/README.md index e1a27cf..8abc6a5 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ The hook registers once per mount — re-rendering costs nothing, and the handle Call it from your terminal: ```bash -appduct invoke seed_cart --input '{"items":3}' +appduct tools call seed_cart --input '{"items":3}' ``` Or hand it to an agent — see [Use it with an agent](#use-it-with-an-agent). The CLI and the MCP server both read your app's deep-link scheme from its project files — `app.json` in an Expo app, `Info.plist` on iOS, `build.gradle` on Android — so there's nothing to configure. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 54b3cc4..96b5f70 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -112,7 +112,7 @@ without an operator hand-picking a port for each (the test suite's daemons all r Any other value must be a port number in `1..65535`. `advertisedIp` overrides auto-detection of the address advertised in minted bootstrap -payloads. `scheme` is the deep-link URI scheme composed into `appduct link`'s output +payloads. `scheme` is the deep-link URI scheme composed into `appduct sessions link`'s output when `--scheme` is not passed (§10) — set it once here instead of on every invocation. Unlike `scheme`, the app id `--open android`/`--open ios-device` need (issue #63) has no home in this file: it lives only in a project `.appduct/config.json`'s `appId.` @@ -301,7 +301,7 @@ tool does not buy it sixty seconds. Extending a tool's budget is the app's decis by declaring `timeoutMs` on the registration. Callers that hold their own transport watchdog over a `tools.call` (the MCP server, -`appduct invoke`, `appduct/client`) must size it from the same arithmetic — +`appduct tools call`, `appduct/client`) must size it from the same arithmetic — `deriveCallTransportTimeoutMs` (`daemon/calls.ts`) is that clamp plus 5 000 ms of slack — so the daemon's `tool_timeout` always arrives first and the real error type reaches the caller instead of a generic transport failure. A caller that knows the effective deadline @@ -337,7 +337,7 @@ after it), falling back to the session's true high-water mark only when nothing returned, so an empty page still lets a caller skip past the unretained kinds' `seq`s rather than re-scanning from an older cursor. `selector` defaults the same way as every other selector-taking method (§ above). The readers built on it — `appduct_events`, -`appduct_wait_for_event`, `appduct events` (whose live mode subscribes with +`appduct_wait_for_event`, `appduct events tail` (whose live mode subscribes with `kinds: ["app_event"]`) and the client SDK — therefore only ever show `app_event`; the internal consumers that need Appduct's own kinds (`appduct_wait_for_session`, `waitForSession`, `appduct_call_tool`'s `callId` and progress tracking) use @@ -481,12 +481,12 @@ proxies daemon RPC (auto-spawning the daemon like any client): - `tools/list` is a fixed set of built-in tools. The app's tools are never listed as MCP tools of their own; an agent reaches them through three built-ins that mirror the CLI (§10): - `appduct_list_tools` (`appduct tools`: one-line signatures from `renderToolSignature`, each + `appduct_list_tools` (`appduct tools ls`: one-line signatures from `renderToolSignature`, each tool's `group` (`null` for an ungrouped tool, as in the summary) and effective policy, with `group`/`filter`/`limit`/`offset` passed through to `tools.list`, `limit` defaulting to 50, and the daemon's whole-registry `groups` summary on - every result), `appduct_describe_tool` (`appduct tools `: the whole descriptor, `group` - included), and `appduct_call_tool` (`appduct invoke`: `{ selector?, name, args?, timeoutMs? }`). + every result), `appduct_describe_tool` (`appduct tools describe `: the whole descriptor, + `group` included), and `appduct_call_tool` (`appduct tools call`: `{ selector?, name, args?, timeoutMs? }`). `timeoutMs` can only shorten the tool's own deadline, since the `tool_call` frame carries no deadline and the app stops the handler at its declared one (`docs/PROTOCOL.md` §5); a longer value, or one outside 1000–600000 ms, is rejected rather than clamped. A client cancel that arrives while the consent @@ -546,7 +546,20 @@ owns no keys. Every command is one RPC call plus formatting, which is why the CL server can't drift in behavior: they are the same calls. The per-command reference lives in the [`appduct` package README](../packages/appduct/README.md), -which is where it stays current. `appduct tools`'s human listing renders each tool through +which is where it stays current. Every command is `appduct [selector] [args]` +(issue #96): `sessions ls|revoke|link`, `tools ls|describe|call`, `events tail|since`, with +`daemon run|start|stop|status` as the model this was generalized from — `init`, `keygen`, `doctor` +and `mcp` stay one-verb nouns. This is a clean break with no aliases (pre-1.0): a removed +top-level word (`ls`, `revoke`, `link`, `invoke`) is a usage error naming its replacement +(`dispatch.ts`'s `REMOVED_COMMANDS`), and a bare noun or an unrecognized verb is a usage error +naming that noun's verbs, exactly like a bare `daemon` already does. `cac` matches only a +command's first word and builds its boolean/string flag table from that command's own declared +options, so each noun in `create-cli.ts` declares every option any of its verbs uses — otherwise a +boolean flag ahead of a positional (`tools ls --full `) would swallow it as that flag's +value. `routes/sessions/`, `routes/tools/` and `routes/events/` are routers exactly like +`routes/daemon/`, one route module per verb. + +`appduct tools ls`'s human listing renders each tool through `@appduct/shared`'s `renderToolSignature` (a one-line call signature derived from the tool's JSON Schema) rather than printing the raw schema, so it stays cheap to read against an app that registers hundreds of tools. Once any tool declares a `group` (PROTOCOL.md §5), that listing prints @@ -555,8 +568,8 @@ under `(ungrouped)`), and its "Showing n of total" footer names the top-level gr counts so an agent narrows with `--group ` rather than guessing a `--filter`. `--group` is `tools.list`'s `group` param, filtered daemon-side like `--filter`; `--groups` prints only the `groups` summary (subgroups indented under their parent). Both are listing-only flags, a usage -error next to a tool ``, and a malformed `--group` is a usage error before the daemon is -asked. Global flags (`cli/global-flags.ts`'s declarative table): `--json` +error next to `tools describe`'s ``, and a malformed `--group` is a usage error before the +daemon is asked. Global flags (`cli/global-flags.ts`'s declarative table): `--json` (machine output, NDJSON for streams; compact by default), `--pretty` (indent `--json` output and embedded JSON values, never NDJSON lines), `--verbose` (include the `meta` block — omitted by default in both human and `--json` output), `--no-color`, `--state-dir`, `--daemon-restart` (force @@ -643,7 +656,7 @@ deferred to `appduct_connect`, which reports `invalid_request` naming every loca resolution *error* (an invalid `--scheme`, a malformed `app.json`) is additionally written to stderr at startup — never stdout, which carries MCP protocol frames only. -`appduct invoke`: a SIGINT while the call is still pending cancels it (§5's +`appduct tools call`: a SIGINT while the call is still pending cancels it (§5's `tools.cancel`, via the RPC connection dropping) rather than leaving the app-side handler running for a caller that has already exited; the process then exits reporting `tool_cancelled`. diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 40db307..fa06173 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -28,7 +28,7 @@ socket, and each gets its own session. Deep link shape: `:///?appduct=&pin=`. The `appduct` payload is unchanged from v1; `pin` is a separate, percent-encoded query param carrying the daemon's SPKI fingerprint (see `docs/ARCHITECTURE.md` §8), appended by both -`appduct link` and `appduct_connect`. **Anything reading the payload must stop at the +`appduct sessions link` and `appduct_connect`. **Anything reading the payload must stop at the `&`** — slicing to the end of the string swallows the pin and corrupts the blob. The `appduct` query value decodes to this binary layout — all multi-byte integers @@ -54,13 +54,13 @@ endpoint and brackets IPv6 literals: `wss://[fd00::1]:8443` vs. `wss://192.168.1 ### Delivery paths -1. **Emulator/simulator fast path** (`appduct link --open android|ios-sim`, or the MCP +1. **Emulator/simulator fast path** (`appduct sessions link --open android|ios-sim`, or the MCP `appduct_connect` tool's `target` argument): the daemon mints the link with the advertised address forced to `127.0.0.1`, `adb reverse`/`simctl openurl` delivers it — no human, fully scriptable. `android` additionally names the app explicitly (`adb shell am start ... -p `, issue #63): without it, more than one installed app declaring the same scheme pops an "Open with" chooser that `am start` still reports as success. -2. **Physical device on LAN**: printed deep link + QR (`appduct link --qr`). +2. **Physical device on LAN**: printed deep link + QR (`appduct sessions link --qr`). 3. **Physical iOS device, experimental** (`--open ios-device` / `target: "ios-device"`, issue #31): `xcrun devicectl device process launch --device --payload-url ` hands the link to an installed, dev-signed app on a connected iOS 17+ device @@ -238,7 +238,7 @@ message existed. ``` Guard: `isEventMessage`. Emitted by `postEvent(name, payload?)` on the React Native -client; surfaced daemon-side as an `app_event` (`events.subscribe`, `appduct events`) and +client; surfaced daemon-side as an `app_event` (`events.subscribe`, `appduct events tail`) and retained per-session (`events.since`, §8) so a request/response caller (an MCP client, a script) can ask "what happened?" after the fact instead of only listening live. @@ -262,7 +262,7 @@ the tool, without `input_schema`/`output_schema`, so agents see a shapeless (`{} the app-side SDK throws on that in development rather than letting it ship silently. The daemon never inspects a schema's internals — only that it is a JSON object. -`annotations` are shown to agents as-is (`appduct tools `, `appduct_describe_tool` over +`annotations` are shown to agents as-is (`appduct tools describe `, `appduct_describe_tool` over MCP) and drive the daemon's policy engine (`docs/ARCHITECTURE.md` §12): `destructiveHint: true` routes a call through `policy.destructive` instead of @@ -280,11 +280,11 @@ is snake_case here like every other protocol-defined descriptor field, while the layers. A camelCase key on this descriptor is an unknown extra, not a deadline. It is the app's *explicit* per-tool value only — never an app-wide default such as `defaultToolTimeoutMs`. Older apps omit the field entirely and keep the daemon's 10 s default, so it is safe to add in either direction. It -is a daemon-side scheduling hint; agents see it through `appduct tools ` and +is a daemon-side scheduling hint; agents see it through `appduct tools describe ` and `appduct_describe_tool`. `group` puts the tool in an app-declared group so an agent can list a large registry one -area at a time (`tools.list`'s `group` param, `appduct tools --group`). It is a single string +area at a time (`tools.list`'s `group` param, `appduct tools ls --group`). It is a single string of one or two `/`-separated segments, each matching the name pattern `^[a-zA-Z0-9_-]{1,64}$`: a top-level group (`checkout`) or a subgroup (`checkout/payment`), nothing deeper. As one pattern: `^[a-zA-Z0-9_-]{1,64}(/[a-zA-Z0-9_-]{1,64})?$`, matched against @@ -327,7 +327,7 @@ entries. `crypto.timingSafeEqual` and is single-use — consumed on a successful claim, and invalidated outright after 5 failed claim attempts against that `sessionId`. - `PENDING → DISCARDED`: the link's TTL elapsed before a claim; cheap, re-issue with - `appduct link` (or `appduct_connect`) again. + `appduct sessions link` (or `appduct_connect`) again. - `PENDING → ACTIVE`: a successful `session_claim`. The daemon issues a `resume_token` in the `session_ack`. - `ACTIVE → SUSPENDED`: socket close, socket error, or two missed keepalive pongs. Tool @@ -339,7 +339,7 @@ entries. app is expected to re-send a full `tool_registry_snapshot` right after (§4) — the daemon treats it as authoritative and discards whatever it retained across the gap. - `SUSPENDED → EXPIRED`: `graceSeconds` elapsed with no successful resume. -- Any state → `REVOKED`: `sessions.revoke` (CLI `appduct revoke`, or the equivalent +- Any state → `REVOKED`: `sessions.revoke` (CLI `appduct sessions revoke`, or the equivalent RPC call). Terminal states (`DISCARDED`, `EXPIRED`, `REVOKED`) free the session's alias for reuse by a future session. - There is no cap on concurrent sessions; every session shares the one `wss://` listener. diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 7b62afd..230529c 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -63,7 +63,7 @@ fresh clone of this repo (or a fresh app project) works with zero setup: - The daemon auto-generates `key.pem` the first time it starts if the file is missing, and prints its `sha256/...` fingerprint. -- `appduct link` composes that fingerprint into the deep link as a separate `pin` query +- `appduct sessions link` composes that fingerprint into the deep link as a separate `pin` query param, alongside the existing binary `appduct` bootstrap payload. The binary payload format is unchanged — an app build that doesn't know about `pin` simply ignores it. - The native client trusts that link-carried pin, for that one link's session only, when the @@ -185,7 +185,7 @@ Android `` meta-data: | `com.callstack.appduct.ALLOW_PRIVATE_LAN_ONLY` | Boolean meta-data value (a `"true"`/`"false"` String is also accepted); defaults to `true` (fail-closed) when absent | Wire **deep links** so the OS can open your app with the host's bootstrap URL, and make -sure the app scheme matches the one `appduct link` (or the `deepLinkScheme` plugin +sure the app scheme matches the one `appduct sessions link` (or the `deepLinkScheme` plugin option, or `config.json`) uses to compose that link. ### Plain native apps (no React Native) @@ -286,7 +286,7 @@ app copies for hardening. **Consequence for agents and E2E flows:** because registration is the app-side allowlist, the tool set legitimately differs per build artifact. A CI testing build may expose a different tool set than a local dev build or a hardened production build. Automated flows -should discover tools (`appduct tools`, or `appduct_list_tools` over MCP) rather than assume +should discover tools (`appduct tools ls`, or `appduct_list_tools` over MCP) rather than assume a fixed set is always present. ## Key handling rules diff --git a/docs/TOOLS.md b/docs/TOOLS.md index a929b62..40c5cf4 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -115,7 +115,7 @@ Because exportable schemas are compared by their *exported* JSON Schema, the reg ## Group tools in a large app -Once your app registers more tools than fit on a screen, give each one a `group`. An agent then runs `appduct tools --groups` to see your app's areas, and `appduct tools --group cart` to list one of them, instead of guessing words to `--filter` on. +Once your app registers more tools than fit on a screen, give each one a `group`. An agent then runs `appduct tools ls --groups` to see your app's areas, and `appduct tools ls --group cart` to list one of them, instead of guessing words to `--filter` on. ```ts useAppductTool({ @@ -127,7 +127,7 @@ useAppductTool({ }); ``` -A group is a top-level name (`cart`) or one subgroup below it (`checkout/payment`). Each part uses the same characters as a tool name (letters, digits, `_` and `-`, at most 64). Nothing deeper than one subgroup is allowed. Add a subgroup only when a group itself outgrows a screen: `appduct tools --group checkout` lists `checkout` together with every `checkout/...` subgroup, and `--group checkout/payment` lists only that subgroup. +A group is a top-level name (`cart`) or one subgroup below it (`checkout/payment`). Each part uses the same characters as a tool name (letters, digits, `_` and `-`, at most 64). Nothing deeper than one subgroup is allowed. Add a subgroup only when a group itself outgrows a screen: `appduct tools ls --group checkout` lists `checkout` together with every `checkout/...` subgroup, and `--group checkout/payment` lists only that subgroup. To register several tools in one group without repeating its name, bind it once with `createToolGroup`: @@ -140,13 +140,13 @@ registerCartTool({ name: "add_item", description: "Add a product to the cart", h registerCartTool({ name: "clear_cart", description: "Remove every item from the cart", handler: clearCart }); ``` -A malformed group (`"checkout/"`, `"a/b/c"`, `"check out"`) makes the registration throw, like a malformed tool name. Groups only change how tools are listed, by `appduct tools` and by `appduct_list_tools` over MCP. They don't change tool names or how tools are called. +A malformed group (`"checkout/"`, `"a/b/c"`, `"check out"`) makes the registration throw, like a malformed tool name. Groups only change how tools are listed, by `appduct tools ls` and by `appduct_list_tools` over MCP. They don't change tool names or how tools are called. ## Make the input schema accept an object A tool call always passes its arguments as a JSON object. An `inputSchema` whose root type is something else — `z.string()`, `z.number()`, `z.array(...)` — can never be satisfied, and registering one logs a dev warning naming the tool. Wrap the value instead: `inputSchema: z.object({ sku: z.string() })` rather than `z.string()`. -Unions and intersections of objects work: `z.union([...])`, `z.discriminatedUnion(...)` and `z.intersection(a, b)` export with no root `type`, and an object argument can still match one of their branches. The one-line signature in `appduct tools` shows their arguments as `(...)`, though, so an agent has to read the full schema (`appduct tools `, or `appduct_describe_tool` over MCP) before it can call them. A single `z.object(...)` gives agents named arguments straight from the listing. +Unions and intersections of objects work: `z.union([...])`, `z.discriminatedUnion(...)` and `z.intersection(a, b)` export with no root `type`, and an object argument can still match one of their branches. The one-line signature in `appduct tools ls` shows their arguments as `(...)`, though, so an agent has to read the full schema (`appduct tools describe `, or `appduct_describe_tool` over MCP) before it can call them. A single `z.object(...)` gives agents named arguments straight from the listing. `outputSchema` has no such limit. A result can be any JSON value, and agents see the schema exactly as you wrote it. @@ -166,14 +166,14 @@ useAppductTool( ); ``` -That deadline is enforced end to end: the app aborts the handler's `signal` at it, and it also travels to the daemon as the descriptor's `timeout_ms`, so an agent calling the tool over MCP (or `appduct invoke` with no `--timeout`) gets the same budget instead of a `tool_timeout` at 10 seconds. +That deadline is enforced end to end: the app aborts the handler's `signal` at it, and it also travels to the daemon as the descriptor's `timeout_ms`, so an agent calling the tool over MCP (or `appduct tools call` with no `--timeout`) gets the same budget instead of a `tool_timeout` at 10 seconds. -The SDK clamps the value to `[1_000, 600_000]` ms before either timer is set, so the handler's abort timer and the daemon's call deadline are always the same number (a value outside that range is clamped with a dev warning). A caller that passes its own timeout (`appduct invoke --timeout`, `app.call(name, args, { timeoutMs })`) can only **shorten** the deadline, never extend it past this one — the app aborts the handler at its own timer regardless, so for a tool that declares nothing, a caller asking for 60 seconds still gets the app's 10-second default. That app-side fallback is fixed natively (`AppductClient`'s own `defaultToolTimeoutMs`, `APPDUCT_DEFAULT_TOOL_TIMEOUT_MS`) and JS cannot override it — `createAppductClient`'s options are empty, and the TurboModule spec has no channel for it. Declare `timeoutMs` per tool when a call needs longer than the default. +The SDK clamps the value to `[1_000, 600_000]` ms before either timer is set, so the handler's abort timer and the daemon's call deadline are always the same number (a value outside that range is clamped with a dev warning). A caller that passes its own timeout (`appduct tools call --timeout`, `app.call(name, args, { timeoutMs })`) can only **shorten** the deadline, never extend it past this one — the app aborts the handler at its own timer regardless, so for a tool that declares nothing, a caller asking for 60 seconds still gets the app's 10-second default. That app-side fallback is fixed natively (`AppductClient`'s own `defaultToolTimeoutMs`, `APPDUCT_DEFAULT_TOOL_TIMEOUT_MS`) and JS cannot override it — `createAppductClient`'s options are empty, and the TurboModule spec has no channel for it. Declare `timeoutMs` per tool when a call needs longer than the default. ## Designing tools for agents Everything above is mechanics. This section is about the reader: the agent that runs -`appduct tools` (or `appduct_list_tools` over MCP) and has to pick and call a tool from a listing +`appduct tools ls` (or `appduct_list_tools` over MCP) and has to pick and call a tool from a listing like ``` @@ -271,7 +271,7 @@ registerTool({ }); ``` -**Read it back.** Once the tools are registered, connect a device and run `appduct tools`. That +**Read it back.** Once the tools are registered, connect a device and run `appduct tools ls`. That listing is exactly what the calling agent sees: fix any `...` in a signature (a schema shape the renderer cannot summarize, usually a root union), any tool without `-> { ... }`, and any description whose first line does not say what the tool needs. The [playground's tools](../playground/app/(tabs)/index.tsx) diff --git a/packages/appduct/README.md b/packages/appduct/README.md index ab766d1..7fcd1dc 100644 --- a/packages/appduct/README.md +++ b/packages/appduct/README.md @@ -17,44 +17,48 @@ The `appduct` package is the operator/agent side of Appduct: a CLI and an MCP se ```bash npm install -g appduct -appduct link --scheme myapp --qr +appduct sessions link --scheme myapp --qr ``` -`link` needs your app's deep-link scheme: pass `--scheme` (the app's `expo.scheme`, or its bare-RN equivalent), or set `"scheme"` once in `~/.appduct/config.json` and omit the flag. Without either, the command exits with a usage error. +`sessions link` needs your app's deep-link scheme: pass `--scheme` (the app's `expo.scheme`, or its bare-RN equivalent), or set `"scheme"` once in `~/.appduct/config.json` and omit the flag. Without either, the command exits with a usage error. Scan the QR (or open the deep link) in an Appduct-enabled app, then: ```bash -appduct tools -appduct invoke sum --input '{"a":2,"b":3}' +appduct tools ls +appduct tools call sum --input '{"a":2,"b":3}' ``` That's the whole loop. There is no host process to start — `appduct` auto-spawns its daemon the first time any command needs it. ## Commands +Every command is `appduct [selector] [args]`, the same shape as `appduct daemon run|start|stop|status`: + | Command | Role | | --- | --- | | `appduct init [--scheme ] [--ios-app-id ] [--android-app-id ] [--force]` | set up an app directory: write `.appduct/config.json`, print the MCP snippet | | `appduct keygen [--out ] [--force]` | generate a daemon private key, print its app pin | -| `appduct link [--ttl ] [--qr] [--open android\|ios-sim\|ios-device] [--device ] [--app-id ] [--scheme ]` | mint a pending session and print its deep link | -| `appduct ls` | list sessions: alias, state, device, tool count | -| `appduct tools [selector] [name] [--full] [--group ] [--filter ] [--limit ] [--offset ]` | list a session's tools (one call signature + description per line), or show one tool's full schema | -| `appduct tools [selector] --groups` | list a session's tool groups with their tool counts | -| `appduct invoke [selector] --input '' [--timeout ]` | call a tool | -| `appduct events [selector] [--follow] [--since ]` | stream the events the app posts with `postEvent` (default), or one-shot pull the ones retained since `` (`--since`); `--json` emits NDJSON | -| `appduct revoke [selector]` | revoke a session | +| `appduct sessions ls` | list sessions: alias, state, device, tool count | +| `appduct sessions link [--ttl ] [--qr] [--open android\|ios-sim\|ios-device] [--device ] [--app-id ] [--scheme ]` | mint a pending session and print its deep link | +| `appduct sessions revoke [selector]` | revoke a session | +| `appduct tools ls [selector] [--full] [--group ] [--filter ] [--limit ] [--offset ]` | list a session's tools (one call signature + description per line) | +| `appduct tools ls [selector] --groups` | list a session's tool groups with their tool counts | +| `appduct tools describe [selector] ` | show one tool's full schema | +| `appduct tools call [selector] --input '' [--timeout ]` | call a tool | +| `appduct events tail [selector] [--follow]` | stream the events the app posts with `postEvent`; `--json` emits NDJSON | +| `appduct events since [selector] ` | one-shot pull the events retained since ``; `--json` emits NDJSON | | `appduct daemon run\|start\|stop\|status` | daemon lifecycle | | `appduct mcp [--scheme ]` | start a stdio MCP server proxying connected apps' tools to MCP clients | | `appduct doctor [--assert-present\|--assert-absent]` | release-gate step: report or assert whether a built `.app`/`.ipa`/`.apk`/`.aab` contains Appduct | -Every command that targets a session accepts an optional `selector` (a session id or an alias from `appduct ls`); omit it when exactly one session is active. Global flags: `--json` (machine-readable output; compact by default, one line), `--pretty` (indent `--json` output, and JSON values embedded in human output, 2 spaces — never NDJSON event lines), `--verbose` (include the `meta` block — `command`, `timestamp`, `duration_ms` — omitted by default in both human and `--json` output), `--no-color`, `--state-dir ` (default `~/.appduct`), `--daemon-restart` (on a daemon/CLI version mismatch, restart the daemon even though that drops live sessions and unclaimed links — `APPDUCT_DAEMON_RESTART=1` and `config.json`'s `restartDaemonOnVersionMismatch` do the same for every command, and `--no-daemon-restart` overrules both for one). Run `appduct --help` for the exact flags of any command. +Every command that targets a session accepts an optional `selector` (a session id or an alias from `appduct sessions ls`); omit it when exactly one session is active. Global flags: `--json` (machine-readable output; compact by default, one line), `--pretty` (indent `--json` output, and JSON values embedded in human output, 2 spaces — never NDJSON event lines), `--verbose` (include the `meta` block — `command`, `timestamp`, `duration_ms` — omitted by default in both human and `--json` output), `--no-color`, `--state-dir ` (default `~/.appduct`), `--daemon-restart` (on a daemon/CLI version mismatch, restart the daemon even though that drops live sessions and unclaimed links — `APPDUCT_DAEMON_RESTART=1` and `config.json`'s `restartDaemonOnVersionMismatch` do the same for every command, and `--no-daemon-restart` overrules both for one). Run `appduct --help` for the exact flags any of its verbs takes. -`--timeout` on `invoke` is clamped to 1,000–600,000 ms and can only **shorten** the deadline, never extend it past the app's own timer: the app aborts the handler at the tool's declared `timeoutMs`, or 10 seconds for a tool that declares none, regardless of what the caller asks for. If a tool needs more room, declare `timeoutMs` on its registration. +`--timeout` on `tools call` is clamped to 1,000–600,000 ms and can only **shorten** the deadline, never extend it past the app's own timer: the app aborts the handler at the tool's declared `timeoutMs`, or 10 seconds for a tool that declares none, regardless of what the caller asks for. If a tool needs more room, declare `timeoutMs` on its registration. -### `appduct tools`: a signature per tool +### `appduct tools ls`: a signature per tool -`appduct tools` prints one call signature plus a one-line description per tool, not the full schema — cheap to read even against an app that registers hundreds of tools: +`appduct tools ls` prints one call signature plus a one-line description per tool, not the full schema — cheap to read even against an app that registers hundreds of tools: ``` Tools @@ -63,16 +67,16 @@ Tools set_flag(name: "dark_mode" | "new_checkout", enabled: bool) [prompt] Toggle a feature flag. -Run `appduct tools ` for a tool's full schema. +Run `appduct tools describe ` for a tool's full schema. ``` -A signature is derived straight from the tool's JSON Schema: required params are `name: type`, optional ones `name?: type` (with `= ` when the schema declares a short one), and `-> type` is the result when the tool declares an `output_schema`. `...` anywhere means the schema shape wasn't one this renderer could summarize — the tool's full schema (`appduct tools `) still has it. A `[prompt]`/`[deny]` tag follows a tool whose effective policy isn't `"allow"`. +A signature is derived straight from the tool's JSON Schema: required params are `name: type`, optional ones `name?: type` (with `= ` when the schema declares a short one), and `-> type` is the result when the tool declares an `output_schema`. `...` anywhere means the schema shape wasn't one this renderer could summarize — the tool's full schema (`appduct tools describe `) still has it. A `[prompt]`/`[deny]` tag follows a tool whose effective policy isn't `"allow"`. -Use `--filter ` to narrow the listing to tools whose name or description contains `` (case-insensitive), and `--limit `/`--offset ` to page through it; a truncated listing prints a trailing `Showing n of total tools (offset o). Narrow with --filter or page with --offset .` line so you know more were left out. `appduct tools --json` returns `{ tools, total, groups }` — `total` is the count after `--group`/`--filter` but before `--limit`/`--offset`. `appduct tools ` (a single tool) is unaffected by any of this and always returns the bare tool descriptor; passing `--group`, `--groups`, `--filter`, `--limit` or `--offset` with a `` is a usage error. +Use `--filter ` to narrow the listing to tools whose name or description contains `` (case-insensitive), and `--limit `/`--offset ` to page through it; a truncated listing prints a trailing `Showing n of total tools (offset o). Narrow with --filter or page with --offset .` line so you know more were left out. `appduct tools ls --json` returns `{ tools, total, groups }` — `total` is the count after `--group`/`--filter` but before `--limit`/`--offset`. `appduct tools describe ` (a single tool) is unaffected by any of this and always returns the bare tool descriptor; passing `--group`, `--groups`, `--filter`, `--limit` or `--offset` with `describe` is a usage error. ### Tool groups -When the app puts its tools in groups ([`docs/TOOLS.md`](../../docs/TOOLS.md#group-tools-in-a-large-app)), `appduct tools` lists them under group headings, with subgroups indented under their parent and ungrouped tools last: +When the app puts its tools in groups ([`docs/TOOLS.md`](../../docs/TOOLS.md#group-tools-in-a-large-app)), `appduct tools ls` lists them under group headings, with subgroups indented under their parent and ungrouped tools last: ``` Tools @@ -93,23 +97,23 @@ Tools On a large app, start with `--groups` to see what there is, then list one group: ``` -$ appduct tools --groups +$ appduct tools ls --groups Groups cart 12 checkout 8 checkout/payment 3 (ungrouped) 2 -22 tools in total. Run `appduct tools --group ` to list one group's tools. +22 tools in total. Run `appduct tools ls --group ` to list one group's tools. -$ appduct tools --group checkout +$ appduct tools ls --group checkout ``` `--group checkout` lists `checkout` and all of its subgroups; `--group checkout/payment` lists only that subgroup. Matching is by whole name and case-sensitive, so `--group checkout` never matches a `checkoutx` group. `--group` combines with `--filter`, `--limit` and `--offset`. When a listing without `--group` is cut short, the footer names the top-level groups to narrow to: `Showing 5 of 22 tools (offset 0). Narrow with --group (groups: cart 12, checkout 8) or --filter , or page with --offset .` A group that is not one or two `/`-separated names of letters, digits, `_` and `-` (for example `checkout/` or `a/b/c`) is a usage error. With `--json`, each tool carries its `group` — `null` for an ungrouped tool, exactly the value `groups` uses for its own ungrouped row — and `groups` lists every group with its count, whatever `--group` or `--filter` you passed. ### The deep-link scheme -`appduct link`, `appduct mcp`, and the MCP `appduct_connect` tool all resolve the scheme the same way, first match wins: +`appduct sessions link`, `appduct mcp`, and the MCP `appduct_connect` tool all resolve the scheme the same way, first match wins: 1. `--scheme ` 2. the `APPDUCT_SCHEME` environment variable @@ -164,17 +168,17 @@ It needs all of: - **iOS 17 or newer** on the device, and **Xcode 15 or newer** on the host (`devicectl` doesn't exist before that). For iOS 16 and below, use the QR/deep-link flow instead. - The device **paired and trusted** by this Mac, with **Developer Mode** enabled on it (Settings → Privacy & Security → Developer Mode). - A **development-signed build of your app already installed** — `devicectl` launches an installed app, it doesn't install one. -- The phone and this machine **on the same network**, reachable at the address the link advertises. `appduct link` prints it on its `Endpoint` line (`--json`: `endpoint.address`); `advertisedIp` in `config.json` overrides detection. If no routable address is found, detection falls back to `127.0.0.1` — which a phone can't reach — so `ios-device` refuses to deliver such a link and tells you to set `advertisedIp`. +- The phone and this machine **on the same network**, reachable at the address the link advertises. `appduct sessions link` prints it on its `Endpoint` line (`--json`: `endpoint.address`); `advertisedIp` in `config.json` overrides detection. If no routable address is found, detection falls back to `127.0.0.1` — which a phone can't reach — so `ios-device` refuses to deliver such a link and tells you to set `advertisedIp`. - The app's **bundle id** — see [Delivering the link to a device](#delivering-the-link-to-a-device) for how to supply it. ```bash -appduct link --scheme myapp --open ios-device --app-id com.example.myapp +appduct sessions link --scheme myapp --open ios-device --app-id com.example.myapp ``` Two things worth knowing before you rely on this: - **What happens when the app is already running hasn't been verified on hardware.** If delivery to an already-running app does nothing, pass **`--relaunch`** (`relaunch: true` over MCP) — it kills the running instance first. Appduct copes with the restart either way; a delivered link supersedes a held session. -- **A physical iPhone is never auto-detected.** `appduct_connect` called without a `target` only considers booted simulators and attached Android devices, and `appduct link` without `--open` doesn't look for a device at all — a paired iPhone is often someone's personal phone, so it has to be asked for explicitly. +- **A physical iPhone is never auto-detected.** `appduct_connect` called without a `target` only considers booted simulators and attached Android devices, and `appduct sessions link` without `--open` doesn't look for a device at all — a paired iPhone is often someone's personal phone, so it has to be asked for explicitly. ## MCP setup (Claude Code, Cursor, and similar) @@ -208,11 +212,11 @@ Once configured, an agent reaches the connected app's tools through three built- | Tool | Does what | CLI equivalent | | --- | --- | --- | -| `appduct_list_tools` | Lists the app's tools as one-line signatures, with each tool's group and policy, plus the app's groups with counts. Returns 50 at a time unless given `limit`; takes `group`, `filter` and `offset`. | `appduct tools` | -| `appduct_describe_tool` | Shows one tool's full input and output schema. | `appduct tools ` | -| `appduct_call_tool` | Calls a tool by `name` with `args`, with progress and errors preserved. | `appduct invoke` | +| `appduct_list_tools` | Lists the app's tools as one-line signatures, with each tool's group and policy, plus the app's groups with counts. Returns 50 at a time unless given `limit`; takes `group`, `filter` and `offset`. | `appduct tools ls` | +| `appduct_describe_tool` | Shows one tool's full input and output schema. | `appduct tools describe ` | +| `appduct_call_tool` | Calls a tool by `name` with `args`, with progress and errors preserved. | `appduct tools call` | -The app's tools don't show up as MCP tools of their own. An app with hundreds of tools still adds only these three to the agent's tool list, and that list doesn't change when tools register or a device connects. With more than one device connected, each of the three needs `selector`: the session alias or id from `appduct ls`. +The app's tools don't show up as MCP tools of their own. An app with hundreds of tools still adds only these three to the agent's tool list, and that list doesn't change when tools register or a device connects. With more than one device connected, each of the three needs `selector`: the session alias or id from `appduct sessions ls`. Your MCP client asks permission for `appduct_call_tool` as a single tool, so choosing "always allow" there approves every tool the app registers, destructive ones included. To keep a person approving those calls, set `policy.destructive` to `"prompt"` in the state directory's `config.json`. Each call to a tool marked `destructiveHint` then shows an approval prompt in clients that support it, and is denied in clients that don't. A destructive tool without that annotation falls under `policy.default` instead. @@ -224,7 +228,7 @@ The other two give an agent a pull surface over `postEvent()`-pushed app events: ## Test runners: `appduct/client` -A thin typed wrapper over the same daemon RPC the CLI and MCP server use — for a Jest/Vitest/Detox spec that drives a running app without spawning `appduct invoke ... --json` and parsing stdout: +A thin typed wrapper over the same daemon RPC the CLI and MCP server use — for a Jest/Vitest/Detox spec that drives a running app without spawning `appduct tools call ... --json` and parsing stdout: ```ts import { connect } from "appduct/client"; @@ -275,7 +279,7 @@ For everything else, the package exports `runCli` and the command handlers from ## Keys and pins -You can skip this entirely while your app has no `cliPins` configured — the zero-config default, in any build type. The daemon auto-generates its own `key.pem` the first time it starts if one isn't already there (mode `0600`) and prints its `sha256/...` fingerprint on that first run; `appduct link` carries that fingerprint on the deep link for the app to pick up. See [`docs/SECURITY.md`][security]'s "Trust modes" for what that does and doesn't protect. +You can skip this entirely while your app has no `cliPins` configured — the zero-config default, in any build type. The daemon auto-generates its own `key.pem` the first time it starts if one isn't already there (mode `0600`) and prints its `sha256/...` fingerprint on that first run; `appduct sessions link` carries that fingerprint on the deep link for the app to pick up. See [`docs/SECURITY.md`][security]'s "Trust modes" for what that does and doesn't protect. For a build that should trust only a key you embedded ahead of time, generate one explicitly: @@ -285,7 +289,7 @@ appduct keygen This writes an unencrypted PEM private key (PKCS#8) to `/key.pem` by default (override with `--out`; add `--force` to overwrite) and prints the exact `sha256/...` SPKI fingerprint your app should place into `cliPins`. It runs non-interactively, so it's safe to call from CI or a setup script. -`appduct link`'s deep link is `:///?appduct=&pin=`. The `appduct` param is the binary v2 bootstrap payload (address, session id, token, expiry); `pin` is a separate, out-of-band query param carrying the daemon's current SPKI fingerprint for apps that want to pick it up. An app build with embedded `cliPins` ignores `pin` outright — embedded pins always win, in every build type. A build with no embedded pins trusts it for that one session. +`appduct sessions link`'s deep link is `:///?appduct=&pin=`. The `appduct` param is the binary v2 bootstrap payload (address, session id, token, expiry); `pin` is a separate, out-of-band query param carrying the daemon's current SPKI fingerprint for apps that want to pick it up. An app build with embedded `cliPins` ignores `pin` outright — embedded pins always win, in every build type. A build with no embedded pins trusts it for that one session. ## Daemon lifecycle diff --git a/packages/native/android/README.md b/packages/native/android/README.md index c61fd61..657ffdb 100644 --- a/packages/native/android/README.md +++ b/packages/native/android/README.md @@ -42,7 +42,7 @@ Forgetting this is a **manifest-merger error at build time**, not a silent no-op Attribute data@scheme at ... requires a placeholder substitution but no value for is provided. ``` -The scheme you pick is what `appduct link --scheme ` / `appduct init --scheme ` must +The scheme you pick is what `appduct sessions link --scheme ` / `appduct init --scheme ` must match on the CLI side. It can be a dedicated scheme distinct from your app's own primary deep-link scheme — see [Deep links](#3-deep-links) for why that matters if you already handle your own scheme. @@ -95,7 +95,7 @@ signs out or pays, and give every tool an `outputSchema`. See [`docs/TOOLS.md`](../../../docs/TOOLS.md#designing-tools-for-agents) for the full list of rules. On an app with many tools, pass `group` so agents can list them one area at a time -(`appduct tools --group cart`). A group is `"cart"` or one subgroup below it, like +(`appduct tools ls --group cart`). A group is `"cart"` or one subgroup below it, like `"checkout/payment"`; each part uses tool-name characters (letters, digits, `_`, `-`, at most 64). A malformed group makes `register` throw `IllegalArgumentException`, like a malformed name: @@ -134,7 +134,7 @@ does no further suspending work simply runs to completion, the same way a JS han Appduct ships a no-UI trampoline activity, `AppductLinkActivity`, declared in `core`'s own manifest with an intent filter on `${appductScheme}`. Open a bootstrap link -(`appduct link --scheme myapp`) and it reaches your registered tools with no code required on +(`appduct sessions link --scheme myapp`) and it reaches your registered tools with no code required on your side: the OS routes the link to the trampoline, which calls `Appduct.handle(intent)` and finishes immediately. @@ -240,10 +240,10 @@ appduct`, or invoke the workspace build directly if the two might disagree. `manifestPlaceholders["appductScheme"]` in your app's `build.gradle` — see [The `appductScheme` placeholder](#the-appductscheme-placeholder) above. -**`appduct link --open android` reports "unable to resolve Intent".** Either the app id +**`appduct sessions link --open android` reports "unable to resolve Intent".** Either the app id (`--app-id`, or `appId.android` in `.appduct/config.json`) isn't the installed build's `applicationId` — check for an `applicationIdSuffix` on the variant you installed — or the scheme -`appduct link` used doesn't match `manifestPlaceholders["appductScheme"]` on the installed build. +`appduct sessions link` used doesn't match `manifestPlaceholders["appductScheme"]` on the installed build. If the app was built before that placeholder was set, rebuild and reinstall — placeholders are baked in at build time, not read at runtime. diff --git a/packages/native/ios/README.md b/packages/native/ios/README.md index 8640219..3bd72d9 100644 --- a/packages/native/ios/README.md +++ b/packages/native/ios/README.md @@ -74,7 +74,7 @@ Add a `CFBundleURLTypes` entry to your `Info.plist` (Xcode: target → Info → ``` -This is the scheme `appduct link --scheme myapp` (or `appduct init --scheme myapp` once, or +This is the scheme `appduct sessions link --scheme myapp` (or `appduct init --scheme myapp` once, or `APPDUCT_SCHEME`) composes the bootstrap deep link with. There is nothing to configure on the Swift side for this step — the scheme lives entirely in `Info.plist`. @@ -174,11 +174,11 @@ try Appduct.shared.register( - `annotations` (`ToolAnnotations(readOnlyHint:destructiveHint:idempotentHint:)`) and `timeoutMs` are optional, exactly like the JS API's `registerTool`. Set them the way an agent needs them: `readOnlyHint` on every observer, `destructiveHint` on - anything that deletes, signs out or pays, and an `outputSchema` on every tool, so `appduct tools` + anything that deletes, signs out or pays, and an `outputSchema` on every tool, so `appduct tools ls` shows a complete signature. [`docs/TOOLS.md`](../../../docs/TOOLS.md#designing-tools-for-agents) has the full list of rules, with examples. - `group` is optional too. On an app with many tools, set it so agents can list them one area at a - time (`appduct tools --group cart`). A group is `"cart"` or one subgroup below it, like + time (`appduct tools ls --group cart`). A group is `"cart"` or one subgroup below it, like `"checkout/payment"`; each part uses tool-name characters (letters, digits, `_`, `-`, at most 64). A malformed group makes `register` throw, like a malformed name: @@ -215,7 +215,7 @@ without a listener — useful for a view's initial render before its first event try await Appduct.shared.postEvent("checkout_completed", payload: ["orderId": "abc123"]) ``` -Read back with `appduct events`. Throws (does not send) unless a session is currently active. +Read back with `appduct events tail`. Throws (does not send) unless a session is currently active. ## Hardened builds @@ -276,15 +276,15 @@ call from any thread and need no such hop. ## Troubleshooting **`handle(_:)` always returns `false`.** The URL doesn't carry a `appduct` query parameter — -check the scheme in `Info.plist` matches what `appduct link --scheme ` used, and that +check the scheme in `Info.plist` matches what `appduct sessions link --scheme ` used, and that you're forwarding the *actual* opened URL (not a re-derived one) into `handle(_:)`. **A tool call never reaches your handler.** Confirm `Appduct.shared.state == .active` and that -`appduct tools` lists the name you registered — a call for an unregistered name gets +`appduct tools ls` lists the name you registered — a call for an unregistered name gets `tool_not_found` without ever reaching app code, by design. **Registering the same name twice.** `register` upserts by name; the second registration's handler -replaces the first's, and the tool keeps its original position in `appduct tools`' listing. +replaces the first's, and the tool keeps its original position in `appduct tools ls`' listing. **A Release build still connects.** You depended on the `AlwaysEnabled` trait (SwiftPM) or dropped CocoaPods' `:configurations` restriction, most likely on purpose for an internal/QA build — see diff --git a/packages/react-native/README.md b/packages/react-native/README.md index 02c0721..f794a42 100644 --- a/packages/react-native/README.md +++ b/packages/react-native/README.md @@ -28,9 +28,9 @@ npm install -g appduct ### 2. Nothing to configure yet -No key, no pins, and no config plugin are needed for a first run, in any build type. The daemon auto-generates a key on first start, and `appduct link` carries its `sha256/...` fingerprint on the deep link for the app to trust for that session. +No key, no pins, and no config plugin are needed for a first run, in any build type. The daemon auto-generates a key on first start, and `appduct sessions link` carries its `sha256/...` fingerprint on the deep link for the app to trust for that session. -Wire your deep-link scheme so the OS can open the app with that link. For an Expo app that's all: `appduct link` reads `expo.scheme` straight out of `app.json`. Otherwise (a dynamic `app.config.js`, which Appduct never executes, or bare React Native) name it with `appduct init --scheme `, `--scheme`, or `APPDUCT_SCHEME` — the [CLI README](https://github.com/callstackincubator/appduct/blob/main/packages/appduct/README.md#the-deep-link-scheme) has the full resolution order. To make a build trust only pins you embedded ahead of time, see [Configuring trust](https://github.com/callstackincubator/appduct/blob/main/docs/SECURITY.md#configuring-trust). +Wire your deep-link scheme so the OS can open the app with that link. For an Expo app that's all: `appduct sessions link` reads `expo.scheme` straight out of `app.json`. Otherwise (a dynamic `app.config.js`, which Appduct never executes, or bare React Native) name it with `appduct init --scheme `, `--scheme`, or `APPDUCT_SCHEME` — the [CLI README](https://github.com/callstackincubator/appduct/blob/main/packages/appduct/README.md#the-deep-link-scheme) has the full resolution order. To make a build trust only pins you embedded ahead of time, see [Configuring trust](https://github.com/callstackincubator/appduct/blob/main/docs/SECURITY.md#configuring-trust). By default the native module ships in **debug** builds only: a release build has none, so the API is inert and `connect()` rejects with `appduct_disabled` (see [Build variants](https://github.com/callstackincubator/appduct/blob/main/docs/BUILD-VARIANTS.md)). @@ -94,17 +94,17 @@ An agent picks a tool from one signature line and the first line of its descript `appduct` auto-spawns its daemon. `link` needs your app's deep-link scheme: pass `--scheme` (matching `expo.scheme`), or set `scheme` once in `~/.appduct/config.json`: ```bash -appduct link --scheme myapp --qr +appduct sessions link --scheme myapp --qr ``` Scan the QR (or open the link) in the app, then list and invoke tools: ```bash -appduct tools -appduct invoke sum --input '{"a":2,"b":3}' +appduct tools ls +appduct tools call sum --input '{"a":2,"b":3}' ``` -Omit the session selector when only one session is active; pass an alias or session id when several are (`appduct ls`). +Omit the session selector when only one session is active; pass an alias or session id when several are (`appduct sessions ls`). ## API reference @@ -125,7 +125,7 @@ Omit the session selector when only one session is active; pass an alias or sess | `createToolGroup` | `(group)` → a `registerTool` that puts every tool it registers in `group`. | | `useAppductTool` | `(definition, deps?, { enabled? })`. Registers once per mount, re-registering only when the descriptor changes; `deps` overrides that derivation. `enabled` defaults to `true`; `false` never registers, and removes any registration that hook owns. | | `handler` | `(args, context)`. `context.signal` is an `AbortSignal`, aborted when the caller cancels or the connection drops mid-call. Forward it (`fetch(url, { signal })`), check `signal.aborted`, or listen for `"abort"` — ignoring it is fine, the handler replies normally. | -| `postEvent` | `(name, payload?)` — pushes an app event, read by `appduct events` and the MCP event tools. | +| `postEvent` | `(name, payload?)` — pushes an app event, read by `appduct events tail` and the MCP event tools. | | `addAppductListener` | `(kind, callback)` → `{ remove() }`. Kinds `"stateChange"`, `"sessionChange"`, `"error"` — the last one is a unified channel for bootstrap-parse, connect, socket, and tool-handler failures. | | `getRegisteredTools` | → `ToolDescriptor[]`, the current registry. | | `getAppductState` | → the client's connection state (`"idle"` with no session). | diff --git a/playground-native/android/README.md b/playground-native/android/README.md index 8eb067f..a67001f 100644 --- a/playground-native/android/README.md +++ b/playground-native/android/README.md @@ -69,10 +69,10 @@ sequence). Run it from inside `playground-native/`: the scheme and the app id ar `playground-native/.appduct/config.json`. ```bash -appduct link --open android -appduct tools -appduct invoke sum --input '{"a":2,"b":3}' -appduct events +appduct sessions link --open android +appduct tools ls +appduct tools call sum --input '{"a":2,"b":3}' +appduct events tail ``` ## `pnpm exec appduct` can resolve the wrong binary diff --git a/playground-native/ios/README.md b/playground-native/ios/README.md index 56f0912..a2062fc 100644 --- a/playground-native/ios/README.md +++ b/playground-native/ios/README.md @@ -3,7 +3,7 @@ A plain SwiftUI app -- no React Native, no Expo -- that consumes `AppductCore` (`packages/native/ios`) directly through the `Appduct` facade (`packages/native/ios/Sources/AppductCore/Real/AppductAPI.swift`). It registers the same five -tools the Expo playground (`playground/`) registers, so `appduct tools` reports an equivalent +tools the Expo playground (`playground/`) registers, so `appduct tools ls` reports an equivalent surface regardless of which playground app answered the link. See [`packages/native/ios/README.md`](../../packages/native/ios/README.md) for the SDK itself; @@ -31,9 +31,9 @@ xcrun simctl launch booted com.callstack.appduct.playgroundnative # 3. Point the CLI at it. playground-native/.appduct/config.json records the app's # `appduct-native` URL scheme, so no --scheme is needed. -appduct link --open ios-sim -appduct tools -appduct invoke sum --input '{"a":2,"b":3}' +appduct sessions link --open ios-sim +appduct tools ls +appduct tools call sum --input '{"a":2,"b":3}' ``` Opening the project in Xcode (`open AppductPlayground.xcodeproj`) and hitting Run works exactly @@ -79,5 +79,5 @@ appduct doctor path/to/Release-iphonesimulator/AppductPlayground.app --assert-ab button that calls `Appduct.shared.postEvent(...)`, and the activity log. - `AppductPlayground/Info.plist` -- declares the `appduct-native` URL scheme (`CFBundleURLTypes`). No `AppductTrust`/`AppductCliPins` keys: this playground is the - zero-config example, so it trusts whatever pin `appduct link` puts on the deep link for that + zero-config example, so it trusts whatever pin `appduct sessions link` puts on the deep link for that session (`trust: "link"`) -- see [`docs/SECURITY.md`](../../docs/SECURITY.md#trust-modes). diff --git a/playground/README.md b/playground/README.md index d27a3ce..a46a500 100644 --- a/playground/README.md +++ b/playground/README.md @@ -77,7 +77,7 @@ Tap **Send playground_ping** on the Status tab while `events --follow` is runnin With a session active, trigger a Metro reload (press `r` in the Metro terminal, or shake the device and choose Reload). The Status tab should show `reconnecting` then `active` again with the -**same alias**—no new `appduct link` needed. Keep the native app process alive: the resume +**same alias**—no new `appduct sessions link` needed. Keep the native app process alive: the resume lease exists only in native process memory, so killing/relaunching the app requires a new link. The daemon-side session grace window (`graceSeconds` in `config.json`) starts when the transport suspends/disconnects. diff --git a/playground/app/(tabs)/index.tsx b/playground/app/(tabs)/index.tsx index 8c23ac2..aad3e56 100644 --- a/playground/app/(tabs)/index.tsx +++ b/playground/app/(tabs)/index.tsx @@ -54,7 +54,7 @@ export default function ToolsScreen() { }; // Groups: `counter` and `diagnostics` (with a `diagnostics/progress` subgroup), plus `sum` - // left ungrouped -- so `appduct tools` shows headings, `--groups` has something to list, and + // left ungrouped -- so `appduct tools ls` shows headings, `--groups` has something to list, and // `--group diagnostics` vs `--group diagnostics/progress` differ. // // These tools are the template an agent copies (docs/TOOLS.md, "Designing tools for agents"): @@ -181,7 +181,7 @@ export default function ToolsScreen() { {" '{\"a\":1,\"b\":2}'"}. - In your own app, run appduct link from the app root: the scheme and app ids come from + In your own app, run appduct sessions link from the app root: the scheme and app ids come from .appduct/config.json (appduct init writes it), and there is no keygen and no pin to paste. diff --git a/playground/app/(tabs)/status.tsx b/playground/app/(tabs)/status.tsx index adf7006..38eea42 100644 --- a/playground/app/(tabs)/status.tsx +++ b/playground/app/(tabs)/status.tsx @@ -171,8 +171,8 @@ export default function StatusScreen() { {lastPingAt !== null - ? `Last sent at ${new Date(lastPingAt).toLocaleTimeString()}. Watch it with: appduct events --follow` - : "Watch it arrive with: appduct events --follow"} + ? `Last sent at ${new Date(lastPingAt).toLocaleTimeString()}. Watch it with: appduct events tail --follow` + : "Watch it arrive with: appduct events tail --follow"} diff --git a/playground/app/_layout.tsx b/playground/app/_layout.tsx index f345f5a..60b7525 100644 --- a/playground/app/_layout.tsx +++ b/playground/app/_layout.tsx @@ -7,7 +7,7 @@ import { Stack } from "expo-router"; import { StatusBar } from "expo-status-bar"; import "react-native-reanimated"; // Side-effect entry (ARCHITECTURE.md §11): installs the default deep-link bootstrap listener on -// import, so opening the host's bootstrap link (QR / `appduct link --open`) is enough to start +// import, so opening the host's bootstrap link (QR / `appduct sessions link --open`) is enough to start // a session. `require("@appduct/react-native/auto")` would install it lazily instead. import "@appduct/react-native/auto"; diff --git a/skills/appduct/SKILL.md b/skills/appduct/SKILL.md index 56ddd28..6527491 100644 --- a/skills/appduct/SKILL.md +++ b/skills/appduct/SKILL.md @@ -12,23 +12,27 @@ of it and start it on first use. There is no server for you to start. ## The loop ```bash -appduct ls # connected devices -appduct tools # one signature + description per tool -appduct tools # one tool's full input/output schema -appduct invoke --input '{"k":"v"}' # call it; --input is required, use '{}' for no args +appduct sessions ls # connected devices +appduct tools ls # one signature + description per tool +appduct tools describe # one tool's full input/output schema +appduct tools call --input '{"k":"v"}' # call it; --input is required, use '{}' for no args ``` -- If `appduct ls` is empty, connect a device first: read [references/cli.md](./references/cli.md), - section "Connect a device". -- Every session command takes an optional **selector** (alias or session id from `appduct ls`) - as its first positional argument. Omit it when one device is connected. With several, the - CLI fails with `ambiguous_session` and lists the aliases; pass one. +Every command is `appduct [selector] [args]` — `sessions`, `tools` and `events` +work like `appduct daemon run|start|stop|status` already does; there are no other forms. + +- If `appduct sessions ls` is empty, connect a device first: read + [references/cli.md](./references/cli.md), section "Connect a device". +- Every session command takes an optional **selector** (alias or session id from + `appduct sessions ls`) as its first positional argument. Omit it when one device is connected. + With several, the CLI fails with `ambiguous_session` and lists the aliases; pass one. - A signature reads `name(param: type, optional?: type = default) -> { result }`. A trailing `[prompt]` or `[deny]` is the tool's policy. `...` means that part of the schema could not be - summarized: run `appduct tools ` before calling that tool, and only for such tools. -- When the listing footer says tools were left out: run `appduct tools --groups`, then - `appduct tools --group ` (a parent group includes its subgroups), or - `appduct tools --filter ` (matches name and description), or page with + summarized: run `appduct tools describe ` before calling that tool, and only for such + tools. +- When the listing footer says tools were left out: run `appduct tools ls --groups`, then + `appduct tools ls --group ` (a parent group includes its subgroups), or + `appduct tools ls --filter ` (matches name and description), or page with `--limit --offset `. ## Run a known sequence as one command @@ -40,9 +44,9 @@ whole sequence, then pick the smallest form that fits: first one. ```bash - appduct invoke login --input '{"userId":"u_42"}' \ - && appduct invoke seed_cart --input '{"items":3}' \ - && appduct invoke get_cart --input '{}' + appduct tools call login --input '{"userId":"u_42"}' \ + && appduct tools call seed_cart --input '{"items":3}' \ + && appduct tools call get_cart --input '{}' ``` 2. **A later call needs an earlier result:** add `--json` and parse it with `jq`. Success is @@ -50,8 +54,8 @@ whole sequence, then pick the smallest form that fits: on stderr. ```bash - cart_id=$(appduct invoke create_cart --input '{}' --json | jq -r .data.cartId) - appduct invoke add_item --input "{\"cartId\":\"$cart_id\",\"sku\":\"SKU-1042\"}" + cart_id=$(appduct tools call create_cart --input '{}' --json | jq -r .data.cartId) + appduct tools call add_item --input "{\"cartId\":\"$cart_id\",\"sku\":\"SKU-1042\"}" ``` 3. **A loop, a branch on a result, or a wait for an app event:** write a short `.mjs` script @@ -66,14 +70,14 @@ whole sequence, then pick the smallest form that fits: - Read the plain-text output. Add `--json` only when a command or script parses it; `--pretty` indents it. -- `no_session`, `unknown_session`, or an empty `appduct ls`: no device is connected. Connect one - ([references/cli.md](./references/cli.md), "Connect a device"). +- `no_session`, `unknown_session`, or an empty `appduct sessions ls`: no device is connected. + Connect one ([references/cli.md](./references/cli.md), "Connect a device"). - `policy_denied`: the daemon's policy blocks this tool. Do not retry and do not edit `~/.appduct/config.json`; tell the user which tool was denied. - `tool_timeout`: a call gets 10 s unless the app registered the tool with `timeoutMs`. `--timeout ` can only shorten that; the fix is in the app's registration. - `tool_execution_error`: the app's handler threw; report its message. -- An empty `appduct tools` listing is not an error: the app registered no tools. +- An empty `appduct tools ls` listing is not an error: the app registered no tools. ## Over MCP @@ -87,9 +91,9 @@ MCP tools of their own). With several devices connected, pass `selector`. A tool Read a reference only when its trigger applies: -- [references/cli.md](./references/cli.md): `appduct ls` is empty, or you need a command or flag - not shown above (`link`, `events`, `revoke`, `init`, `--open`, QR, MCP `appduct_connect`), or - you are writing a script or test with `appduct/client`. +- [references/cli.md](./references/cli.md): `appduct sessions ls` is empty, or you need a command + or flag not shown above (`sessions link`, `events tail`/`since`, `sessions revoke`, `init`, + `--open`, QR, MCP `appduct_connect`), or you are writing a script or test with `appduct/client`. - [references/writing-tools.md](./references/writing-tools.md): the task is to add, change or review tools in the app's code (`registerTool`, `useAppductTool`, Swift or Kotlin `register`). - [references/setup.md](./references/setup.md): the task is to add Appduct to a project that diff --git a/skills/appduct/references/cli.md b/skills/appduct/references/cli.md index 53cabb2..3f4138c 100644 --- a/skills/appduct/references/cli.md +++ b/skills/appduct/references/cli.md @@ -1,21 +1,21 @@ # Appduct CLI reference -Read this when `appduct ls` is empty, when you need a command the main skill file does not -cover, or when a test suite should drive the app without shelling out. +Read this when `appduct sessions ls` is empty, when you need a command the main skill file does +not cover, or when a test suite should drive the app without shelling out. ## Connect a device -A device connects by opening a one-time deep link in the app. `appduct link` mints the link; -run it **from the app's root directory** so the scheme is discovered from the project's own +A device connects by opening a one-time deep link in the app. `appduct sessions link` mints the +link; run it **from the app's root directory** so the scheme is discovered from the project's own files (`app.json`'s `expo.scheme`, else the Android `build.gradle`/`AndroidManifest.xml`, else the iOS `Info.plist`/`project.yml`). No key, pin or config file is needed for a dev loop. | Device | Command | | --- | --- | -| Booted iOS Simulator | `appduct link --open ios-sim` | -| Android emulator or USB device | `appduct link --open android --app-id ` | -| Any device on the same network (a human scans) | `appduct link --qr` | -| Wired iPhone/iPad, experimental | `appduct link --open ios-device --app-id ` | +| Booted iOS Simulator | `appduct sessions link --open ios-sim` | +| Android emulator or USB device | `appduct sessions link --open android --app-id ` | +| Any device on the same network (a human scans) | `appduct sessions link --qr` | +| Wired iPhone/iPad, experimental | `appduct sessions link --open ios-device --app-id ` | - **`--open android` needs the app's package name.** Without it, another installed app declaring the same scheme can pop an "Open with" chooser, `adb` still reports success, and @@ -28,14 +28,14 @@ the iOS `Info.plist`/`project.yml`). No key, pin or config file is needed for a Mode on, a dev build installed, and the phone on this machine's network. If the link does not take on a running app add `--relaunch`. If it fails, fall back to `--qr`. It is never picked automatically; use it only when the user says the app runs on a physical iPhone. -- No device you can reach: `appduct link --json` and relay `data.deepLink` **whole** to a human - (the trailing `&pin=sha256/...` is what lets a build with no embedded pins trust the daemon, - in any build type), or use `--qr` +- No device you can reach: `appduct sessions link --json` and relay `data.deepLink` **whole** to a + human (the trailing `&pin=sha256/...` is what lets a build with no embedded pins trust the + daemon, in any build type), or use `--qr` on a TTY. -Then wait for the app to claim the session: poll `appduct ls --json` until the session shows -`"state": "active"`, or over MCP call `appduct_wait_for_session` with `data.sessionId` from -`link --json`. The link expires after 5 minutes and works once. +Then wait for the app to claim the session: poll `appduct sessions ls --json` until the session +shows `"state": "active"`, or over MCP call `appduct_wait_for_session` with `data.sessionId` from +`sessions link --json`. The link expires after 5 minutes and works once. **Scheme not found.** Pass `--scheme ` (or set `APPDUCT_SCHEME`) when you are not in the app root or the project uses a dynamic `app.config.js`, which Appduct never executes. `appduct init @@ -60,20 +60,27 @@ device and delivers the link there. On `delivered: true`, call ## Commands +Every command is `appduct [selector] [args]`; `daemon` is the model: + | Command | Does | | --- | --- | -| `appduct ls` | sessions: alias, state, device, tool count | -| `appduct tools [selector] [name] [--groups] [--group ] [--filter ] [--limit ] [--offset ] [--full]` | list tools, or one tool's full schema and annotations | -| `appduct invoke [selector] --input '' [--timeout ]` | call a tool; `--input` is required and must be a JSON object | -| `appduct events [selector] [--since ]` | stream the events the app posts with `postEvent` (`app_event` only); `--since` pulls the ones retained since a cursor and exits; `--json` emits NDJSON | -| `appduct revoke [selector]` | end one session without touching the daemon or other sessions | -| `appduct link [--open …] [--app-id ] [--device ] [--qr] [--scheme ] [--ttl ]` | mint a session and its deep link | +| `appduct sessions ls` | sessions: alias, state, device, tool count | +| `appduct sessions link [--open …] [--app-id ] [--device ] [--qr] [--scheme ] [--ttl ]` | mint a session and its deep link | +| `appduct sessions revoke [selector]` | end one session without touching the daemon or other sessions | +| `appduct tools ls [selector] [--groups] [--group ] [--filter ] [--limit ] [--offset ] [--full]` | list tools | +| `appduct tools describe [selector] ` | one tool's full schema and annotations | +| `appduct tools call [selector] --input '' [--timeout ]` | call a tool; `--input` is required and must be a JSON object | +| `appduct events tail [selector] [--follow]` | stream the events the app posts with `postEvent` (`app_event` only); `--json` emits NDJSON | +| `appduct events since [selector] ` | pull the app events retained since `` and exit; `--json` emits NDJSON | | `appduct init [--scheme ] [--android-app-id ] [--ios-app-id ] [--force]` | record scheme and app ids in `.appduct/config.json`; prints the MCP server entry; safe to re-run | | `appduct daemon status\|stop` | inspect or stop the daemon; `stop` disconnects every device, so only for a port or key rotation | | `appduct keygen [--out ]` | hardening only: the daemon generates its own key on first start | | `appduct doctor --assert-absent\|--assert-present` | release gate: does a built `.ipa`/`.apk`/`.aab`/`.app` contain Appduct | | `appduct mcp [--scheme ]` | stdio MCP server; what an agent's MCP config runs | +There are no aliases for the removed bare-verb forms (`ls`, `revoke`, `link`, `invoke`, and +`events --since`) — each fails with a usage error naming its replacement. + Global flags: `--json`, `--pretty`, `--verbose` (adds `meta` with duration), `--no-color`, `--state-dir ` (default `~/.appduct`; never point it at a project's `.appduct/`, the state dir holds the private key), `--daemon-restart` (accept dropping live sessions to replace a daemon diff --git a/skills/appduct/references/setup.md b/skills/appduct/references/setup.md index 8dedd2c..fa7b720 100644 --- a/skills/appduct/references/setup.md +++ b/skills/appduct/references/setup.md @@ -27,7 +27,7 @@ not do it as part of a first-time setup. 5. Optional, for pinned trust: generate a TLS private key for the daemon with `appduct keygen` (non-interactive with `--out `; writes `~/.appduct/key.pem` by default). Skipping this is fine — the daemon generates its own key on first start and - `appduct link` carries the fingerprint on the deep link. + `appduct sessions link` carries the fingerprint on the deep link. 6. If you generated a key in step 5, add its `sha256/...` SPKI pin to the app configuration, in a `cliPins` array (plural — the native clients accept a pin *set*, which is what makes future rotation non-breaking). Configuring `cliPins` switches the @@ -55,7 +55,7 @@ not do it as part of a first-time setup. *validated* against `expo.scheme` — it is not what the CLI reads. A zero-config app can skip the plugin entry entirely (`docs/SECURITY.md`, "Configuring trust"). 3. Make sure `expo.scheme` is set — it is both what registers the app for deep links and - what `appduct link` discovers automatically from `app.json`. + what `appduct sessions link` discovers automatically from `app.json`. 4. Run `appduct init` in the app root. It records the scheme in `.appduct/config.json` and prints the MCP server entry to paste into an agent's config. Re-running it is always safe: it keeps the scheme already recorded, and only @@ -108,10 +108,10 @@ For builds that leave your machine, replace link-carried trust with embedded pin - The app trusts the daemon's current pin — via `cliPins` for a pinned build, or via the link-carried pin when no `cliPins` are configured. - The app has at least one registered tool. -- The app's deep-link scheme matches what `appduct link` resolves — run - `appduct link --json` in the app root and read the scheme off the deep link; the +- The app's deep-link scheme matches what `appduct sessions link` resolves — run + `appduct sessions link --json` in the app root and read the scheme off the deep link; the error names every location it looked in if it cannot find one. -- `appduct ls` (or `appduct_connect` + `appduct_wait_for_session` over MCP) +- `appduct sessions ls` (or `appduct_connect` + `appduct_wait_for_session` over MCP) reaches `state: "active"` once the app opens the link. - Hardened builds only: the daemon's key is present and `0600`, and the app trusts its current pin in `cliPins`. diff --git a/skills/appduct/references/writing-tools.md b/skills/appduct/references/writing-tools.md index 7f8807a..10aad55 100644 --- a/skills/appduct/references/writing-tools.md +++ b/skills/appduct/references/writing-tools.md @@ -1,7 +1,7 @@ # Writing Appduct tools Read this when the task is to register tools in an app, or to review the tools it already has. -The tools you write are what a calling agent sees in `appduct tools`; write them for that reader. +The tools you write are what a calling agent sees in `appduct tools ls`; write them for that reader. ## Register a tool @@ -120,7 +120,7 @@ registerTool({ }); ``` -**Read it back.** After registering, connect a device and run `appduct tools` (or +**Read it back.** After registering, connect a device and run `appduct tools ls` (or `appduct_list_tools`). Fix any `...` in a signature, any tool without `-> { ... }`, and any description whose first line does not say what the tool needs. That listing is exactly what the calling agent gets. diff --git a/website/src/content/docs/guides/agents.mdx b/website/src/content/docs/guides/agents.mdx index 5274a0b..67f3e6f 100644 --- a/website/src/content/docs/guides/agents.mdx +++ b/website/src/content/docs/guides/agents.mdx @@ -64,9 +64,9 @@ Your app's tools aren't added to the agent's tool list one by one. Instead, the | Tool | What it does | CLI equivalent | | --- | --- | --- | -| `appduct_list_tools` | Lists your app's tools as one-line signatures, with each tool's group and policy, and your app's groups with counts. Returns 50 at a time unless given `limit`; takes `group`, `filter`, and `offset`. | `appduct tools` | -| `appduct_describe_tool` | Shows one tool's full input and output schema. | `appduct tools ` | -| `appduct_call_tool` | Calls a tool by `name` with `args`. Progress and errors come through unchanged. | `appduct invoke` | +| `appduct_list_tools` | Lists your app's tools as one-line signatures, with each tool's group and policy, and your app's groups with counts. Returns 50 at a time unless given `limit`; takes `group`, `filter`, and `offset`. | `appduct tools ls` | +| `appduct_describe_tool` | Shows one tool's full input and output schema. | `appduct tools describe ` | +| `appduct_call_tool` | Calls a tool by `name` with `args`. Progress and errors come through unchanged. | `appduct tools call` | So an app with hundreds of tools adds only these to the agent's context, and the list doesn't change when tools register or a device connects. @@ -130,12 +130,12 @@ Install it too if you're asking an agent to add Appduct to your app or to write A typical session from an agent: ```bash -appduct ls # is a device connected? -appduct link --open ios-sim # connect one if not -appduct tools --groups # large app: see the areas first -appduct tools --group cart # then list one area -appduct tools add_item # full schema for one tool -appduct invoke add_item --input '{"sku":"SKU-1042","quantity":2}' +appduct sessions ls # is a device connected? +appduct sessions link --open ios-sim # connect one if not +appduct tools ls --groups # large app: see the areas first +appduct tools ls --group cart # then list one area +appduct tools describe add_item # full schema for one tool +appduct tools call add_item --input '{"sku":"SKU-1042","quantity":2}' ``` Plain-text output is meant for reading, and may change between versions. Scripts should add `--json`. See the [CLI guide](/appduct/guides/cli/). diff --git a/website/src/content/docs/guides/cli.mdx b/website/src/content/docs/guides/cli.mdx index 14b57d6..7569d88 100644 --- a/website/src/content/docs/guides/cli.mdx +++ b/website/src/content/docs/guides/cli.mdx @@ -11,16 +11,16 @@ Install it with `npm install -g appduct` (Node 20 or newer). There's no server t ## Connect a device -A device connects by opening a one-time link in your app. `appduct link` creates the link; how it reaches the device depends on the device. Run `appduct link` from your app's root directory, so it finds your deep-link scheme (see [How the CLI finds your scheme](#how-the-cli-finds-your-scheme)). +A device connects by opening a one-time link in your app. `appduct sessions link` creates the link; how it reaches the device depends on the device. Run `appduct sessions link` from your app's root directory, so it finds your deep-link scheme (see [How the CLI finds your scheme](#how-the-cli-finds-your-scheme)). | Device | Command | Needs | | --- | --- | --- | -| iOS Simulator | `appduct link --open ios-sim` | A booted simulator | -| Android emulator or USB device | `appduct link --open android` | `adb`, and your app's package name | -| Any device on your local network | `appduct link --qr` | Scan the QR code with the device's camera | -| Physical iPhone or iPad (experimental) | `appduct link --open ios-device` | See [below](#physical-iphone-or-ipad-experimental) | +| iOS Simulator | `appduct sessions link --open ios-sim` | A booted simulator | +| Android emulator or USB device | `appduct sessions link --open android` | `adb`, and your app's package name | +| Any device on your local network | `appduct sessions link --qr` | Scan the QR code with the device's camera | +| Physical iPhone or iPad (experimental) | `appduct sessions link --open ios-device` | See [below](#physical-iphone-or-ipad-experimental) | -The link expires after 5 minutes and works once. Run `appduct ls` to check the session's state; once the app opens the link, it shows `active`. +The link expires after 5 minutes and works once. Run `appduct sessions ls` to check the session's state; once the app opens the link, it shows `active`. **Android needs your app's package name**, so the link opens your app and not another one that declares the same scheme. Pass `--app-id com.example.shop`, or record it once in your project: @@ -43,7 +43,7 @@ appduct init --android-app-id com.example.shop --ios-app-id com.example.shop - Your app's bundle id: `--app-id`, or `appId.ios` recorded by `appduct init --ios-app-id`. ```bash -appduct link --open ios-device --app-id com.example.shop +appduct sessions link --open ios-device --app-id com.example.shop ``` If the app is already running and nothing happens, add `--relaunch` to restart it first. @@ -53,20 +53,20 @@ If the app is already running and nothing happens, add `--relaunch` to restart i One background service handles every connected device. Each session gets an alias based on the device model, such as `pixel-8` or `iphone15-2`: ```bash -appduct ls +appduct sessions ls ``` Every command that targets a device takes an optional **selector** — an alias or session id — as its first argument. Leave it out when only one device is connected; with several, the command fails with `ambiguous_session` and lists the aliases. ```bash -appduct tools pixel-8 -appduct invoke pixel-8 seed_cart --input '{"items":3}' +appduct tools ls pixel-8 +appduct tools call pixel-8 seed_cart --input '{"items":3}' ``` ## Find a tool ```bash -appduct tools +appduct tools ls ``` This prints one call signature and a one-line description per tool: @@ -87,15 +87,15 @@ Tools For one tool's full input and output schema: ```bash -appduct tools seed_cart +appduct tools describe seed_cart ``` **On an app with many tools**, look at its groups first, then list one: ```bash -appduct tools --groups -appduct tools --group checkout # checkout and its subgroups -appduct tools --group checkout/payment # only that subgroup +appduct tools ls --groups +appduct tools ls --group checkout # checkout and its subgroups +appduct tools ls --group checkout/payment # only that subgroup ``` You can also narrow by text with `--filter `, which matches names and descriptions, and page with `--limit ` and `--offset `. A cut-off listing ends with a line telling you how many tools were left out. @@ -103,7 +103,7 @@ You can also narrow by text with `--filter `, which matches names and desc ## Call a tool ```bash -appduct invoke seed_cart --input '{"items":3}' +appduct tools call seed_cart --input '{"items":3}' ``` `--input` is required and must be a JSON object. Use `--input '{}'` for a tool that takes no arguments. @@ -119,17 +119,17 @@ When a call fails, the error type comes from the app unchanged, for example `too Your app can post events with `postEvent`, such as "checkout finished". To stream them: ```bash -appduct events +appduct events tail ``` -This keeps running until you stop it. To print what already happened and exit, pass the cursor from an earlier result with `--since `. Add `--json` for one JSON object per line. +This keeps running until you stop it. To print what already happened and exit, pass the cursor from an earlier result to `appduct events since `. Add `--json` for one JSON object per line. -Only your app's events are printed, not device connections or tool calls. To check that a device is connected, run `appduct ls`. +Only your app's events are printed, not device connections or tool calls. To check that a device is connected, run `appduct sessions ls`. ## Disconnect a device ```bash -appduct revoke pixel-8 +appduct sessions revoke pixel-8 ``` This ends one session and frees its alias. Other devices stay connected. @@ -139,7 +139,7 @@ This ends one session and frees its alias. Other devices stay connected. Plain-text output is for reading and can change between versions. For scripts, add `--json`: ```bash -appduct invoke seed_cart --input '{"items":3}' --json +appduct tools call seed_cart --input '{"items":3}' --json ``` The result is one compact JSON line on stdout. Errors are JSON on stderr. Add `--pretty` to indent it. @@ -214,7 +214,7 @@ It reads `.app`, `.ipa`, `.apk`, and `.aab` files. With `--assert-absent` or `-- ## How the CLI finds your scheme -`appduct link`, `appduct mcp`, and the agent's `appduct_connect` need your app's deep-link scheme. They use the first of: +`appduct sessions link`, `appduct mcp`, and the agent's `appduct_connect` need your app's deep-link scheme. They use the first of: 1. The `--scheme` flag. 2. The `APPDUCT_SCHEME` environment variable. diff --git a/website/src/content/docs/guides/writing-tools.mdx b/website/src/content/docs/guides/writing-tools.mdx index c7af302..d9b0910 100644 --- a/website/src/content/docs/guides/writing-tools.mdx +++ b/website/src/content/docs/guides/writing-tools.mdx @@ -139,7 +139,7 @@ Unions of objects (`z.union`, `z.discriminatedUnion`, `z.intersection`) work, bu ## Group tools in a large app -Once your app has more tools than fit on a screen, give each a `group`. An agent then lists your app's areas with `appduct tools --groups` and one area with `--group cart`, instead of guessing search words. +Once your app has more tools than fit on a screen, give each a `group`. An agent then lists your app's areas with `appduct tools ls --groups` and one area with `--group cart`, instead of guessing search words. ```ts useAppductTool({ @@ -201,7 +201,7 @@ An agent picks a tool from one line, `name(params) -> result`, plus the first li - **Few coarse tools beat many fine ones.** `complete_onboarding()`, not `dismiss_step_1` through `dismiss_step_7`. - **Set `timeoutMs`** on anything that touches the network, and **never register a tool that needs a person to finish**: return when the state change is done, and [post an event](#tell-callers-something-happened) for anything later. -Then connect a device and run `appduct tools`. That listing is what the agent sees: fix any `...` in a signature, any tool without `-> { ... }`, and any first line that doesn't say what the tool needs. The full list of rules, with before-and-after examples, is in [`docs/TOOLS.md`](https://github.com/callstackincubator/appduct/blob/main/docs/TOOLS.md#designing-tools-for-agents). +Then connect a device and run `appduct tools ls`. That listing is what the agent sees: fix any `...` in a signature, any tool without `-> { ... }`, and any first line that doesn't say what the tool needs. The full list of rules, with before-and-after examples, is in [`docs/TOOLS.md`](https://github.com/callstackincubator/appduct/blob/main/docs/TOOLS.md#designing-tools-for-agents). ## Long-running tools @@ -217,7 +217,7 @@ useAppductTool({ }); ``` -`timeoutMs` must be between 1,000 and 600,000; values outside are clamped, with a warning in development. It's the only way to give a tool more time: callers can shorten the limit (`appduct invoke --timeout`) but never extend it. +`timeoutMs` must be between 1,000 and 600,000; values outside are clamped, with a warning in development. It's the only way to give a tool more time: callers can shorten the limit (`appduct tools call --timeout`) but never extend it. When the time is up, the call fails with `tool_timeout`, and whatever the handler returns later is ignored. diff --git a/website/src/content/docs/install/android.mdx b/website/src/content/docs/install/android.mdx index a361547..738c3f0 100644 --- a/website/src/content/docs/install/android.mdx +++ b/website/src/content/docs/install/android.mdx @@ -90,12 +90,12 @@ This page adds Appduct to a plain Android app, with no React Native. You registe Install the debug build on an emulator or a USB-connected device, then, from your project directory: ```bash -appduct link --open android -appduct tools -appduct invoke seed_cart --input '{"items":3}' +appduct sessions link --open android +appduct tools ls +appduct tools call seed_cart --input '{"items":3}' ``` -`--open android` forwards the connection through `adb`, so it works over USB without the device being on your network. To connect by QR code instead, run `appduct link --qr` and scan it with the device's camera; the device must then be on the same local network as your computer. +`--open android` forwards the connection through `adb`, so it works over USB without the device being on your network. To connect by QR code instead, run `appduct sessions link --qr` and scan it with the device's camera; the device must then be on the same local network as your computer. The app restores its session on its own after a short network drop. If the app process is killed, connect again with a new link. @@ -127,7 +127,7 @@ The app restores its session on its own after a short network drop. If the app p - **Optional arguments:** `outputSchema`, `annotations` (`ToolAnnotations(readOnlyHint, destructiveHint, idempotentHint)`), `timeoutMs` (default 10 seconds), and `group`. See [Writing tools](/appduct/guides/writing-tools/) for what each one does. A malformed name or group throws `IllegalArgumentException`. - **Unregistering:** `register` returns a `ToolRegistration`; call `.remove()`. -To watch the connection, use `Appduct.addListener { event -> … }`, which reports state changes, session changes, and errors, and returns a subscription you can `remove()`. `Appduct.state` and `Appduct.sessionId` give the current values. To tell a test or agent that something happened, call `Appduct.postEvent(name, payload)` from a coroutine. The event is dropped if no session is connected. Read events with `appduct events`, or [wait for one from a test](/appduct/guides/cli/#watch-events). +To watch the connection, use `Appduct.addListener { event -> … }`, which reports state changes, session changes, and errors, and returns a subscription you can `remove()`. `Appduct.state` and `Appduct.sessionId` give the current values. To tell a test or agent that something happened, call `Appduct.postEvent(name, payload)` from a coroutine. The event is dropped if no session is connected. Read events with `appduct events tail`, or [wait for one from a test](/appduct/guides/cli/#watch-events). ## Deep links @@ -184,7 +184,7 @@ appduct doctor app/build/outputs/apk/debug/app-debug.apk --assert-present **Manifest-merger error naming `appductScheme`.** Set `manifestPlaceholders["appductScheme"]` in `build.gradle`. -**`appduct link --open android` reports "unable to resolve Intent".** Either the app id isn't the installed build's `applicationId` (check for an `applicationIdSuffix`), or the scheme doesn't match `appductScheme` in the installed build. Placeholders are fixed at build time, so rebuild and reinstall after changing one. +**`appduct sessions link --open android` reports "unable to resolve Intent".** Either the app id isn't the installed build's `applicationId` (check for an `applicationIdSuffix`), or the scheme doesn't match `appductScheme` in the installed build. Placeholders are fixed at build time, so rebuild and reinstall after changing one. **The link opens the app, but it never connects, with a permission-style error.** Add the `INTERNET` permission to your manifest. diff --git a/website/src/content/docs/install/ios.mdx b/website/src/content/docs/install/ios.mdx index 59eab1a..ecaa930 100644 --- a/website/src/content/docs/install/ios.mdx +++ b/website/src/content/docs/install/ios.mdx @@ -139,12 +139,12 @@ This page adds Appduct to a plain iOS app, with no React Native or Expo. You reg Build and run the **Debug** configuration on a simulator, then, from your project directory: ```bash -appduct link --open ios-sim -appduct tools -appduct invoke seed_cart --input '{"items":3}' +appduct sessions link --open ios-sim +appduct tools ls +appduct tools call seed_cart --input '{"items":3}' ``` -For a physical device, run `appduct link --qr` and scan the code with the phone's camera. The phone must be on the same local network as your Mac. See [Connect a device](/appduct/guides/cli/#connect-a-device) for other options. +For a physical device, run `appduct sessions link --qr` and scan the code with the phone's camera. The phone must be on the same local network as your Mac. See [Connect a device](/appduct/guides/cli/#connect-a-device) for other options. The app restores its session on its own after a short network drop. If the app process is killed, connect again with a new link. @@ -195,7 +195,7 @@ To tell a test or agent that something happened, post an event. It throws unless try await Appduct.shared.postEvent("checkout_completed", payload: ["orderId": "ord_4821"]) ``` -Read events with `appduct events`, or wait for one from a test. See [Wait for app events](/appduct/guides/cli/#watch-events). +Read events with `appduct events tail`, or wait for one from a test. See [Wait for app events](/appduct/guides/cli/#watch-events). ## Ship Appduct in a Release build @@ -228,9 +228,9 @@ appduct doctor build/Shop.app --assert-present # a build that should ## Troubleshooting -**`handle(_:)` always returns `false`.** The URL doesn't carry an Appduct link. Check that the scheme in `Info.plist` matches what `appduct link` used, and that you pass the URL you received, unchanged. +**`handle(_:)` always returns `false`.** The URL doesn't carry an Appduct link. Check that the scheme in `Info.plist` matches what `appduct sessions link` used, and that you pass the URL you received, unchanged. -**A tool call never reaches the handler.** Check that `Appduct.shared.state` is `.active`, and that `appduct tools` lists the tool's name. Calls to an unregistered name fail with `tool_not_found`. +**A tool call never reaches the handler.** Check that `Appduct.shared.state` is `.active`, and that `appduct tools ls` lists the tool's name. Calls to an unregistered name fail with `tool_not_found`. **A Release build still connects.** You enabled `AlwaysEnabled` or removed the CocoaPods restriction. Run `appduct doctor` on the artifact to confirm what shipped. diff --git a/website/src/content/docs/install/react-native.mdx b/website/src/content/docs/install/react-native.mdx index 225f4a8..9bc6368 100644 --- a/website/src/content/docs/install/react-native.mdx +++ b/website/src/content/docs/install/react-native.mdx @@ -137,16 +137,16 @@ You don't need keys, certificates, or a config plugin for a first run, in any bu Run these from your app's root directory: ```bash -appduct link --open ios-sim # a booted iOS Simulator -appduct link --open android # an Android emulator or USB device -appduct link --qr # a physical device: scan the QR code with its camera +appduct sessions link --open ios-sim # a booted iOS Simulator +appduct sessions link --open android # an Android emulator or USB device +appduct sessions link --qr # a physical device: scan the QR code with its camera ``` Then: ```bash -appduct tools -appduct invoke sum --input '{"a":2,"b":3}' +appduct tools ls +appduct tools call sum --input '{"a":2,"b":3}' ``` A physical device must be on the same local network as your computer. For every way to connect, see [Connect a device](/appduct/guides/cli/#connect-a-device). @@ -165,7 +165,7 @@ If your app handles incoming links itself (custom routing, an in-app QR scanner) ## Troubleshooting -**`appduct tools` fails with `no_session`.** No device has connected yet. Run `appduct ls` to see sessions, then connect with `appduct link`. +**`appduct tools ls` fails with `no_session`.** No device has connected yet. Run `appduct sessions ls` to see sessions, then connect with `appduct sessions link`. **The link opens the app, but it never connects.** - Check that the app imports `@appduct/react-native/auto`, or calls `connect()` itself. diff --git a/website/src/content/docs/reference/architecture.md b/website/src/content/docs/reference/architecture.md index 39a921b..ea0ef26 100644 --- a/website/src/content/docs/reference/architecture.md +++ b/website/src/content/docs/reference/architecture.md @@ -25,7 +25,7 @@ This page explains what happens between your terminal and your app, as far as it ## How a device connects -1. `appduct link` (or `appduct_connect`) asks the background service for a **pending session** and gets back a link: `:///?appduct=&pin=`. The payload holds your computer's address and port, a session id, a one-time token, and an expiry time. +1. `appduct sessions link` (or `appduct_connect`) asks the background service for a **pending session** and gets back a link: `:///?appduct=&pin=`. The payload holds your computer's address and port, a session id, a one-time token, and an expiry time. 2. The link reaches the app: through `adb` or the simulator, or as a QR code you scan. 3. The app opens an encrypted connection to the address in the link and checks your computer's key against its pins, or, with no pins, against the fingerprint in the link. See [Security](/appduct/guides/security/#choose-what-a-build-trusts). 4. The app sends the token. If it matches, the session becomes **active**, gets an alias based on the device model (like `pixel-8`), and the app sends its tool list. @@ -44,11 +44,11 @@ A link can be used once and expires after 5 minutes (`linkTtlSeconds`). Five wro | Disconnected for more than 10 minutes (`graceSeconds`) | The session expires. Connect again with a new link. | | App process killed or relaunched | The session can't resume: resume credentials are kept only in memory. Connect again. | | Background service stopped or restarted | Every session ends. Connect devices again. | -| `appduct revoke` | That session ends immediately. | +| `appduct sessions revoke` | That session ends immediately. | While a session is disconnected, its alias and tool list are kept, and calls fail fast with `session_suspended`. After a resume, the app sends its full tool list again. -Session states: **pending** (link created) → **active** → **suspended** (disconnected) → **active** again, or **expired**. A link nobody opened in time is **discarded**. `appduct revoke` moves any session to **revoked**. Ending a session frees its alias for the next device. +Session states: **pending** (link created) → **active** → **suspended** (disconnected) → **active** again, or **expired**. A link nobody opened in time is **discarded**. `appduct sessions revoke` moves any session to **revoked**. Ending a session frees its alias for the next device. ## How a call runs @@ -66,7 +66,7 @@ Session states: **pending** (link created) → **active** → **suspended** (dis ### Cancellation -A call is cancelled when the caller goes away: Ctrl-C on `appduct invoke`, an MCP client cancelling a request, or a test process exiting. The app then aborts the handler's `signal`. A handler that ignores the signal runs to the end anyway. After a timeout, whatever it returns is ignored. +A call is cancelled when the caller goes away: Ctrl-C on `appduct tools call`, an MCP client cancelling a request, or a test process exiting. The app then aborts the handler's `signal`. A handler that ignores the signal runs to the end anyway. After a timeout, whatever it returns is ignored. ## Events diff --git a/website/src/content/docs/reference/cli.md b/website/src/content/docs/reference/cli.md index ca9005a..641558e 100644 --- a/website/src/content/docs/reference/cli.md +++ b/website/src/content/docs/reference/cli.md @@ -7,7 +7,10 @@ sidebar: For task-based instructions, see [Use the CLI](/appduct/guides/cli/). Run `appduct --help` for a command's flags on your installed version. -Commands that target a device take an optional `[selector]`: a session alias or id from `appduct ls`. Leave it out when exactly one device is connected. +Every command is `appduct [selector] [args]` — `sessions`, `tools`, and `events` +each work like `appduct daemon run|start|stop|status` already does. Commands that target a device +take an optional `[selector]`: a session alias or id from `appduct sessions ls`. Leave it out when +exactly one device is connected. ## Commands @@ -24,7 +27,11 @@ Sets up the current app directory: writes `.appduct/config.json` (mode `0600`) a A plain re-run keeps the recorded scheme and adds a note if your project files now declare a different one. -### `appduct link` +### `appduct sessions ls` + +Lists sessions with alias, state, device, and tool count. + +### `appduct sessions link` Creates a one-time connection link and prints it. @@ -50,13 +57,13 @@ Without `--device`, each target requires exactly one candidate and lists them ot The link has the form `:///?appduct=&pin=`. Pass it on whole. -### `appduct ls` +### `appduct sessions revoke [selector]` -Lists sessions with alias, state, device, and tool count. +Ends one session and frees its alias. -### `appduct tools [selector] [name]` +### `appduct tools ls [selector]` -Lists a device's tools as one-line signatures, or shows one tool's full schema when you pass `name`. +Lists a device's tools as one-line signatures. | Flag | Description | | --- | --- | @@ -67,11 +74,15 @@ Lists a device's tools as one-line signatures, or shows one tool's full schema w | `--offset ` | Skip the first `n` tools of the name-sorted list. | | `--full` | Print full schemas for every listed tool. | -`--groups`, `--group`, `--filter`, `--limit`, and `--offset` can't be combined with `name`. With `--json`, the listing is `{ tools, total, groups }`, where `total` counts matches before `--limit` and `--offset`. Every listed tool carries a `group` — its group name, or `null` when it has none, which is also how `groups` spells its ungrouped row. +With `--json`, the listing is `{ tools, total, groups }`, where `total` counts matches before `--limit` and `--offset`. Every listed tool carries a `group` — its group name, or `null` when it has none, which is also how `groups` spells its ungrouped row. Signature syntax: `name: type` for a required argument, `name?: type` for an optional one, `= value` for a default, `-> type` for the result. `...` marks a part the signature can't summarize. `[prompt]` or `[deny]` marks a tool whose policy isn't `"allow"`. -### `appduct invoke [selector] ` +### `appduct tools describe [selector] ` + +Shows one tool's full schema. `--groups`, `--group`, `--filter`, `--limit`, and `--offset` are `tools ls`-only flags and can't be combined with `describe`. + +### `appduct tools call [selector] ` Calls a tool. @@ -82,22 +93,21 @@ Calls a tool. Ctrl-C cancels the call in the app. -### `appduct events [selector]` +### `appduct events tail [selector]` Streams the events your app posts with `postEvent` until you stop it. With `--json`, prints one JSON object per line, each with `kind: "app_event"`. | Flag | Description | | --- | --- | -| `--since ` | Print the app events retained since this cursor, then exit. | | `--follow` | Accepted for readability; streaming is the default. | -Device connections and tool calls are not printed. To see when a device connects, use `appduct ls`. +Device connections and tool calls are not printed. To see when a device connects, use `appduct sessions ls`. Each device keeps its last 256 app events (`eventBufferSize`), however many tool calls run in between. They're discarded when the session ends. -### `appduct revoke [selector]` +### `appduct events since [selector] ` -Ends one session and frees its alias. +Prints the app events retained since ``, then exits. With `--json`, prints one JSON object per event line plus a trailing `{ "cursor": n }` line to resume from. ### `appduct mcp` @@ -107,7 +117,7 @@ Starts an MCP server over stdio. See [MCP tools](#mcp-tools). | --- | --- | | `--scheme ` | Scheme for `appduct_connect`. | -Unlike `link`, it starts even when no scheme is found; only `appduct_connect` fails. +Unlike `sessions link`, it starts even when no scheme is found; only `appduct_connect` fails. ### `appduct doctor ` @@ -178,7 +188,7 @@ With `--json`, the error's `type` field names the exact error. See [Error types] ## Scheme resolution -`link`, `mcp`, `appduct_connect`, and `appduct/client`'s `link()` use the first of: +`sessions link`, `mcp`, `appduct_connect`, and `appduct/client`'s `link()` use the first of: 1. `--scheme` (or the `scheme` option). 2. `APPDUCT_SCHEME`. diff --git a/website/src/content/docs/reference/react-native-api.md b/website/src/content/docs/reference/react-native-api.md index 2e24fd3..1aa506f 100644 --- a/website/src/content/docs/reference/react-native-api.md +++ b/website/src/content/docs/reference/react-native-api.md @@ -117,7 +117,7 @@ Returns the tool descriptors currently registered, for example to show them in y await postEvent("checkout_completed", { orderId: "ord_4821" }); ``` -Sends an app event to connected callers (`appduct events`, `appduct_wait_for_event`, `app.waitForEvent`). Dropped, with a warning in development, when no session is connected. +Sends an app event to connected callers (`appduct events tail`, `appduct_wait_for_event`, `app.waitForEvent`). Dropped, with a warning in development, when no session is connected. ### `addAppductListener` diff --git a/website/src/content/docs/start/playground.mdx b/website/src/content/docs/start/playground.mdx index 329b341..c4217ce 100644 --- a/website/src/content/docs/start/playground.mdx +++ b/website/src/content/docs/start/playground.mdx @@ -109,9 +109,9 @@ A SwiftUI app that uses `AppductCore` directly. Its scheme, `appduct-native`, is 3. Connect and call a tool, from `playground-native/ios`: ```bash - appduct link --open ios-sim - appduct tools - appduct invoke sum --input '{"a":2,"b":3}' + appduct sessions link --open ios-sim + appduct tools ls + appduct tools call sum --input '{"a":2,"b":3}' ``` @@ -134,10 +134,10 @@ A Jetpack Compose app that uses `com.callstack.appduct:core` directly. It builds 2. Connect and call a tool, from inside `playground-native/`, where the scheme and app id are recorded: ```bash - appduct link --open android - appduct tools - appduct invoke sum --input '{"a":2,"b":3}' - appduct events + appduct sessions link --open android + appduct tools ls + appduct tools call sum --input '{"a":2,"b":3}' + appduct events tail ``` diff --git a/website/src/content/docs/start/quick-start.mdx b/website/src/content/docs/start/quick-start.mdx index 4317c7e..18a4dfc 100644 --- a/website/src/content/docs/start/quick-start.mdx +++ b/website/src/content/docs/start/quick-start.mdx @@ -96,14 +96,14 @@ By the end of this page you'll have called a function inside your running app fr ```bash - appduct link --open ios-sim + appduct sessions link --open ios-sim ``` Use your app's package name, `expo.android.package` in `app.json`: ```bash - appduct link --open android --app-id com.example.shop + appduct sessions link --open android --app-id com.example.shop ``` @@ -113,11 +113,11 @@ By the end of this page you'll have called a function inside your running app fr 7. List the app's tools, then call one: ```bash - appduct tools - appduct invoke seed_cart --input '{"items":3}' + appduct tools ls + appduct tools call seed_cart --input '{"items":3}' ``` - `appduct tools` prints one line per tool, such as `seed_cart(items: number)`. `appduct invoke` prints what the handler returned: `{"added":3}`. + `appduct tools ls` prints one line per tool, such as `seed_cart(items: number)`. `appduct tools call` prints what the handler returned: `{"added":3}`. @@ -141,13 +141,13 @@ Run `appduct init --ios-app-id com.example.shop --android-app-id com.example.sho ## If something goes wrong -**`appduct link` says it can't find a scheme.** You ran it outside the app root, or your app uses a dynamic `app.config.js`, which Appduct never runs. Pass `--scheme myapp`, or run `appduct init --scheme myapp` once. +**`appduct sessions link` says it can't find a scheme.** You ran it outside the app root, or your app uses a dynamic `app.config.js`, which Appduct never runs. Pass `--scheme myapp`, or run `appduct init --scheme myapp` once. **`--open android` fails before doing anything.** It needs the installed app's package name. Pass `--app-id`, or record it with `appduct init --android-app-id`. -**The app opens but `appduct tools` says there's no session.** Check that the app imports `@appduct/react-native/auto`, and that you rebuilt after installing the package. In Expo Go, or a release build, Appduct is inactive. +**The app opens but `appduct tools ls` says there's no session.** Check that the app imports `@appduct/react-native/auto`, and that you rebuilt after installing the package. In Expo Go, or a release build, Appduct is inactive. -**`appduct invoke` fails with `tool_not_found`.** The component that registers the tool isn't mounted. Tools exist only while their component is mounted. +**`appduct tools call` fails with `tool_not_found`.** The component that registers the tool isn't mounted. Tools exist only while their component is mounted. ## Next steps From 79f2248f34085b17cce764f21fe14adf754d49e6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 16:04:20 +0000 Subject: [PATCH 04/16] test(cli): assert each noun's --help names its verbs (#96) sessions/tools/events --help exited 0 without naming any verb: cac's per-command help never prints a command's description, only usage and flags. Red for that reason. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014wk7yigkPHWoWLy9nvocyt --- .../__tests__/noun-verb-cli.integration.test.ts | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/packages/appduct/src/__tests__/noun-verb-cli.integration.test.ts b/packages/appduct/src/__tests__/noun-verb-cli.integration.test.ts index 21c2d83..54d8b00 100644 --- a/packages/appduct/src/__tests__/noun-verb-cli.integration.test.ts +++ b/packages/appduct/src/__tests__/noun-verb-cli.integration.test.ts @@ -62,16 +62,23 @@ describe("a bare noun with no verb, or an unknown verb, names its verbs (criteri expect(parsed.error.message).toBe(`The ${noun} command requires a verb: ${verbList} (got "bogus-verb").`); }); - test.each(["sessions", "tools", "events"])('"appduct %s --help" exits 0', (noun) => { + // The issue asks for the verbs on the *noun's own* `--help`, not only the global one: an agent + // that runs `appduct events --help` needs to learn about `since` right there. + test.each([ + ["sessions", ["ls", "revoke", "link"]], + ["tools", ["ls", "describe", "call"]], + ["events", ["tail", "since"]], + ])('"appduct %s --help" exits 0 and names its verbs', (noun, verbs) => { const result = runCliBinary([noun, "--help"]); expect(result.exitCode).toBe(0); + for (const verb of verbs) { + expect(result.stdout).toContain(verb); + } }); - // cac's per-command `--help` shows usage and flags but never a command's own description - // (checked against `appduct daemon --help`, the existing model, which doesn't print its verbs - // either) — a description only surfaces in the *global* `--help`'s "Commands" section, so that - // is where each noun's verbs actually get printed, exactly like `daemon`'s already does. + // The global `--help`'s "Commands" section also names every verb, exactly like `daemon`'s + // already does. test.each([ ["sessions", ["ls", "revoke", "link"]], ["tools", ["ls", "describe", "call"]], From b8c4f61bae40488d392b38efd6a5f04cd2ab194a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 16:04:22 +0000 Subject: [PATCH 05/16] fix(cli): print each noun's verbs on its own --help (#96) sessions/tools/events --help exited 0 but never named a verb, since cac's per-command help shows usage and flags, never a command's description. Each noun now sets a `.usage(...)` naming its verbs, so `appduct events --help` names `since` and `appduct tools --help` names `describe`, matching criterion 3 as the issue states it. 1 failing -> 0 failing Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014wk7yigkPHWoWLy9nvocyt --- packages/appduct/src/cli/create-cli.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/appduct/src/cli/create-cli.ts b/packages/appduct/src/cli/create-cli.ts index daa20a1..0632862 100644 --- a/packages/appduct/src/cli/create-cli.ts +++ b/packages/appduct/src/cli/create-cli.ts @@ -48,6 +48,7 @@ export const createCli = () => { // positional (`sessions link --qr --open ios-sim`) would swallow it as that flag's value. cli .command("sessions [...args]", "Manage Appduct sessions: ls, revoke, or link.") + .usage("sessions [selector] [args]") .option("--ttl ", "link: time-to-live in seconds (default: from config.json).") .option("--qr", "link: also render the deep link as a terminal QR code.") .option( @@ -71,6 +72,7 @@ export const createCli = () => { cli .command("tools [...args]", "List a session's tools, describe one, or call one: ls, describe, or call.") + .usage("tools [selector] [args]") .option("--full", "ls: render full schemas/annotations for every listed tool.") .option("--group ", "ls: only tools in this group (\"checkout\" includes \"checkout/payment\").") .option("--groups", "ls: list the session's groups with tool counts instead of its tools.") @@ -86,6 +88,7 @@ export const createCli = () => { cli .command("events [...args]", "Stream the app's events, or replay them since a cursor: tail or since.") + .usage("events [selector] [args]") .option("--follow", "tail: accepted for script readability; the default behavior already follows."); cli From d8775a04991e4f05cedc95ab3792f608abd64704 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 16:04:28 +0000 Subject: [PATCH 06/16] fix(website): show tools call instead of the removed invoke form (#96) The landing page's terminal code sample and the hero diagram's caller label still read "appduct invoke", which now exits 64. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014wk7yigkPHWoWLy9nvocyt --- website/src/components/Hero.astro | 2 +- website/src/pages/index.astro | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/website/src/components/Hero.astro b/website/src/components/Hero.astro index 481e882..8bc0bfc 100644 --- a/website/src/components/Hero.astro +++ b/website/src/components/Hero.astro @@ -62,7 +62,7 @@ const { title = '', tagline, actions = [] } = hero ?? {}; Terminal - appduct invoke + appduct tools call seed_cart({'{items:3}'}) diff --git a/website/src/pages/index.astro b/website/src/pages/index.astro index 1921b7d..85f2d01 100644 --- a/website/src/pages/index.astro +++ b/website/src/pages/index.astro @@ -18,7 +18,7 @@ useAppductTool({ handler: async ({ items }) => ({ added: items }), });`; -const invokeCode = `appduct invoke seed_cart --input '{"items":3}'`; +const invokeCode = `appduct tools call seed_cart --input '{"items":3}'`; const testCode = `import { connect } from "appduct/client"; From 4577d2ea1929a0ddc580ca5ec34f0306a690ce07 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 16:04:30 +0000 Subject: [PATCH 07/16] docs: pair every removed CLI form with its replacement (#96) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Unreleased entry said "tools" was gone, which was false — only its bare and "tools " forms went — and it didn't say what any old form became. Rewritten as explicit pairs for ls, revoke, link, tools, tools , invoke, events and events --since . Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014wk7yigkPHWoWLy9nvocyt --- CHANGELOG.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f5a728a..c84385e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,9 +11,11 @@ section into a versioned heading. ## Unreleased -- **Breaking: the CLI commands are now noun-verb, with no aliases.** `ls`, `revoke`, `link`, - `tools` and `invoke` are gone — use `sessions ls|revoke|link`, `tools ls|describe|call`, and - `events tail|since ` instead; a removed command's error names its replacement. +- **Breaking: the CLI commands are now noun-verb, with no aliases.** `ls` is now `sessions ls`, + `revoke` is `sessions revoke`, `link` is `sessions link`, `tools` is `tools ls`, `tools ` + is `tools describe `, `invoke` is `tools call`, `events` is `events tail`, and + `events --since ` is `events since `; a removed command's error names its + replacement. ## 0.12.0 (2026-09-24) From 2431d5fbfe1b4df41f3c501acba62d6f6b7268a4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 16:04:37 +0000 Subject: [PATCH 08/16] docs: switch remaining agent skills to the noun-verb CLI forms (#96) e2e-device's smoke pass and connect step still used link, ls and invoke, the forms the E2E stage on this PR would have run against. writing-changelog's tool-groups example still showed the old `tools --group` form. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014wk7yigkPHWoWLy9nvocyt --- .claude/skills/e2e-device/SKILL.md | 20 ++++++++++---------- .claude/skills/writing-changelog/SKILL.md | 2 +- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/.claude/skills/e2e-device/SKILL.md b/.claude/skills/e2e-device/SKILL.md index 89b9b58..58331dc 100644 --- a/.claude/skills/e2e-device/SKILL.md +++ b/.claude/skills/e2e-device/SKILL.md @@ -43,8 +43,8 @@ cd .. Connect and wait until the session is active: ```bash -pnpm playground:appduct -- link --open ios-sim -until pnpm playground:appduct -- ls --json | jq -e '.data[] | select(.state=="active")' >/dev/null; do sleep 2; done +pnpm playground:appduct -- sessions link --open ios-sim +until pnpm playground:appduct -- sessions ls --json | jq -e '.data[] | select(.state=="active")' >/dev/null; do sleep 2; done ``` ## Android, Expo playground @@ -57,7 +57,7 @@ cd playground pnpm exec expo start --dev-client --port 8081 > /tmp/metro.log 2>&1 & pnpm exec expo run:android --no-bundler cd .. -pnpm playground:appduct -- link --open android # app id comes from playground/.appduct/config.json +pnpm playground:appduct -- sessions link --open android # app id comes from playground/.appduct/config.json ``` ## Native playgrounds @@ -73,13 +73,13 @@ All five tools, one chain. Expected values are on the right. ```bash a="pnpm playground:appduct --" -$a invoke reset_counter --input '{}' --json | jq -e '.data.count == 0' \ -&& $a invoke sum --input '{"a":1,"b":2}' --json | jq -e '.data.total == 3' \ -&& $a invoke call_count --input '{}' --json | jq -e '.data.count == 1' \ -&& $a invoke slow_task --input '{}' --json | jq -e '.data.done == true' \ -&& $a invoke call_count --input '{}' --json | jq -e '.data.count == 2' \ +$a tools call reset_counter --input '{}' --json | jq -e '.data.count == 0' \ +&& $a tools call sum --input '{"a":1,"b":2}' --json | jq -e '.data.total == 3' \ +&& $a tools call call_count --input '{}' --json | jq -e '.data.count == 1' \ +&& $a tools call slow_task --input '{}' --json | jq -e '.data.done == true' \ +&& $a tools call call_count --input '{}' --json | jq -e '.data.count == 2' \ && echo SMOKE_OK -$a invoke throwing_tool --input '{}' --json; echo "exit=$? (non-zero expected, type tool_execution_error)" +$a tools call throwing_tool --input '{}' --json; echo "exit=$? (non-zero expected, type tool_execution_error)" ``` A checked-in script for this pass is planned; until it exists, this chain is the suite. @@ -105,7 +105,7 @@ gh pr edit --body-file Target: iOS simulator (iPhone 17, iOS 26), Expo playground, commit Smoke: SMOKE_OK Feature: -$ pnpm playground:appduct -- invoke --input '{...}' +$ pnpm playground:appduct -- tools call --input '{...}' ``` diff --git a/.claude/skills/writing-changelog/SKILL.md b/.claude/skills/writing-changelog/SKILL.md index d9101a1..d7a1c12 100644 --- a/.claude/skills/writing-changelog/SKILL.md +++ b/.claude/skills/writing-changelog/SKILL.md @@ -70,7 +70,7 @@ A feature: ``` - **New: tool groups.** A tool can declare a `group` such as `"cart"` or `"checkout/payment"`, - and `appduct tools --group ` and `appduct_list_tools` list one group; see + and `appduct tools ls --group ` and `appduct_list_tools` list one group; see `docs/TOOLS.md`. ``` From 785ae537b09a0b991f253bfa5ab891a57e0be25a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 16:16:08 +0000 Subject: [PATCH 09/16] docs: fix remaining playground/native old CLI forms (#96) Round-2 review found the sweep missed `playground:appduct -- ` forms: website/start/playground.mdx, playground/README.md and the playground app's on-screen hints still told readers to run `link`, `ls`, `tools`, `invoke` and `events --follow`, each exiting 64. Also fixed the same stale bare forms turned up by a wider grep in the native playgrounds (Info.plist, PlaygroundTools.swift, PlaygroundApplication.kt) and a comment in command-options.ts that described `events --since` as a flag after it became the `` positional. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014wk7yigkPHWoWLy9nvocyt --- packages/appduct/src/cli/command-options.ts | 5 ++-- .../playground/PlaygroundApplication.kt | 2 +- .../ios/AppductPlayground/Info.plist | 4 ++-- .../AppductPlayground/PlaygroundTools.swift | 2 +- playground/README.md | 24 +++++++++---------- playground/app/(tabs)/index.tsx | 4 ++-- website/src/content/docs/start/playground.mdx | 20 ++++++++-------- 7 files changed, 31 insertions(+), 30 deletions(-) diff --git a/packages/appduct/src/cli/command-options.ts b/packages/appduct/src/cli/command-options.ts index a1bc914..afba65e 100644 --- a/packages/appduct/src/cli/command-options.ts +++ b/packages/appduct/src/cli/command-options.ts @@ -26,8 +26,9 @@ export const parsePositiveIntegerOption = (value: unknown, flagName: string): nu }; /** Parses a `cac`-provided option value into a non-negative integer (0 allowed, unlike - * {@link parsePositiveIntegerOption}), or throws a clear `usage_error`. Used by `events --since`, - * whose cursor `0` is a meaningful "everything retained" value, not an omission. */ + * {@link parsePositiveIntegerOption}), or throws a clear `usage_error`. Used by `events since`'s + * `` positional, whose value `0` is a meaningful "everything retained" value, not an + * omission. */ export const parseNonNegativeIntegerOption = (value: unknown, flagName: string): number | undefined => { if (value === undefined) { return undefined; diff --git a/playground-native/android/app/src/main/java/com/callstack/appduct/playground/PlaygroundApplication.kt b/playground-native/android/app/src/main/java/com/callstack/appduct/playground/PlaygroundApplication.kt index ae2d344..502d8eb 100644 --- a/playground-native/android/app/src/main/java/com/callstack/appduct/playground/PlaygroundApplication.kt +++ b/playground-native/android/app/src/main/java/com/callstack/appduct/playground/PlaygroundApplication.kt @@ -9,7 +9,7 @@ import org.json.JSONObject /** * Registers the same tool set as the Expo playground (`playground/app/(tabs)/index.tsx`) so the - * two can be driven identically from `appduct tools`/`invoke` (docs/tasks/19-android-entry-points.md). + * two can be driven identically from `appduct tools ls`/`tools call` (docs/tasks/19-android-entry-points.md). * By the time [onCreate] runs, [com.callstack.appduct.AppductInitProvider] has * already captured this process's application `Context` and started lease recovery -- nothing * else needs to happen before [Appduct.register] works. diff --git a/playground-native/ios/AppductPlayground/Info.plist b/playground-native/ios/AppductPlayground/Info.plist index f8e93ed..63a6818 100644 --- a/playground-native/ios/AppductPlayground/Info.plist +++ b/playground-native/ios/AppductPlayground/Info.plist @@ -19,7 +19,7 @@ CFBundleVersion $(CURRENT_PROJECT_VERSION) CFBundleURLTypes @@ -33,7 +33,7 @@ LSRequiresIPhoneOS diff --git a/playground-native/ios/AppductPlayground/PlaygroundTools.swift b/playground-native/ios/AppductPlayground/PlaygroundTools.swift index 5ddd2cd..e0ab89d 100644 --- a/playground-native/ios/AppductPlayground/PlaygroundTools.swift +++ b/playground-native/ios/AppductPlayground/PlaygroundTools.swift @@ -2,7 +2,7 @@ import AppductCore import Foundation /// Registers the same tools `playground/app/(tabs)/index.tsx` (the Expo playground) registers -- -/// same names, descriptions, and schemas -- so `appduct tools` reports an equivalent surface +/// same names, descriptions, and schemas -- so `appduct tools ls` reports an equivalent surface /// regardless of which playground app answered the link. Implementations are simple in-memory /// mirrors of the Expo versions, backed by `PlaygroundViewModel.shared` instead of React state. enum PlaygroundTools { diff --git a/playground/README.md b/playground/README.md index a46a500..ff3ca89 100644 --- a/playground/README.md +++ b/playground/README.md @@ -50,9 +50,9 @@ step required for the smoke test below. ### 3. Bootstrap a session -- **iOS Simulator**: `pnpm run playground:appduct -- link --open ios-sim` -- **Android emulator**: `pnpm run playground:appduct -- link --open android` -- **Physical device**: `pnpm run playground:appduct -- link --qr`, then scan the QR code with the device's camera (it +- **iOS Simulator**: `pnpm run playground:appduct -- sessions link --open ios-sim` +- **Android emulator**: `pnpm run playground:appduct -- sessions link --open android` +- **Physical device**: `pnpm run playground:appduct -- sessions link --qr`, then scan the QR code with the device's camera (it must be on the same LAN as the daemon, or `allowPrivateLanOnly` will reject it) The **Status** tab should flip to `active` with an alias once the app claims the session. @@ -60,17 +60,17 @@ The **Status** tab should flip to `active` with an alias once the app claims the ### 4. Drive it from the CLI ```sh -pnpm run playground:appduct -- ls -pnpm run playground:appduct -- tools -pnpm run playground:appduct -- invoke sum --input '{"a":1,"b":2}' -pnpm run playground:appduct -- invoke call_count --input '{}' # reads state a handler closes over -pnpm run playground:appduct -- invoke reset_counter --input '{}' # destructive; denied if policy.destructive=deny -pnpm run playground:appduct -- invoke slow_task --input '{}' # watch progress with events --follow -pnpm run playground:appduct -- invoke throwing_tool --input '{}' # exercises tool_execution_error -pnpm run playground:appduct -- events --follow +pnpm run playground:appduct -- sessions ls +pnpm run playground:appduct -- tools ls +pnpm run playground:appduct -- tools call sum --input '{"a":1,"b":2}' +pnpm run playground:appduct -- tools call call_count --input '{}' # reads state a handler closes over +pnpm run playground:appduct -- tools call reset_counter --input '{}' # destructive; denied if policy.destructive=deny +pnpm run playground:appduct -- tools call slow_task --input '{}' # watch progress with events tail --follow +pnpm run playground:appduct -- tools call throwing_tool --input '{}' # exercises tool_execution_error +pnpm run playground:appduct -- events tail --follow ``` -Tap **Send playground_ping** on the Status tab while `events --follow` is running to see the +Tap **Send playground_ping** on the Status tab while `events tail --follow` is running to see the `app_event` show up on the stream. ### 5. Try the resume behavior diff --git a/playground/app/(tabs)/index.tsx b/playground/app/(tabs)/index.tsx index aad3e56..36ebdfc 100644 --- a/playground/app/(tabs)/index.tsx +++ b/playground/app/(tabs)/index.tsx @@ -18,7 +18,7 @@ import { useThemeColor } from "@/hooks/use-theme-color"; // records the scheme and the app ids `--open` needs. const CONNECT_COMMANDS = [ "pnpm exec expo run:ios # or: pnpm exec expo run:android", - "pnpm run playground:appduct -- link --open ios-sim # or: --open android / --qr", + "pnpm run playground:appduct -- sessions link --open ios-sim # or: --open android / --qr", ].join("\n"); /** Delays `ms` without leaking a dangling timer past the call: each tool invocation owns its own. */ @@ -177,7 +177,7 @@ export default function ToolsScreen() { Then drive tools from another terminal, via the same launcher: pnpm run - playground:appduct -- ls / tools / invoke sum --input + playground:appduct -- sessions ls / tools ls / tools call sum --input {" '{\"a\":1,\"b\":2}'"}. diff --git a/website/src/content/docs/start/playground.mdx b/website/src/content/docs/start/playground.mdx index c4217ce..fe366c3 100644 --- a/website/src/content/docs/start/playground.mdx +++ b/website/src/content/docs/start/playground.mdx @@ -52,9 +52,9 @@ The Expo app has two tabs: **Tools** lists the registered tools, and **Status** 3. Back in the repository root, connect the app: ```bash - pnpm run playground:appduct -- link --open ios-sim # iOS Simulator - pnpm run playground:appduct -- link --open android # Android emulator - pnpm run playground:appduct -- link --qr # physical device on your local network + pnpm run playground:appduct -- sessions link --open ios-sim # iOS Simulator + pnpm run playground:appduct -- sessions link --open android # Android emulator + pnpm run playground:appduct -- sessions link --qr # physical device on your local network ``` `playground:appduct` runs the repository's own build of the CLI. The **Status** tab switches to `active` and shows the session's alias. @@ -62,21 +62,21 @@ The Expo app has two tabs: **Tools** lists the registered tools, and **Status** 4. Drive it: ```bash - pnpm run playground:appduct -- tools - pnpm run playground:appduct -- invoke sum --input '{"a":1,"b":2}' - pnpm run playground:appduct -- invoke slow_task --input '{}' - pnpm run playground:appduct -- events --follow + pnpm run playground:appduct -- tools ls + pnpm run playground:appduct -- tools call sum --input '{"a":1,"b":2}' + pnpm run playground:appduct -- tools call slow_task --input '{}' + pnpm run playground:appduct -- events tail --follow ``` - While `events --follow` runs, tap **Send playground_ping** on the Status tab to see the event arrive. + While `events tail --follow` runs, tap **Send playground_ping** on the Status tab to see the event arrive. Things to try: -- **Groups:** `tools --groups`, then `tools --group diagnostics`. The playground puts its tools in `counter`, `diagnostics`, and `diagnostics/progress`, and leaves `sum` ungrouped. +- **Groups:** `tools ls --groups`, then `tools ls --group diagnostics`. The playground puts its tools in `counter`, `diagnostics`, and `diagnostics/progress`, and leaves `sum` ungrouped. - **Reloads:** press `r` in the Metro terminal. The Status tab shows `reconnecting`, then `active` with the same alias. No new link is needed as long as the app process stays alive. -- **Errors:** `invoke throwing_tool --input '{}'` fails with `tool_execution_error`. +- **Errors:** `tools call throwing_tool --input '{}'` fails with `tool_execution_error`. - **Policy:** set `policy.destructive` to `"deny"` in `~/.appduct/config.json`, run `pnpm run playground:appduct -- daemon stop`, reconnect, and `reset_counter` is refused with `policy_denied`. See [Security](/appduct/guides/security/#limit-what-callers-can-run). ## Native iOS playground From dbf46534b6d06c0f0ffc4172337ab226e26e73d8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 08:04:47 +0000 Subject: [PATCH 10/16] test(cli): pin the events-since resume hint to the new form (#96) output.test.ts pinned the removed --since flag in the human-mode cursor line; expect the noun-verb command instead. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014wk7yigkPHWoWLy9nvocyt --- packages/appduct/src/__tests__/output.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/appduct/src/__tests__/output.test.ts b/packages/appduct/src/__tests__/output.test.ts index 51a010f..8d5dca8 100644 --- a/packages/appduct/src/__tests__/output.test.ts +++ b/packages/appduct/src/__tests__/output.test.ts @@ -591,10 +591,10 @@ describe("renderEventsCursorLine", () => { expect(JSON.parse(line)).toEqual({ cursor: 42 }); }); - test("human mode includes the cursor value and the resume flag", () => { + test("human mode includes the cursor value and the resume command", () => { const line = renderEventsCursorLine(42, flags()); expect(line).toContain("42"); - expect(line).toContain("--since 42"); + expect(line).toContain('appduct events since 42'); }); }); From 36b460ca8aeb702612693c5a3a63b5e3e397eb88 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 08:05:04 +0000 Subject: [PATCH 11/16] fix(cli): point the events-since cursor hint at the new command (#96) The human-mode trailing cursor line named the removed --since flag. It now names appduct events since , which actually resumes. 1 failing -> 0 failing Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014wk7yigkPHWoWLy9nvocyt --- packages/appduct/src/output.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/appduct/src/output.ts b/packages/appduct/src/output.ts index 07cda9d..d79295c 100644 --- a/packages/appduct/src/output.ts +++ b/packages/appduct/src/output.ts @@ -751,5 +751,5 @@ export const renderEventsCursorLine = (cursor: number, flags: GlobalFlags): stri } const colors = pc.createColors(flags.color); - return colors.dim(`cursor: ${cursor} (pass --since ${cursor} to resume from here)`); + return colors.dim(`cursor: ${cursor} (run "appduct events since ${cursor}" to resume from here)`); }; From 1bc0e66848d8aca784d6a0306a836f5b2ffdd009 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 08:18:08 +0000 Subject: [PATCH 12/16] docs: switch remaining invoke/bare-verb mentions to noun-verb forms (#96) README.md and ARCHITECTURE.md still named the removed `invoke` command outside the earlier diff. Also swept source comments, test titles and docblocks repo-wide for the same staleness: `invoke`, bare `ls`/`link`/ `revoke`, `tools `/`tools [selector]`, and `--since` as an example flag, replacing each with its noun-verb equivalent. No behavior change; comments, test names, a stale snapshot key and doc prose only. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014wk7yigkPHWoWLy9nvocyt --- docs/ARCHITECTURE.md | 6 +++--- packages/appduct/README.md | 2 +- packages/appduct/scripts/bundle.mjs | 4 ++-- .../src/__tests__/__snapshots__/output.test.ts.snap | 12 ++++++------ packages/appduct/src/__tests__/call-timeouts.test.ts | 2 +- .../appduct/src/__tests__/cli-v2.integration.test.ts | 8 ++++---- .../appduct/src/__tests__/command-options.test.ts | 12 ++++++------ packages/appduct/src/__tests__/e2e/churn.e2e.test.ts | 9 +++++---- .../appduct/src/__tests__/e2e/cold-start.e2e.test.ts | 7 ++++--- .../src/__tests__/e2e/multi-device.e2e.test.ts | 7 ++++--- .../appduct/src/__tests__/events.integration.test.ts | 2 +- packages/appduct/src/__tests__/output.test.ts | 4 ++-- .../__tests__/tool-invocation.integration.test.ts | 2 +- packages/appduct/src/cli/command-options.ts | 8 ++++---- packages/appduct/src/commands/events.ts | 2 +- packages/appduct/src/commands/tools.ts | 2 +- packages/appduct/src/daemon/rpc-errors.ts | 2 +- packages/appduct/src/errors.ts | 2 +- packages/appduct/src/scheme.ts | 2 +- packages/react-native/README.md | 2 +- packages/shared/src/domains/tool-signature.ts | 6 +++--- 21 files changed, 53 insertions(+), 50 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 96b5f70..7bb0965 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -306,7 +306,7 @@ Callers that hold their own transport watchdog over a `tools.call` (the MCP serv so the daemon's `tool_timeout` always arrives first and the real error type reaches the caller instead of a generic transport failure. A caller that knows the effective deadline sizes the watchdog from it (the MCP server reads the tool's `timeout_ms` straight off its -`tools.list` entry); a caller that does not — `invoke` with no `--timeout`, +`tools.list` entry); a caller that does not — `tools call` with no `--timeout`, `AppClient.call` with no `timeoutMs`, both of which leave the deadline to the tool's own undeclared-to-them value — sizes it from `MAX_TOOL_TIMEOUT_MS` instead. That backstop is only ever reached by a daemon that accepts a request and then answers nothing: @@ -607,7 +607,7 @@ The project `.appduct/config.json` carries a second key alongside `scheme` since per delivery target (`android` → `appId.android`, `ios-device` → `appId.ios`; `ios-sim` needs none — see §8), in a shorter order than `scheme`'s, first match wins: -1. `--app-id` (CLI `link`) / `appId` (MCP `appduct_connect`) / `appId` (`mintLink`, +1. `--app-id` (CLI `sessions link`) / `appId` (MCP `appduct_connect`) / `appId` (`mintLink`, `appduct/client`'s `link()`) — the target is known at the call site, so this is unambiguous 2. the nearest `.appduct/config.json` declaring `appId.`, using the *same* walk-up `scheme` uses (`findProjectConfigs`) @@ -703,7 +703,7 @@ process loads only the modules the command it is running needs. Concretely: instance shared by all of them. Its own build bundles it into a single `dist/index.js` for the same reason this one bundles: one resolution, not one per source file). Code splitting was deliberately *not* used: esbuild tree-shakes per bundle, so a chunk shared by several routes - carries whatever any of them uses from a module (`invoke` would have loaded the daemon's RPC + carries whatever any of them uses from a module (`tools call` would have loaded the daemon's RPC server because `daemon run` needs it). Bundling each route alone tree-shakes it alone. - **Duplication is the accepted price, and it has one rule.** The modules a route shares with the eager entry (`errors`, `output`, `rpc/client`, …) are copied into every route bundle: tens of diff --git a/packages/appduct/README.md b/packages/appduct/README.md index 7fcd1dc..181c2ea 100644 --- a/packages/appduct/README.md +++ b/packages/appduct/README.md @@ -9,7 +9,7 @@ The `appduct` package is the operator/agent side of Appduct: a CLI and an MCP se ## Why use this package - **One daemon, many devices.** It auto-spawns on first use, serves every device on one `wss://` port, and survives Metro reloads, backgrounding, and network flaps by suspending and resuming sessions instead of dying with them. -- **Same surface for humans and agents.** The CLI (`tools`, `invoke`, `events`, ...) and `appduct mcp` use the same RPC methods, so an agent sees the tools a human operator does. +- **Same surface for humans and agents.** The CLI (`sessions`, `tools`, `events`, ...) and `appduct mcp` use the same RPC methods, so an agent sees the tools a human operator does. - **Hardened control plane.** The CLI and MCP server never touch sockets, keys, or state files directly — everything goes through a Unix-domain-socket RPC surface gated by filesystem permissions (see [`docs/SECURITY.md`][security]). - **Fits production-minded apps.** The app only exposes what you register; trust boundaries are pins and TLS, and production deployments can gate tools with policy and audit every call. diff --git a/packages/appduct/scripts/bundle.mjs b/packages/appduct/scripts/bundle.mjs index 195d18c..20fd06c 100644 --- a/packages/appduct/scripts/bundle.mjs +++ b/packages/appduct/scripts/bundle.mjs @@ -7,8 +7,8 @@ * files a command does need into as few as possible. * * Why no code splitting: esbuild's tree-shaking is per bundle, and a chunk shared by several - * routes carries everything *any* of them uses from the modules in it (`invoke` would load the - * daemon's RPC server because `daemon run` needs it). Making every route its own entry point + * routes carries everything *any* of them uses from the modules in it (`tools call` would load + * the daemon's RPC server because `daemon run` needs it). Making every route its own entry point * bundles each one alone, so each is tree-shaken alone and a command loads exactly two files of * ours: `dist/bin.js` and its route. The price is that the modules a route shares with the eager * entry (`errors`, `output`, `rpc/client`, …) are duplicated into every route bundle — a few tens diff --git a/packages/appduct/src/__tests__/__snapshots__/output.test.ts.snap b/packages/appduct/src/__tests__/__snapshots__/output.test.ts.snap index 9836e82..c68e362 100644 --- a/packages/appduct/src/__tests__/__snapshots__/output.test.ts.snap +++ b/packages/appduct/src/__tests__/__snapshots__/output.test.ts.snap @@ -82,12 +82,6 @@ Next " `; -exports[`output rendering > invoke human output prints the raw tool result 1`] = ` -"Result -{"echoed":"hello"} -" -`; - exports[`output rendering > keygen success output includes the key path and fingerprint 1`] = ` "Key Ready Key @@ -120,6 +114,12 @@ exports[`output rendering > ls human output includes alias, state, device, tools " `; +exports[`output rendering > tools call human output prints the raw tool result 1`] = ` +"Result +{"echoed":"hello"} +" +`; + exports[`output rendering > tools detail output renders full schema/annotations 1`] = ` "Tool: echo Signature echo(...) diff --git a/packages/appduct/src/__tests__/call-timeouts.test.ts b/packages/appduct/src/__tests__/call-timeouts.test.ts index e77ecfa..5617daf 100644 --- a/packages/appduct/src/__tests__/call-timeouts.test.ts +++ b/packages/appduct/src/__tests__/call-timeouts.test.ts @@ -66,7 +66,7 @@ describe("deriveCallTransportTimeoutMs", () => { * the thing that fails a long tool call. */ describe("callers that do not know the effective deadline", () => { - test("invoke with no --timeout sizes its watchdog for the largest deadline the daemon could enforce", () => { + test("tools call with no --timeout sizes its watchdog for the largest deadline the daemon could enforce", () => { // Mirrors `commands/invoke.ts`: `options.timeoutMs ?? MAX_CALL_TIMEOUT_MS`. const noCallerTimeout: number | undefined = undefined; expect(deriveCallTransportTimeoutMs(noCallerTimeout ?? MAX_CALL_TIMEOUT_MS)).toBe( diff --git a/packages/appduct/src/__tests__/cli-v2.integration.test.ts b/packages/appduct/src/__tests__/cli-v2.integration.test.ts index b0d7ee7..8476a8b 100644 --- a/packages/appduct/src/__tests__/cli-v2.integration.test.ts +++ b/packages/appduct/src/__tests__/cli-v2.integration.test.ts @@ -71,7 +71,7 @@ type CliJsonResult = { ok: boolean; data?: unknown; error?: { type: string; mess /** * Runs the CLI as a subprocess and returns its parsed `--json` output. It uses an async process: - * several flows below (`invoke`) need the daemon to round-trip through this + * several flows below (`tools call`) need the daemon to round-trip through this * test's own fake app WebSocket client while the CLI subprocess is in flight — `spawnSync` blocks * this process's event loop for the subprocess's entire lifetime, which would starve that * WebSocket's `message` handler and deadlock the round-trip. @@ -242,7 +242,7 @@ describe("appduct CLI v2: end-to-end command table", () => { expect(toolsDetailMissing.error?.type).toBe("usage_error"); expect(toolsDetailMissing.error?.message).toBe('Tool "does-not-exist" is not registered.'); - // invoke: round-trip through the fake app. + // tools call: round-trip through the fake app. appSocket.on("message", (data) => { const msg = JSON.parse(data.toString("utf8")) as Record; if (msg.type === "tool_call") { @@ -264,12 +264,12 @@ describe("appduct CLI v2: end-to-end command table", () => { expect(invokeResult.ok).toBe(true); expect(invokeResult.data).toEqual({ echoed: "hello" }); - // invoke a nonexistent tool: the wire error type is preserved verbatim. + // tools call on a nonexistent tool: the wire error type is preserved verbatim. const invokeMissing = await runCliJson(["tools", "call", alias, "does-not-exist", "--input", "{}"], stateDir); expect(invokeMissing.ok).toBe(false); expect(invokeMissing.error?.type).toBe("tool_not_found"); - // revoke: the session disappears from ls. + // sessions revoke: the session disappears from sessions ls. const revokeResult = await runCliJson(["sessions", "revoke", alias], stateDir); expect(revokeResult.ok).toBe(true); expect(revokeResult.data).toEqual({ ok: true }); diff --git a/packages/appduct/src/__tests__/command-options.test.ts b/packages/appduct/src/__tests__/command-options.test.ts index f66cc61..52d01c3 100644 --- a/packages/appduct/src/__tests__/command-options.test.ts +++ b/packages/appduct/src/__tests__/command-options.test.ts @@ -27,18 +27,18 @@ describe("parsePositiveIntegerOption", () => { describe("parseNonNegativeIntegerOption", () => { test("passes through undefined", () => { - expect(parseNonNegativeIntegerOption(undefined, "--since")).toBeUndefined(); + expect(parseNonNegativeIntegerOption(undefined, "--offset")).toBeUndefined(); }); test("accepts a numeric string, including zero", () => { - expect(parseNonNegativeIntegerOption("0", "--since")).toBe(0); - expect(parseNonNegativeIntegerOption("42", "--since")).toBe(42); + expect(parseNonNegativeIntegerOption("0", "--offset")).toBe(0); + expect(parseNonNegativeIntegerOption("42", "--offset")).toBe(42); }); test("rejects negative, non-integer, and non-numeric values", () => { - expect(() => parseNonNegativeIntegerOption("-1", "--since")).toThrow(/non-negative integer/u); - expect(() => parseNonNegativeIntegerOption("1.5", "--since")).toThrow(/non-negative integer/u); - expect(() => parseNonNegativeIntegerOption("abc", "--since")).toThrow(/non-negative integer/u); + expect(() => parseNonNegativeIntegerOption("-1", "--offset")).toThrow(/non-negative integer/u); + expect(() => parseNonNegativeIntegerOption("1.5", "--offset")).toThrow(/non-negative integer/u); + expect(() => parseNonNegativeIntegerOption("abc", "--offset")).toThrow(/non-negative integer/u); }); }); diff --git a/packages/appduct/src/__tests__/e2e/churn.e2e.test.ts b/packages/appduct/src/__tests__/e2e/churn.e2e.test.ts index 644cd8c..b21c654 100644 --- a/packages/appduct/src/__tests__/e2e/churn.e2e.test.ts +++ b/packages/appduct/src/__tests__/e2e/churn.e2e.test.ts @@ -1,9 +1,10 @@ /** * E2E scenario: churn. * - * claim -> `dropSocket()` -> `ls` shows SUSPENDED -> `invoke` fails `session_suspended` -> - * `resume()` -> `invoke` succeeds -> grace expiry (short configured grace) -> EXPIRED and alias - * freed. This is the "survive churn" goal from ARCHITECTURE.md §1 exercised end-to-end. + * claim -> `dropSocket()` -> `sessions ls` shows SUSPENDED -> `tools call` fails + * `session_suspended` -> `resume()` -> `tools call` succeeds -> grace expiry (short configured + * grace) -> EXPIRED and alias freed. This is the "survive churn" goal from ARCHITECTURE.md §1 + * exercised end-to-end. * * Grace expiry is synchronized on the daemon's own `session_expired` event (via a raw UDS * subscription used only for test synchronization, never to drive the scenario) rather than a @@ -28,7 +29,7 @@ afterEach(cleanupAfterEach); describe("e2e: churn", () => { test( - "suspend on socket loss, session_suspended on invoke, resume, then grace expiry frees the alias", + "suspend on socket loss, session_suspended on tools call, resume, then grace expiry frees the alias", async () => { const { stateDir } = await makeTempStateDir({ graceSeconds: 2 }); await ensureDaemon(stateDir); diff --git a/packages/appduct/src/__tests__/e2e/cold-start.e2e.test.ts b/packages/appduct/src/__tests__/e2e/cold-start.e2e.test.ts index 2200cce..678e247 100644 --- a/packages/appduct/src/__tests__/e2e/cold-start.e2e.test.ts +++ b/packages/appduct/src/__tests__/e2e/cold-start.e2e.test.ts @@ -1,8 +1,9 @@ /** * E2E scenario: cold start. * - * `keygen --out` -> `link --json` (daemon auto-spawns) -> claim -> `ls` shows ACTIVE with alias -> - * `tools`/`invoke` round-trip -> `revoke` -> `daemon stop` leaves no socket/pidfile. + * `keygen --out` -> `sessions link --json` (daemon auto-spawns) -> claim -> `sessions ls` shows + * ACTIVE with alias -> `tools ls`/`tools call` round-trip -> `sessions revoke` -> `daemon stop` + * leaves no socket/pidfile. * * Every step is a real CLI subprocess (`runCliJson`) driving a real auto-spawned daemon, except the * claim itself, which is the scripted fake app client verifying the daemon's SPKI pin before @@ -31,7 +32,7 @@ afterEach(cleanupAfterEach); describe("e2e: cold start", () => { test( - "keygen -> link auto-spawns -> claim (pin-verified) -> ls ACTIVE -> tools/invoke -> revoke -> daemon stop leaves no socket/pidfile", + "keygen -> sessions link auto-spawns -> claim (pin-verified) -> sessions ls ACTIVE -> tools ls/tools call -> sessions revoke -> daemon stop leaves no socket/pidfile", async () => { const { stateDir } = await makeTempStateDir(); // The daemon binds an OS-assigned wss port (`wssPort: 0`), so the port is read back diff --git a/packages/appduct/src/__tests__/e2e/multi-device.e2e.test.ts b/packages/appduct/src/__tests__/e2e/multi-device.e2e.test.ts index 578a496..e9d861a 100644 --- a/packages/appduct/src/__tests__/e2e/multi-device.e2e.test.ts +++ b/packages/appduct/src/__tests__/e2e/multi-device.e2e.test.ts @@ -1,9 +1,10 @@ /** * E2E scenario: multi-device. * - * Two fake clients get distinct aliases; `invoke` without a selector errors `ambiguous_session`, - * with an alias it works; `revoke` on one leaves the other untouched (ARCHITECTURE.md §1 goal 3: - * "one daemon serves N concurrent device sessions on one port"). + * Two fake clients get distinct aliases; `tools call` without a selector errors + * `ambiguous_session`, with an alias it works; `sessions revoke` on one leaves the other + * untouched (ARCHITECTURE.md §1 goal 3: "one daemon serves N concurrent device sessions on one + * port"). */ import { afterEach, describe, expect, test } from "vitest"; diff --git a/packages/appduct/src/__tests__/events.integration.test.ts b/packages/appduct/src/__tests__/events.integration.test.ts index 793bbbf..eed53bd 100644 --- a/packages/appduct/src/__tests__/events.integration.test.ts +++ b/packages/appduct/src/__tests__/events.integration.test.ts @@ -174,7 +174,7 @@ describe("appduct events tail --json", () => { expect(stopResult.ok).toBe(true); }, 15_000); - test("--since pulls only retained app events one-shot for a claimed session, and a later pull with the returned cursor sees nothing new", async () => { + test("events since pulls only retained app events one-shot for a claimed session, and a later pull with the returned cursor sees nothing new", async () => { const { stateDir } = await makeTempStateDir(); const status = runCliJson(["daemon", "status"], stateDir); diff --git a/packages/appduct/src/__tests__/output.test.ts b/packages/appduct/src/__tests__/output.test.ts index 8d5dca8..2c19d97 100644 --- a/packages/appduct/src/__tests__/output.test.ts +++ b/packages/appduct/src/__tests__/output.test.ts @@ -389,7 +389,7 @@ describe("output rendering", () => { expect(rendered.stdout).toMatchSnapshot(); }); - test("invoke human output prints the raw tool result", () => { + test("tools call human output prints the raw tool result", () => { const rendered = renderResult( { ok: true, @@ -404,7 +404,7 @@ describe("output rendering", () => { expect(rendered.stdout).toMatchSnapshot(); }); - test("human invoke output embeds compact JSON by default, and indented JSON under --pretty", () => { + test("human tools call output embeds compact JSON by default, and indented JSON under --pretty", () => { const result = { ok: true as const, data: { echoed: { nested: { value: true } } }, diff --git a/packages/appduct/src/__tests__/tool-invocation.integration.test.ts b/packages/appduct/src/__tests__/tool-invocation.integration.test.ts index 4ec3f3a..535b60a 100644 --- a/packages/appduct/src/__tests__/tool-invocation.integration.test.ts +++ b/packages/appduct/src/__tests__/tool-invocation.integration.test.ts @@ -617,7 +617,7 @@ describe("tools.call: timeout", () => { describe("caller transport timeouts over a slow tool", () => { test( - "invoke (with and without --timeout) and appduct/client all outlive the daemon's 10 s default (issue #25)", + "tools call (with and without --timeout) and appduct/client all outlive the daemon's 10 s default (issue #25)", async () => { const { daemon, port } = await startTestDaemon(); const app = await claimApp(daemon, port); diff --git a/packages/appduct/src/cli/command-options.ts b/packages/appduct/src/cli/command-options.ts index afba65e..4a1f210 100644 --- a/packages/appduct/src/cli/command-options.ts +++ b/packages/appduct/src/cli/command-options.ts @@ -87,9 +87,9 @@ export const readTextOption = ( }; /** - * Splits the positional args of a command shaped ` [selector] ` (e.g. `invoke - * [selector] `, `tools [selector] `): the last positional is always the required - * target, everything before it (zero or one args) is the optional selector. + * Splits the positional args of a command shaped ` [selector] ` (e.g. `tools + * call [selector] `, `tools describe [selector] `): the last positional is always the + * required target, everything before it (zero or one args) is the optional selector. */ export const splitSelectorAndRequiredTarget = ( args: readonly string[], @@ -121,7 +121,7 @@ export const splitOptionalSelector = (args: readonly string[], commandUsage: str }; /** - * Parses the JSON payload for `invoke --input`; never throws a raw `SyntaxError` at the CLI + * Parses the JSON payload for `tools call --input`; never throws a raw `SyntaxError` at the CLI * boundary. A missing flag is a usage error (nothing was given); a present-but-unparseable or * wrong-shaped value is a validation error (something was given, and it's invalid). */ diff --git a/packages/appduct/src/commands/events.ts b/packages/appduct/src/commands/events.ts index 04c93e3..337eae4 100644 --- a/packages/appduct/src/commands/events.ts +++ b/packages/appduct/src/commands/events.ts @@ -30,7 +30,7 @@ export type EventsCommandContext = { stateDir: string; spawn?: SpawnFn; onEvent: (event: EventNotification) => void; - /** `--since` mode only: called once with the pull's resulting cursor, so a scripted caller + /** `events since` mode only: called once with the pull's resulting cursor, so a scripted caller * doesn't have to reconstruct it by maxing `seq` over the printed lines (impossible when the * response is empty — the whole point of a cursor is knowing where to resume from either way). */ onCursor?: (cursor: number) => void; diff --git a/packages/appduct/src/commands/tools.ts b/packages/appduct/src/commands/tools.ts index 918bd4a..6c45c8c 100644 --- a/packages/appduct/src/commands/tools.ts +++ b/packages/appduct/src/commands/tools.ts @@ -136,7 +136,7 @@ const listOrGroups = async ( try { return await listGroups(selector, context); } catch (error) { - // `--groups` takes no value, so `tools --groups checkout` reads `checkout` as a session + // `--groups` takes no value, so `tools ls --groups checkout` reads `checkout` as a session // selector. When no such session exists, the likelier intent is `--group checkout`. if ( selector !== undefined && diff --git a/packages/appduct/src/daemon/rpc-errors.ts b/packages/appduct/src/daemon/rpc-errors.ts index 528e891..dfb219e 100644 --- a/packages/appduct/src/daemon/rpc-errors.ts +++ b/packages/appduct/src/daemon/rpc-errors.ts @@ -2,7 +2,7 @@ * The error the daemon's RPC handlers throw to answer a request with a typed wire error * (ARCHITECTURE.md §5). It lives apart from `rpc-server.ts` so the modules that *raise* it — the * session engine, the call engine — don't drag the socket server itself into every bundle that - * needs, say, a call-timeout constant from `calls.ts` (the CLI's `invoke` route does). + * needs, say, a call-timeout constant from `calls.ts` (the CLI's `tools call` route does). */ import type { ErrorType } from "@appduct/shared"; diff --git a/packages/appduct/src/errors.ts b/packages/appduct/src/errors.ts index 3eec21f..b243fd4 100644 --- a/packages/appduct/src/errors.ts +++ b/packages/appduct/src/errors.ts @@ -109,7 +109,7 @@ const rpcErrorClass = (error: DaemonRpcError): CliErrorType | undefined => { * Converts any error thrown from a command handler into the CLI's rendered `CliError` shape. * RPC/app-originated failures (`DaemonRpcError` with a preserved `data.type`) keep that type * **verbatim** in `type` — never re-wrapped under a generic bucket (ARCHITECTURE.md §5) — so e.g. - * `invoke --json` on a failing tool call renders `error.type === "tool_execution_error"`, not + * `tools call --json` on a failing tool call renders `error.type === "tool_execution_error"`, not * `"tool_error"`. `getExitCodeForError` still buckets that same error into a sysexits class. */ export const toCliError = (error: unknown): CliError => { diff --git a/packages/appduct/src/scheme.ts b/packages/appduct/src/scheme.ts index 6d08982..b1b06e9 100644 --- a/packages/appduct/src/scheme.ts +++ b/packages/appduct/src/scheme.ts @@ -557,7 +557,7 @@ export type ResolveAppIdOptions = { /** Which `appId.` key to read from a project config; see `cli/open-target.ts`'s * `platformOf`. */ platform: AppIdPlatform; - /** `--app-id` (CLI `link`) / `appId` (MCP `appduct_connect`) / `appId` (`mintLink`, the + /** `--app-id` (CLI `sessions link`) / `appId` (MCP `appduct_connect`) / `appId` (`mintLink`, the * `appduct/client` `link()`) — highest precedence, because the target is known at the call * site. */ flagAppId?: string; diff --git a/packages/react-native/README.md b/packages/react-native/README.md index f794a42..06be963 100644 --- a/packages/react-native/README.md +++ b/packages/react-native/README.md @@ -91,7 +91,7 @@ An agent picks a tool from one signature line and the first line of its descript ### 5. Start the daemon and test the flow -`appduct` auto-spawns its daemon. `link` needs your app's deep-link scheme: pass `--scheme` (matching `expo.scheme`), or set `scheme` once in `~/.appduct/config.json`: +`appduct` auto-spawns its daemon. `sessions link` needs your app's deep-link scheme: pass `--scheme` (matching `expo.scheme`), or set `scheme` once in `~/.appduct/config.json`: ```bash appduct sessions link --scheme myapp --qr diff --git a/packages/shared/src/domains/tool-signature.ts b/packages/shared/src/domains/tool-signature.ts index b2b1e1e..11af19f 100644 --- a/packages/shared/src/domains/tool-signature.ts +++ b/packages/shared/src/domains/tool-signature.ts @@ -4,8 +4,8 @@ * * This exists so `appduct tools ls` can list hundreds of tools cheaply for an agent to read: a * signature plus the description's first line says far more per line than a bare name, without - * the cost of printing every tool's full schema (`--full`/`tools ` remain the source of - * truth for that). + * the cost of printing every tool's full schema (`--full`/`tools describe ` remain the + * source of truth for that). * * A tool's `input_schema`/`output_schema` are draft-2020-12 JSON Schema fragments whose internals * are never validated anywhere in this codebase (`tool-descriptor.ts`'s `isToolDescriptor` only @@ -36,7 +36,7 @@ const isUnresolvable = (schema: JsonSchema): boolean => { const MAX_ENUM_VALUES = 5; /** `default` only appends to a param entry when its JSON rendering stays this short — a longer - * default belongs in the full schema (`tools `), not a one-line signature. */ + * default belongs in the full schema (`tools describe `), not a one-line signature. */ const MAX_DEFAULT_LENGTH = 20; /** An `enum`/`const` value longer than this (as JSON) is cut with `…` — one 5 KB enum string must From 919ff03f0db8027bd6bba70f4bbaeaad23c57d15 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 08:27:43 +0000 Subject: [PATCH 13/16] test(cli): cover the events-since resume hint with a selector (#96) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014wk7yigkPHWoWLy9nvocyt --- packages/appduct/src/__tests__/output.test.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/appduct/src/__tests__/output.test.ts b/packages/appduct/src/__tests__/output.test.ts index 2c19d97..8b77e2e 100644 --- a/packages/appduct/src/__tests__/output.test.ts +++ b/packages/appduct/src/__tests__/output.test.ts @@ -596,6 +596,16 @@ describe("renderEventsCursorLine", () => { expect(line).toContain("42"); expect(line).toContain('appduct events since 42'); }); + + test("human mode includes the selector in the resume command when one was given", () => { + const line = renderEventsCursorLine(3, flags(), "pixel-8"); + expect(line).toContain('appduct events since pixel-8 3'); + }); + + test("NDJSON mode does not change when a selector was given", () => { + const line = renderEventsCursorLine(3, flags({ json: true }), "pixel-8"); + expect(JSON.parse(line)).toEqual({ cursor: 3 }); + }); }); describe("daemon status rendering", () => { From 0db8961c70b031a83a490198ce54f3a4a0d0575a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 08:32:08 +0000 Subject: [PATCH 14/16] fix(cli): include the selector in the events-since resume hint (#96) 1 failing -> 0 failing Copying the hint after `appduct events since pixel-8 0` dropped the device selector, so with two devices connected the suggested command resolved to ambiguous_session instead of resuming. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014wk7yigkPHWoWLy9nvocyt --- packages/appduct/src/cli/routes/events/since.ts | 2 +- packages/appduct/src/output.ts | 10 +++++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/appduct/src/cli/routes/events/since.ts b/packages/appduct/src/cli/routes/events/since.ts index bf9fa8c..cb69812 100644 --- a/packages/appduct/src/cli/routes/events/since.ts +++ b/packages/appduct/src/cli/routes/events/since.ts @@ -38,7 +38,7 @@ export const route: Route = async (context) => { env.stdout.write(`${renderEventLine(event, env.flags)}\n`); }, onCursor: (cursor) => { - env.stdout.write(`${renderEventsCursorLine(cursor, env.flags)}\n`); + env.stdout.write(`${renderEventsCursorLine(cursor, env.flags, selector)}\n`); }, }, ), diff --git a/packages/appduct/src/output.ts b/packages/appduct/src/output.ts index d79295c..9498347 100644 --- a/packages/appduct/src/output.ts +++ b/packages/appduct/src/output.ts @@ -743,13 +743,17 @@ export const renderEventLine = (event: EventNotification, flags: GlobalFlags): s /** Renders the trailing cursor line for `appduct events since` (issue #6): NDJSON under * `--json` so a scripted caller can parse the resume point without maxing `seq` over the printed - * events (impossible when the response is empty), a human note otherwise. */ -export const renderEventsCursorLine = (cursor: number, flags: GlobalFlags): string => { + * events (impossible when the response is empty), a human note otherwise. `selector` is the + * device/session selector the caller passed (issue #96): with two devices connected, a resume + * hint that dropped it would resolve to `ambiguous_session`, so it is echoed back in the hinted + * command when present. */ +export const renderEventsCursorLine = (cursor: number, flags: GlobalFlags, selector?: string): string => { if (flags.json) { // Same NDJSON rule as renderEventLine above: always one compact line, never `--pretty`. return JSON.stringify({ cursor }); } const colors = pc.createColors(flags.color); - return colors.dim(`cursor: ${cursor} (run "appduct events since ${cursor}" to resume from here)`); + const target = selector === undefined ? `${cursor}` : `${selector} ${cursor}`; + return colors.dim(`cursor: ${cursor} (run "appduct events since ${target}" to resume from here)`); }; From b05aea1630f03af5099ec997cac2b86c9058a228 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 08:32:14 +0000 Subject: [PATCH 15/16] docs: rejoin appduct sessions link split across a line break (#96) The removed `appduct link` form survived the noun-verb sweep because it wrapped across two lines, with "appduct" ending one line and "link" starting the next. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014wk7yigkPHWoWLy9nvocyt --- docs/ARCHITECTURE.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7bb0965..2de082e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -575,8 +575,8 @@ embedded JSON values, never NDJSON lines), `--verbose` (include the `meta` block default in both human and `--json` output), `--no-color`, `--state-dir`, `--daemon-restart` (force a version-drift restart, §4). -The deep-link scheme used to compose a link is resolved by `scheme.ts`, shared by `appduct -link`, `appduct mcp`, `appduct/client`'s `link()` and the MCP `appduct_connect` tool so +The deep-link scheme used to compose a link is resolved by `scheme.ts`, shared by +`appduct sessions link`, `appduct mcp`, `appduct/client`'s `link()` and the MCP `appduct_connect` tool so they cannot drift. First match wins: 1. the `--scheme` flag (or the equivalent programmatic option) From bef6b09c759a603f16e8236c6195472e4b979e0f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 08:35:50 +0000 Subject: [PATCH 16/16] chore(memory): note the CLI-rename sweep friction from #111 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014wk7yigkPHWoWLy9nvocyt --- .agents/memory/INBOX.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.agents/memory/INBOX.md b/.agents/memory/INBOX.md index f0b4bdc..a4c1fd8 100644 --- a/.agents/memory/INBOX.md +++ b/.agents/memory/INBOX.md @@ -23,3 +23,8 @@ One note per PR that hit friction, four lines: Would have prevented it: at the start, check for a simulator (xcrun, or an Android emulator with KVM) and, if there is none, ask the human up front to run e2e-device locally. Cost: blocked Seen: 2026-09-24 + +- 2026-09-25 #111 skill: implement-issue + What went wrong: sweeps for removed CLI forms grepped whole command patterns (`appduct `) and missed `playground:appduct -- link`, a command wrapped across a line break, and a removed flag named inside an output hint. + Would have prevented it: when renaming a CLI surface, grep the bare old words (`invoke`, `--since`) across the whole repo, multiline too, and check every hit by hand before the first review. + Cost: review rounds 2-4, blocked at the fix-round limit; E2E also blocked (no simulator in the cloud container)