invoket 0.1.8 → 0.1.10

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,57 @@
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`, `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
+ ## Namespaces
34
+
35
+ ```typescript
36
+ class Db {
37
+ /** Migrate */
38
+ async migrate(c: Context, direction: string = "up") {}
39
+ }
40
+ export class Tasks {
41
+ db = new Db(); // invt db:migrate up
42
+ }
43
+ ```
44
+
45
+ ## Context
46
+
47
+ ```typescript
48
+ await c.run(cmd); // throw on failure
49
+ await c.run(cmd, { warn: true }); // don't throw
50
+ const { stdout } = await c.run(cmd, { hide: true }); // capture
51
+ await c.run(cmd, { stream: true }); // real-time output
52
+ await c.run(cmd, { echo: true }); // print command first
53
+ await c.sudo(cmd); // sudo prefix
54
+ for await (const _ of c.cd(dir)) { } // temp cd
55
+ ```
56
+
57
+ Result: `{ stdout, stderr, code, ok, failed }`. Failure throws `CommandError` with `.result`.
package/README.md CHANGED
@@ -28,13 +28,21 @@ One script solves one problem. A `tasks.ts` file is a project's command centre.
28
28
  ## Installation
29
29
 
30
30
  ```bash
31
- bun add -d invoket # Add to project
32
- bun link invoket # Or link globally for development
31
+ bun add -d invoket # Add to project (use bunx invt to run)
32
+ bun add -g invoket # Or install globally (invt available on PATH)
33
33
  ```
34
34
 
35
+ Then scaffold your project:
36
+
37
+ ```bash
38
+ bunx invt --init # Creates tasks.ts and CLAUDE.md
39
+ ```
40
+
41
+ `--init` creates a starter `tasks.ts` and copies the `CLAUDE.md` reference card into your project so AI agents know how to write tasks.
42
+
35
43
  ## Quick Start
36
44
 
37
- Create `tasks.ts`:
45
+ Edit `tasks.ts`:
38
46
 
39
47
  ```typescript
40
48
  import { Context } from "invoket/context";
@@ -153,6 +161,7 @@ Flags and positional args can be freely mixed in any order.
153
161
  | `<task> -h` | Help for a specific task |
154
162
  | `-l`, `--list` | List tasks |
155
163
  | `--version` | Show version |
164
+ | `--init` | Scaffold `tasks.ts` and `CLAUDE.md` |
156
165
 
157
166
  ## Context API
158
167
 
@@ -300,180 +309,146 @@ EOF`);
300
309
 
301
310
  ## Agentic Tools
302
311
 
303
- invoket shines as a toolbox for AI agents. Instead of writing ad-hoc scripts each session, the agent adds methods to `tasks.ts` that persist across sessions. `invt --help` shows what tools are available. The file becomes the project's growing command centre.
304
-
305
- ### Memory — persist context across sessions
306
-
307
- ```typescript
308
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
309
-
310
- class Memory {
311
- private dir = ".memory";
312
-
313
- private ensure() {
314
- if (!existsSync(this.dir)) mkdirSync(this.dir, { recursive: true });
315
- }
316
-
317
- /** Store a value by key */
318
- async store(c: Context, key: string, ...value: string[]) {
319
- this.ensure();
320
- writeFileSync(`${this.dir}/${key}.md`, value.join(" "));
321
- console.log(`Stored: ${key}`);
322
- }
323
-
324
- /** Recall a value by key */
325
- async recall(c: Context, key: string) {
326
- const path = `${this.dir}/${key}.md`;
327
- if (!existsSync(path)) { console.log(`Not found: ${key}`); return; }
328
- console.log(readFileSync(path, "utf-8"));
329
- }
312
+ AI agents like Claude Code have built-in tools for searching files, reading code, and running commands. What they lack is **project context** the state of migrations, the history of decisions, the shape of your API, which tests are flaky and why. That knowledge lives in developers' heads, scattered across commits, issues, and Slack threads.
330
313
 
331
- /** List all stored keys */
332
- async list(c: Context) {
333
- this.ensure();
334
- const { stdout } = await c.run(`ls ${this.dir}`, { hide: true, warn: true });
335
- console.log(stdout || "(empty)");
336
- }
337
- }
314
+ 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.
338
315
 
339
- export class Tasks {
340
- memory = new Memory();
341
- }
342
- ```
343
-
344
- ```bash
345
- invt memory:store arch "Monorepo with packages/api and packages/web"
346
- invt memory:recall arch
347
- invt memory:list
348
- ```
349
-
350
- ### Task planning — break work into steps
316
+ ### Project context — a SQLite-backed knowledge base
351
317
 
352
318
  ```typescript
353
- import { existsSync, readFileSync, writeFileSync } from "fs";
354
-
355
- class Plan {
356
- private file = ".plan.json";
319
+ import { Context } from "invoket/context";
320
+ import { Database } from "bun:sqlite";
321
+
322
+ class Ctx {
323
+ private db: Database;
324
+
325
+ constructor() {
326
+ this.db = new Database(".ctx.db", { create: true });
327
+ this.db.run(`CREATE TABLE IF NOT EXISTS context (
328
+ key TEXT PRIMARY KEY,
329
+ value TEXT NOT NULL,
330
+ updated_at TEXT DEFAULT (datetime('now'))
331
+ )`);
332
+ this.db.run(`CREATE TABLE IF NOT EXISTS decisions (
333
+ id INTEGER PRIMARY KEY,
334
+ subject TEXT NOT NULL,
335
+ decision TEXT NOT NULL,
336
+ rationale TEXT,
337
+ status TEXT DEFAULT 'active',
338
+ created_at TEXT DEFAULT (datetime('now'))
339
+ )`);
340
+ }
357
341
 
358
- private load(): { task: string; done: boolean }[] {
359
- if (!existsSync(this.file)) return [];
360
- return JSON.parse(readFileSync(this.file, "utf-8"));
342
+ /** Store a key-value fact about the project */
343
+ async set(c: Context, key: string, ...value: string[]) {
344
+ this.db.run(
345
+ `INSERT OR REPLACE INTO context (key, value, updated_at)
346
+ VALUES (?, ?, datetime('now'))`,
347
+ [key, value.join(" ")]
348
+ );
349
+ console.log(`Set: ${key}`);
361
350
  }
362
351
 
363
- private save(tasks: { task: string; done: boolean }[]) {
364
- writeFileSync(this.file, JSON.stringify(tasks, null, 2));
352
+ /** Retrieve a fact */
353
+ async get(c: Context, key: string) {
354
+ const row = this.db.query("SELECT value, updated_at FROM context WHERE key = ?").get(key) as any;
355
+ if (!row) { console.log(`Not found: ${key}`); return; }
356
+ console.log(`${row.value} (${row.updated_at})`);
365
357
  }
366
358
 
367
- /** Add a step to the plan */
368
- async add(c: Context, ...task: string[]) {
369
- const tasks = this.load();
370
- tasks.push({ task: task.join(" "), done: false });
371
- this.save(tasks);
372
- console.log(`Added step ${tasks.length}: ${task.join(" ")}`);
359
+ /** Search facts by keyword */
360
+ async search(c: Context, ...terms: string[]) {
361
+ const pattern = `%${terms.join(" ")}%`;
362
+ const rows = this.db.query(
363
+ "SELECT key, value FROM context WHERE key LIKE ? OR value LIKE ?"
364
+ ).all(pattern, pattern) as any[];
365
+ for (const r of rows) console.log(`${r.key}: ${r.value}`);
373
366
  }
374
367
 
375
- /** Mark step as done */
376
- async done(c: Context, step: number) {
377
- const tasks = this.load();
378
- tasks[step - 1].done = true;
379
- this.save(tasks);
380
- console.log(`Done: ${tasks[step - 1].task}`);
368
+ /** Record an architectural decision */
369
+ async decide(c: Context, subject: string, decision: string, ...rationale: string[]) {
370
+ this.db.run(
371
+ "INSERT INTO decisions (subject, decision, rationale) VALUES (?, ?, ?)",
372
+ [subject, decision, rationale.join(" ")]
373
+ );
374
+ console.log(`Recorded: ${subject}`);
381
375
  }
382
376
 
383
- /** Show the plan */
384
- async show(c: Context) {
385
- const tasks = this.load();
386
- if (!tasks.length) { console.log("No plan yet."); return; }
387
- for (const [i, t] of tasks.entries()) {
388
- console.log(`${t.done ? "✓" : " "} ${i + 1}. ${t.task}`);
377
+ /** List active decisions */
378
+ async decisions(c: Context) {
379
+ const rows = this.db.query(
380
+ "SELECT id, subject, decision, rationale FROM decisions WHERE status = 'active' ORDER BY created_at DESC"
381
+ ).all() as any[];
382
+ for (const r of rows) {
383
+ console.log(`#${r.id} ${r.subject}: ${r.decision}`);
384
+ if (r.rationale) console.log(` ${r.rationale}`);
389
385
  }
390
386
  }
391
387
 
392
- /** Clear the plan */
393
- async clear(c: Context) {
394
- this.save([]);
395
- console.log("Plan cleared.");
388
+ /** Dump all context as JSON */
389
+ async dump(c: Context) {
390
+ const facts = this.db.query("SELECT key, value FROM context ORDER BY key").all();
391
+ const decisions = this.db.query("SELECT * FROM decisions WHERE status = 'active'").all();
392
+ console.log(JSON.stringify({ facts, decisions }, null, 2));
396
393
  }
397
394
  }
398
395
 
399
396
  export class Tasks {
400
- plan = new Plan();
397
+ ctx = new Ctx();
401
398
  }
402
399
  ```
403
400
 
404
401
  ```bash
405
- invt plan:add "Set up database schema"
406
- invt plan:add "Write API endpoints"
407
- invt plan:add "Add tests"
408
- invt plan:show
409
- invt plan:done 1
402
+ # Store project facts
403
+ invt ctx:set db "Postgres 16 on Supabase, migrations in prisma/"
404
+ invt ctx:set api "REST with /api/v2 prefix, auth via JWT middleware"
405
+ invt ctx:set deploy "Fly.io, auto-deploy on push to main"
406
+
407
+ # Record decisions with rationale
408
+ invt ctx:decide auth "JWT in httpOnly cookies" "Chose over localStorage for XSS protection"
409
+ invt ctx:decide orm "Prisma over Drizzle" "Team familiarity, existing migrations"
410
+
411
+ # Query context
412
+ invt ctx:get db
413
+ invt ctx:search auth
414
+ invt ctx:decisions
415
+
416
+ # Dump everything for agent context
417
+ invt ctx:dump
410
418
  ```
411
419
 
412
- ### Session journallog decisions
413
-
414
- ```typescript
415
- import { appendFileSync, existsSync, readFileSync } from "fs";
420
+ An agent starts a session with `invt ctx:dump` and immediately has structured project knowledge not flat markdown, not grep results, but queryable facts and decisions with timestamps.
416
421
 
417
- class Journal {
418
- private file = ".journal.md";
422
+ ### Why SQLite?
419
423
 
420
- /** Log a decision or finding */
421
- async log(c: Context, ...entry: string[]) {
422
- const ts = new Date().toISOString().slice(0, 16);
423
- appendFileSync(this.file, `\n## ${ts}\n\n${entry.join(" ")}\n`);
424
- console.log("Logged.");
425
- }
426
-
427
- /** Show recent entries */
428
- async show(c: Context) {
429
- if (!existsSync(this.file)) { console.log("No journal yet."); return; }
430
- console.log(readFileSync(this.file, "utf-8"));
431
- }
432
- }
424
+ Bun bundles SQLite natively — `import { Database } from "bun:sqlite"` just works. No dependencies, no server, no config. The `.ctx.db` file is a single file you can `.gitignore` or commit. You can extend the schema as the project grows: add tables for endpoints, test history, deployment logs, whatever your project needs.
433
425
 
434
- export class Tasks {
435
- journal = new Journal();
436
- }
437
- ```
438
-
439
- ```bash
440
- invt journal:log "Chose Postgres over SQLite for concurrent writes"
441
- invt journal:show
442
- ```
426
+ ### Growing the schema
443
427
 
444
- ### Codebase search structured context gathering
428
+ The example above is a starting point. A real project might track more:
445
429
 
446
430
  ```typescript
447
- class Search {
448
- /** Find files matching a pattern */
449
- async files(c: Context, pattern: string) {
450
- await c.run(`find . -name "${pattern}" -not -path "*/node_modules/*"`, { stream: true });
451
- }
452
-
453
- /** Search code for a pattern */
454
- async code(c: Context, pattern: string, ...glob: string[]) {
455
- const g = glob.length ? `--glob '${glob.join("' --glob '")}'` : "";
456
- await c.run(`rg "${pattern}" ${g} --type-not binary`, { stream: true, warn: true });
457
- }
458
-
459
- /** Summarise project structure */
460
- async tree(c: Context) {
461
- await c.run("find . -type f -not -path '*/node_modules/*' -not -path '*/.git/*' | head -50", { stream: true });
462
- }
463
- }
464
-
465
- export class Tasks {
466
- search = new Search();
467
- }
468
- ```
469
-
470
- ```bash
471
- invt search:code "async.*Context" "*.ts"
472
- invt search:files "*.test.ts"
473
- invt search:tree
431
+ // Track API endpoints and their status
432
+ this.db.run(`CREATE TABLE IF NOT EXISTS endpoints (
433
+ path TEXT PRIMARY KEY,
434
+ method TEXT,
435
+ handler TEXT,
436
+ auth TEXT DEFAULT 'required',
437
+ status TEXT DEFAULT 'active'
438
+ )`);
439
+
440
+ // Track test health
441
+ this.db.run(`CREATE TABLE IF NOT EXISTS test_runs (
442
+ id INTEGER PRIMARY KEY,
443
+ suite TEXT,
444
+ passed INTEGER,
445
+ failed INTEGER,
446
+ skipped INTEGER,
447
+ ran_at TEXT DEFAULT (datetime('now'))
448
+ )`);
474
449
  ```
475
450
 
476
- These tasks persist in the project. Every session, the agent starts with `invt --help` and has its full toolbox ready.
451
+ The namespace class is the interface. The schema is yours to shape around what your project actually needs to remember.
477
452
 
478
453
  ## Requirements
479
454
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "invoket",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
4
4
  "type": "module",
5
5
  "description": "TypeScript task runner for Bun - uses type annotations to parse CLI arguments",
6
6
  "bin": {
@@ -10,7 +10,8 @@
10
10
  "./context": "./src/context.ts"
11
11
  },
12
12
  "files": [
13
- "src"
13
+ "src",
14
+ "CLAUDE.md"
14
15
  ],
15
16
  "keywords": [
16
17
  "task-runner",
package/src/cli.ts CHANGED
@@ -1,8 +1,12 @@
1
1
  #!/usr/bin/env bun
2
2
  import { Context } from "./context";
3
+ import { dirname } from "path";
3
4
  import {
4
5
  discoverAllTasks,
5
6
  discoverRuntimeNamespaces,
7
+ extractImports,
8
+ extractMethodsFromClass,
9
+ findUnresolvedNamespaces,
6
10
  parseCommand,
7
11
  parseCliArgs,
8
12
  resolveArgs,
@@ -23,13 +27,18 @@ async function main() {
23
27
  return;
24
28
  }
25
29
 
26
- // Find tasks.ts
27
- let tasksPath: string;
28
- try {
29
- tasksPath = Bun.resolveSync("./tasks.ts", process.cwd());
30
- } catch {
31
- console.log("No tasks.ts found. Create one to get started:\n");
32
- console.log(`import { Context } from "invoket/context";
30
+ // --init flag: scaffold tasks.ts and CLAUDE.md
31
+ if (args[0] === "--init") {
32
+ const { existsSync } = await import("fs");
33
+ const cwd = process.cwd();
34
+
35
+ const tasksFile = `${cwd}/tasks.ts`;
36
+ if (existsSync(tasksFile)) {
37
+ console.log("tasks.ts already exists, skipping.");
38
+ } else {
39
+ await Bun.write(
40
+ tasksFile,
41
+ `import { Context } from "invoket/context";
33
42
 
34
43
  export class Tasks {
35
44
  /** Say hello */
@@ -37,7 +46,36 @@ export class Tasks {
37
46
  console.log("Hello, World!");
38
47
  }
39
48
  }
40
- `);
49
+ `,
50
+ );
51
+ console.log("Created tasks.ts");
52
+ }
53
+
54
+ const claudeFile = `${cwd}/CLAUDE.md`;
55
+ const claudeMdPath = new URL("../CLAUDE.md", import.meta.url).pathname;
56
+ const claudeMd = await Bun.file(claudeMdPath).text();
57
+ if (existsSync(claudeFile)) {
58
+ const existing = await Bun.file(claudeFile).text();
59
+ if (existing.includes("invoket")) {
60
+ console.log("CLAUDE.md already has invoket section, skipping.");
61
+ } else {
62
+ await Bun.write(claudeFile, existing.trimEnd() + "\n\n" + claudeMd);
63
+ console.log("Appended invoket guide to CLAUDE.md");
64
+ }
65
+ } else {
66
+ await Bun.write(claudeFile, claudeMd);
67
+ console.log("Created CLAUDE.md");
68
+ }
69
+
70
+ return;
71
+ }
72
+
73
+ // Find tasks.ts
74
+ let tasksPath: string;
75
+ try {
76
+ tasksPath = Bun.resolveSync("./tasks.ts", process.cwd());
77
+ } catch {
78
+ console.log("No tasks.ts found. Run 'invt --init' to get started.");
41
79
  process.exit(1);
42
80
  }
43
81
 
@@ -51,6 +89,24 @@ export class Tasks {
51
89
  // Discover all tasks including namespaced
52
90
  const discovered = discoverAllTasks(source);
53
91
 
92
+ // Resolve imported namespace classes from their source files
93
+ const imports = extractImports(source);
94
+ const unresolved = findUnresolvedNamespaces(source, discovered);
95
+ for (const [propName, className] of unresolved) {
96
+ const importPath = imports.get(className);
97
+ if (!importPath) continue;
98
+ try {
99
+ const resolvedPath = Bun.resolveSync(importPath, dirname(tasksPath));
100
+ const importedSource = await Bun.file(resolvedPath).text();
101
+ const methods = extractMethodsFromClass(importedSource, className);
102
+ if (methods.size > 0) {
103
+ discovered.namespaced.set(propName, methods);
104
+ }
105
+ } catch {
106
+ // Can't resolve import — runtime discovery will handle it
107
+ }
108
+ }
109
+
54
110
  // Also discover imported namespaces from runtime
55
111
  discoverRuntimeNamespaces(instance, discovered);
56
112
 
package/src/parser.ts CHANGED
@@ -219,6 +219,47 @@ export function parseParams(
219
219
  return params;
220
220
  }
221
221
 
222
+ // Extract import statements: maps imported identifiers to their module paths
223
+ export function extractImports(
224
+ source: string,
225
+ ): Map<string, string> {
226
+ const imports = new Map<string, string>();
227
+ const pattern =
228
+ /import\s+(?!\s*type\s)(?:\{([^}]+)\}|(\w+))\s+from\s+["']([^"']+)["']/g;
229
+ let match;
230
+ while ((match = pattern.exec(source)) !== null) {
231
+ const [, namedImports, defaultImport, importPath] = match;
232
+ if (namedImports) {
233
+ for (const spec of namedImports.split(",")) {
234
+ const parts = spec.trim().split(/\s+as\s+/);
235
+ const localName = parts[parts.length - 1].trim();
236
+ if (localName) imports.set(localName, importPath);
237
+ }
238
+ }
239
+ if (defaultImport) {
240
+ imports.set(defaultImport, importPath);
241
+ }
242
+ }
243
+ return imports;
244
+ }
245
+
246
+ // Find namespace assignments whose class wasn't found in the local source
247
+ export function findUnresolvedNamespaces(
248
+ source: string,
249
+ discovered: DiscoveredTasks,
250
+ ): Map<string, string> {
251
+ const unresolved = new Map<string, string>(); // propName -> className
252
+ const nsPattern = /(\w+)\s*=\s*new\s+(\w+)\s*\(\s*\)/g;
253
+ let match;
254
+ while ((match = nsPattern.exec(source)) !== null) {
255
+ const [, propName, className] = match;
256
+ if (propName.startsWith("_")) continue;
257
+ if (discovered.namespaced.has(propName)) continue;
258
+ unresolved.set(propName, className);
259
+ }
260
+ return unresolved;
261
+ }
262
+
222
263
  // Discover all tasks including namespaced ones (source parsing only)
223
264
  export function discoverAllTasks(source: string): DiscoveredTasks {
224
265
  const root = extractMethodsFromClass(source, "Tasks");