@inetafrica/open-claudia 2.6.59 → 2.7.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/CHANGELOG.md +7 -0
- package/bin/cli.js +1 -1
- package/bin/tool.js +177 -23
- package/core/tool-repo.js +270 -0
- package/core/tools.js +268 -59
- package/package.json +1 -1
- package/test-tools.js +139 -19
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v2.7.0
|
|
4
|
+
- **Tools become memory objects (tool-first architecture, Phase 1).** A tool is no longer a flat script — it's a directory: `tools/<name>/tool` (the executable), `TOOL.md` (Interface · Known issues · State · Journal — the curated, human-readable condition of the tool), and `state.json` (machine telemetry, runtime-written, gitignored). The tools dir is now a **git repository**: every add, metadata change, note, and removal auto-commits (with a per-invocation identity, never touching git config), so `git log` *is* the upgrade history and any change is one revert from undone. `tool show` is tiered disclosure: header metadata, live telemetry, TOOL.md, and the recent commit log — the source only with `--source`, so reading about a tool no longer costs its whole body in context.
|
|
5
|
+
- **Risk tiers + run gate.** Every tool declares `risk: read-only | write | destructive` in its header (absent = treated as write, fail-safe). `tool run` now enforces the ask-first rule mechanically: read-only runs freely; write requires `--yes`; destructive requires `--yes-destructive` — and a plain `--yes` does **not** unlock a destructive tool. Refusals exit 3 with a clear message. The flags pass through to the tool unstripped, so tools with their own confirmation gates (e.g. prod-k8s) keep working. `tool add`/`tool set` accept `--risk`; adding without it defaults to write and says so.
|
|
6
|
+
- **Run telemetry stamped at the point of use.** `tool run` itself records every execution into the tool's `state.json`: run count, last used, last exit, last success/failure, per-verb health (first non-flag arg), and a rolling history of the last 20 invocations with timing. A failing run prints a nudge to journal the defect (`tool note <name> --issue "..."`). This is the v1.2 health doctrine in code: no smoke tests, no health crons — health is verified by use, and breakage is captured automatically where it happens.
|
|
7
|
+
- **`tool scaffold`, `tool note`, `tool migrate`.** `scaffold <name>` creates a ready-to-extend verb-dispatch stub (node or bash) with full header, TOOL.md skeleton, and initial telemetry — and refuses if the tool exists ("extend it instead"). `note <name> --issue|--state|--journal` maintains TOOL.md from the CLI: issues and journal lines insert newest-first; `--state` rewrites the State section (it describes *now*, not history). `migrate` converts legacy flat-file tools into directories — one commit each, sidecar telemetry carried over, TOOL.md generated — and runs automatically on first CLI use, so existing tools arrive in the new layout with their memory intact.
|
|
8
|
+
- **Secrets can no longer be saved into a tool.** The add-time secret lint is now **blocking** (was advisory): `tool add`, `tool set`, and `tool note` refuse content matching credential patterns, because the git-backed dir would remember the secret forever. Other lints (requires mismatch, syntax check, dangling pack) stay advisory. Extends `test-tools.js` end to end: layout, risk gate matrix, per-verb telemetry, secret refusal, migration, scaffold, git history.
|
|
9
|
+
|
|
3
10
|
## v2.6.58
|
|
4
11
|
- **Tiered recall gate — stop paying 15 seconds for "thanks".** Measured over 700+ live turns, the old discoverer pre-gate returned true whenever *any* seed matched, so it gated just 0.4% of turns — a bare "ok" with an incidental keyword hit still paid the full seed → graph → walker pass (p50 ~15s). A new `recallTier()` classifies each turn into three tiers: **skip** (pleasantries/acks up to 4 words *regardless of seeds*, emoji-only, or ≤2 words with no seeds — no recall at all), **seeds** (short command-like turns ≤4 words — user-origin keyword seeds inject directly with no graph expansion, no walker, no excerpts; the classic-engine baseline at ~ms cost), and **full** (substantive turns — the whole pipeline). The tier is logged per turn and the metrics summary now tracks the seeds-only share alongside the gated share.
|
|
5
12
|
- **Walker candidate cap.** The latency deep-dive showed the walk itself dominates, not process spawn: 15–40 candidates × 600-char excerpts = 10–25KB prompts to the haiku judge on every substantive turn. Full-tier candidates are now priority-ordered — user-origin seeds by match score, then context seeds, then graph-activated nodes by activation — and capped at `RECALL_WALKER_MAX_CANDIDATES` (default 14), so the judge reads the strongest few instead of everything the graph touched.
|
package/bin/cli.js
CHANGED
|
@@ -374,7 +374,7 @@ Memory tools:
|
|
|
374
374
|
open-claudia transcript-window <pattern> Search project transcript, show hits with context
|
|
375
375
|
(alias: tw; --help for options)
|
|
376
376
|
open-claudia pack list|show|match|archive|restore|archived Context packs: living topic docs (skills + memory)
|
|
377
|
-
open-claudia tool list|search|show|add|set|edit|run|remove
|
|
377
|
+
open-claudia tool list|search|show|scaffold|add|set|note|edit|run|migrate|remove Reusable tools: git-tracked dirs (exec + TOOL.md + telemetry), risk-gated runs
|
|
378
378
|
open-claudia entity list|show|match|note Entity notes: people/places/projects memory
|
|
379
379
|
open-claudia lessons list|add|remove|show Always-loaded learned rules (cross-cutting, promoted after a miss)
|
|
380
380
|
open-claudia ideas list|add|remove|show Self-improvement backlog (captured by the nightly dream)
|
package/bin/tool.js
CHANGED
|
@@ -1,23 +1,38 @@
|
|
|
1
|
-
// CLI: manage and run reusable tools —
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// CLI: manage and run reusable tools — the interface operational work happens
|
|
2
|
+
// through. Tool-first loop: search → exists: use it → missing a verb: extend →
|
|
3
|
+
// nothing: scaffold, then work through the tool.
|
|
4
4
|
//
|
|
5
5
|
// open-claudia tool list — index (name — description [skill])
|
|
6
6
|
// open-claudia tool search <query> — lexical match (find before adding a dup)
|
|
7
|
-
// open-claudia tool show <name>
|
|
8
|
-
// open-claudia tool
|
|
9
|
-
// [--pack <dir>] [--desc "..."] [--
|
|
7
|
+
// open-claudia tool show <name> [--source] — TOOL.md, telemetry, history (source with --source)
|
|
8
|
+
// open-claudia tool scaffold <name> — create a new tool skeleton (dir + stub + TOOL.md)
|
|
9
|
+
// [--pack <dir>] [--desc "..."] [--risk read-only|write|destructive]
|
|
10
|
+
// [--requires "k1,k2"] [--usage "..."] [--lang node|bash]
|
|
11
|
+
// open-claudia tool add <path> [--name n] — register an existing script as a tool
|
|
12
|
+
// [--pack <dir>] [--desc "..."] [--risk tier] [--requires "k1,k2"] [--usage "..."]
|
|
10
13
|
// open-claudia tool set <name> [--desc ...] — fix a tool's metadata in place
|
|
11
|
-
// [--pack <dir>] [--requires "k1,k2"] [--usage "..."]
|
|
14
|
+
// [--pack <dir>] [--risk tier] [--requires "k1,k2"] [--usage "..."]
|
|
15
|
+
// open-claudia tool note <name> — append to TOOL.md (auto-committed)
|
|
16
|
+
// [--issue "..."] [--state "..."] [--journal "..."]
|
|
12
17
|
// open-claudia tool edit <name> — print the file path to open & edit
|
|
13
|
-
// open-claudia tool run <name> [args...] — run it
|
|
18
|
+
// open-claudia tool run <name> [args...] — run it (keyring pre-loaded, risk-gated)
|
|
19
|
+
// open-claudia tool migrate — convert legacy flat-file tools to directories
|
|
14
20
|
// open-claudia tool remove <name> — delete one
|
|
15
21
|
// open-claudia tool path — print the tools directory
|
|
16
22
|
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
23
|
+
// A tool is a directory: executable + TOOL.md (Interface · Known issues ·
|
|
24
|
+
// State · Journal) + state.json (runtime telemetry). The dir is git-tracked;
|
|
25
|
+
// every change commits, so `git -C $(open-claudia tool path) log` is the
|
|
26
|
+
// upgrade history.
|
|
27
|
+
//
|
|
28
|
+
// Risk tiers gate `tool run`: write-tier needs --yes, destructive-tier needs
|
|
29
|
+
// --yes-destructive — get the user's approval first; the flag records that
|
|
30
|
+
// deliberate step. Read-only tools run freely.
|
|
31
|
+
//
|
|
32
|
+
// Fix before fork: when a tool is wrong, prefer `tool set` (metadata), `tool
|
|
33
|
+
// note` (docs), or `tool edit` (body) over adding a near-duplicate. Hardcoded
|
|
34
|
+
// secrets BLOCK a save; other lints (requires/keyring mismatch, syntax,
|
|
35
|
+
// dangling pack link) stay advisory.
|
|
21
36
|
//
|
|
22
37
|
// Credentials: a tool runs with the operational keyring merged into its env, so
|
|
23
38
|
// reference creds as $NAME inside the script. `open-claudia keyring list` shows
|
|
@@ -25,6 +40,7 @@
|
|
|
25
40
|
|
|
26
41
|
const { spawnSync } = require("child_process");
|
|
27
42
|
const tools = require("../core/tools");
|
|
43
|
+
const repo = require("../core/tool-repo");
|
|
28
44
|
|
|
29
45
|
// Minimal flag parser: pulls --key value (and --key=value) out of argv, returns
|
|
30
46
|
// { positional, flags }.
|
|
@@ -88,6 +104,17 @@ function run(args) {
|
|
|
88
104
|
const cmd = (args[0] || "list").toLowerCase();
|
|
89
105
|
const rest = args.slice(1);
|
|
90
106
|
|
|
107
|
+
// Auto-migrate legacy flat-file tools to the directory layout on first use
|
|
108
|
+
// (idempotent, one git commit per tool, telemetry carried over).
|
|
109
|
+
if (cmd !== "path") {
|
|
110
|
+
try {
|
|
111
|
+
if (repo.hasLegacyTools()) {
|
|
112
|
+
const { migrated } = repo.migrateLegacyTools();
|
|
113
|
+
if (migrated.length) console.error(`(migrated ${migrated.length} tool(s) to directory layout: ${migrated.join(", ")})`);
|
|
114
|
+
}
|
|
115
|
+
} catch (e) { /* best-effort */ }
|
|
116
|
+
}
|
|
117
|
+
|
|
91
118
|
switch (cmd) {
|
|
92
119
|
case "list":
|
|
93
120
|
case "ls": {
|
|
@@ -98,7 +125,8 @@ function run(args) {
|
|
|
98
125
|
console.log(`${all.length} reusable tool(s) — run via 'open-claudia tool run <name>':\n`);
|
|
99
126
|
for (const t of all) {
|
|
100
127
|
const skill = t.pack ? ` [skill: ${t.pack}]` : "";
|
|
101
|
-
|
|
128
|
+
const risk = t.risk === "read-only" ? "" : ` [${t.risk || "write?"}]`;
|
|
129
|
+
console.log(`• ${t.name} — ${t.description || "(no description)"}${skill}${risk}`);
|
|
102
130
|
}
|
|
103
131
|
console.log(`\nDocs: open-claudia tool show <name> · Dir: ${tools.TOOLS_DIR}`);
|
|
104
132
|
break;
|
|
@@ -125,13 +153,15 @@ function run(args) {
|
|
|
125
153
|
|
|
126
154
|
case "show":
|
|
127
155
|
case "cat": {
|
|
128
|
-
const
|
|
129
|
-
|
|
156
|
+
const { positional, flags } = parseFlags(rest);
|
|
157
|
+
const name = positional[0];
|
|
158
|
+
if (!name) { console.error("Usage: tool show <name> [--source]"); process.exitCode = 1; return; }
|
|
130
159
|
const t = tools.findTool(name);
|
|
131
160
|
if (!t) { console.error(`No tool named "${name}". Run: open-claudia tool list`); process.exitCode = 1; return; }
|
|
132
161
|
console.log(`Tool: ${t.name}`);
|
|
133
162
|
if (t.description) console.log(`Description: ${t.description}`);
|
|
134
163
|
if (t.pack) console.log(`Skill pack: ${t.pack} (open-claudia pack show ${t.pack})`);
|
|
164
|
+
console.log(`Risk: ${t.risk || "(undeclared — treated as write)"}`);
|
|
135
165
|
console.log(`Usage: open-claudia tool run ${t.name}${t.usage ? " (" + t.usage + ")" : ""}`);
|
|
136
166
|
if (t.requires.length) {
|
|
137
167
|
const missing = tools.missingRequires(t);
|
|
@@ -139,6 +169,22 @@ function run(args) {
|
|
|
139
169
|
console.log(`Requires keyring keys: ${t.requires.join(", ")} (${status})`);
|
|
140
170
|
}
|
|
141
171
|
console.log(`File: ${t.file}${t.executable ? "" : " (not executable!)"}`);
|
|
172
|
+
|
|
173
|
+
// Telemetry: overall + per-verb health from state.json.
|
|
174
|
+
if (t.runCount || t.lastUsed) {
|
|
175
|
+
const exit = t.lastExit === undefined ? "" : ` · last exit ${t.lastExit}`;
|
|
176
|
+
console.log(`Runs: ${t.runCount}${t.lastUsed ? ` · last used ${t.lastUsed.slice(0, 16).replace("T", " ")}` : ""}${exit}`);
|
|
177
|
+
}
|
|
178
|
+
if (t.verbs && Object.keys(t.verbs).length) {
|
|
179
|
+
const rows = Object.entries(t.verbs).map(([v, s]) =>
|
|
180
|
+
` • ${v}: ${s.runs} run(s), last exit ${s.lastExit}${s.lastSuccess ? `, last success ${s.lastSuccess.slice(0, 10)}` : ""}`);
|
|
181
|
+
console.log("Verbs:\n" + rows.join("\n"));
|
|
182
|
+
}
|
|
183
|
+
try {
|
|
184
|
+
const log = repo.toolLog(t.name, 5);
|
|
185
|
+
if (log.length) console.log("Recent changes:\n" + log.map((l) => " " + l).join("\n"));
|
|
186
|
+
} catch (e) { /* git optional */ }
|
|
187
|
+
|
|
142
188
|
// Directed tool-graph: what tends to run right after / before this one.
|
|
143
189
|
try {
|
|
144
190
|
const graph = require("../core/tool-graph");
|
|
@@ -156,7 +202,13 @@ function run(args) {
|
|
|
156
202
|
}
|
|
157
203
|
}
|
|
158
204
|
} catch (e) { /* graph optional (old node) */ }
|
|
159
|
-
|
|
205
|
+
|
|
206
|
+
// Tiered disclosure: TOOL.md is the manual; source only when modifying.
|
|
207
|
+
const doc = tools.readToolDoc(t.name);
|
|
208
|
+
if (doc) console.log(`\n----- TOOL.md -----\n${doc.trim()}`);
|
|
209
|
+
if (flags.source) console.log(`\n----- source -----\n${t.content}`);
|
|
210
|
+
else if (!doc) console.log(`\n----- source (no TOOL.md yet) -----\n${t.content}`);
|
|
211
|
+
else console.log(`\n(source: open-claudia tool show ${t.name} --source)`);
|
|
160
212
|
break;
|
|
161
213
|
}
|
|
162
214
|
|
|
@@ -173,10 +225,14 @@ function run(args) {
|
|
|
173
225
|
name: typeof flags.name === "string" ? flags.name : undefined,
|
|
174
226
|
pack: typeof flags.pack === "string" ? flags.pack : undefined,
|
|
175
227
|
description: typeof flags.desc === "string" ? flags.desc : (typeof flags.description === "string" ? flags.description : undefined),
|
|
228
|
+
risk: typeof flags.risk === "string" ? flags.risk : undefined,
|
|
176
229
|
requires: typeof flags.requires === "string" ? flags.requires.split(/[,\s]+/).filter(Boolean) : undefined,
|
|
177
230
|
usage: typeof flags.usage === "string" ? flags.usage : undefined,
|
|
178
231
|
});
|
|
179
|
-
console.log(`Saved tool "${t.name}"${t.pack ? ` (skill: ${t.pack})` : ""}.`);
|
|
232
|
+
console.log(`Saved tool "${t.name}"${t.pack ? ` (skill: ${t.pack})` : ""} — risk: ${t.risk}.`);
|
|
233
|
+
if (t.defaultedRisk) {
|
|
234
|
+
console.log(` No --risk given → defaulted to "write" (runs will demand --yes). If it only reads, fix: open-claudia tool set ${t.name} --risk read-only`);
|
|
235
|
+
}
|
|
180
236
|
console.log(`Run it: open-claudia tool run ${t.name}`);
|
|
181
237
|
if (t.requires.length) {
|
|
182
238
|
const missing = tools.missingRequires(t);
|
|
@@ -217,17 +273,21 @@ function run(args) {
|
|
|
217
273
|
if (typeof flags.desc === "string") patch.description = flags.desc;
|
|
218
274
|
else if (typeof flags.description === "string") patch.description = flags.description;
|
|
219
275
|
if (typeof flags.pack === "string") patch.pack = flags.pack;
|
|
276
|
+
if (typeof flags.risk === "string") patch.risk = flags.risk;
|
|
220
277
|
if (typeof flags.requires === "string") patch.requires = flags.requires.split(/[,\s]+/).filter(Boolean);
|
|
221
278
|
if (typeof flags.usage === "string") patch.usage = flags.usage;
|
|
222
279
|
if (Object.keys(patch).length === 0) {
|
|
223
|
-
console.error('Nothing to set. Pass at least one of --desc / --pack / --requires / --usage. Body edits: open-claudia tool edit ' + name);
|
|
280
|
+
console.error('Nothing to set. Pass at least one of --desc / --pack / --risk / --requires / --usage. Body edits: open-claudia tool edit ' + name);
|
|
224
281
|
process.exitCode = 1; return;
|
|
225
282
|
}
|
|
226
|
-
|
|
283
|
+
let updated;
|
|
284
|
+
try { updated = tools.updateToolHeader(name, patch); }
|
|
285
|
+
catch (e) { console.error(`Could not update "${name}": ${e.message}`); process.exitCode = 1; return; }
|
|
227
286
|
if (!updated) { console.error(`Could not update "${name}".`); process.exitCode = 1; return; }
|
|
228
287
|
console.log(`Updated tool "${updated.name}".`);
|
|
229
288
|
console.log(` Description: ${updated.description || "(none)"}`);
|
|
230
289
|
if (updated.pack) console.log(` Skill pack: ${updated.pack}`);
|
|
290
|
+
console.log(` Risk: ${updated.risk || "(undeclared — treated as write)"}`);
|
|
231
291
|
if (updated.requires.length) console.log(` Requires: ${updated.requires.join(", ")}`);
|
|
232
292
|
if (updated.usage) console.log(` Usage: ${updated.usage}`);
|
|
233
293
|
reportSafety(updated);
|
|
@@ -252,14 +312,108 @@ function run(args) {
|
|
|
252
312
|
if (!name) { console.error("Usage: tool run <name> [args...]"); process.exitCode = 1; return; }
|
|
253
313
|
const t = tools.findTool(name);
|
|
254
314
|
if (!t) { console.error(`No tool named "${name}". Run: open-claudia tool list`); process.exitCode = 1; return; }
|
|
315
|
+
const toolArgs = rest.slice(1);
|
|
316
|
+
|
|
317
|
+
// Risk-tier gate: write needs --yes, destructive needs --yes-destructive.
|
|
318
|
+
// The flag is passed through to the tool (which may have its own gate).
|
|
319
|
+
const gate = tools.runGate(t, toolArgs);
|
|
320
|
+
if (!gate.ok) { console.error(gate.message); process.exitCode = 3; return; }
|
|
321
|
+
|
|
255
322
|
const missing = tools.missingRequires(t);
|
|
256
323
|
if (missing.length) {
|
|
257
324
|
console.error(`Cannot run ${t.name}: missing keyring keys ${missing.join(", ")}. Set them with 'open-claudia keyring set <name> <value>'.`);
|
|
258
325
|
process.exitCode = 1; return;
|
|
259
326
|
}
|
|
260
|
-
const
|
|
261
|
-
|
|
262
|
-
|
|
327
|
+
const started = Date.now();
|
|
328
|
+
const r = spawnSync(t.file, toolArgs, { stdio: "inherit", env: tools.runEnv() });
|
|
329
|
+
const ms = Date.now() - started;
|
|
330
|
+
if (r.error) {
|
|
331
|
+
tools.recordRunResult(t.name, { args: toolArgs, exit: -1, ms });
|
|
332
|
+
console.error(`Failed to run ${t.name}: ${r.error.message}`);
|
|
333
|
+
process.exitCode = 1; return;
|
|
334
|
+
}
|
|
335
|
+
const exit = typeof r.status === "number" ? r.status : 1;
|
|
336
|
+
tools.recordRunResult(t.name, { args: toolArgs, exit, ms });
|
|
337
|
+
if (exit !== 0) {
|
|
338
|
+
console.error(`(exit ${exit} recorded in ${t.name} telemetry — if this is a real defect, journal it: ` +
|
|
339
|
+
`open-claudia tool note ${t.name} --issue "<what broke>")`);
|
|
340
|
+
}
|
|
341
|
+
process.exitCode = exit;
|
|
342
|
+
break;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
case "scaffold":
|
|
346
|
+
case "new": {
|
|
347
|
+
const { positional, flags } = parseFlags(rest);
|
|
348
|
+
const name = positional[0];
|
|
349
|
+
if (!name) {
|
|
350
|
+
console.error('Usage: tool scaffold <name> [--pack dir] [--desc "..."] [--risk read-only|write|destructive] [--requires "k1,k2"] [--usage "..."] [--lang node|bash]');
|
|
351
|
+
process.exitCode = 1; return;
|
|
352
|
+
}
|
|
353
|
+
try {
|
|
354
|
+
const t = repo.scaffoldTool(name, {
|
|
355
|
+
pack: typeof flags.pack === "string" ? flags.pack : undefined,
|
|
356
|
+
description: typeof flags.desc === "string" ? flags.desc : (typeof flags.description === "string" ? flags.description : undefined),
|
|
357
|
+
risk: typeof flags.risk === "string" ? flags.risk : undefined,
|
|
358
|
+
requires: typeof flags.requires === "string" ? flags.requires.split(/[,\s]+/).filter(Boolean) : [],
|
|
359
|
+
usage: typeof flags.usage === "string" ? flags.usage : undefined,
|
|
360
|
+
lang: typeof flags.lang === "string" ? flags.lang : "node",
|
|
361
|
+
});
|
|
362
|
+
console.log(`Scaffolded tool "${t.name}" (risk: ${t.risk}).`);
|
|
363
|
+
console.log(` Executable: ${t.file}`);
|
|
364
|
+
console.log(` Docs: ${t.docFile}`);
|
|
365
|
+
console.log(`Fill in the verbs, then work through it: open-claudia tool run ${t.name} <verb>`);
|
|
366
|
+
} catch (e) {
|
|
367
|
+
console.error(`Could not scaffold: ${e.message}`); process.exitCode = 1;
|
|
368
|
+
}
|
|
369
|
+
break;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
case "note": {
|
|
373
|
+
const { positional, flags } = parseFlags(rest);
|
|
374
|
+
const name = positional[0];
|
|
375
|
+
const issue = typeof flags.issue === "string" ? flags.issue : null;
|
|
376
|
+
const state = typeof flags.state === "string" ? flags.state : null;
|
|
377
|
+
const journal = typeof flags.journal === "string" ? flags.journal : null;
|
|
378
|
+
if (!name || (!issue && !state && !journal)) {
|
|
379
|
+
console.error('Usage: tool note <name> [--issue "..."] [--state "..."] [--journal "..."]');
|
|
380
|
+
process.exitCode = 1; return;
|
|
381
|
+
}
|
|
382
|
+
const t = tools.findTool(name);
|
|
383
|
+
if (!t) { console.error(`No tool named "${name}". Run: open-claudia tool list`); process.exitCode = 1; return; }
|
|
384
|
+
if (!t.docFile) { console.error(`"${t.name}" is a legacy flat tool — run any tool command to migrate it first.`); process.exitCode = 1; return; }
|
|
385
|
+
try {
|
|
386
|
+
tools.assertNoSecrets([issue, state, journal].filter(Boolean).join("\n"), "note");
|
|
387
|
+
const fs = require("fs");
|
|
388
|
+
let doc = tools.readToolDoc(t.name) || repo.toolMdSkeleton(t);
|
|
389
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
390
|
+
const appendUnder = (heading, line) => {
|
|
391
|
+
const re = new RegExp(`(##\\s*${heading}[^\\n]*\\n)`, "i");
|
|
392
|
+
if (re.test(doc)) doc = doc.replace(re, `$1${line}\n`);
|
|
393
|
+
else doc += `\n## ${heading}\n${line}\n`;
|
|
394
|
+
};
|
|
395
|
+
if (issue) appendUnder("Known issues", `- [${date}] ${issue}`);
|
|
396
|
+
if (state) {
|
|
397
|
+
// State is a rewrite, not an append — it describes NOW.
|
|
398
|
+
const re = /(##\s*State[^\n]*\n)([\s\S]*?)(?=\n##\s|$)/i;
|
|
399
|
+
if (re.test(doc)) doc = doc.replace(re, `$1${state}\n\n`);
|
|
400
|
+
else doc += `\n## State\n${state}\n`;
|
|
401
|
+
}
|
|
402
|
+
if (journal) appendUnder("Journal", `- [${date}] ${journal}`);
|
|
403
|
+
fs.writeFileSync(t.docFile, doc, { mode: 0o600 });
|
|
404
|
+
repo.commitTool(t.name, `note ${t.name}: ${(issue || state || journal).slice(0, 60)}`);
|
|
405
|
+
console.log(`Noted in ${t.name}/TOOL.md${issue ? " (known issue)" : ""}${state ? " (state)" : ""}${journal ? " (journal)" : ""}.`);
|
|
406
|
+
} catch (e) {
|
|
407
|
+
console.error(`Could not note: ${e.message}`); process.exitCode = 1;
|
|
408
|
+
}
|
|
409
|
+
break;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
case "migrate": {
|
|
413
|
+
const { migrated, skipped } = repo.migrateLegacyTools();
|
|
414
|
+
if (!migrated.length && !skipped.length) console.log("Nothing to migrate — all tools already use the directory layout.");
|
|
415
|
+
if (migrated.length) console.log(`Migrated: ${migrated.join(", ")}`);
|
|
416
|
+
if (skipped.length) console.log(`Skipped (no tool header — left untouched): ${skipped.join(", ")}`);
|
|
263
417
|
break;
|
|
264
418
|
}
|
|
265
419
|
|
|
@@ -277,7 +431,7 @@ function run(args) {
|
|
|
277
431
|
break;
|
|
278
432
|
|
|
279
433
|
default:
|
|
280
|
-
console.log('Usage: open-claudia tool [list | search <query> | show <name> | add <path> [flags] | set <name> [flags] | edit <name> | run <name> [args] | remove <name> | path]');
|
|
434
|
+
console.log('Usage: open-claudia tool [list | search <query> | show <name> [--source] | scaffold <name> [flags] | add <path> [flags] | set <name> [flags] | note <name> [flags] | edit <name> | run <name> [args] | migrate | remove <name> | path]');
|
|
281
435
|
}
|
|
282
436
|
}
|
|
283
437
|
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
// Git-backed tool repository: every tool is a directory (executable + TOOL.md +
|
|
2
|
+
// state.json) inside a git-initialised TOOLS_DIR, so `git log` IS the upgrade
|
|
3
|
+
// history and any change is one revert from undone.
|
|
4
|
+
//
|
|
5
|
+
// Split from core/tools.js so the registry (read/parse/match) stays pure fs;
|
|
6
|
+
// this module owns everything that shells out to git, plus the scaffold and
|
|
7
|
+
// legacy-migration machinery that writes whole tool directories. Requires
|
|
8
|
+
// between the two modules are lazy to tolerate the natural circularity.
|
|
9
|
+
//
|
|
10
|
+
// state.json is deliberately gitignored: it is runtime telemetry stamped on
|
|
11
|
+
// every run — committing it would bury real code/doc changes in noise. The
|
|
12
|
+
// curated, human-readable condition of a tool lives in TOOL.md's State section.
|
|
13
|
+
|
|
14
|
+
const fs = require("fs");
|
|
15
|
+
const path = require("path");
|
|
16
|
+
const { spawnSync } = require("child_process");
|
|
17
|
+
|
|
18
|
+
const GIT_IDENT = ["-c", "user.name=open-claudia", "-c", "user.email=tools@open-claudia.local"];
|
|
19
|
+
const GITIGNORE = ["*/state.json", ".usage.json", "__pycache__/", ".DS_Store", ""].join("\n");
|
|
20
|
+
|
|
21
|
+
function toolsDir() {
|
|
22
|
+
return require("./tools").TOOLS_DIR;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function git(args, { ident = false } = {}) {
|
|
26
|
+
try {
|
|
27
|
+
const full = ["-C", toolsDir(), ...(ident ? GIT_IDENT : []), ...args];
|
|
28
|
+
const r = spawnSync("git", full, { encoding: "utf-8" });
|
|
29
|
+
if (r.error) return { ok: false, status: -1, stdout: "", stderr: String(r.error.message || r.error) };
|
|
30
|
+
return { ok: r.status === 0, status: r.status, stdout: (r.stdout || "").trim(), stderr: (r.stderr || "").trim() };
|
|
31
|
+
} catch (e) {
|
|
32
|
+
return { ok: false, status: -1, stdout: "", stderr: String(e.message || e) };
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function gitAvailable() {
|
|
37
|
+
try { return !spawnSync("git", ["--version"], { encoding: "utf-8" }).error; }
|
|
38
|
+
catch (e) { return false; }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Initialise the tools dir as a git repo (idempotent). Best-effort: on a box
|
|
42
|
+
// without git everything else still works, there's just no history.
|
|
43
|
+
function ensureRepo() {
|
|
44
|
+
const dir = toolsDir();
|
|
45
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
46
|
+
const ignoreFile = path.join(dir, ".gitignore");
|
|
47
|
+
if (!fs.existsSync(ignoreFile)) { try { fs.writeFileSync(ignoreFile, GITIGNORE, { mode: 0o600 }); } catch (e) {} }
|
|
48
|
+
if (fs.existsSync(path.join(dir, ".git"))) return true;
|
|
49
|
+
if (!gitAvailable()) return false;
|
|
50
|
+
if (!git(["init", "-q"]).ok) return false;
|
|
51
|
+
git(["add", "-A"]);
|
|
52
|
+
git(["commit", "-q", "-m", "init tool repository"], { ident: true });
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Stage + commit one tool's paths (or everything with "."). No-op when the tree
|
|
57
|
+
// is clean or git is unavailable. Every add/change/remove funnels through here.
|
|
58
|
+
function commitTool(name, message) {
|
|
59
|
+
if (!ensureRepo()) return false;
|
|
60
|
+
const target = name ? require("./tools").sanitizeName(name) : ".";
|
|
61
|
+
if (!git(["add", "-A", "--", target || "."]).ok) return false;
|
|
62
|
+
const staged = git(["diff", "--cached", "--quiet"]);
|
|
63
|
+
if (staged.status === 0) return false; // nothing to commit
|
|
64
|
+
return git(["commit", "-q", "-m", message], { ident: true }).ok;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Recent history for one tool — surfaced by `tool show` as the upgrade log.
|
|
68
|
+
function toolLog(name, limit = 5) {
|
|
69
|
+
const n = require("./tools").sanitizeName(name);
|
|
70
|
+
if (!n || !fs.existsSync(path.join(toolsDir(), ".git"))) return [];
|
|
71
|
+
const r = git(["log", "--date=short", `--pretty=%ad %s`, "-n", String(limit), "--", n]);
|
|
72
|
+
return r.ok && r.stdout ? r.stdout.split("\n").filter(Boolean) : [];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ── Scaffold ─────────────────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
function today() { return new Date().toISOString().slice(0, 10); }
|
|
78
|
+
|
|
79
|
+
function toolMdSkeleton({ name, description, usage, risk, requires, pack, stateLine, journalLine }) {
|
|
80
|
+
return [
|
|
81
|
+
`# ${name}`,
|
|
82
|
+
"",
|
|
83
|
+
"## Interface",
|
|
84
|
+
description || "(describe what system this tool fronts)",
|
|
85
|
+
"",
|
|
86
|
+
`Usage: \`open-claudia tool run ${name}${usage ? " " + usage.replace(new RegExp("^" + name + "\\s*"), "") : " <verb> [args]"}\``,
|
|
87
|
+
`Risk: ${risk || "write"}${requires && requires.length ? ` · Requires: ${requires.join(", ")}` : ""}${pack ? ` · Pack: ${pack}` : ""}`,
|
|
88
|
+
"",
|
|
89
|
+
"## Known issues",
|
|
90
|
+
"(none yet)",
|
|
91
|
+
"",
|
|
92
|
+
"## State",
|
|
93
|
+
stateLine || `Scaffolded ${today()} — not yet proven against the live system.`,
|
|
94
|
+
"",
|
|
95
|
+
"## Journal",
|
|
96
|
+
`- [${today()}] ${journalLine || "Scaffolded."}`,
|
|
97
|
+
"",
|
|
98
|
+
].join("\n");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function nodeStub({ name, description, pack, risk, requires, usage }) {
|
|
102
|
+
const req = requires && requires.length ? `// requires: ${requires.join(", ")}\n` : "";
|
|
103
|
+
return `#!/usr/bin/env node
|
|
104
|
+
// open-claudia-tool: ${name}
|
|
105
|
+
// description: ${description || ""}
|
|
106
|
+
${pack ? `// pack: ${pack}\n` : ""}// risk: ${risk}
|
|
107
|
+
${req}// usage: ${usage || `${name} <verb> [args]`}
|
|
108
|
+
//
|
|
109
|
+
// One CLI per system, verbs as subcommands. Add a function per verb below.
|
|
110
|
+
// Confirmation flags (--yes / --yes-destructive) are enforced by the tool
|
|
111
|
+
// runner and may appear in argv — ignore them unless you need them.
|
|
112
|
+
|
|
113
|
+
const args = process.argv.slice(2).filter((a) => a !== "--yes" && a !== "--yes-destructive");
|
|
114
|
+
const verb = args[0] || "help";
|
|
115
|
+
|
|
116
|
+
const HANDLERS = {
|
|
117
|
+
help() {
|
|
118
|
+
console.log("${name} — ${(description || "").replace(/"/g, '\\"')}");
|
|
119
|
+
console.log("verbs: " + Object.keys(HANDLERS).join(", "));
|
|
120
|
+
},
|
|
121
|
+
// example(rest) { ... },
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const fn = HANDLERS[verb] || (verb === "--help" ? HANDLERS.help : null);
|
|
125
|
+
if (!fn) { console.error("unknown verb: " + verb); HANDLERS.help(); process.exit(1); }
|
|
126
|
+
Promise.resolve(fn(args.slice(1))).catch((e) => { console.error(e.message || e); process.exit(1); });
|
|
127
|
+
`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function bashStub({ name, description, pack, risk, requires, usage }) {
|
|
131
|
+
const req = requires && requires.length ? `# requires: ${requires.join(", ")}\n` : "";
|
|
132
|
+
return `#!/usr/bin/env bash
|
|
133
|
+
# open-claudia-tool: ${name}
|
|
134
|
+
# description: ${description || ""}
|
|
135
|
+
${pack ? `# pack: ${pack}\n` : ""}# risk: ${risk}
|
|
136
|
+
${req}# usage: ${usage || `${name} <verb> [args]`}
|
|
137
|
+
#
|
|
138
|
+
# One CLI per system, verbs as subcommands: add a case per verb below.
|
|
139
|
+
# Confirmation flags (--yes / --yes-destructive) are enforced by the tool
|
|
140
|
+
# runner and may appear in "$@" — ignore them unless you need them.
|
|
141
|
+
set -euo pipefail
|
|
142
|
+
|
|
143
|
+
verb="\${1:-help}"; shift || true
|
|
144
|
+
|
|
145
|
+
case "$verb" in
|
|
146
|
+
help|--help)
|
|
147
|
+
echo "${name} — ${description || ""}"
|
|
148
|
+
echo "verbs: help" ;;
|
|
149
|
+
*)
|
|
150
|
+
echo "unknown verb: $verb" >&2; exit 1 ;;
|
|
151
|
+
esac
|
|
152
|
+
`;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Create a new tool directory with an executable stub, TOOL.md skeleton, and
|
|
156
|
+
// initial state.json — then commit. The generated stub already carries the full
|
|
157
|
+
// header (name, desc, pack, risk, requires, usage) so the tool is registered
|
|
158
|
+
// and runnable the moment it exists.
|
|
159
|
+
function scaffoldTool(name, opts = {}) {
|
|
160
|
+
const tools = require("./tools");
|
|
161
|
+
const n = tools.sanitizeName(name);
|
|
162
|
+
if (!n) throw new Error("scaffold needs a tool name");
|
|
163
|
+
if (tools.findTool(n)) throw new Error(`tool "${n}" already exists — extend it (tool edit ${n}) instead of re-scaffolding`);
|
|
164
|
+
const risk = tools.normalizeRisk(opts.risk) || "write";
|
|
165
|
+
const requires = [].concat(opts.requires || []).filter(Boolean);
|
|
166
|
+
const spec = { name: n, description: opts.description || "", pack: opts.pack || "", risk, requires, usage: opts.usage || "" };
|
|
167
|
+
|
|
168
|
+
ensureRepo();
|
|
169
|
+
const dir = path.join(toolsDir(), n);
|
|
170
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
171
|
+
const stub = (opts.lang === "bash" || opts.lang === "sh") ? bashStub(spec) : nodeStub(spec);
|
|
172
|
+
fs.writeFileSync(path.join(dir, "tool"), stub, { mode: 0o700 });
|
|
173
|
+
try { fs.chmodSync(path.join(dir, "tool"), 0o700); } catch (e) {}
|
|
174
|
+
fs.writeFileSync(path.join(dir, "TOOL.md"), toolMdSkeleton(spec), { mode: 0o600 });
|
|
175
|
+
tools.writeState(n, { createdAt: new Date().toISOString(), runCount: 0 });
|
|
176
|
+
commitTool(n, `scaffold ${n}`);
|
|
177
|
+
return tools.findTool(n);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ── Legacy migration ─────────────────────────────────────────────────────────
|
|
181
|
+
// Convert flat-file tools (pre-2.7 layout) into directories, one commit each.
|
|
182
|
+
// Seeds each tool's state.json from the old .usage.json sidecar and generates a
|
|
183
|
+
// TOOL.md so every migrated tool arrives with its memory intact. Files in the
|
|
184
|
+
// tools dir that don't parse as tools are left untouched.
|
|
185
|
+
function migrateLegacyTools() {
|
|
186
|
+
const tools = require("./tools");
|
|
187
|
+
const dir = toolsDir();
|
|
188
|
+
let entries;
|
|
189
|
+
try { entries = fs.readdirSync(dir); } catch (e) { return { migrated: [], skipped: [] }; }
|
|
190
|
+
|
|
191
|
+
const legacy = [];
|
|
192
|
+
for (const name of entries) {
|
|
193
|
+
if (name.startsWith(".")) continue;
|
|
194
|
+
const p = path.join(dir, name);
|
|
195
|
+
try { if (!fs.statSync(p).isFile()) continue; } catch (e) { continue; }
|
|
196
|
+
legacy.push(name);
|
|
197
|
+
}
|
|
198
|
+
if (!legacy.length) return { migrated: [], skipped: [] };
|
|
199
|
+
|
|
200
|
+
ensureRepo();
|
|
201
|
+
const usage = tools.readUsage();
|
|
202
|
+
const migrated = [], skipped = [];
|
|
203
|
+
|
|
204
|
+
for (const fileName of legacy) {
|
|
205
|
+
const src = path.join(dir, fileName);
|
|
206
|
+
let content;
|
|
207
|
+
try { content = fs.readFileSync(src, "utf-8"); } catch (e) { skipped.push(fileName); continue; }
|
|
208
|
+
const header = tools.parseHeader(content);
|
|
209
|
+
if (!header) { skipped.push(fileName); continue; } // not a registered tool — leave it alone
|
|
210
|
+
|
|
211
|
+
const n = header.name || tools.sanitizeName(fileName);
|
|
212
|
+
const tmp = path.join(dir, `.migrating-${n}`);
|
|
213
|
+
try {
|
|
214
|
+
fs.renameSync(src, tmp);
|
|
215
|
+
const toolDir = path.join(dir, n);
|
|
216
|
+
fs.mkdirSync(toolDir, { recursive: true, mode: 0o700 });
|
|
217
|
+
fs.renameSync(tmp, path.join(toolDir, "tool"));
|
|
218
|
+
try { fs.chmodSync(path.join(toolDir, "tool"), 0o700); } catch (e) {}
|
|
219
|
+
|
|
220
|
+
const u = usage[n] || {};
|
|
221
|
+
tools.writeState(n, {
|
|
222
|
+
createdAt: u.createdAt || new Date().toISOString(),
|
|
223
|
+
lastUsed: u.lastUsed || "",
|
|
224
|
+
runCount: u.runCount || 0,
|
|
225
|
+
});
|
|
226
|
+
if (!fs.existsSync(path.join(toolDir, "TOOL.md"))) {
|
|
227
|
+
fs.writeFileSync(path.join(toolDir, "TOOL.md"), toolMdSkeleton({
|
|
228
|
+
...header,
|
|
229
|
+
risk: header.risk || "",
|
|
230
|
+
stateLine: `Migrated from flat layout ${today()}. Risk tier ${header.risk ? `"${header.risk}"` : "not yet classified — treated as write (fail-safe) until set"}.`,
|
|
231
|
+
journalLine: "Migrated from flat file layout; telemetry carried over from .usage.json.",
|
|
232
|
+
}), { mode: 0o600 });
|
|
233
|
+
}
|
|
234
|
+
delete usage[n];
|
|
235
|
+
commitTool(n, `migrate ${n} to directory layout`);
|
|
236
|
+
migrated.push(n);
|
|
237
|
+
} catch (e) {
|
|
238
|
+
// Roll the file back if anything failed mid-move.
|
|
239
|
+
try { if (fs.existsSync(tmp)) fs.renameSync(tmp, src); } catch (e2) {}
|
|
240
|
+
skipped.push(fileName);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (migrated.length) {
|
|
245
|
+
try {
|
|
246
|
+
if (Object.keys(usage).length) fs.writeFileSync(tools.USAGE_FILE, JSON.stringify(usage), { mode: 0o600 });
|
|
247
|
+
else fs.rmSync(tools.USAGE_FILE, { force: true });
|
|
248
|
+
} catch (e) {}
|
|
249
|
+
}
|
|
250
|
+
return { migrated, skipped };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// True when flat-file tools still exist (cheap check used to auto-trigger
|
|
254
|
+
// migration from the CLI entry point).
|
|
255
|
+
function hasLegacyTools() {
|
|
256
|
+
const tools = require("./tools");
|
|
257
|
+
let entries;
|
|
258
|
+
try { entries = fs.readdirSync(toolsDir()); } catch (e) { return false; }
|
|
259
|
+
for (const name of entries) {
|
|
260
|
+
if (name.startsWith(".")) continue;
|
|
261
|
+
const p = path.join(toolsDir(), name);
|
|
262
|
+
try {
|
|
263
|
+
if (!fs.statSync(p).isFile()) continue;
|
|
264
|
+
if (tools.parseHeader(fs.readFileSync(p, "utf-8"))) return true;
|
|
265
|
+
} catch (e) {}
|
|
266
|
+
}
|
|
267
|
+
return false;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
module.exports = { ensureRepo, commitTool, toolLog, scaffoldTool, migrateLegacyTools, hasLegacyTools, toolMdSkeleton };
|