Skip to content
Merged
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,33 @@ package versions for a release.
liveness probe now also reads `/proc/<pid>/status` on Linux and treats `State: Z` as dead;
everywhere `/proc` is absent or unreadable the previous behaviour is unchanged.

- **Breaking (CLI): `--json` output is compact by default.** Every `appduct <command> --json`
invocation used to pretty-print its JSON with 2-space indentation; it now prints it on a single
line (`JSON.stringify`, no whitespace). Any JSON parser is unaffected. A script that greps or
diffs the indented text directly is not — pass the new `--pretty` flag to restore the old
indentation.
- **Breaking (CLI): the `meta` block (`command`, `timestamp`, `duration_ms`) is no longer emitted
by default**, in either human or `--json` output. Pass the new `--verbose` flag to restore it —
the trailing `Meta` lines in human mode, the `meta` field on the `--json` envelope.
- **New: `--pretty` and `--verbose` global flags**, alongside `--json` and `--no-color`. See the
[`appduct` README](packages/appduct/README.md) for the full description of each.
- **Breaking (CLI): `appduct tools --json` for a listing now returns `{ tools, total }`** instead
of a bare array. The single-tool form (`appduct tools <selector> <name>`) is unchanged — it still
returns the bare tool descriptor.
- **New: a signature-based `tools` listing, with `--filter`/`--limit`/`--offset`.** The human
listing now shows one call signature (`name(params) -> result`) plus a one-line description per
tool instead of a bare name/description table, and `appduct tools` gains `--filter <text>` to
narrow by name/description, and `--limit <n>`/`--offset <n>` to page through a large registry —
making it cheap to read `appduct tools` against an app that registers hundreds of tools. See the
[`appduct` README](packages/appduct/README.md)'s "`appduct tools`: a signature per tool" section
for details.
- The `appduct` agent skill now defaults its example commands to plain-text output, adding
`--json` only where a script (not the agent itself) will parse the result.
- **Fixed:** a bad argument to `tools`, `invoke`, `revoke` or `events` (a missing `<tool>`, too
many positionals, `--limit 0`) crashed the CLI with a stack trace instead of printing a usage
error with exit code 64. A numeric flag given without a value (`--limit`, `--limit -1`,
`--since`, `--ttl`, `--timeout`) is now a usage error; it used to be read as `1`.

## 0.10.0 (2026-09-16)

- **New: native SDKs for apps without React Native.** The same Appduct core the React Native
Expand Down
12 changes: 9 additions & 3 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -262,7 +262,7 @@ Methods:
| `sessions.list` | — | `SessionSummary[]` |
| `sessions.describe` | `{ selector? }` | full session detail incl. device metadata, state timestamps, tool count |
| `sessions.revoke` | `{ selector? }` | `{ ok: true }` — closes socket (code 1000), frees alias |
| `tools.list` | `{ selector? }` | `ToolsListEntry[]` — `ToolDescriptor` (full schema + annotations) plus the tool's effective `policy: "allow" \| "deny" \| "prompt"` (§12), resolved daemon-side |
| `tools.list` | `{ selector?, filter?, limit?, offset? }` | `{ tools: ToolsListEntry[], total }` — `tools` is the registry sorted by `name` (code-point order), `filter`ed (case-insensitive substring match against name/description) and paged with `limit`/`offset`; each entry is a `ToolDescriptor` (full schema + annotations) plus the tool's effective `policy: "allow" \| "deny" \| "prompt"` (§12), resolved daemon-side. `total` is the filtered count *before* paging, so a caller can tell how much a page left out |
| `tools.call` | `{ selector?, name, args, timeoutMs?, caller?: "cli" \| "mcp", consent?: "client" \| "elicitation" }` | `{ result, callId }` on success — `callId` lets a caller with several in-flight calls match `tool_call_progress`/`tool_call_finished` events back to this call; JSON-RPC error with `data.type` preserving the wire error type on failure. `caller` attributes the audit record (§12); `consent` is the MCP server's evidence of a `"prompt"`-policy human gate (§12) — `"client"` (the flag-based gate) or `"elicitation"` (the elicitation-based gate), absent for the CLI. |
| `tools.cancel` | `{ selector?, callId, reason? }` | `{ cancelled: boolean }` — sends `tool_cancel` (§7) to the app for a still-pending call; `false` for an unknown/already-finished `callId` or no active socket (a no-op, not an error) |
| `events.subscribe` | `{ sessionSelector?, kinds? }` | `{ ok: true }`, then `event` notifications on this connection |
Expand Down Expand Up @@ -492,8 +492,14 @@ 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. Global flags: `--json` (machine output, NDJSON for streams),
`--no-color`, `--state-dir`, `--daemon-restart` (force a version-drift restart, §4).
which is where it stays current. `appduct tools`'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. 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
Expand Down
22 changes: 20 additions & 2 deletions packages/appduct/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,18 +39,36 @@ That's the whole loop. There is no host process to start — `appduct` auto-spaw
| `appduct keygen [--out <path>] [--force]` | generate a daemon private key, print its app pin |
| `appduct link [--ttl <s>] [--qr] [--open android\|ios-sim\|ios-device] [--device <id>] [--bundle-id <id>] [--scheme <s>]` | mint a pending session and print its deep link |
| `appduct ls` | list sessions: alias, state, device, tool count |
| `appduct tools [selector] [name] [--full]` | list a session's tools, or show one tool's full schema |
| `appduct tools [selector] [name] [--full] [--filter <text>] [--limit <n>] [--offset <n>]` | list a session's tools (one call signature + description per line), or show one tool's full schema |
| `appduct invoke [selector] <tool> --input '<json>' [--timeout <ms>]` | call a tool |
| `appduct events [selector] [--follow] [--since <cursor>]` | stream session/tool events (default), or one-shot pull everything retained since `<cursor>` (`--since`); `--json` emits NDJSON |
| `appduct revoke [selector]` | revoke a session |
| `appduct daemon run\|start\|stop\|status` | daemon lifecycle |
| `appduct mcp [--scheme <s>]` | start a stdio MCP server proxying connected apps' tools to MCP clients |
| `appduct doctor <artifact> [--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), `--no-color`, `--state-dir <path>` (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 <command> --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 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 <path>` (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 <command> --help` for the exact flags of any command.

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

### `appduct tools`: 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:

```
Tools
seed_cart(items: int, sku?: string, clear?: bool = true) -> { added: int, cartId: string }
Fill the cart with test items for the current user.
set_flag(name: "dark_mode" | "new_checkout", enabled: bool) [prompt]
Toggle a feature flag.

Run `appduct tools <name>` 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 `= <default>` 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 <name>`) still has it. A `[prompt]`/`[deny]` tag follows a tool whose effective policy isn't `"allow"`.

Use `--filter <text>` to narrow the listing to tools whose name or description contains `<text>` (case-insensitive), and `--limit <n>`/`--offset <n>` to page through it; a truncated listing prints a trailing `Showing n of total tools (offset o). Narrow with --filter <text> or page with --offset <n>.` line so you know more were left out. `appduct tools --json` returns `{ tools, total }` — `total` is the count after `--filter` but before `--limit`/`--offset`. `appduct tools <name>` (a single tool) is unaffected by any of this and always returns the bare tool descriptor.

### The deep-link scheme

`appduct link`, `appduct mcp`, and the MCP `appduct_connect` tool all resolve the scheme the same way, first match wins:
Expand Down
76 changes: 8 additions & 68 deletions packages/appduct/src/__tests__/__snapshots__/output.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,6 @@ Policy
Audit
Path /tmp/state/audit
Failed writes 3

Meta
Command: daemon status
Timestamp: 2026-03-17T10:00:00.000Z
Duration: 4 ms
"
`;

Expand All @@ -48,11 +43,6 @@ Audit
Size 1.5 MiB
Failed writes 0
Failed prunes 2

Meta
Command: daemon status
Timestamp: 2026-03-17T10:00:00.000Z
Duration: 4 ms
"
`;

Expand All @@ -61,14 +51,7 @@ exports[`output rendering > human errors render on stderr 1`] = `
Error
Type tool_execution_error
Message The tool handler threw.
Details {
"hint": "test"
}

Meta
Command: invoke
Timestamp: 2026-03-17T10:00:00.000Z
Duration: 2 ms
Details {"hint":"test"}
"
`;

Expand Down Expand Up @@ -96,24 +79,12 @@ MCP server entry

Next
1. Add \`import "@appduct/react-native/auto";\` to your app entry.

Meta
Command: init
Timestamp: 2026-03-17T10:00:00.000Z
Duration: 3 ms
"
`;

exports[`output rendering > invoke human output prints the raw tool result 1`] = `
"Result
{
"echoed": "hello"
}

Meta
Command: invoke
Timestamp: 2026-03-17T10:00:00.000Z
Duration: 6 ms
{"echoed":"hello"}
"
`;

Expand All @@ -122,11 +93,6 @@ exports[`output rendering > keygen success output includes the key path and fing
Key
Path /tmp/appduct-key.pem
Fingerprint sha256/example

Meta
Command: keygen
Timestamp: 2026-03-17T10:00:00.000Z
Duration: 3 ms
"
`;

Expand All @@ -138,62 +104,36 @@ Link
Endpoint wss://192.168.1.10:8443
Pin sha256/example
Expires 2026-03-17T10:00:30.000Z

Meta
Command: link
Timestamp: 2026-03-17T10:00:00.000Z
Duration: 5 ms
"
`;

exports[`output rendering > ls human output handles an empty session list 1`] = `
"Sessions
No Appduct sessions are registered.

Meta
Command: ls
Timestamp: 2026-03-17T10:00:00.000Z
Duration: 1 ms
"
`;

exports[`output rendering > ls human output includes alias, state, device, tools, and age 1`] = `
"Sessions
Alias State Device Tools Age
pixel-8 active Google Pixel 8 (Android 14) 3 1m

Meta
Command: ls
Timestamp: 2026-03-17T10:00:00.000Z
Duration: 2 ms
"
`;

exports[`output rendering > tools detail output renders full schema/annotations 1`] = `
"Tool: echo
Signature echo(...)
Description Echo a payload on the connected device.
Input schema {
"type": "object"
}
Annotations {
"readOnlyHint": true
}

Meta
Command: tools
Timestamp: 2026-03-17T10:00:00.000Z
Duration: 4 ms
Input schema {"type":"object"}
Annotations {"readOnlyHint":true}
"
`;

exports[`output rendering > tools list output stays structured 1`] = `
"Tools
Name Description
echo Echo a payload on the connected device.
echo(...) -> ...
Echo a payload on the connected device.

Meta
Command: tools
Timestamp: 2026-03-17T10:00:00.000Z
Duration: 4 ms
Run \`appduct tools <name>\` for a tool's full schema.
"
`;
32 changes: 32 additions & 0 deletions packages/appduct/src/__tests__/app-client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { describe, expect, test } from "vitest";

import { makeAppClient } from "../client/app-client.js";
import type { DaemonStream } from "../rpc/client.js";

const toolEntry = {
name: "ping",
description: "Ping.",
input_schema: { type: "object" },
policy: "allow",
};

const streamAnswering = (result: unknown): DaemonStream => {
return {
call: async <T>() => result as T,
onNotification: () => () => {},
onClose: () => () => {},
close: () => {},
};
};

describe("AppClient.tools()", () => {
test("unwraps the `{ tools, total }` tools.list result", async () => {
const client = makeAppClient(streamAnswering({ tools: [toolEntry], total: 1 }), "s1");
expect(await client.tools()).toEqual([toolEntry]);
});

test("still accepts a bare array from a daemon that predates `{ tools, total }`", async () => {
const client = makeAppClient(streamAnswering([toolEntry]), "s1");
expect(await client.tools()).toEqual([toolEntry]);
});
});
Loading
Loading