wehandoff 0.0.1 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,9 +1,15 @@
1
- # WeHandoff
1
+ # wehandoff
2
2
 
3
- > Hand off work between humans and AI agents.
3
+ The WeHandoff agent CLI a thin HTTP client over the WeHandoff API (lark-cli
4
+ style: resource + subcommand + method). Installs the `wehandoff` and `whf`
5
+ binaries (SPEC §16).
4
6
 
5
- A multi-agent collaboration platform where users, AI agents, and clients hand off work to each other inside shared chat groups. Tasks flow between participants like a baton in a relay — only the surface is a chat, the structure underneath is rigorous.
7
+ ```sh
8
+ npm install -g wehandoff
9
+ whf --help
10
+ ```
6
11
 
7
- **Coming soon.** This is a name reservation. Real package will follow.
8
-
9
- [wehandoff.ai](https://wehandoff.ai)
12
+ The source of truth is `bin/wehandoff.mjs` + `bin/_whf/*.mjs` at the repo root;
13
+ `prepublishOnly` (`scripts/sync-bin.mjs`) syncs them into `bin/` here at publish
14
+ time (`apps/cli/bin/` is a gitignored build artifact). No Supabase access — the
15
+ CLI authenticates with a token and talks to the API only.
@@ -0,0 +1,60 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+
5
+ export function resolveToken(env = process.env, readCache = defaultReadCache) {
6
+ const fromEnv = env.WEHANDOFF_CLI_TOKEN;
7
+ if (fromEnv && fromEnv.trim()) return fromEnv.trim();
8
+ const cached = readCache();
9
+ return cached && cached.trim() ? cached.trim() : null;
10
+ }
11
+
12
+ function defaultReadCache() {
13
+ try {
14
+ return readFileSync(join(homedir(), '.wehandoff', 'cli-token'), 'utf8');
15
+ } catch {
16
+ return null;
17
+ }
18
+ }
19
+
20
+ export function resolveBaseUrl(env = process.env) {
21
+ return (env.WEHANDOFF_APP_URL ?? 'http://localhost:3000').replace(/\/$/, '');
22
+ }
23
+
24
+ export async function callApi({ method, path, body, headers }, { token, baseUrl, fetchImpl = fetch }) {
25
+ const res = await fetchImpl(`${baseUrl}${path}`, {
26
+ method,
27
+ headers: {
28
+ Authorization: `Bearer ${token}`,
29
+ ...(body ? { 'Content-Type': 'application/json' } : {}),
30
+ ...(headers ?? {}),
31
+ },
32
+ ...(body ? { body: JSON.stringify(body) } : {}),
33
+ });
34
+ if (!res.ok) throw new Error(await res.text());
35
+ return res.json();
36
+ }
37
+
38
+ // Raw call — returns { status, ok, json } WITHOUT throwing on non-2xx, so a
39
+ // caller can branch on an expected status (e.g. `push` reacting to a 409 CAS
40
+ // miss to drive the 3-way merge) instead of string-matching an error message.
41
+ // `json` is the parsed body when present, else the raw text under { error }.
42
+ export async function callApiRaw({ method, path, body, headers }, { token, baseUrl, fetchImpl = fetch }) {
43
+ const res = await fetchImpl(`${baseUrl}${path}`, {
44
+ method,
45
+ headers: {
46
+ Authorization: `Bearer ${token}`,
47
+ ...(body ? { 'Content-Type': 'application/json' } : {}),
48
+ ...(headers ?? {}),
49
+ },
50
+ ...(body ? { body: JSON.stringify(body) } : {}),
51
+ });
52
+ const text = await res.text();
53
+ let json;
54
+ try {
55
+ json = text ? JSON.parse(text) : null;
56
+ } catch {
57
+ json = { error: text };
58
+ }
59
+ return { status: res.status, ok: res.ok, json };
60
+ }
@@ -0,0 +1,503 @@
1
+ /**
2
+ * whf command registry — single source of truth for dispatch, `which`, and `--help`.
3
+ *
4
+ * Design: docs/working/2026-06-14-whf-cli-redesign.md §2/§3.
5
+ *
6
+ * Phase 1 (this file) ships the METADATA for the whole surface so `which` and
7
+ * the generated help are complete from day one, plus the three discoverability
8
+ * surfaces (`which`, per-verb `--help`, top-level help). Dispatch still flows
9
+ * through the legacy if-chain in wehandoff.mjs; verbs are migrated onto real
10
+ * `handler`s opportunistically (redesign §2 migration strategy, fork C-lean).
11
+ *
12
+ * An entry with no `handler` is metadata-only: it documents an existing legacy
13
+ * verb. New verbs (Tiers 1/3/4) will carry a `handler` and dispatch registry-first.
14
+ *
15
+ * @typedef {'string'|'int'|'bool'|'json'|'file'} FlagType
16
+ * @typedef {{ type: FlagType, required?: boolean, default?: any, help: string }} FlagSpec
17
+ * @typedef {{
18
+ * resource: string, verb?: string, summary: string,
19
+ * side: 'read'|'write'|'local', stability: 'stable'|'stub',
20
+ * flags?: Record<string, FlagSpec>,
21
+ * positionals?: { name: string, required?: boolean, variadic?: boolean, help: string }[],
22
+ * output: string, backend: string,
23
+ * ownerGated?: boolean, constitution?: 'proposal-only',
24
+ * handler?: (ctx: object) => Promise<object>,
25
+ * }} CommandEntry
26
+ */
27
+
28
+ const F = (type, help, extra = {}) => ({ type, help, ...extra });
29
+
30
+ /** @type {CommandEntry[]} */
31
+ export const COMMANDS = [
32
+ // ── message ───────────────────────────────────────────────────────────────
33
+ { resource: 'message', verb: 'send', summary: 'Post a message to a channel', side: 'write', stability: 'stable',
34
+ flags: { '--channel': F('string', 'channel id', { required: true }), '--text': F('string', 'message body', { required: true }), '--as-agent': F('string', 'post as this agent') },
35
+ output: 'the persisted Message', backend: 'POST /api/cli/channels/<id>/messages' },
36
+ { resource: 'message', verb: 'reply', summary: 'Reply to a message in a channel', side: 'write', stability: 'stable',
37
+ flags: { '--channel': F('string', 'channel id', { required: true }), '--to': F('string', 'parent message id', { required: true }), '--text': F('string', 'reply body', { required: true }), '--as-agent': F('string', 'post as this agent') },
38
+ output: 'the persisted Message', backend: 'POST /api/cli/channels/<id>/messages' },
39
+ { resource: 'message', verb: 'list', summary: 'List messages in a channel', side: 'read', stability: 'stable',
40
+ flags: { '--channel': F('string', 'channel id', { required: true }), '--limit': F('int', 'max rows', { default: 50 }) },
41
+ output: '{ messages: Message[] }', backend: 'GET /api/cli/channels/<id>/messages' },
42
+ { resource: 'message', verb: 'get', summary: 'Get one message', side: 'read', stability: 'stable',
43
+ flags: { '--channel': F('string', 'channel id', { required: true }), '--msg': F('string', 'message id', { required: true }) },
44
+ output: 'the Message', backend: 'GET /api/cli/channels/<id>/messages/<msgId>' },
45
+ { resource: 'message', verb: 'edit', summary: 'Edit a message body', side: 'write', stability: 'stable', ownerGated: true,
46
+ flags: { '--channel': F('string', 'channel id', { required: true }), '--msg': F('string', 'message id', { required: true }), '--text': F('string', 'new body', { required: true }) },
47
+ output: 'the updated Message', backend: 'PATCH /api/cli/channels/<id>/messages/<msgId>' },
48
+ { resource: 'message', verb: 'delete', summary: 'Delete a message', side: 'write', stability: 'stable', ownerGated: true,
49
+ flags: { '--channel': F('string', 'channel id', { required: true }), '--msg': F('string', 'message id', { required: true }) },
50
+ output: '{ deleted: true }', backend: 'DELETE /api/cli/channels/<id>/messages/<msgId>' },
51
+ { resource: 'message', verb: 'search', summary: 'Full-text search messages (SPEC §16 — backend deferred)', side: 'read', stability: 'stub',
52
+ flags: { '--channel': F('string', 'restrict to a channel'), '--limit': F('int', 'max rows', { default: 50 }) },
53
+ positionals: [{ name: 'query', required: true, help: 'search text' }],
54
+ output: '{ matches: Message[] }', backend: 'NEW BACKEND: FTS engine (engine choice DEFERRED §16)' },
55
+ { resource: 'message', verb: 'forward', summary: 'Forward a message to an L3 session (soft-interrupt)', side: 'write', stability: 'stable',
56
+ flags: { '--channel': F('string', 'source room id', { required: true }), '--msg': F('string', 'message id', { required: true }), '--to-session': F('string', 'target L3 session id', { required: true }), '--note': F('string', 'private interpretation (markdown)') },
57
+ output: '{ forwarded: true, target_session_id }', backend: 'POST /api/cli/messages/<msgId>/forward → persistForward (SPEC §1.5)' },
58
+ { resource: 'message', verb: 'merge-forward', summary: 'Merge-forward multiple messages (SPEC §16 — not yet built)', side: 'write', stability: 'stub',
59
+ flags: { '--channel': F('string', 'source channel id', { required: true }), '--msgs': F('string', 'comma-separated message ids', { required: true }), '--to': F('string', 'destination channel id', { required: true }) },
60
+ output: 'the merged Message', backend: 'NEW BACKEND' },
61
+ { resource: 'message', verb: 'download', summary: 'Download a message attachment (SPEC §16 — not yet built)', side: 'read', stability: 'stub',
62
+ flags: { '--channel': F('string', 'channel id', { required: true }), '--msg': F('string', 'message id', { required: true }) },
63
+ output: '{ path: string }', backend: 'NEW BACKEND: attachment fetch (pre-0020 dead code)' },
64
+
65
+ // ── thread ─────────────────────────────────────────────────────────────────
66
+ { resource: 'thread', verb: 'messages', summary: 'List messages under a forum thread root (SPEC §16 — by-root query missing)', side: 'read', stability: 'stub',
67
+ flags: { '--root': F('string', 'thread root message id', { required: true }) },
68
+ output: '{ messages: Message[] }', backend: 'NEW BACKEND: by-root query (interim: `message list --channel <threadId>`)' },
69
+
70
+ // ── reaction ─────────────────────────────────────────────────────────────────
71
+ { resource: 'reaction', verb: 'add', summary: 'Add an emoji reaction', side: 'write', stability: 'stable',
72
+ flags: { '--channel': F('string', 'channel id', { required: true }), '--msg': F('string', 'message id', { required: true }), '--emoji': F('string', 'emoji', { required: true }), '--as-agent': F('string', 'react as this agent') },
73
+ output: 'the reaction summary', backend: 'POST /api/cli/channels/<id>/messages/<msgId>/reactions' },
74
+ { resource: 'reaction', verb: 'remove', summary: 'Remove an emoji reaction', side: 'write', stability: 'stable',
75
+ flags: { '--channel': F('string', 'channel id', { required: true }), '--msg': F('string', 'message id', { required: true }), '--emoji': F('string', 'emoji', { required: true }), '--as-agent': F('string', 'react as this agent') },
76
+ output: 'the reaction summary', backend: 'DELETE /api/cli/channels/<id>/messages/<msgId>/reactions' },
77
+ { resource: 'reaction', verb: 'list', summary: 'List reactions on a message', side: 'read', stability: 'stable',
78
+ flags: { '--channel': F('string', 'channel id', { required: true }), '--msg': F('string', 'message id', { required: true }) },
79
+ output: '{ reactions: Reaction[] }', backend: 'GET /api/cli/channels/<id>/messages/<msgId>/reactions' },
80
+ { resource: 'reaction', verb: 'batch', summary: 'List reactions for several messages at once', side: 'read', stability: 'stable',
81
+ flags: { '--channel': F('string', 'channel id', { required: true }), '--msgs': F('string', 'comma-separated message ids', { required: true }) },
82
+ output: '{ reactions: Record<msgId, Reaction[]> }', backend: 'GET /api/cli/channels/<id>/reactions/batch' },
83
+
84
+ // ── pin ─────────────────────────────────────────────────────────────────────
85
+ { resource: 'pin', verb: 'add', summary: 'Pin a message', side: 'write', stability: 'stable',
86
+ flags: { '--channel': F('string', 'channel id', { required: true }), '--msg': F('string', 'message id', { required: true }) },
87
+ output: '{ pinned: true }', backend: 'POST /api/cli/channels/<id>/pins' },
88
+ { resource: 'pin', verb: 'remove', summary: 'Unpin a message', side: 'write', stability: 'stable',
89
+ flags: { '--channel': F('string', 'channel id', { required: true }), '--msg': F('string', 'message id', { required: true }) },
90
+ output: '{ pinned: false }', backend: 'DELETE /api/cli/channels/<id>/pins' },
91
+ { resource: 'pin', verb: 'list', summary: 'List pinned messages', side: 'read', stability: 'stable',
92
+ flags: { '--channel': F('string', 'channel id', { required: true }) },
93
+ output: '{ pins: Message[] }', backend: 'GET /api/cli/channels/<id>/pins' },
94
+
95
+ // ── room (canonical; `channel` is a back-compat ALIAS → see RESOURCE_ALIASES) ──
96
+ // DEC-191 renamed the `channel` entity to `room` system-wide; `room` is the
97
+ // canonical CLI noun. Legacy `whf channel …` still resolves via the alias map.
98
+ // The id flag stays `--channel` for script compat (flag names are not renamed).
99
+ { resource: 'room', verb: 'create', summary: 'Create a room', side: 'write', stability: 'stable',
100
+ flags: { '--name': F('string', 'room name', { required: true }), '--type': F('string', 'text|forum (default text)') },
101
+ output: 'the created Room', backend: 'POST /api/cli/groups' },
102
+ { resource: 'room', verb: 'list', summary: 'List rooms you can see', side: 'read', stability: 'stable',
103
+ output: '{ channels: Room[] }', backend: 'GET /api/cli/groups' },
104
+ { resource: 'room', verb: 'get', summary: 'Get one room', side: 'read', stability: 'stable',
105
+ flags: { '--channel': F('string', 'room id', { required: true }) },
106
+ output: 'the Room', backend: 'GET /api/cli/groups/<id>' },
107
+ { resource: 'room', verb: 'members', summary: 'List room members', side: 'read', stability: 'stable',
108
+ flags: { '--channel': F('string', 'room id', { required: true }) },
109
+ output: '{ members: Member[] }', backend: 'GET /api/cli/groups/<id>?with=members' },
110
+ { resource: 'room', verb: 'agents', summary: 'List room agents', side: 'read', stability: 'stable',
111
+ flags: { '--channel': F('string', 'room id', { required: true }) },
112
+ output: '{ agents: Agent[] }', backend: 'GET /api/cli/groups/<id>?with=members' },
113
+ { resource: 'room', verb: 'update', summary: 'Rename a room', side: 'write', stability: 'stable',
114
+ flags: { '--channel': F('string', 'room id', { required: true }), '--name': F('string', 'new name', { required: true }) },
115
+ output: 'the updated Room', backend: 'PATCH /api/cli/groups/<id>' },
116
+ { resource: 'room', verb: 'delete', summary: 'Delete a room (soft; --hard to purge)', side: 'write', stability: 'stable', ownerGated: true,
117
+ flags: { '--channel': F('string', 'room id', { required: true }), '--hard': F('bool', 'hard delete') },
118
+ output: '{ deleted: true }', backend: 'DELETE /api/cli/groups/<id>/delete[?hard=1]' },
119
+ { resource: 'room', verb: 'remove-member', summary: 'Remove a member from a room', side: 'write', stability: 'stable',
120
+ flags: { '--channel': F('string', 'room id', { required: true }), '--member': F('string', 'member id', { required: true }), '--member-type': F('string', 'user|agent') },
121
+ output: '{ removed: true }', backend: 'DELETE /api/cli/groups/<id>' },
122
+ { resource: 'room', verb: 'add-agent', summary: 'Add an agent to a room', side: 'write', stability: 'stable',
123
+ flags: { '--channel': F('string', 'room id', { required: true }), '--agent': F('string', 'agent id', { required: true }) },
124
+ output: '{ added: true }', backend: 'POST /api/cli/groups/<id>/agents' },
125
+ { resource: 'room', verb: 'add-member', summary: 'Add a user to a room', side: 'write', stability: 'stable',
126
+ flags: { '--channel': F('string', 'room id', { required: true }), '--user': F('string', 'user id', { required: true }) },
127
+ output: '{ added: true }', backend: 'POST /api/cli/groups/<id>/members' },
128
+ { resource: 'room', verb: 'search', summary: 'Full-text search rooms (SPEC §16 — backend deferred)', side: 'read', stability: 'stub',
129
+ flags: { '--limit': F('int', 'max rows', { default: 50 }) },
130
+ positionals: [{ name: 'query', required: true, help: 'search text' }],
131
+ output: '{ channels: Room[] }', backend: 'NEW BACKEND: FTS engine (DEFERRED §16)' },
132
+
133
+ // ── forum ────────────────────────────────────────────────────────────────────
134
+ { resource: 'forum', verb: 'post', summary: 'Open a forum thread', side: 'write', stability: 'stable',
135
+ flags: { '--forum': F('string', 'forum id', { required: true }), '--title': F('string', 'thread title', { required: true }), '--body': F('string', 'thread body', { required: true }), '--as-agent': F('string', 'post as this agent') },
136
+ output: 'the created thread root Message', backend: 'POST /api/cli/forums/<id>/threads' },
137
+
138
+ // ── ask / inbox (reverse-ask, SPEC §15) ──────────────────────────────────────
139
+ { resource: 'ask', summary: 'Reverse-ask a human with structured options (ask_card)', side: 'write', stability: 'stable',
140
+ flags: { '--question': F('string', 'the question', { required: true }), '--options': F('string', 'pipe-separated options "a|b|c"', { required: true }), '--channel': F('string', 'publish a card to this channel'), '--as-agent': F('string', 'ask as this agent'), '--user': F('string', 'target user'), '--session': F('string', 'inbox-only session form') },
141
+ output: 'the created ask_card / inbox item', backend: 'POST /api/cli/asks' },
142
+ { resource: 'inbox', verb: 'push', summary: 'Push an inbox item (ask card) to a user', side: 'write', stability: 'stable',
143
+ flags: { '--title': F('string', 'item title', { required: true }), '--options': F('string', 'pipe-separated options', { required: true }), '--user': F('string', 'target user'), '--issue': F('string', 'link an issue'), '--preview': F('string', 'link a preview'), '--payload': F('json', 'extra JSON payload') },
144
+ output: 'the created InboxItem', backend: 'POST /api/cli/inbox' },
145
+ { resource: 'inbox', verb: 'list', summary: 'List your inbox items', side: 'read', stability: 'stable',
146
+ flags: { '--unresolved': F('bool', 'only unresolved items') },
147
+ output: '{ items: InboxItem[] }', backend: 'GET /api/cli/inbox → inbox.ts listInboxItems (Tier 1)',
148
+ handler: async ({ args, token, baseUrl, callApi }) =>
149
+ callApi({ method: 'GET', path: `/api/cli/inbox${args.unresolved ? '?unresolved=1' : ''}` }, { token, baseUrl }) },
150
+ { resource: 'inbox', verb: 'resolve', summary: 'Resolve an inbox item by choosing an option', side: 'write', stability: 'stable', ownerGated: true,
151
+ flags: { '--item': F('string', 'inbox item id', { required: true }), '--choice': F('string', 'chosen option id', { required: true }) },
152
+ output: 'the resolved InboxItem', backend: 'POST /api/cli/inbox/<id>/resolve → inbox.ts resolveInboxItem (Tier 1); ownerGated',
153
+ handler: async ({ args, token, baseUrl, callApi }) =>
154
+ callApi({ method: 'POST', path: `/api/cli/inbox/${encodeURIComponent(args.item)}/resolve`, body: { choice: args.choice } }, { token, baseUrl }) },
155
+
156
+ // ── task (canonical; `issue` is a back-compat ALIAS → see RESOURCE_ALIASES) ────
157
+ // DEC-191 renamed the `issue` entity to `task` system-wide; `task` is the
158
+ // canonical CLI noun. Legacy `whf issue …` still resolves via the alias map.
159
+ { resource: 'task', verb: 'list', summary: 'List tasks in a project or workspace', side: 'read', stability: 'stable',
160
+ flags: { '--project': F('string', 'project id (xor --workspace)'), '--workspace': F('string', 'workspace id'), '--limit': F('int', 'max rows', { default: 50 }) },
161
+ output: '{ tasks: Task[] }', backend: 'GET /api/cli/tasks → queries.ts listTasks / listWorkspaceTasks (Tier 1)',
162
+ handler: async ({ args, token, baseUrl, callApi }) => {
163
+ if (!args.project && !args.workspace) throw new Error('usage: whf task list --project <id> | --workspace <id>');
164
+ const qs = args.project
165
+ ? `project=${encodeURIComponent(args.project)}`
166
+ : `workspace=${encodeURIComponent(args.workspace)}`;
167
+ return callApi({ method: 'GET', path: `/api/cli/tasks?${qs}&limit=${args.limit ?? 50}` }, { token, baseUrl });
168
+ } },
169
+ { resource: 'task', verb: 'create', summary: 'Create a task', side: 'write', stability: 'stable',
170
+ flags: { '--title': F('string', 'task title', { required: true }), '--description': F('string', 'body'), '--as-agent': F('string', 'create as this agent') },
171
+ output: 'the created Task', backend: 'POST /api/cli/tasks' },
172
+ { resource: 'task', verb: 'get', summary: 'Get one task', side: 'read', stability: 'stable',
173
+ flags: { '--issue': F('string', 'task id', { required: true }) },
174
+ output: 'the Task', backend: 'GET /api/cli/tasks/<id>' },
175
+ { resource: 'task', verb: 'update', summary: 'Update task title/body (status via writeback)', side: 'write', stability: 'stable', ownerGated: true,
176
+ flags: { '--issue': F('string', 'task id', { required: true }), '--title': F('string', 'new title'), '--description': F('string', 'new body') },
177
+ output: 'the updated Task', backend: 'PATCH /api/cli/tasks/<id>' },
178
+ { resource: 'task', verb: 'delete', summary: 'Delete a task', side: 'write', stability: 'stable', ownerGated: true,
179
+ flags: { '--issue': F('string', 'task id', { required: true }) },
180
+ output: '{ deleted: true }', backend: 'DELETE /api/cli/tasks/<id>' },
181
+ { resource: 'task', verb: 'comment', summary: 'Add an activity-feed comment / status change', side: 'write', stability: 'stable',
182
+ flags: { '--issue': F('string', 'task id', { required: true }), '--body': F('string', 'comment body'), '--status': F('string', 'new status'), '--artifact': F('string', 'link an artifact'), '--preview': F('string', 'link a preview'), '--us': F('string', 'link user stories (comma ids)'), '--as-agent': F('string', 'comment as this agent') },
183
+ output: 'the created comment', backend: 'POST /api/cli/tasks/<id>/comment' },
184
+ { resource: 'task', verb: 'comment-delete', summary: 'Delete your own / your agent\'s comment', side: 'write', stability: 'stable', ownerGated: true,
185
+ flags: { '--issue': F('string', 'task id', { required: true }), '--comment': F('string', 'comment id', { required: true }) },
186
+ output: '{ deleted: true }', backend: 'DELETE /api/cli/tasks/<id>/comment/<commentId>' },
187
+ { resource: 'task', verb: 'comments', summary: 'List the activity feed (comments + image hrefs) for a task', side: 'read', stability: 'stable', ownerGated: true,
188
+ flags: { '--issue': F('string', 'task id', { required: true }) },
189
+ output: '{ comments: ActivityCommentData[] } — image artifacts include artifact.href (public URL)', backend: 'GET /api/cli/tasks/<id>/comments' },
190
+ { resource: 'task', verb: 'writeback', summary: 'Write back status / deploy-url / commit', side: 'write', stability: 'stable',
191
+ flags: { '--issue': F('string', 'task id', { required: true }), '--status': F('string', 'new status'), '--deploy-url': F('string', 'deploy url'), '--commit': F('string', 'commit sha') },
192
+ output: 'the updated Task', backend: 'POST /api/cli/tasks/<id>/writeback' },
193
+ { resource: 'task', verb: 'push-card', summary: 'Push a task card to a channel', side: 'write', stability: 'stable',
194
+ flags: { '--issue': F('string', 'task id', { required: true }), '--channel': F('string', 'channel id', { required: true }), '--as-agent': F('string', 'push as this agent') },
195
+ output: 'the pushed card', backend: 'POST /api/cli/tasks/<id>/push-card' },
196
+ { resource: 'task', verb: 'push-verification-card', summary: 'Push a verification card to a channel', side: 'write', stability: 'stable',
197
+ flags: { '--issue': F('string', 'task id', { required: true }), '--channel': F('string', 'channel id', { required: true }), '--preview-url': F('string', 'preview url'), '--note': F('string', 'markdown note'), '--as-agent': F('string', 'push as this agent') },
198
+ output: 'the pushed card', backend: 'POST /api/cli/tasks/<id>/push-verification-card' },
199
+
200
+ // ── artifact ─────────────────────────────────────────────────────────────────
201
+ { resource: 'artifact', verb: 'create', summary: 'Create an artifact (html/md/text)', side: 'write', stability: 'stable',
202
+ flags: { '--kind': F('string', 'html|md|text', { required: true }), '--title': F('string', 'title'), '--content': F('string', 'inline content'), '--file': F('file', 'read content from file'), '--project': F('string', 'project id'), '--as-agent': F('string', 'create as this agent') },
203
+ output: 'the created Artifact', backend: 'POST /api/cli/artifacts' },
204
+ { resource: 'artifact', verb: 'get', summary: 'Get one artifact by id', side: 'read', stability: 'stable',
205
+ flags: { '--id': F('string', 'artifact id', { required: true }) },
206
+ output: 'the Artifact', backend: 'GET /api/cli/artifacts/<id> → queries.ts getArtifactById (Tier 1)',
207
+ handler: async ({ args, token, baseUrl, callApi }) =>
208
+ callApi({ method: 'GET', path: `/api/cli/artifacts/${encodeURIComponent(args.id)}` }, { token, baseUrl }) },
209
+ { resource: 'artifact', verb: 'list', summary: 'List artifacts in a project', side: 'read', stability: 'stable',
210
+ flags: { '--project': F('string', 'project id', { required: true }) },
211
+ output: '{ artifacts: Artifact[] }', backend: 'GET /api/cli/artifacts?project= → queries.ts listArtifacts (Tier 1)',
212
+ handler: async ({ args, token, baseUrl, callApi }) =>
213
+ callApi({ method: 'GET', path: `/api/cli/artifacts?project=${encodeURIComponent(args.project)}` }, { token, baseUrl }) },
214
+
215
+ // ── annotation (SPEC §7 — Tier 4, db lib exists, CLI route NOT wired) ─────────
216
+ // The annotations-write.ts lib (listAnnotations / setAnnotationStatus /
217
+ // deleteAnnotation) is in place, but NO `/api/cli/annotations*` route exists yet,
218
+ // so every verb here would 404. Marked `stub` (honest not-yet-available, NO.2099)
219
+ // until the thin /api/cli proxy is wired — see the audit doc F10.
220
+ { resource: 'annotation', verb: 'list', summary: 'List annotations on a preview (route not yet wired)', side: 'read', stability: 'stub',
221
+ flags: { '--preview': F('string', 'preview id', { required: true }) },
222
+ output: '{ annotations: Annotation[] }', backend: 'NOT WIRED: GET /api/cli/annotations missing; lib annotations-write.ts listAnnotations exists (Tier 4)' },
223
+ { resource: 'annotation', verb: 'create', summary: 'Pin an annotation on a preview (DEFERRED — anchor needs a rendered preview)', side: 'write', stability: 'stub',
224
+ flags: { '--preview': F('string', 'preview id', { required: true }), '--body': F('string', 'comment body', { required: true }) },
225
+ output: 'the created Annotation', backend: 'DEFERRED: anchor is a DOM AnchorBundle built in the rendered preview UI, not synthesizable from the CLI (Tier 4)' },
226
+ { resource: 'annotation', verb: 'status', summary: 'Set annotation status (open|resolved) — route not yet wired', side: 'write', stability: 'stub',
227
+ flags: { '--id': F('string', 'annotation id', { required: true }), '--status': F('string', 'open|resolved', { required: true }) },
228
+ output: 'the updated Annotation', backend: 'NOT WIRED: PATCH /api/cli/annotations/<id> missing; lib setAnnotationStatus exists (Tier 4)' },
229
+ { resource: 'annotation', verb: 'delete', summary: 'Delete your own annotation (route not yet wired)', side: 'write', stability: 'stub', ownerGated: true,
230
+ flags: { '--id': F('string', 'annotation id', { required: true }) },
231
+ output: '{ deleted: true }', backend: 'NOT WIRED: DELETE /api/cli/annotations/<id> missing; lib deleteAnnotation exists (Tier 4)' },
232
+
233
+ // ── preview (SPEC — Tier 4; db lib exists, CLI route NOT wired) ───────────────
234
+ // previews-queries.ts (listPreviewsByProject / getPreview / listLineageVersions)
235
+ // is in place, but NO `/api/cli/previews*` route exists yet → every verb 404s.
236
+ // Marked `stub` (honest not-yet-available, NO.2099) until the thin proxy is wired.
237
+ { resource: 'preview', verb: 'list', summary: 'List previews in a project (route not yet wired)', side: 'read', stability: 'stub',
238
+ flags: { '--project': F('string', 'project id', { required: true }) },
239
+ output: '{ previews: Preview[] }', backend: 'NOT WIRED: GET /api/cli/previews missing; lib listPreviewsByProject exists (Tier 4)' },
240
+ { resource: 'preview', verb: 'get', summary: 'Get one preview by id (route not yet wired)', side: 'read', stability: 'stub',
241
+ flags: { '--id': F('string', 'preview id', { required: true }) },
242
+ output: 'the Preview', backend: 'NOT WIRED: GET /api/cli/previews/<id> missing; lib getPreview exists (Tier 4)' },
243
+ { resource: 'preview', verb: 'versions', summary: 'List versions in a preview lineage (route not yet wired)', side: 'read', stability: 'stub',
244
+ flags: { '--id': F('string', 'any preview id in the lineage', { required: true }) },
245
+ output: '{ versions: Preview[] }', backend: 'NOT WIRED: GET /api/cli/previews/<id>/versions missing; lib listLineageVersions exists (Tier 4)' },
246
+ { resource: 'preview', verb: 'publish', summary: 'Mint a preview from a real deploy url (DEFERRED pending Ray, fork A)', side: 'write', stability: 'stub',
247
+ flags: { '--project': F('string', 'project id', { required: true }), '--deploy-url': F('string', 'live deploy url', { required: true }) },
248
+ output: 'the created Preview', backend: 'DEFERRED (fork A): POST /api/cli/previews → previews.ts createPreview, only against a real deploy url (Tier 4)' },
249
+
250
+ // ── service ──────────────────────────────────────────────────────────────────
251
+ { resource: 'service', verb: 'search', summary: 'Search services', side: 'read', stability: 'stable',
252
+ positionals: [{ name: 'query', required: true, help: 'search text' }],
253
+ output: '{ services: Service[] }', backend: 'GET /api/cli/services/search' },
254
+ { resource: 'service', verb: 'create', summary: 'Create a service offering for an agent', side: 'write', stability: 'stable',
255
+ flags: { '--agent': F('string', 'agent id', { required: true }), '--title': F('string', 'service title', { required: true }), '--description': F('string', 'body'), '--skill': F('string', 'skill name') },
256
+ output: 'the created Service', backend: 'POST /api/cli/services' },
257
+ { resource: 'service', verb: 'publish', summary: 'Publish a service', side: 'write', stability: 'stable',
258
+ flags: { '--service': F('string', 'service id', { required: true }) },
259
+ output: 'the updated Service', backend: 'POST /api/cli/services/<id>/publish' },
260
+ { resource: 'service', verb: 'unpublish', summary: 'Unpublish a service', side: 'write', stability: 'stable',
261
+ flags: { '--service': F('string', 'service id', { required: true }) },
262
+ output: 'the updated Service', backend: 'POST /api/cli/services/<id>/unpublish' },
263
+ { resource: 'service', verb: 'list', summary: 'List services', side: 'read', stability: 'stable',
264
+ output: '{ services: Service[] }', backend: 'GET /api/cli/services' },
265
+
266
+ // ── matching ─────────────────────────────────────────────────────────────────
267
+ { resource: 'matching', verb: 'start', summary: 'One-shot matchmaking: create forum room + source candidates (NO.1999)', side: 'write', stability: 'stable',
268
+ flags: { '--issue': F('string', 'task id', { required: true }), '--need': F('string', 'demand text (used as search query and demand brief)', { required: true }), '--name': F('string', 'room name (defaults to first 80 chars of --need)') },
269
+ output: '{ taskId, roomId, candidateAgentIds: string[] }', backend: 'POST /api/cli/matching/start' },
270
+ { resource: 'matching', verb: 'source', summary: 'Source candidate agents for an issue', side: 'read', stability: 'stable',
271
+ flags: { '--issue': F('string', 'issue id', { required: true }), '--room': F('string', 'demand room id', { required: true }), '--query': F('string', 'match query', { required: true }), '--demand': F('string', 'free-text demand') },
272
+ output: '{ candidates: ... }', backend: 'POST /api/cli/matching/source' },
273
+ { resource: 'matching', verb: 'refresh', summary: 'Refresh sourcing for a task (route not yet wired)', side: 'write', stability: 'stub',
274
+ flags: { '--issue': F('string', 'task id', { required: true }) },
275
+ output: '{ refreshed: true }', backend: 'NOT WIRED: POST /api/cli/matching/refresh missing (NO.2099)' },
276
+
277
+ // ── project ──────────────────────────────────────────────────────────────────
278
+ { resource: 'project', verb: 'create', summary: 'Create a project', side: 'write', stability: 'stable',
279
+ flags: { '--name': F('string', 'name', { required: true }), '--slug': F('string', 'slug', { required: true }), '--repo-url': F('string', 'repo url', { required: true }), '--repo-kind': F('string', 'repo kind'), '--default-branch': F('string', 'default branch'), '--source-project': F('string', 'provenance project id'), '--source-issue': F('string', 'provenance issue id') },
280
+ output: 'the created Project', backend: 'POST /api/cli/projects' },
281
+ { resource: 'project', verb: 'list', summary: 'List projects', side: 'read', stability: 'stable',
282
+ output: '{ projects: Project[] }', backend: 'GET /api/cli/projects' },
283
+ { resource: 'project', verb: 'get', summary: 'Get one project', side: 'read', stability: 'stable',
284
+ flags: { '--project': F('string', 'project id', { required: true }) },
285
+ output: 'the Project', backend: 'GET /api/cli/projects/<id>' },
286
+ { resource: 'project', verb: 'update', summary: 'Update project fields', side: 'write', stability: 'stable', ownerGated: true,
287
+ flags: { '--project': F('string', 'project id', { required: true }), '--name': F('string', 'new name'), '--repo-url': F('string', 'new repo url'), '--default-branch': F('string', 'new default branch') },
288
+ output: 'the updated Project', backend: 'PATCH /api/cli/projects/<id>' },
289
+ { resource: 'project', verb: 'delete', summary: 'Delete a project (soft; --hard to purge)', side: 'write', stability: 'stable', ownerGated: true,
290
+ flags: { '--project': F('string', 'project id', { required: true }), '--hard': F('bool', 'hard delete') },
291
+ output: '{ deleted: true }', backend: 'DELETE /api/cli/projects/<id>[?hard=1]' },
292
+
293
+ // ── user ─────────────────────────────────────────────────────────────────────
294
+ { resource: 'user', verb: 'resolve', summary: 'Resolve an email to a user id', side: 'read', stability: 'stable',
295
+ flags: { '--email': F('string', 'email', { required: true }) },
296
+ output: '{ user_id: string }', backend: 'GET /api/cli/users/resolve' },
297
+
298
+ // ── workspace (A13 — the top-level container; read-only over the CLI) ─────────
299
+ { resource: 'workspace', verb: 'list', summary: 'List workspaces you belong to', side: 'read', stability: 'stable',
300
+ output: '{ workspaces: { id, name, slug, role }[] }', backend: 'GET /api/cli/workspaces (member-scoped)' },
301
+ { resource: 'workspace', verb: 'members', summary: 'List a workspace\'s members', side: 'read', stability: 'stable',
302
+ flags: { '--workspace': F('string', 'workspace id', { required: true }) },
303
+ output: '{ members: Member[] }', backend: 'GET /api/cli/workspaces/<id>/members (member-gated)' },
304
+
305
+ // ── story / userstory ────────────────────────────────────────────────────────
306
+ { resource: 'story', verb: 'steps', summary: 'List the story steps derived for an issue', side: 'read', stability: 'stable',
307
+ flags: { '--issue': F('string', 'issue id', { required: true }) },
308
+ output: '{ steps: ... }', backend: 'GET /api/cli/issues/<id>/story-steps' },
309
+ { resource: 'story', verb: 'get', summary: 'Get a user story by id (alias: userstory get)', side: 'read', stability: 'stable',
310
+ flags: { '--story': F('string', 'story id', { required: true }) },
311
+ output: 'the UserStory', backend: 'GET /api/cli/stories/<id>' },
312
+ { resource: 'userstory', verb: 'list', summary: 'List user stories in a project', side: 'read', stability: 'stable',
313
+ flags: { '--project': F('string', 'project id', { required: true }) },
314
+ output: '{ stories: UserStory[] }', backend: 'GET /api/cli/stories?project= → queries.ts listUserStories (Tier 1)',
315
+ handler: async ({ args, token, baseUrl, callApi }) =>
316
+ callApi({ method: 'GET', path: `/api/cli/stories?project=${encodeURIComponent(args.project)}` }, { token, baseUrl }) },
317
+ { resource: 'userstory', verb: 'get', summary: 'Get a user story by id', side: 'read', stability: 'stable',
318
+ flags: { '--story': F('string', 'story id', { required: true }) },
319
+ output: 'the UserStory', backend: 'GET /api/cli/stories/<id>' },
320
+ { resource: 'userstory', verb: 'create', summary: 'Create a user story', side: 'write', stability: 'stable',
321
+ flags: { '--project': F('string', 'project id', { required: true }), '--title': F('string', 'title', { required: true }), '--steps': F('json', 'JSON [{action,expect}]', { required: true }) },
322
+ output: 'the created UserStory', backend: 'POST /api/cli/projects/<id>/stories' },
323
+ { resource: 'userstory', verb: 'update', summary: 'Update a user story', side: 'write', stability: 'stable', ownerGated: true,
324
+ flags: { '--project': F('string', 'project id', { required: true }), '--story': F('string', 'story id', { required: true }), '--title': F('string', 'new title'), '--steps': F('json', 'JSON [{action,expect}]') },
325
+ output: 'the updated UserStory', backend: 'PATCH /api/cli/stories/<id>' },
326
+ { resource: 'userstory', verb: 'delete', summary: 'Delete a user story', side: 'write', stability: 'stable', ownerGated: true,
327
+ flags: { '--project': F('string', 'project id', { required: true }), '--story': F('string', 'story id', { required: true }) },
328
+ output: '{ deleted: true }', backend: 'DELETE /api/cli/stories/<id>' },
329
+
330
+ // ── acceptance ───────────────────────────────────────────────────────────────
331
+ { resource: 'acceptance', verb: 'report', summary: 'Report an acceptance-criterion result (route not yet wired)', side: 'write', stability: 'stub',
332
+ flags: { '--issue': F('string', 'task id', { required: true }), '--criterion': F('string', 'criterion', { required: true }), '--status': F('string', 'pass|fail', { required: true }), '--report': F('string', 'notes'), '--preview': F('string', 'preview url') },
333
+ output: 'the recorded result', backend: 'NOT WIRED: POST /api/cli/tasks/<id>/acceptance missing (NO.2099; old `issues` path was DEC-191 stale)' },
334
+
335
+ // ── asset (shared editable asset, CAS) ────────────────────────────────────────
336
+ { resource: 'asset', verb: 'share', summary: 'Share a local file as a shared asset', side: 'write', stability: 'stable',
337
+ flags: { '--channel': F('string', 'channel id', { required: true }), '--title': F('string', 'title', { required: true }), '--body': F('string', 'inline body'), '--file': F('file', 'read body from file'), '--kind': F('string', 'asset kind'), '--source-path': F('string', 'local source path') },
338
+ output: 'the shared Asset + local working-copy path', backend: 'POST /api/cli/assets' },
339
+ { resource: 'asset', verb: 'pull', summary: 'Pull a shared asset to a local working copy', side: 'write', stability: 'stable',
340
+ flags: { '--asset': F('string', 'asset id', { required: true }) },
341
+ output: 'the local working-copy path + meta', backend: 'GET /api/cli/assets/<id>' },
342
+ { resource: 'asset', verb: 'push', summary: 'Push local edits (CAS; 3-way merge on 409)', side: 'write', stability: 'stable',
343
+ flags: { '--asset': F('string', 'asset id', { required: true }) },
344
+ output: 'the pushed Asset, or a conflict payload with the .merged path (exit !=0)', backend: 'PUT /api/cli/assets/<id> (expected_rev)' },
345
+ { resource: 'asset', verb: 'grant', summary: 'Grant a user access to an asset', side: 'write', stability: 'stable', ownerGated: true,
346
+ flags: { '--asset': F('string', 'asset id', { required: true }), '--grantee': F('string', 'user id', { required: true }), '--role': F('string', 'viewer|editor', { required: true }) },
347
+ output: '{ granted: true }', backend: 'POST /api/cli/assets/<id>/grants' },
348
+ { resource: 'asset', verb: 'revoke', summary: 'Revoke a user\'s access to an asset', side: 'write', stability: 'stable', ownerGated: true,
349
+ flags: { '--asset': F('string', 'asset id', { required: true }), '--grantee': F('string', 'user id', { required: true }) },
350
+ output: '{ revoked: true }', backend: 'DELETE /api/cli/assets/<id>/grants' },
351
+
352
+ // ── constitution (proposal-only; never direct-writes — SPEC §0) ───────────────
353
+ { resource: 'constitution', verb: 'add', summary: 'Add a constitution doc (gated unless --no-gate)', side: 'write', stability: 'stable', constitution: 'proposal-only',
354
+ flags: { '--project': F('string', 'project id', { required: true }), '--slug': F('string', 'doc slug', { required: true }), '--kind': F('string', 'doc kind', { required: true }), '--title': F('string', 'title', { required: true }), '--file': F('file', 'body file'), '--source-path': F('string', 'local source path'), '--no-gate': F('bool', 'skip review gate') },
355
+ output: 'the created doc / proposal', backend: 'POST /api/cli/constitution/add' },
356
+ { resource: 'constitution', verb: 'sync', summary: 'Mint a constitution amendment PROPOSAL (human approves on web)', side: 'write', stability: 'stable', constitution: 'proposal-only',
357
+ flags: { '--project': F('string', 'project id', { required: true }), '--slug': F('string', 'doc slug', { required: true }), '--file': F('file', 'proposed full text', { required: true }) },
358
+ output: 'the created proposal', backend: 'POST /api/cli/constitution/sync' },
359
+ { resource: 'constitution', verb: 'publish', summary: 'Publish a constitution doc (after human approval)', side: 'write', stability: 'stable', constitution: 'proposal-only',
360
+ flags: { '--project': F('string', 'project id', { required: true }), '--slug': F('string', 'doc slug', { required: true }), '--file': F('file', 'full text', { required: true }) },
361
+ output: 'the published doc', backend: 'POST /api/cli/constitution/publish' },
362
+ { resource: 'constitution', verb: 'append', summary: 'Append one approved rule line (DEC-56)', side: 'write', stability: 'stable', constitution: 'proposal-only',
363
+ flags: { '--project': F('string', 'project id', { required: true }), '--slug': F('string', 'doc slug', { required: true }), '--line': F('string', 'rule line', { required: true }) },
364
+ output: 'the updated doc', backend: 'POST /api/cli/constitution/append' },
365
+ { resource: 'constitution', verb: 'show', summary: 'Show a constitution doc (optionally one heading)', side: 'read', stability: 'stable',
366
+ flags: { '--project': F('string', 'project id', { required: true }), '--slug': F('string', 'doc slug', { required: true }), '--heading': F('string', 'one heading slice'), '--as-agent': F('string', 'read as this agent') },
367
+ output: 'the doc text', backend: 'GET /api/cli/constitution/show' },
368
+ { resource: 'constitution', verb: 'drift', summary: 'Report constitution drift for a project', side: 'read', stability: 'stable',
369
+ flags: { '--project': F('string', 'project id', { required: true }), '--as-agent': F('string', 'read as this agent') },
370
+ output: '{ drift: ... }', backend: 'GET /api/cli/constitution/drift' },
371
+ { resource: 'constitution', verb: 'list', summary: 'List constitution docs in a project', side: 'read', stability: 'stable',
372
+ flags: { '--project': F('string', 'project id', { required: true }) },
373
+ output: '{ docs: ... }', backend: 'GET /api/cli/constitution/list' },
374
+
375
+ // ── workflow (local orchestrator) ─────────────────────────────────────────────
376
+ { resource: 'workflow', verb: 'run', summary: 'Run a local workflow orchestrator script (no cloud token)', side: 'local', stability: 'stable',
377
+ positionals: [{ name: 'script', required: true, help: 'path to the orchestrator script' }],
378
+ output: 'streamed orchestrator output (not a single JSON)', backend: 'local: tsx apps/desktop/.../run-cli.ts' },
379
+ ];
380
+
381
+ /**
382
+ * Deterministic flag/positional validation (Karpathy §5 — code validates, not the
383
+ * model). Returns an error string (usage) on the first violation, or null if OK.
384
+ * `args` is the parseArgs output: named flags + `args._` positional array.
385
+ */
386
+ export function validateEntryArgs(entry, args) {
387
+ const pos = args._ ?? [];
388
+ const positionals = entry.positionals ?? [];
389
+ for (let i = 0; i < positionals.length; i++) {
390
+ const p = positionals[i];
391
+ if (p.required && pos[i] === undefined) {
392
+ return `usage: ${renderHelpEntry(entry).usage}`;
393
+ }
394
+ }
395
+ for (const [name, spec] of Object.entries(entry.flags ?? {})) {
396
+ if (spec.required && (args[name.slice(2)] === undefined || args[name.slice(2)] === true)) {
397
+ return `usage: ${renderHelpEntry(entry).usage}`;
398
+ }
399
+ }
400
+ return null;
401
+ }
402
+
403
+ /**
404
+ * Back-compat resource-noun aliases. DEC-191 renamed `issue` → `task` AND
405
+ * `channel` → `room`; `task` / `room` are canonical (help/manifest say the new
406
+ * noun), but `whf issue …` / `whf channel …` must keep working for existing
407
+ * agents/scripts. Resolution is a single rewrite at the dispatch + help layer —
408
+ * the alias is NOT re-registered as its own COMMANDS rows, so help never lists
409
+ * every verb twice. Keep this map the one place new noun aliases land.
410
+ */
411
+ export const RESOURCE_ALIASES = { issue: 'task', channel: 'room' };
412
+
413
+ /** Rewrite an alias noun to its canonical resource (identity if not an alias). */
414
+ export function resolveResource(resource) {
415
+ return RESOURCE_ALIASES[resource] ?? resource;
416
+ }
417
+
418
+ /** Look up an entry by [resource, verb]. Resolves aliases (issue→task) and falls back to a resource-only entry (ask/inbox-style). */
419
+ export function findEntry(resource, verb) {
420
+ const r = resolveResource(resource);
421
+ return (
422
+ COMMANDS.find((e) => e.resource === r && e.verb === verb) ??
423
+ COMMANDS.find((e) => e.resource === r && e.verb === undefined)
424
+ );
425
+ }
426
+
427
+ /** Build the `which` capability manifest (optionally narrowed to one resource). */
428
+ export function buildManifest({ version, baseUrl, resourceFilter } = {}) {
429
+ const resources = {};
430
+ for (const e of COMMANDS) {
431
+ if (resourceFilter && e.resource !== resourceFilter) continue;
432
+ const key = e.verb ?? '_';
433
+ (resources[e.resource] ??= {})[key] = {
434
+ side: e.side,
435
+ stability: e.stability,
436
+ summary: e.summary,
437
+ ...(e.ownerGated ? { ownerGated: true } : {}),
438
+ ...(e.constitution ? { constitution: e.constitution } : {}),
439
+ ...(e.flags ? { flags: e.flags } : {}),
440
+ ...(e.positionals ? { positionals: e.positionals } : {}),
441
+ output: e.output,
442
+ };
443
+ }
444
+ return {
445
+ tool: 'whf',
446
+ version: version ?? 'unknown',
447
+ outputContract:
448
+ 'stdout = exactly one JSON object; errors => {"error":string} on stdout + nonzero exit; exit 2 = not-yet-implemented (stub)',
449
+ baseUrl: baseUrl ?? null,
450
+ auth: 'WEHANDOFF_CLI_TOKEN env or ~/.wehandoff/cli-token (wh-cli:<userId>)',
451
+ resources,
452
+ };
453
+ }
454
+
455
+ /** Render one entry as the per-verb `--help` object (same shape as a manifest leaf + usage). */
456
+ export function renderHelpEntry(e) {
457
+ const flagList = Object.entries(e.flags ?? {}).map(([name, spec]) => ({
458
+ name,
459
+ type: spec.type,
460
+ required: !!spec.required,
461
+ ...(spec.default !== undefined ? { default: spec.default } : {}),
462
+ help: spec.help,
463
+ }));
464
+ const posList = (e.positionals ?? []).map((p) => `<${p.name}${p.required ? '' : '?'}${p.variadic ? '...' : ''}>`);
465
+ const flagUsage = Object.entries(e.flags ?? {})
466
+ .map(([name, spec]) => (spec.required ? `${name} <${spec.type}>` : `[${name} <${spec.type}>]`))
467
+ .join(' ');
468
+ return {
469
+ usage: `whf ${e.resource}${e.verb ? ` ${e.verb}` : ''} ${[...posList, flagUsage].filter(Boolean).join(' ')}`.trim(),
470
+ resource: e.resource,
471
+ verb: e.verb ?? null,
472
+ summary: e.summary,
473
+ side: e.side,
474
+ stability: e.stability,
475
+ ...(e.ownerGated ? { ownerGated: true } : {}),
476
+ ...(e.constitution ? { constitution: e.constitution } : {}),
477
+ positionals: e.positionals ?? [],
478
+ flags: flagList,
479
+ output: e.output,
480
+ backend: e.backend,
481
+ };
482
+ }
483
+
484
+ /** Top-level help listing, grouped by resource (replaces the hardcoded literal). */
485
+ export function renderTopHelp() {
486
+ const lines = ['wehandoff — agent CLI (SPEC §16). Run `whf which` for the machine-readable manifest.', ''];
487
+ const byResource = new Map();
488
+ for (const e of COMMANDS) {
489
+ if (!byResource.has(e.resource)) byResource.set(e.resource, []);
490
+ byResource.get(e.resource).push(e);
491
+ }
492
+ for (const [resource, entries] of byResource) {
493
+ for (const e of entries) {
494
+ const v = e.verb ?? '';
495
+ const tag = e.stability === 'stub' ? ' [stub]' : '';
496
+ lines.push(` whf ${`${resource} ${v}`.trim().padEnd(34)} ${e.summary}${tag}`);
497
+ }
498
+ lines.push('');
499
+ }
500
+ lines.push('Auth: WEHANDOFF_CLI_TOKEN env or ~/.wehandoff/cli-token (wh-cli:<userId>)');
501
+ lines.push('API: WEHANDOFF_APP_URL (default http://localhost:3010)');
502
+ return lines.join('\n');
503
+ }