Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
06e964c
test: cover the noun-verb CLI surface (#96)
claude Sep 24, 2026
8bae42f
feat(cli): unify sessions/tools/events as noun-verb commands (#96)
claude Sep 24, 2026
5a6e4db
docs: switch to the noun-verb CLI forms everywhere (#96)
claude Sep 24, 2026
79f2248
test(cli): assert each noun's --help names its verbs (#96)
claude Sep 24, 2026
b8c4f61
fix(cli): print each noun's verbs on its own --help (#96)
claude Sep 24, 2026
d8775a0
fix(website): show tools call instead of the removed invoke form (#96)
claude Sep 24, 2026
4577d2e
docs: pair every removed CLI form with its replacement (#96)
claude Sep 24, 2026
2431d5f
docs: switch remaining agent skills to the noun-verb CLI forms (#96)
claude Sep 24, 2026
785ae53
docs: fix remaining playground/native old CLI forms (#96)
claude Sep 24, 2026
dbf4653
test(cli): pin the events-since resume hint to the new form (#96)
claude Sep 25, 2026
36b460c
fix(cli): point the events-since cursor hint at the new command (#96)
claude Sep 25, 2026
1bc0e66
docs: switch remaining invoke/bare-verb mentions to noun-verb forms (…
claude Sep 25, 2026
919ff03
test(cli): cover the events-since resume hint with a selector (#96)
claude Sep 25, 2026
0db8961
fix(cli): include the selector in the events-since resume hint (#96)
claude Sep 25, 2026
b05aea1
docs: rejoin appduct sessions link split across a line break (#96)
claude Sep 25, 2026
bef6b09
chore(memory): note the CLI-rename sweep friction from #111
claude Sep 25, 2026
07cf46a
Merge origin/main into claude/pensive-sagan-hoi3m2
claude Sep 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .agents/memory/INBOX.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,8 @@ One note per PR that hit friction, four lines:
What went wrong: the new no-tls-bypass lint rule's red tests covered only the literal spellings in the issue, so `vi.stubEnv(...)` and `globalAgent.options.rejectUnauthorized = false` got through.
Would have prevented it: when an issue asks a lint rule to catch "equivalent" forms, write a red test for each way the pattern can be spelled (assignment, call argument, member assignment, object property) before implementing.
Cost: review round

- 2026-09-25 #111 skill: implement-issue
What went wrong: sweeps for removed CLI forms grepped whole command patterns (`appduct <word>`) 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)
20 changes: 10 additions & 10 deletions .claude/skills/e2e-device/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,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
Expand All @@ -60,7 +60,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
Expand All @@ -76,13 +76,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.
Expand All @@ -108,7 +108,7 @@ gh pr edit <N> --body-file <updated body>
Target: iOS simulator (iPhone 17, iOS 26), Expo playground, commit <sha>
Smoke: SMOKE_OK
Feature:
$ pnpm playground:appduct -- invoke <tool> --input '{...}'
$ pnpm playground:appduct -- tools call <tool> --input '{...}'
<output>
```

Expand Down
2 changes: 1 addition & 1 deletion .claude/skills/writing-changelog/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>` and `appduct_list_tools` list one group; see
and `appduct tools ls --group <name>` and `appduct_list_tools` list one group; see
`docs/TOOLS.md`.
```

Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ section into a versioned heading.

## Unreleased

- **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 <name>`
is `tools describe <name>`, `invoke` is `tools call`, `events` is `events tail`, and
`events --since <cursor>` is `events since <cursor>`; 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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
43 changes: 28 additions & 15 deletions docs/ARCHITECTURE.md
Comment thread
V3RON marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -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.<platform>`
Expand Down Expand Up @@ -301,12 +301,12 @@ 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
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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 <name>`: the whole descriptor, `group`
included), and `appduct_call_tool` (`appduct invoke`: `{ selector?, name, args?, timeoutMs? }`).
every result), `appduct_describe_tool` (`appduct tools describe <name>`: 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
Expand Down Expand Up @@ -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 <noun> <verb> [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 <selector>`) 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
Expand All @@ -555,15 +568,15 @@ under `(ungrouped)`), and its "Showing n of total" footer names the top-level gr
counts so an agent narrows with `--group <name>` 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 `<name>`, 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 `<name>`, 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
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)
Expand Down Expand Up @@ -594,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.<platform>`, using the *same* walk-up
`scheme` uses (`findProjectConfigs`)
Expand Down Expand Up @@ -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`.
Expand Down Expand Up @@ -690,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
Expand Down
18 changes: 9 additions & 9 deletions docs/PROTOCOL.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ socket, and each gets its own session.
Deep link shape: `<scheme>:///?appduct=<base64url-no-padding>&pin=<sha256/...>`. 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
Expand All @@ -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 <app-id>`, 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 <udid> --payload-url <link>
<app-id>` hands the link to an installed, dev-signed app on a connected iOS 17+ device
Expand Down Expand Up @@ -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.

Expand All @@ -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 <name>`, `appduct_describe_tool` over
`annotations` are shown to agents as-is (`appduct tools describe <name>`, `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
Expand All @@ -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 <name>` and
is a daemon-side scheduling hint; agents see it through `appduct tools describe <name>` 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
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand Down
Loading
Loading