invoket 0.1.9 → 0.2.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/CLAUDE.md ADDED
@@ -0,0 +1,77 @@
1
+ # CLAUDE.md
2
+
3
+ Add reusable commands to `tasks.ts`. invoket gives them typed args, `--help`, and error handling.
4
+
5
+ Use invoket when a command will be reused. Write a raw script for throwaway one-offs.
6
+
7
+ ## Task skeleton
8
+
9
+ ```typescript
10
+ import { Context } from "invoket/context";
11
+
12
+ export class Tasks {
13
+ /** Description (required — no JSDoc = not discovered) */
14
+ async taskName(c: Context, required: string, optional: number = 1) {}
15
+ }
16
+ ```
17
+
18
+ `async` + JSDoc + `c: Context` first param. `_prefix` = private.
19
+
20
+ ## Types → CLI
21
+
22
+ `string` as-is, `number` rejects NaN, `boolean` accepts `true/false/1/0/--flag/--no-flag`, `"a" | "b"` validates against the listed choices, `Interface` and `Record` expect JSON string, `type[]` expects JSON array, `...args: string[]` collects remaining, `= default` and `| null` make optional.
23
+
24
+ ## Flags
25
+
26
+ Auto: `--paramName`. For short flags add `@flag` in JSDoc:
27
+
28
+ ```typescript
29
+ /** @flag env -e --environment */
30
+ async deploy(c: Context, env: string) {}
31
+ ```
32
+
33
+ Unknown flags and extra positional args are errors. Negative numbers work as flag values (`--count -3`). Use `--` to pass remaining args literally (including `-h`).
34
+
35
+ ## Namespaces
36
+
37
+ ```typescript
38
+ class Db {
39
+ /** Migrate */
40
+ async migrate(c: Context, direction: string = "up") {}
41
+ }
42
+ export class Tasks {
43
+ db = new Db(); // invt db:migrate up
44
+ }
45
+ ```
46
+
47
+ ## Context
48
+
49
+ ```typescript
50
+ await c.run(cmd); // throw on failure
51
+ await c.run(cmd, { warn: true }); // don't throw
52
+ const { stdout } = await c.run(cmd, { hide: true }); // capture
53
+ await c.run(cmd, { stream: true }); // real-time output
54
+ await c.run(cmd, { echo: true }); // print command first
55
+ await c.sudo(cmd); // sudo prefix
56
+ for await (const _ of c.cd(dir)) { } // temp cd
57
+ ```
58
+
59
+ Result: `{ stdout, stderr, code, ok, failed }`. Failure throws `CommandError` with `.result`; the command's output is printed before the throw (with `hide`, stderr is folded into the error message).
60
+
61
+ Commands run via `sh -c` and interpolations are not escaped — single-quote values that may contain spaces or shell characters: `` c.run(`git commit -m '${msg.replace(/'/g, `'\\''`)}'`) ``.
62
+
63
+ `invt` finds `tasks.ts` in the current directory or any parent, and runs commands relative to where `tasks.ts` lives.
64
+
65
+ ## Agent batteries
66
+
67
+ ```typescript
68
+ import { Ctx, Session } from "invoket/agent";
69
+ export class Tasks {
70
+ ctx = new Ctx(); // invt ctx:set/get/search/decide/decisions/dump — project memory
71
+ session = new Session(); // invt session:start — SessionStart hook body (skills sync + memory inject)
72
+ }
73
+ ```
74
+
75
+ `.ctx.jsonl` is committed truth; `.ctx.db` is a rebuildable cache (gitignore it). Record facts (`ctx:set key value...`) and decisions (`ctx:decide subject decision rationale...`) as you work.
76
+
77
+ Memory is for **application projects only**. Library and tool repos stay atomic — their agent interface is the skills they ship, not a memory store. Don't retrofit `Ctx`/`Session` hooks into a library repo.
package/README.md CHANGED
@@ -25,16 +25,32 @@ You could. But then you write arg parsing, help text, and error handling every t
25
25
 
26
26
  One script solves one problem. A `tasks.ts` file is a project's command centre.
27
27
 
28
+ ## Do you need invoket?
29
+
30
+ Maybe not. invoket's pitch — write a method, get arg parsing, help, and error handling for free — amortizes *authoring* effort. If an AI agent writes your commands, authoring is already free, and a bespoke `cli.ts` beside the code it operates on works just as well.
31
+
32
+ What every project still needs is an **inventory**: one well-known place where commands live, so the next session — human or agent — finds the existing command instead of writing a duplicate. invoket provides that, but so does a cheaper convention: `package.json` scripts, a justfile, or a paragraph in CLAUDE.md saying "commands live as `cli.ts` beside the code they operate on; check for an existing one before writing a new one".
33
+
34
+ invoket earns its keep when a human runs operational commands often enough to want one consistent grammar (`invt db:migrate up`) and a single `--help` that lists everything. If that's not you, a documented convention is enough.
35
+
28
36
  ## Installation
29
37
 
30
38
  ```bash
31
- bun add -d invoket # Add to project
32
- bun link invoket # Or link globally for development
39
+ bun add -d invoket # Add to project (use bunx invt to run)
40
+ bun add -g invoket # Or install globally (invt available on PATH)
33
41
  ```
34
42
 
43
+ Then scaffold your project:
44
+
45
+ ```bash
46
+ bunx invt --init # Creates tasks.ts and CLAUDE.md
47
+ ```
48
+
49
+ `--init` creates a starter `tasks.ts` and copies the `CLAUDE.md` reference card into your project so AI agents know how to write tasks.
50
+
35
51
  ## Quick Start
36
52
 
37
- Create `tasks.ts`:
53
+ Edit `tasks.ts`:
38
54
 
39
55
  ```typescript
40
56
  import { Context } from "invoket/context";
@@ -117,6 +133,7 @@ invt db.seed # dot separator also works
117
133
  | `name: string = "default"` | `[name]` (optional) | `hello` |
118
134
  | `count: number` | `<count>` | `42` |
119
135
  | `force: boolean` | `<force>` | `true`, `1`, `false`, `0` |
136
+ | `env: "dev" \| "prod"` | `<env>` (validated choice) | `dev` |
120
137
  | `params: SomeInterface` | `<params>` | `'{"key": "value"}'` |
121
138
  | `items: string[]` | `<items>` | `'["a", "b"]'` |
122
139
  | `...args: string[]` | `[args...]` (variadic) | `a b c` |
@@ -145,6 +162,8 @@ invt install -- --not-a-flag # -- stops flag parsing
145
162
 
146
163
  Flags and positional args can be freely mixed in any order.
147
164
 
165
+ Arguments are strict: an unknown flag or an extra positional argument is an error, not silently ignored — a typo'd `--cuont 3` fails loudly instead of running with the wrong count. Negative numbers are treated as values, so `--count -3` works.
166
+
148
167
  ### CLI Flags
149
168
 
150
169
  | Flag | Description |
@@ -153,6 +172,11 @@ Flags and positional args can be freely mixed in any order.
153
172
  | `<task> -h` | Help for a specific task |
154
173
  | `-l`, `--list` | List tasks |
155
174
  | `--version` | Show version |
175
+ | `--init` | Scaffold `tasks.ts` and `CLAUDE.md` |
176
+
177
+ ## Finding tasks.ts
178
+
179
+ `invt` looks for `tasks.ts` in the current directory, then walks up parent directories until it finds one (like `make` or `just`). Commands run relative to the directory containing `tasks.ts`, so `invt build` behaves the same from anywhere in the project.
156
180
 
157
181
  ## Context API
158
182
 
@@ -197,6 +221,15 @@ interface RunResult {
197
221
  }
198
222
  ```
199
223
 
224
+ ### Quoting
225
+
226
+ Commands run via `sh -c`, and interpolated values are **not** escaped. Wrap values that may contain spaces or shell characters in single quotes, escaping any embedded single quotes:
227
+
228
+ ```typescript
229
+ const safe = message.replace(/'/g, `'\\''`);
230
+ await c.run(`git commit -m '${safe}'`);
231
+ ```
232
+
200
233
  ### Error Handling
201
234
 
202
235
  Failed commands throw `CommandError`:
@@ -216,6 +249,8 @@ try {
216
249
 
217
250
  Use `{ warn: true }` to suppress throws and inspect the result instead.
218
251
 
252
+ A failing command's output is always printed before the throw, so errors are never silent. With `{ hide: true }` the captured stderr is folded into the `CommandError` message instead.
253
+
219
254
  ## Private Methods
220
255
 
221
256
  Prefix with `_` to hide from CLI:
@@ -253,7 +288,8 @@ async setup(c: Context) {
253
288
  async ship(c: Context, message: string) {
254
289
  const { stdout } = await c.run("git branch --show-current", { hide: true });
255
290
  await c.run("git add -A");
256
- await c.run(`git commit -m "${message}"`);
291
+ const safe = message.replace(/'/g, `'\\''`);
292
+ await c.run(`git commit -m '${safe}'`);
257
293
  await c.run(`git push -u origin ${stdout.trim()}`);
258
294
  }
259
295
  ```
@@ -304,6 +340,47 @@ AI agents like Claude Code have built-in tools for searching files, reading code
304
340
 
305
341
  invoket lets you build a **structured, queryable project knowledge base** that agents can read and write through the same CLI interface humans use. Bun's built-in SQLite makes this trivial — no external database, no setup, just a `.ctx.db` file that travels with the project.
306
342
 
343
+ ### Batteries included — `invoket/agent`
344
+
345
+ The pattern below ships ready-made. Two lines in `tasks.ts`:
346
+
347
+ ```typescript
348
+ import { Ctx, Session } from "invoket/agent";
349
+
350
+ export class Tasks {
351
+ ctx = new Ctx(); // invt ctx:set / get / search / decide / decisions / dump
352
+ session = new Session(); // invt session:start / skills
353
+ }
354
+ ```
355
+
356
+ `Ctx` stores facts and decisions in `.ctx.db` (a rebuildable cache — gitignore it) and `.ctx.jsonl` (the committed source of truth — diffable, merge-friendly; a fresh clone or a pulled change is replayed automatically). `Session.start` is built to be a Claude Code **SessionStart hook** body: it syncs `.claude/skills/` from any dependency that ships skills, rebuilds the cache, and prints a bounded context payload to stdout — which the hook injects into the model's context on startup, resume, `/clear`, and compaction:
357
+
358
+ ```json
359
+ {
360
+ "hooks": {
361
+ "SessionStart": [
362
+ {
363
+ "matcher": "startup|resume|clear|compact",
364
+ "hooks": [{ "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/session-start.sh" }]
365
+ }
366
+ ]
367
+ }
368
+ }
369
+ ```
370
+
371
+ ```sh
372
+ #!/bin/sh
373
+ cd "${CLAUDE_PROJECT_DIR:-$(dirname "$0")/../..}" || exit 0
374
+ [ -d node_modules ] || bun install >/dev/null 2>&1 || true
375
+ exec bun node_modules/invoket/src/cli.ts session:start
376
+ ```
377
+
378
+ `bunx create-blueshed my-app` scaffolds a Bun project with all of this wired — delta + railroad + invoket, skills sync, hook, seeded memory (see `packages/create-blueshed`).
379
+
380
+ **Scope: memory belongs to applications.** Library packages stay atomic — their agent interface is the skills they ship, versioned with the code. Tools carry their own process (their feedback loop *is* the memory). Only an application accumulates project-specific facts and decisions worth a store; that's where `Ctx` and the SessionStart hook go.
381
+
382
+ Prefer your own schema? The hand-rolled version of the same pattern:
383
+
307
384
  ### Project context — a SQLite-backed knowledge base
308
385
 
309
386
  ```typescript
package/package.json CHANGED
@@ -1,16 +1,18 @@
1
1
  {
2
2
  "name": "invoket",
3
- "version": "0.1.9",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "description": "TypeScript task runner for Bun - uses type annotations to parse CLI arguments",
6
6
  "bin": {
7
7
  "invt": "./src/cli.ts"
8
8
  },
9
9
  "exports": {
10
- "./context": "./src/context.ts"
10
+ "./context": "./src/context.ts",
11
+ "./agent": "./src/agent.ts"
11
12
  },
12
13
  "files": [
13
- "src"
14
+ "src",
15
+ "CLAUDE.md"
14
16
  ],
15
17
  "keywords": [
16
18
  "task-runner",
package/src/agent.ts ADDED
@@ -0,0 +1,444 @@
1
+ /**
2
+ * invoket/agent — batteries for agent-assisted projects.
3
+ *
4
+ * Two namespace classes for a project's tasks.ts:
5
+ *
6
+ * import { Ctx, Session } from "invoket/agent";
7
+ * export class Tasks {
8
+ * ctx = new Ctx(); // invt ctx:set / get / search / decide / decisions / dump / inject
9
+ * session = new Session(); // invt session:start / skills
10
+ * }
11
+ *
12
+ * Ctx is a SQLite-backed project knowledge base (the pattern from the README,
13
+ * productionized). Facts and decisions live in `.ctx.db` (a rebuildable cache,
14
+ * gitignore it) and `.ctx.jsonl` (the committed source of truth — diffable,
15
+ * merge-friendly). Every mutation rewrites the JSONL; opening the database
16
+ * replays the JSONL whenever its content hash differs from the one recorded
17
+ * at the last sync, so a fresh clone or a pulled change is picked up
18
+ * automatically.
19
+ *
20
+ * Session wires a project for agent sessions. `session:start` is built to be
21
+ * a Claude Code SessionStart hook body: it syncs `.claude/skills/` from any
22
+ * dependency that ships skills, rebuilds the context cache, and prints a
23
+ * bounded context payload to stdout (a SessionStart hook's stdout becomes
24
+ * model context; the harness caps it at 10k characters, so the payload
25
+ * targets well under that).
26
+ *
27
+ * All paths resolve from `c.cwd`, which the CLI anchors to the directory
28
+ * containing tasks.ts.
29
+ */
30
+ import { Database } from "bun:sqlite";
31
+ import {
32
+ cpSync,
33
+ existsSync,
34
+ mkdirSync,
35
+ readdirSync,
36
+ readFileSync,
37
+ rmSync,
38
+ writeFileSync,
39
+ } from "fs";
40
+ import { join } from "path";
41
+ import { Context } from "./context";
42
+
43
+ /** Project knowledge base — facts and decisions agents query and append to */
44
+ export class Ctx {
45
+ /** Store a key-value fact about the project */
46
+ async set(c: Context, key: string, ...value: string[]) {
47
+ ctxSet(c.cwd, key, value.join(" "));
48
+ console.log(`Set: ${key}`);
49
+ }
50
+
51
+ /** Retrieve a fact by key */
52
+ async get(c: Context, key: string) {
53
+ const fact = ctxGet(c.cwd, key);
54
+ if (!fact) {
55
+ console.error(`Not found: ${key}`);
56
+ process.exitCode = 1;
57
+ return;
58
+ }
59
+ console.log(`${fact.value} (${fact.updated_at})`);
60
+ }
61
+
62
+ /** Search facts and decisions by keyword */
63
+ async search(c: Context, ...terms: string[]) {
64
+ const hits = ctxSearch(c.cwd, terms.join(" "));
65
+ for (const h of hits) console.log(h);
66
+ if (hits.length === 0) console.log("No matches.");
67
+ }
68
+
69
+ /** Record a decision with its rationale */
70
+ async decide(c: Context, subject: string, decision: string, ...rationale: string[]) {
71
+ const id = ctxDecide(c.cwd, subject, decision, rationale.join(" "));
72
+ console.log(`Recorded decision #${id}: ${subject}`);
73
+ }
74
+
75
+ /** List active decisions, newest first */
76
+ async decisions(c: Context) {
77
+ const rows = ctxDecisions(c.cwd);
78
+ for (const r of rows) {
79
+ console.log(`#${r.id} ${r.subject}: ${r.decision}`);
80
+ if (r.rationale) console.log(` ${r.rationale}`);
81
+ }
82
+ if (rows.length === 0) console.log("No decisions recorded.");
83
+ }
84
+
85
+ /** Dump all facts and active decisions as JSON */
86
+ async dump(c: Context) {
87
+ console.log(JSON.stringify(ctxDump(c.cwd), null, 2));
88
+ }
89
+
90
+ /** Print the bounded context block used by session:start */
91
+ async inject(c: Context, budget: number = 6000) {
92
+ console.log(buildMemoryBlock(c.cwd, budget));
93
+ }
94
+ }
95
+
96
+ /** Session bring-up for agent-assisted projects */
97
+ export class Session {
98
+ /** Sync skills, rebuild context, print the session payload (SessionStart hook body) */
99
+ async start(c: Context) {
100
+ console.log(buildSessionPayload(c.cwd));
101
+ }
102
+
103
+ /** Sync .claude/skills/ from dependencies that ship skills */
104
+ async skills(c: Context) {
105
+ const synced = syncSkills(c.cwd);
106
+ if (synced.length === 0) {
107
+ console.log("No dependency skills found (is node_modules installed?)");
108
+ return;
109
+ }
110
+ for (const s of synced) console.log(`Synced ${s.name} (from ${s.from})`);
111
+ }
112
+ }
113
+
114
+ // ---------------------------------------------------------------------------
115
+ // Storage: SQLite cache (.ctx.db) + committed JSONL source of truth (.ctx.jsonl)
116
+ // ---------------------------------------------------------------------------
117
+
118
+ export interface Fact {
119
+ key: string;
120
+ value: string;
121
+ updated_at: string;
122
+ }
123
+
124
+ export interface Decision {
125
+ id: number;
126
+ subject: string;
127
+ decision: string;
128
+ rationale: string;
129
+ status: string;
130
+ created_at: string;
131
+ }
132
+
133
+ const DB_FILE = ".ctx.db";
134
+ const JSONL_FILE = ".ctx.jsonl";
135
+
136
+ function openDb(dir: string): Database {
137
+ const db = new Database(join(dir, DB_FILE), { create: true });
138
+ db.run(`CREATE TABLE IF NOT EXISTS context (
139
+ key TEXT PRIMARY KEY,
140
+ value TEXT NOT NULL,
141
+ updated_at TEXT DEFAULT (datetime('now'))
142
+ )`);
143
+ db.run(`CREATE TABLE IF NOT EXISTS decisions (
144
+ id INTEGER PRIMARY KEY,
145
+ subject TEXT NOT NULL,
146
+ decision TEXT NOT NULL,
147
+ rationale TEXT,
148
+ status TEXT DEFAULT 'active',
149
+ created_at TEXT DEFAULT (datetime('now'))
150
+ )`);
151
+ db.run(`CREATE TABLE IF NOT EXISTS ctx_meta (
152
+ key TEXT PRIMARY KEY,
153
+ value TEXT NOT NULL
154
+ )`);
155
+ replayJsonlIfChanged(dir, db);
156
+ return db;
157
+ }
158
+
159
+ // The JSONL is the source of truth: replay it into the cache whenever its
160
+ // content differs from what the cache last saw (fresh clone, pulled change,
161
+ // hand edit). Hash comparison, not mtime — mtimes lie across clones.
162
+ function replayJsonlIfChanged(dir: string, db: Database): void {
163
+ const jsonlPath = join(dir, JSONL_FILE);
164
+ if (!existsSync(jsonlPath)) return;
165
+ const text = readFileSync(jsonlPath, "utf8");
166
+ const hash = String(Bun.hash(text));
167
+ const seen = db
168
+ .query("SELECT value FROM ctx_meta WHERE key = 'jsonl_hash'")
169
+ .get() as { value: string } | null;
170
+ if (seen?.value === hash) return;
171
+
172
+ db.run("DELETE FROM context");
173
+ db.run("DELETE FROM decisions");
174
+ for (const line of text.split("\n")) {
175
+ if (!line.trim()) continue;
176
+ let rec: any;
177
+ try {
178
+ rec = JSON.parse(line);
179
+ } catch {
180
+ continue; // a corrupt line shouldn't take the whole store down
181
+ }
182
+ if (rec.t === "fact" && rec.key) {
183
+ db.run(
184
+ "INSERT OR REPLACE INTO context (key, value, updated_at) VALUES (?, ?, ?)",
185
+ [rec.key, rec.value ?? "", rec.updated_at ?? new Date().toISOString()],
186
+ );
187
+ } else if (rec.t === "decision" && rec.subject) {
188
+ db.run(
189
+ "INSERT INTO decisions (id, subject, decision, rationale, status, created_at) VALUES (?, ?, ?, ?, ?, ?)",
190
+ [
191
+ rec.id ?? null,
192
+ rec.subject,
193
+ rec.decision ?? "",
194
+ rec.rationale ?? "",
195
+ rec.status ?? "active",
196
+ rec.created_at ?? new Date().toISOString(),
197
+ ],
198
+ );
199
+ }
200
+ }
201
+ db.run(
202
+ "INSERT OR REPLACE INTO ctx_meta (key, value) VALUES ('jsonl_hash', ?)",
203
+ [hash],
204
+ );
205
+ }
206
+
207
+ // Mutations rewrite the whole JSONL — it stays small, ordering stays
208
+ // deterministic (facts by key, then decisions by id), diffs stay readable.
209
+ function saveJsonl(dir: string, db: Database): void {
210
+ const facts = db
211
+ .query("SELECT key, value, updated_at FROM context ORDER BY key")
212
+ .all() as Fact[];
213
+ const decisions = db
214
+ .query(
215
+ "SELECT id, subject, decision, rationale, status, created_at FROM decisions ORDER BY id",
216
+ )
217
+ .all() as Decision[];
218
+ const lines: string[] = [];
219
+ for (const f of facts) lines.push(JSON.stringify({ t: "fact", ...f }));
220
+ for (const d of decisions) lines.push(JSON.stringify({ t: "decision", ...d }));
221
+ const text = lines.join("\n") + (lines.length ? "\n" : "");
222
+ writeFileSync(join(dir, JSONL_FILE), text);
223
+ db.run(
224
+ "INSERT OR REPLACE INTO ctx_meta (key, value) VALUES ('jsonl_hash', ?)",
225
+ [String(Bun.hash(text))],
226
+ );
227
+ }
228
+
229
+ export function ctxSet(dir: string, key: string, value: string): void {
230
+ const db = openDb(dir);
231
+ db.run(
232
+ "INSERT OR REPLACE INTO context (key, value, updated_at) VALUES (?, ?, datetime('now'))",
233
+ [key, value],
234
+ );
235
+ saveJsonl(dir, db);
236
+ db.close();
237
+ }
238
+
239
+ export function ctxGet(dir: string, key: string): Fact | null {
240
+ const db = openDb(dir);
241
+ const row = db
242
+ .query("SELECT key, value, updated_at FROM context WHERE key = ?")
243
+ .get(key) as Fact | null;
244
+ db.close();
245
+ return row;
246
+ }
247
+
248
+ export function ctxSearch(dir: string, term: string): string[] {
249
+ const db = openDb(dir);
250
+ const pattern = `%${term}%`;
251
+ const facts = db
252
+ .query("SELECT key, value FROM context WHERE key LIKE ? OR value LIKE ? ORDER BY key")
253
+ .all(pattern, pattern) as Fact[];
254
+ const decisions = db
255
+ .query(
256
+ "SELECT id, subject, decision, rationale FROM decisions WHERE status = 'active' AND (subject LIKE ? OR decision LIKE ? OR rationale LIKE ?) ORDER BY id",
257
+ )
258
+ .all(pattern, pattern, pattern) as Decision[];
259
+ db.close();
260
+ return [
261
+ ...facts.map((f) => `${f.key}: ${f.value}`),
262
+ ...decisions.map((d) => `#${d.id} ${d.subject}: ${d.decision}`),
263
+ ];
264
+ }
265
+
266
+ export function ctxDecide(
267
+ dir: string,
268
+ subject: string,
269
+ decision: string,
270
+ rationale: string,
271
+ ): number {
272
+ const db = openDb(dir);
273
+ db.run(
274
+ "INSERT INTO decisions (subject, decision, rationale) VALUES (?, ?, ?)",
275
+ [subject, decision, rationale],
276
+ );
277
+ const id = (
278
+ db.query("SELECT last_insert_rowid() AS id").get() as { id: number }
279
+ ).id;
280
+ saveJsonl(dir, db);
281
+ db.close();
282
+ return id;
283
+ }
284
+
285
+ export function ctxDecisions(dir: string): Decision[] {
286
+ const db = openDb(dir);
287
+ const rows = db
288
+ .query(
289
+ "SELECT id, subject, decision, rationale, status, created_at FROM decisions WHERE status = 'active' ORDER BY id DESC",
290
+ )
291
+ .all() as Decision[];
292
+ db.close();
293
+ return rows;
294
+ }
295
+
296
+ export function ctxDump(dir: string): { facts: Fact[]; decisions: Decision[] } {
297
+ const db = openDb(dir);
298
+ const facts = db
299
+ .query("SELECT key, value, updated_at FROM context ORDER BY key")
300
+ .all() as Fact[];
301
+ const decisions = db
302
+ .query(
303
+ "SELECT id, subject, decision, rationale, status, created_at FROM decisions WHERE status = 'active' ORDER BY id DESC",
304
+ )
305
+ .all() as Decision[];
306
+ db.close();
307
+ return { facts, decisions };
308
+ }
309
+
310
+ // ---------------------------------------------------------------------------
311
+ // Skills sync — copy .claude/skills/* from any dependency that ships them
312
+ // ---------------------------------------------------------------------------
313
+
314
+ export interface SyncedSkill {
315
+ name: string;
316
+ from: string;
317
+ }
318
+
319
+ export function syncSkills(dir: string): SyncedSkill[] {
320
+ const pkgPath = join(dir, "package.json");
321
+ if (!existsSync(pkgPath)) return [];
322
+ let pkg: any;
323
+ try {
324
+ pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
325
+ } catch {
326
+ return [];
327
+ }
328
+ const deps = Object.keys({
329
+ ...pkg.dependencies,
330
+ ...pkg.devDependencies,
331
+ });
332
+ const synced: SyncedSkill[] = [];
333
+ for (const dep of deps) {
334
+ const skillsDir = join(dir, "node_modules", dep, ".claude", "skills");
335
+ if (!existsSync(skillsDir)) continue;
336
+ for (const name of readdirSync(skillsDir)) {
337
+ const src = join(skillsDir, name);
338
+ const dest = join(dir, ".claude", "skills", name);
339
+ mkdirSync(join(dir, ".claude", "skills"), { recursive: true });
340
+ rmSync(dest, { recursive: true, force: true });
341
+ cpSync(src, dest, { recursive: true });
342
+ synced.push({ name, from: dep });
343
+ }
344
+ }
345
+ return synced;
346
+ }
347
+
348
+ // ---------------------------------------------------------------------------
349
+ // Session payload — what a SessionStart hook prints into model context
350
+ // ---------------------------------------------------------------------------
351
+
352
+ const PAYLOAD_BUDGET = 9000; // the harness caps hook stdout at 10k chars
353
+ const FACT_VALUE_CAP = 300;
354
+
355
+ export function buildMemoryBlock(dir: string, budget: number = 6000): string {
356
+ const { facts, decisions } = ctxDump(dir);
357
+ const lines: string[] = ["## Project memory"];
358
+ lines.push(
359
+ "Record what you learn as you work: `invt ctx:set <key> <value...>` for facts,",
360
+ "`invt ctx:decide <subject> <decision> <rationale...>` for decisions.",
361
+ "Query with `invt ctx:get|search|decisions|dump`.",
362
+ "",
363
+ );
364
+ if (facts.length === 0 && decisions.length === 0) {
365
+ lines.push("(empty — this project has no recorded memory yet)");
366
+ return lines.join("\n");
367
+ }
368
+ if (facts.length > 0) {
369
+ lines.push("### Facts");
370
+ for (const f of facts) {
371
+ const v =
372
+ f.value.length > FACT_VALUE_CAP
373
+ ? f.value.slice(0, FACT_VALUE_CAP) + "…"
374
+ : f.value;
375
+ lines.push(`- ${f.key}: ${v}`);
376
+ }
377
+ lines.push("");
378
+ }
379
+ if (decisions.length > 0) {
380
+ lines.push("### Decisions (newest first)");
381
+ let used = lines.join("\n").length;
382
+ let dropped = 0;
383
+ for (const d of decisions) {
384
+ const line = `- #${d.id} ${d.subject}: ${d.decision}${d.rationale ? ` — ${d.rationale}` : ""} (${d.created_at})`;
385
+ if (used + line.length > budget) {
386
+ dropped = decisions.length - decisions.indexOf(d);
387
+ break;
388
+ }
389
+ lines.push(line);
390
+ used += line.length + 1;
391
+ }
392
+ if (dropped > 0) {
393
+ lines.push(`…and ${dropped} older decision(s) — \`invt ctx:decisions\` for all.`);
394
+ }
395
+ }
396
+ return lines.join("\n");
397
+ }
398
+
399
+ export function buildSessionPayload(dir: string): string {
400
+ const lines: string[] = ["# Session context (generated by `invt session:start`)", ""];
401
+
402
+ // Stack: the project's dependencies with installed versions
403
+ const pkgPath = join(dir, "package.json");
404
+ if (existsSync(pkgPath)) {
405
+ try {
406
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
407
+ const deps = Object.keys({ ...pkg.dependencies, ...pkg.devDependencies });
408
+ const stack: string[] = [];
409
+ for (const dep of deps) {
410
+ const depPkgPath = join(dir, "node_modules", dep, "package.json");
411
+ if (!existsSync(depPkgPath)) continue;
412
+ try {
413
+ const depPkg = JSON.parse(readFileSync(depPkgPath, "utf8"));
414
+ stack.push(`${dep}@${depPkg.version}`);
415
+ } catch {
416
+ stack.push(dep);
417
+ }
418
+ }
419
+ if (pkg.name) lines[0] = `# Session context — ${pkg.name} (generated by \`invt session:start\`)`;
420
+ if (stack.length > 0) lines.push(`Stack: ${stack.join(", ")}`, "");
421
+ } catch {
422
+ // unreadable package.json — payload still useful without the stack line
423
+ }
424
+ }
425
+
426
+ const synced = syncSkills(dir);
427
+ if (synced.length > 0) {
428
+ const names = synced.map((s) => `${s.name} (${s.from})`).join(", ");
429
+ lines.push(
430
+ `Skills synced into .claude/skills: ${names}.`,
431
+ "Read the matching skill before writing code against that package — it overrides trained habits.",
432
+ "",
433
+ );
434
+ }
435
+
436
+ lines.push(buildMemoryBlock(dir));
437
+ let payload = lines.join("\n");
438
+ if (payload.length > PAYLOAD_BUDGET) {
439
+ payload =
440
+ payload.slice(0, PAYLOAD_BUDGET) +
441
+ "\n…(truncated — `invt ctx:dump` for the full store)";
442
+ }
443
+ return payload;
444
+ }
package/src/cli.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  #!/usr/bin/env bun
2
- import { Context } from "./context";
3
- import { dirname } from "path";
2
+ import { Context, CommandError } from "./context";
3
+ import { dirname, join } from "path";
4
+ import { existsSync } from "fs";
5
+ import { fileURLToPath } from "url";
4
6
  import {
5
7
  discoverAllTasks,
6
8
  discoverRuntimeNamespaces,
@@ -15,25 +17,41 @@ import {
15
17
  showTaskHelp,
16
18
  } from "./parser";
17
19
 
20
+ // Walk up from startDir looking for tasks.ts (like make/just find their files)
21
+ function findTasksFile(startDir: string): string | null {
22
+ let dir = startDir;
23
+ while (true) {
24
+ const candidate = join(dir, "tasks.ts");
25
+ if (existsSync(candidate)) return candidate;
26
+ const parent = dirname(dir);
27
+ if (parent === dir) return null;
28
+ dir = parent;
29
+ }
30
+ }
31
+
18
32
  // Main CLI entry point
19
33
  async function main() {
20
34
  const args = Bun.argv.slice(2);
21
35
 
22
36
  // --version flag
23
37
  if (args[0] === "--version") {
24
- const pkgPath = new URL("../package.json", import.meta.url).pathname;
38
+ const pkgPath = fileURLToPath(new URL("../package.json", import.meta.url));
25
39
  const pkg = await Bun.file(pkgPath).json();
26
40
  console.log(pkg.version);
27
41
  return;
28
42
  }
29
43
 
30
- // Find tasks.ts
31
- let tasksPath: string;
32
- try {
33
- tasksPath = Bun.resolveSync("./tasks.ts", process.cwd());
34
- } catch {
35
- console.log("No tasks.ts found. Create one to get started:\n");
36
- console.log(`import { Context } from "invoket/context";
44
+ // --init flag: scaffold tasks.ts and CLAUDE.md
45
+ if (args[0] === "--init") {
46
+ const cwd = process.cwd();
47
+
48
+ const tasksFile = `${cwd}/tasks.ts`;
49
+ if (existsSync(tasksFile)) {
50
+ console.log("tasks.ts already exists, skipping.");
51
+ } else {
52
+ await Bun.write(
53
+ tasksFile,
54
+ `import { Context } from "invoket/context";
37
55
 
38
56
  export class Tasks {
39
57
  /** Say hello */
@@ -41,7 +59,42 @@ export class Tasks {
41
59
  console.log("Hello, World!");
42
60
  }
43
61
  }
44
- `);
62
+ `,
63
+ );
64
+ console.log("Created tasks.ts");
65
+ }
66
+
67
+ const claudeFile = `${cwd}/CLAUDE.md`;
68
+ const claudeMdPath = fileURLToPath(new URL("../CLAUDE.md", import.meta.url));
69
+ const claudeMd = await Bun.file(claudeMdPath).text();
70
+ // First substantive line of the shipped guide doubles as the "already
71
+ // installed" marker — a mere mention of invoket shouldn't skip the append
72
+ const marker = claudeMd
73
+ .split("\n")
74
+ .map((line) => line.trim())
75
+ .find((line) => line && !line.startsWith("#"));
76
+ if (existsSync(claudeFile)) {
77
+ const existing = await Bun.file(claudeFile).text();
78
+ if (marker && existing.includes(marker)) {
79
+ console.log("CLAUDE.md already has invoket section, skipping.");
80
+ } else {
81
+ await Bun.write(claudeFile, existing.trimEnd() + "\n\n" + claudeMd);
82
+ console.log("Appended invoket guide to CLAUDE.md");
83
+ }
84
+ } else {
85
+ await Bun.write(claudeFile, claudeMd);
86
+ console.log("Created CLAUDE.md");
87
+ }
88
+
89
+ return;
90
+ }
91
+
92
+ // Find tasks.ts in cwd or any parent directory
93
+ const tasksPath = findTasksFile(process.cwd());
94
+ if (!tasksPath) {
95
+ console.error(
96
+ "No tasks.ts found in this directory or any parent. Run 'invt --init' to get started.",
97
+ );
45
98
  process.exit(1);
46
99
  }
47
100
 
@@ -51,6 +104,8 @@ export class Tasks {
51
104
  const { Tasks } = await import(tasksPath);
52
105
  const instance = new Tasks();
53
106
  const context = new Context();
107
+ // Tasks run relative to tasks.ts, wherever invt was invoked from
108
+ context.cwd = dirname(tasksPath);
54
109
 
55
110
  // Discover all tasks including namespaced
56
111
  const discovered = discoverAllTasks(source);
@@ -105,7 +160,10 @@ export class Tasks {
105
160
  const taskArgs = args.slice(1);
106
161
 
107
162
  // Check if asking for task-specific help: invt hello -h
108
- const wantsTaskHelp = taskArgs.includes("-h") || taskArgs.includes("--help");
163
+ // Anything after -- is a literal task argument, never a help flag
164
+ const ddIdx = taskArgs.indexOf("--");
165
+ const beforeDD = ddIdx === -1 ? taskArgs : taskArgs.slice(0, ddIdx);
166
+ const wantsTaskHelp = beforeDD.includes("-h") || beforeDD.includes("--help");
109
167
 
110
168
  const { namespace, method: methodName } = parseCommand(command);
111
169
 
@@ -156,7 +214,7 @@ export class Tasks {
156
214
  // If method exists at runtime but not in source (inherited), allow it
157
215
  if (!meta && typeof method === "function") {
158
216
  // Inherited method - no type info, treat all args as strings
159
- meta = { description: "", params: [] };
217
+ meta = { description: "", params: [], untyped: true };
160
218
  } else if (!meta) {
161
219
  console.error(`Unknown task: ${command}`);
162
220
  const allTasks = [...discovered.root.keys()];
@@ -181,8 +239,12 @@ export class Tasks {
181
239
  return;
182
240
  }
183
241
 
184
- // Filter out help flags from taskArgs before parsing
185
- const argsWithoutHelp = taskArgs.filter((a) => a !== "-h" && a !== "--help");
242
+ // Filter out help flags from taskArgs before parsing (only before --)
243
+ const notHelp = (a: string) => a !== "-h" && a !== "--help";
244
+ const argsWithoutHelp =
245
+ ddIdx === -1
246
+ ? taskArgs.filter(notHelp)
247
+ : [...beforeDD.filter(notHelp), ...taskArgs.slice(ddIdx)];
186
248
 
187
249
  // Parse CLI args into flags and positional
188
250
  const parsed = parseCliArgs(argsWithoutHelp);
@@ -190,8 +252,8 @@ export class Tasks {
190
252
  // Validate and coerce arguments
191
253
  let coercedArgs: unknown[];
192
254
 
193
- // If no param info (imported namespace), pass all args as strings
194
- if (meta.params.length === 0 && argsWithoutHelp.length > 0) {
255
+ // If no signature info (runtime-discovered method), pass all args as strings
256
+ if (meta.untyped && argsWithoutHelp.length > 0) {
195
257
  coercedArgs = [...parsed.positional];
196
258
  } else {
197
259
  try {
@@ -208,7 +270,17 @@ export class Tasks {
208
270
  try {
209
271
  await method.call(thisArg, context, ...coercedArgs);
210
272
  } catch (e) {
211
- console.error(`Error running "${command}": ${(e as Error).message}`);
273
+ if (e instanceof CommandError) {
274
+ // Command output was already written (or is in the message when hidden)
275
+ console.error(`Error running "${command}": ${e.message}`);
276
+ } else if (e instanceof Error) {
277
+ console.error(`Error running "${command}": ${e.message}`);
278
+ // A bug in the task itself — show where it happened
279
+ const frames = e.stack?.split("\n").slice(1).join("\n");
280
+ if (frames) console.error(frames);
281
+ } else {
282
+ console.error(`Error running "${command}": ${String(e)}`);
283
+ }
212
284
  process.exit(1);
213
285
  }
214
286
  }
package/src/context.ts CHANGED
@@ -79,19 +79,22 @@ export class Context {
79
79
  failed: result.exitCode !== 0,
80
80
  };
81
81
 
82
- if (!opts.warn && runResult.failed) {
83
- throw new CommandError(
84
- `Command failed with exit code ${runResult.code}: ${command}`,
85
- runResult,
86
- );
87
- }
88
-
89
- // When streaming, output already went to terminal; otherwise write captured output
82
+ // When streaming, output already went to terminal; otherwise write captured
83
+ // output — before any throw, so a failing command's stderr is never silent
90
84
  if (!opts.stream && !opts.hide) {
91
85
  if (runResult.stdout) process.stdout.write(runResult.stdout);
92
86
  if (runResult.stderr) process.stderr.write(runResult.stderr);
93
87
  }
94
88
 
89
+ if (!opts.warn && runResult.failed) {
90
+ let message = `Command failed with exit code ${runResult.code}: ${command}`;
91
+ // With hide the output wasn't printed, so surface stderr in the error
92
+ if (opts.hide && runResult.stderr.trim()) {
93
+ message += `\n${runResult.stderr.trim()}`;
94
+ }
95
+ throw new CommandError(message, runResult);
96
+ }
97
+
95
98
  return runResult;
96
99
  }
97
100
 
package/src/parser.ts CHANGED
@@ -15,11 +15,13 @@ export interface ParamMeta {
15
15
  required: boolean;
16
16
  isRest: boolean;
17
17
  flag?: FlagMeta;
18
+ choices?: string[]; // from string-literal union types, e.g. "dev" | "prod"
18
19
  }
19
20
 
20
21
  export interface TaskMeta {
21
22
  description: string;
22
23
  params: ParamMeta[];
24
+ untyped?: boolean; // discovered at runtime only — no signature info available
23
25
  }
24
26
 
25
27
  // Parsed CLI arguments
@@ -176,9 +178,10 @@ export function parseParams(
176
178
  return params;
177
179
  }
178
180
 
179
- // Updated regex to handle union types with null (e.g., string | null)
181
+ // Handles union types with null (e.g., string | null) and string-literal
182
+ // unions (e.g., "dev" | "prod"), which become validated string choices
180
183
  const paramPattern =
181
- /(\w+)\s*:\s*(\w+\[\]|Record<[^>]+>|\{[^}]*\}|string|number|boolean|\w+)(?:\s*\|\s*null)?(?:\s*=\s*[^,)]+)?/g;
184
+ /(\w+)\s*:\s*((?:"[^"]*"|'[^']*')(?:\s*\|\s*(?:"[^"]*"|'[^']*'))*|\w+\[\]|Record<[^>]+>|\{[^}]*\}|string|number|boolean|\w+)(?:\s*\|\s*null)?(?:\s*=\s*[^,)]+)?/g;
182
185
  let paramMatch;
183
186
 
184
187
  while ((paramMatch = paramPattern.exec(paramsStr)) !== null) {
@@ -187,7 +190,13 @@ export function parseParams(
187
190
  const isNullable = fullMatch.includes("| null");
188
191
 
189
192
  let type: ParamType;
190
- if (rawType === "string") {
193
+ let choices: string[] | undefined;
194
+ if (rawType.startsWith('"') || rawType.startsWith("'")) {
195
+ type = "string";
196
+ choices = [...rawType.matchAll(/"([^"]*)"|'([^']*)'/g)].map(
197
+ (m) => m[1] ?? m[2],
198
+ );
199
+ } else if (rawType === "string") {
191
200
  type = "string";
192
201
  } else if (rawType === "number") {
193
202
  type = "number";
@@ -213,6 +222,7 @@ export function parseParams(
213
222
  required: !hasDefault && !isNullable,
214
223
  isRest: false,
215
224
  flag,
225
+ choices,
216
226
  });
217
227
  }
218
228
 
@@ -315,7 +325,7 @@ export function discoverRuntimeNamespaces(
315
325
 
316
326
  // No type info for imported methods - treat args as strings
317
327
  if (!methods.has(methodName)) {
318
- methods.set(methodName, { description: "", params: [] });
328
+ methods.set(methodName, { description: "", params: [], untyped: true });
319
329
  }
320
330
  }
321
331
  proto = Object.getPrototypeOf(proto);
@@ -375,6 +385,11 @@ export function coerceArg(value: string, type: ParamType): unknown {
375
385
  }
376
386
  }
377
387
 
388
+ // Negative numbers (-3, -0.5) are values, not flags
389
+ function isNegativeNumber(arg: string): boolean {
390
+ return /^-(\d+(\.\d+)?|\.\d+)$/.test(arg);
391
+ }
392
+
378
393
  // Parse CLI arguments into flags and positional args
379
394
  export function parseCliArgs(args: string[]): ParsedArgs {
380
395
  const positional: string[] = [];
@@ -416,7 +431,10 @@ export function parseCliArgs(args: string[]): ParsedArgs {
416
431
  const nextArg = args[i + 1];
417
432
 
418
433
  // If next arg exists and doesn't look like a flag, use it as value
419
- if (nextArg !== undefined && !nextArg.startsWith("-")) {
434
+ if (
435
+ nextArg !== undefined &&
436
+ (!nextArg.startsWith("-") || isNegativeNumber(nextArg))
437
+ ) {
420
438
  flags.set(name, nextArg);
421
439
  i++; // Skip next arg
422
440
  } else {
@@ -425,6 +443,12 @@ export function parseCliArgs(args: string[]): ParsedArgs {
425
443
  continue;
426
444
  }
427
445
 
446
+ // Bare negative number is a positional value, not a short flag
447
+ if (isNegativeNumber(arg)) {
448
+ positional.push(arg);
449
+ continue;
450
+ }
451
+
428
452
  // -f=value (short with equals, single char only)
429
453
  if (arg.startsWith("-") && !arg.startsWith("--") && arg.includes("=")) {
430
454
  const eqIdx = arg.indexOf("=");
@@ -441,7 +465,10 @@ export function parseCliArgs(args: string[]): ParsedArgs {
441
465
  const name = arg.slice(1);
442
466
  const nextArg = args[i + 1];
443
467
 
444
- if (nextArg !== undefined && !nextArg.startsWith("-")) {
468
+ if (
469
+ nextArg !== undefined &&
470
+ (!nextArg.startsWith("-") || isNegativeNumber(nextArg))
471
+ ) {
445
472
  flags.set(name, nextArg);
446
473
  i++;
447
474
  } else {
@@ -459,12 +486,42 @@ export function parseCliArgs(args: string[]): ParsedArgs {
459
486
 
460
487
  // Resolve arguments from parsed CLI args using param metadata
461
488
  export function resolveArgs(params: ParamMeta[], parsed: ParsedArgs): unknown[] {
489
+ // Reject unknown flags first — a typo'd flag silently changing behavior is
490
+ // worse than an error, and this diagnostic beats "missing required argument"
491
+ const validFlagNames = new Set<string>();
492
+ for (const param of params) {
493
+ if (!param.flag) continue;
494
+ validFlagNames.add(param.flag.long.slice(2));
495
+ if (param.flag.short) validFlagNames.add(param.flag.short.slice(1));
496
+ for (const alias of param.flag.aliases ?? []) {
497
+ validFlagNames.add(alias.slice(2));
498
+ }
499
+ }
500
+ const unknownFlags = [...parsed.flags.keys()].filter(
501
+ (name) => !validFlagNames.has(name),
502
+ );
503
+ if (unknownFlags.length > 0) {
504
+ const display = unknownFlags
505
+ .map((name) => (name.length === 1 ? `-${name}` : `--${name}`))
506
+ .join(", ");
507
+ const valid = params
508
+ .filter((p) => p.flag)
509
+ .map((p) => p.flag!.long)
510
+ .join(", ");
511
+ throw new Error(
512
+ `Unknown flag${unknownFlags.length > 1 ? "s" : ""}: ${display}` +
513
+ (valid ? `. Valid flags: ${valid}` : ""),
514
+ );
515
+ }
516
+
462
517
  const result: unknown[] = [];
463
518
  const usedPositional = new Set<number>();
519
+ let hasRest = false;
464
520
 
465
521
  for (const param of params) {
466
522
  // Handle rest parameters - collect all remaining positional args
467
523
  if (param.isRest) {
524
+ hasRest = true;
468
525
  const remaining = parsed.positional.filter(
469
526
  (_, i) => !usedPositional.has(i),
470
527
  );
@@ -474,28 +531,17 @@ export function resolveArgs(params: ParamMeta[], parsed: ParsedArgs): unknown[]
474
531
 
475
532
  let value: string | boolean | undefined;
476
533
 
477
- // Try to get value from flags first
534
+ // Try to get value from flags first; long flag wins over short and aliases
478
535
  if (param.flag) {
479
- // Check long flag (without --)
480
- const longName = param.flag.long.slice(2);
481
- if (parsed.flags.has(longName)) {
482
- value = parsed.flags.get(longName);
483
- }
484
- // Check short flag (without -)
485
- else if (param.flag.short) {
486
- const shortName = param.flag.short.slice(1);
487
- if (parsed.flags.has(shortName)) {
488
- value = parsed.flags.get(shortName);
489
- }
536
+ const flagNames = [param.flag.long.slice(2)];
537
+ if (param.flag.short) flagNames.push(param.flag.short.slice(1));
538
+ if (param.flag.aliases) {
539
+ flagNames.push(...param.flag.aliases.map((a) => a.slice(2)));
490
540
  }
491
- // Check aliases
492
- if (value === undefined && param.flag.aliases) {
493
- for (const alias of param.flag.aliases) {
494
- const aliasName = alias.slice(2);
495
- if (parsed.flags.has(aliasName)) {
496
- value = parsed.flags.get(aliasName);
497
- break;
498
- }
541
+ for (const flagName of flagNames) {
542
+ if (parsed.flags.has(flagName)) {
543
+ value = parsed.flags.get(flagName);
544
+ break;
499
545
  }
500
546
  }
501
547
  }
@@ -524,10 +570,29 @@ export function resolveArgs(params: ParamMeta[], parsed: ParsedArgs): unknown[]
524
570
 
525
571
  // Coerce and add to result
526
572
  // Boolean flags that are already boolean don't need coercion
573
+ let coerced: unknown;
527
574
  if (typeof value === "boolean" && param.type === "boolean") {
528
- result.push(value);
575
+ coerced = value;
529
576
  } else {
530
- result.push(coerceArg(String(value), param.type));
577
+ coerced = coerceArg(String(value), param.type);
578
+ }
579
+
580
+ if (param.choices && !param.choices.includes(String(coerced))) {
581
+ throw new Error(
582
+ `Invalid value for <${param.name}>: "${coerced}" (expected one of: ${param.choices.join(", ")})`,
583
+ );
584
+ }
585
+
586
+ result.push(coerced);
587
+ }
588
+
589
+ // Positional args left unclaimed by any parameter are an error too
590
+ if (!hasRest) {
591
+ const extra = parsed.positional.filter((_, i) => !usedPositional.has(i));
592
+ if (extra.length > 0) {
593
+ throw new Error(
594
+ `Unexpected argument${extra.length > 1 ? "s" : ""}: ${extra.join(" ")}`,
595
+ );
531
596
  }
532
597
  }
533
598
 
@@ -558,20 +623,35 @@ export function formatFlagInfo(param: ParamMeta): string {
558
623
 
559
624
  // Display task listing (used by both help and --list)
560
625
  export function printTaskList(discovered: DiscoveredTasks): void {
561
- for (const [name, meta] of discovered.root) {
626
+ const signatureFor = (prefix: string, name: string, meta: TaskMeta) => {
562
627
  const paramStr = meta.params.map(formatParam).join(" ");
563
- const signature = paramStr ? `${name} ${paramStr}` : name;
564
- console.log(` ${signature}`);
628
+ const full = prefix ? `${prefix}:${name}` : name;
629
+ return paramStr ? `${full} ${paramStr}` : full;
630
+ };
631
+
632
+ // Collect every row first so descriptions align across the whole listing
633
+ const rootRows: Array<[string, string]> = [];
634
+ for (const [name, meta] of discovered.root) {
635
+ rootRows.push([signatureFor("", name, meta), meta.description]);
565
636
  }
637
+ const nsRows = new Map<string, Array<[string, string]>>();
566
638
  for (const [ns, methods] of discovered.namespaced) {
567
- console.log(`\n${ns}:`);
639
+ const rows: Array<[string, string]> = [];
568
640
  for (const [name, meta] of methods) {
569
- const paramStr = meta.params.map(formatParam).join(" ");
570
- const signature = paramStr
571
- ? `${ns}:${name} ${paramStr}`
572
- : `${ns}:${name}`;
573
- console.log(` ${signature}`);
641
+ rows.push([signatureFor(ns, name, meta), meta.description]);
574
642
  }
643
+ nsRows.set(ns, rows);
644
+ }
645
+
646
+ const allRows = [...rootRows, ...[...nsRows.values()].flat()];
647
+ const width = Math.max(0, ...allRows.map(([sig]) => sig.length));
648
+ const printRow = ([sig, desc]: [string, string]) =>
649
+ console.log(desc ? ` ${sig.padEnd(width)} ${desc}` : ` ${sig}`);
650
+
651
+ rootRows.forEach(printRow);
652
+ for (const [ns, rows] of nsRows) {
653
+ console.log(`\n${ns}:`);
654
+ rows.forEach(printRow);
575
655
  }
576
656
  }
577
657
 
@@ -590,7 +670,11 @@ export function showTaskHelp(command: string, meta: TaskMeta): void {
590
670
  console.log("Arguments:");
591
671
  for (const param of meta.params) {
592
672
  const reqStr = param.required ? "(required)" : "(optional)";
593
- const typeStr = param.isRest ? `${param.type}...` : param.type;
673
+ const typeStr = param.isRest
674
+ ? `${param.type}...`
675
+ : param.choices
676
+ ? param.choices.join("|")
677
+ : param.type;
594
678
  const flagStr = formatFlagInfo(param);
595
679
  const flagDisplay = flagStr ? ` ${flagStr}` : "";
596
680
  console.log(