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 +77 -0
- package/README.md +81 -4
- package/package.json +5 -3
- package/src/agent.ts +444 -0
- package/src/cli.ts +90 -18
- package/src/context.ts +11 -8
- package/src/parser.ts +122 -38
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
|
|
32
|
-
bun
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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)
|
|
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
|
-
//
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
194
|
-
if (meta.
|
|
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
|
-
|
|
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
|
-
|
|
83
|
-
|
|
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
|
-
//
|
|
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*(
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
480
|
-
|
|
481
|
-
if (
|
|
482
|
-
|
|
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
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
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
|
-
|
|
575
|
+
coerced = value;
|
|
529
576
|
} else {
|
|
530
|
-
|
|
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
|
-
|
|
626
|
+
const signatureFor = (prefix: string, name: string, meta: TaskMeta) => {
|
|
562
627
|
const paramStr = meta.params.map(formatParam).join(" ");
|
|
563
|
-
const
|
|
564
|
-
|
|
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
|
-
|
|
639
|
+
const rows: Array<[string, string]> = [];
|
|
568
640
|
for (const [name, meta] of methods) {
|
|
569
|
-
|
|
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
|
|
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(
|