Skip to content

feat(cli): agent-friendly output — signature tools listing with filter/paging, table-driven global flags, --pretty/--verbose, compact JSON - #67

Merged
V3RON merged 8 commits into
mainfrom
claude/charming-noether-f7mm0t
Sep 21, 2026
Merged

V3RON merged 8 commits into
mainfrom
claude/charming-noether-f7mm0t

Conversation

@V3RON

@V3RON V3RON commented Sep 19, 2026

Copy link
Copy Markdown
Contributor

Why

The CLI's output is increasingly read by agents, where every token counts, and appduct tools had no answer for an app that registers hundreds of tools. Three things cost tokens on every invocation without informing anyone: the full input and output schema of every tool in the listing (about 300 tokens per tool as --json), 2-space indented JSON under --json, and a trailing Meta block that nothing acts on. This PR makes the listing a signature per tool, adds daemon-side filter and paging, makes the other two opt-in, and turns the global output flags into one declarative table so the next flag is a one-entry change.

Measured on a realistic Zod-exported tool: the old --json listing costs about 306 tokens per tool; the new text listing costs about 50.

What changed

appduct tools listing (commit 2)

A signature per tool. The text listing prints one call signature plus the first line of the description per tool:

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.

The renderer is renderToolSignature in @appduct/shared (packages/shared/src/domains/tool-signature.ts). It is pure and total: required and optional params, short defaults, enums, const, type arrays, T[], nested objects expanded one level, and ... for anything it cannot summarise (anyOf/oneOf/allOf/not/$ref, a non-object input root, a missing schema). Schema internals are never validated anywhere in the codebase, so every branch is defensive. A [prompt]/[deny] tag follows tools whose effective policy is not allow. Descriptions are cut to their first line and 120 characters.

--filter, --limit, --offset. Applied daemon-side in the tools.list handler: the registry is sorted by name with a code-point comparison (not localeCompare, so order does not depend on the daemon's locale), filtered by case-insensitive substring on name and description, and sliced. A truncated listing ends with Showing n of total tools (offset o). Narrow with --filter <text> or page with --offset <n>. The single-tool lookup (tools <name>, and the ambiguous single-arg probe) always asks for the unpaged registry so a name lookup can never miss because of paging; combining <name> with any of the three flags is a usage error.

tools.list returns { tools, total }. total is the post-filter, pre-paging count. The MCP server and appduct/client unwrap it; AppClient.tools() keeps returning ToolDescriptor[].

Skill. skills/appduct/SKILL.md now defaults every example to plain text, explains the signature format, ..., --filter and paging, and says --json is for scripts that parse output, not for the agent reading it. Text output is documented as not a stable contract.

Global flags (commit 1)

One table. cli/global-flags.ts declares --json, --pretty, --verbose and --no-color with their cac spec, help text, and how to derive each from parsed options or raw argv. create-cli.ts registers from it; dispatch.ts resolves it.

One environment object. RouteContext.io became RouteContext.env: CliEnv = { flags, stdout, stderr, clock }. A new flag added to the table is available as env.flags.<name> in every route with no plumbing change.

The envelope owns meta. cli/envelope.ts holds createCommandMeta and finalizeResult, which attaches meta only under --verbose and otherwise returns the result with no meta key. Rendering keys off presence.

One JSON formatter. formatJson is compact by default and indents under --pretty, for the --json result, the late-failure JSON on stderr, and JSON embedded in human output. NDJSON event lines stay compact regardless. The init MCP snippet stays indented because it is meant to be pasted.

Breaking changes (CLI only)

  • appduct tools --json for a listing returns { tools, total } instead of a bare array. The single-tool form is unchanged.
  • --json output is compact, one line. JSON parsers are unaffected; --pretty restores indentation.
  • The meta block is gone from both human and --json output by default. --verbose restores it.

No change to the app↔daemon wire protocol, the ToolDescriptor type, the SDKs, or the appduct/client public API. All documented in CHANGELOG.md under Unreleased, plus the README (new "appduct tools: a signature per tool" section) and ARCHITECTURE §5 and §10.

Tests

  • New: tool-signature.test.ts (30 table-driven cases including hostile schemas), global-flags.test.ts, envelope.test.ts, global-flags.integration.test.ts (daemon-free runCli coverage of every flag).
  • Daemon: sort, filter, total before paging, slicing, bad-param rejection in tool-invocation.integration.test.ts.
  • CLI end to end: a ~30-tool fake app in cli-v2.integration.test.ts covering --filter, --limit/--offset, name lookup under paging, the usage error, and the human signature and "Showing" lines.
  • Updated: output.test.ts and snapshot, runner.test.ts, router.test.ts, MCP, client and e2e tests for the { tools, total } shape.

Verification

pnpm -r typecheck                                   # pass
pnpm build                                          # pass
vitest packages/shared                              # 6 files, 268 passed
vitest packages/appduct --exclude e2e               # 46 files, 684 passed, 1 skipped
vitest packages/appduct/src/__tests__/e2e           # 12 files passed, 1 failed

The one e2e failure, daemon-restart.e2e.test.ts ("SIGKILL mid-session → next command auto-spawns a fresh daemon"), fails identically on an untouched build of main in this sandbox, so it is environmental and unrelated.

Smoke-tested the built dist/bin.js: tools --help lists the new flags; bogus --json is one line with no meta; --pretty indents; --verbose adds meta in both modes.

🤖 Generated with Claude Code

https://claude.ai/code/session_018zpvXWBBSJZnNVrqbzpFB4

@V3RON V3RON changed the title feat(cli): table-driven global flags, --pretty and --verbose; compact JSON and no meta by default feat(cli): agent-friendly output — signature tools listing with filter/paging, table-driven global flags, --pretty/--verbose, compact JSON Sep 19, 2026
claude and others added 8 commits September 21, 2026 10:23
… JSON and no meta by default

Replaces the scattered --json/--no-color plumbing with a single declarative
GlobalFlags table (cli/global-flags.ts) that also registers cac options and
resolves flags from argv for the pre-parse-failure fallback path. Adds two
new global flags, --pretty and --verbose, and collapses the ad-hoc `io`
bag into one CliEnv object (`env`) threaded through every route.

Centralizes the two pieces of logic that used to be duplicated across
runner.ts: the result envelope (cli/envelope.ts's finalizeResult, which
owns the `meta` block) and JSON serialization (global-flags.ts's
formatJson, which owns compact-vs-indented output).

Breaking (CLI):
- `--json` output is now compact (single-line) by default; `--pretty`
  restores the old 2-space indentation.
- The `meta` block (command/timestamp/duration_ms) is no longer emitted
  by default, in either human or --json output; `--verbose` restores it.

NDJSON event lines (`appduct events`) stay single-line always, regardless
of --pretty, per the streaming-consumer contract.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018zpvXWBBSJZnNVrqbzpFB4
…fset; agent skill defaults to plain text

`appduct tools` now renders each tool as a one-line call signature
(`name(params) -> result`, via new `@appduct/shared` renderToolSignature)
plus the description's first line, instead of a bare name/description
table, and gains --filter/--limit/--offset so a large registry stays
cheap for an agent to read. `tools.list` sorts by name, filters, and
pages daemon-side, and now returns `{ tools, total }` instead of a bare
array (a breaking CLI --json change) so the CLI can report how many
tools were left off a page. The appduct agent skill's example commands
default to plain text, adding --json only where a script will parse
the output.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018zpvXWBBSJZnNVrqbzpFB4
… verbatim

Parsing positionals and flags in a route body let a usage error escape the runner, so the built CLI crashed with a stack trace on `tools --limit 0`, `invoke` with no tool, or too many positionals. Parse inside the handler instead, before the version check.

cac never enforces a <value> placeholder, so `--limit -1` and a bare `--limit` arrived as true and were read as 1; reject them. cac also coerces numeric-looking values to numbers, which silently dropped `--filter 404` and turned `--filter 007` into 7; recover the verbatim text from argv, now carried on RouteContext.
…riptions

An --offset past the end printed "No tools registered" for a non-empty registry; it now says which offset was empty and how many tools match. The single-arg form that resolves to a tool name silently ignored --filter/--limit/--offset; it now fails the same way an explicit <selector> <name> does. The listed description summary now breaks on any line terminator, drops control characters, and never cuts through a surrogate pair.
…chemas

Nested array items recursed without bound (a cyclic items overflowed the stack), a BigInt const or default threw from JSON.stringify, and a throwing getter escaped. Cap array depth, stringify defensively, and fall back to name(...) on anything else. Enum and const literals are cut at 40 characters, and a property name that is not identifier-like is JSON-quoted so a newline or escape sequence cannot break or hijack the line.
appduct/client runs no daemon version check, so a newer client against an already-running older daemon got undefined from tools(). Accept both shapes.
The SDKs omit input_schema for a tool that takes no input, and the MCP
server already maps that to an empty object schema. Printing (...) sent
agents to fetch a full schema that does not exist.
…daemon

Read the bound wss port from daemon status now that test state dirs ask for
wssPort 0, and answer tools.list from the fake daemon with the sorted
{ tools, total } result.
@V3RON
V3RON force-pushed the claude/charming-noether-f7mm0t branch from 16279c0 to 1c54fbd Compare September 21, 2026 08:27
@V3RON
V3RON merged commit 7c1a4a1 into main Sep 21, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants