@inetafrica/open-claudia 2.6.58 → 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/test-tools.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // Reusable tools: a script the agent crystallises must register with a parseable
2
- // header, run with the keyring merged into its env (preauth), and be findable by
3
- // the always-on tool index. This exercises the core/tools.js library end to end
2
+ // header, live in the git-tracked directory layout (tool + TOOL.md + state.json),
3
+ // run with the keyring merged into its env (preauth), be risk-gated, and be
4
+ // findable by the always-on tool index. Exercises core/tools.js + core/tool-repo.js
4
5
  // in an isolated TOOLS_DIR so nothing touches the real ~/.open-claudia/tools.
5
6
  const assert = require("assert");
6
7
  const fs = require("fs");
@@ -11,9 +12,11 @@ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "tools-test-"));
11
12
  process.env.TOOLS_DIR = path.join(tmp, "tools");
12
13
 
13
14
  const tools = require("./core/tools");
15
+ const repo = require("./core/tool-repo");
14
16
 
15
17
  // ── add a bare script (no header): a header is synthesised from opts, shebang
16
- // is preserved on line 1, and the file is made executable ──
18
+ // is preserved on line 1, the tool lands in the directory layout, and the
19
+ // undeclared risk tier defaults to write (fail-safe) ──
17
20
  const srcA = path.join(tmp, "hello.sh");
18
21
  fs.writeFileSync(srcA, "#!/usr/bin/env bash\necho \"hi $1\"\n");
19
22
  const a = tools.addTool(srcA, {
@@ -31,6 +34,20 @@ assert.strictEqual(a.usage, "hello <name>", "usage parsed back");
31
34
  assert.ok(a.executable, "stored tool is chmod +x");
32
35
  assert.ok(a.content.startsWith("#!/usr/bin/env bash\n"), "shebang stays on line 1");
33
36
  assert.ok(/# open-claudia-tool: hello/.test(a.content), "marker line injected with '#' prefix for a shell script");
37
+ assert.strictEqual(a.risk, "write", "undeclared risk defaults to write");
38
+ assert.strictEqual(a.defaultedRisk, true, "defaulted risk is flagged for the CLI warning");
39
+ assert.strictEqual(a.layout, "dir", "new tools land in the directory layout");
40
+ assert.strictEqual(a.file, path.join(process.env.TOOLS_DIR, "hello", "tool"), "executable lives at <name>/tool");
41
+ assert.ok(fs.existsSync(path.join(process.env.TOOLS_DIR, "hello", "TOOL.md")), "TOOL.md skeleton generated");
42
+ assert.ok(fs.existsSync(path.join(process.env.TOOLS_DIR, "hello", "state.json")), "state.json created");
43
+ assert.ok(/## Known issues/.test(tools.readToolDoc("hello")), "TOOL.md carries the four sections");
44
+
45
+ // ── explicit risk is honoured and normalised ──
46
+ const srcRo = path.join(tmp, "peek.sh");
47
+ fs.writeFileSync(srcRo, "#!/usr/bin/env bash\necho peek\n");
48
+ const ro = tools.addTool(srcRo, { name: "peek", description: "look at things", risk: "READONLY" });
49
+ assert.strictEqual(ro.risk, "read-only", "risk value is normalised (READONLY → read-only)");
50
+ assert.ok(!ro.defaultedRisk, "explicit risk is not flagged as defaulted");
34
51
 
35
52
  // ── findTool / listTools see it ──
36
53
  assert.strictEqual(tools.findTool("hello").name, "hello", "findTool resolves by name");
@@ -45,7 +62,8 @@ assert.ok(/\/\/ open-claudia-tool: fetch/.test(b.content), "JS script gets '//'
45
62
  assert.strictEqual(b.requires.length, 0, "no requires when none supplied");
46
63
 
47
64
  // ── a source that already declares its own header is respected; a --pack passed
48
- // in is grafted on without clobbering the existing marker ──
65
+ // in is grafted on without clobbering the existing marker; missing risk is
66
+ // grafted as write ──
49
67
  const srcC = path.join(tmp, "self-doc.sh");
50
68
  fs.writeFileSync(
51
69
  srcC,
@@ -55,6 +73,24 @@ const c = tools.addTool(srcC, { pack: "linked-pack" });
55
73
  assert.strictEqual(c.name, "selfdoc", "existing marker name wins");
56
74
  assert.strictEqual(c.description, "pre-documented", "existing description preserved");
57
75
  assert.strictEqual(c.pack, "linked-pack", "--pack grafted onto a self-documented script");
76
+ assert.strictEqual(c.risk, "write", "missing risk grafted as write on a self-documented script");
77
+ assert.strictEqual((c.content.match(/open-claudia-tool: selfdoc/g) || []).length, 1, "marker survives exactly once");
78
+
79
+ // ── a source declaring its own risk keeps it ──
80
+ const srcD = path.join(tmp, "declared.sh");
81
+ fs.writeFileSync(
82
+ srcD,
83
+ "#!/usr/bin/env bash\n# open-claudia-tool: declared\n# risk: destructive\necho boom\n"
84
+ );
85
+ const d = tools.addTool(srcD, {});
86
+ assert.strictEqual(d.risk, "destructive", "declared risk tier survives add");
87
+ assert.ok(!d.defaultedRisk, "declared risk is not flagged as defaulted");
88
+
89
+ // ── addTool REFUSES a hardcoded secret (the tools repo history is forever) ──
90
+ const srcSecret = path.join(tmp, "leaky.sh");
91
+ fs.writeFileSync(srcSecret, "#!/usr/bin/env bash\ncurl -H 'x: sk-ant-abcdefgh12345678'\n");
92
+ assert.throws(() => tools.addTool(srcSecret, { name: "leaky" }), /secret/i, "hardcoded secret blocks the save");
93
+ assert.strictEqual(tools.findTool("leaky"), null, "refused tool is not registered");
58
94
 
59
95
  // ── missingRequires: keys absent from keyring AND env are reported; present env
60
96
  // keys are not ──
@@ -69,17 +105,33 @@ delete process.env.api_key;
69
105
  const env = tools.runEnv();
70
106
  assert.ok(env && typeof env === "object" && env.PATH, "runEnv yields an env object carrying PATH");
71
107
 
72
- // ── toolNameFromPath only recognises a direct child of TOOLS_DIR ──
108
+ // ── toolNameFromPath recognises legacy flat files and the directory layout's
109
+ // agent-editable members; state.json and outside paths do not resolve ──
73
110
  assert.strictEqual(
74
- tools.toolNameFromPath(path.join(process.env.TOOLS_DIR, "hello")),
111
+ tools.toolNameFromPath(path.join(process.env.TOOLS_DIR, "hello", "tool")),
75
112
  "hello",
76
- "a file directly in TOOLS_DIR resolves to its tool name"
113
+ "<name>/tool resolves to the tool name"
114
+ );
115
+ assert.strictEqual(
116
+ tools.toolNameFromPath(path.join(process.env.TOOLS_DIR, "hello", "TOOL.md")),
117
+ "hello",
118
+ "<name>/TOOL.md resolves to the tool name"
119
+ );
120
+ assert.strictEqual(
121
+ tools.toolNameFromPath(path.join(process.env.TOOLS_DIR, "hello", "state.json")),
122
+ null,
123
+ "state.json is machine telemetry — not a tool edit"
124
+ );
125
+ assert.strictEqual(
126
+ tools.toolNameFromPath(path.join(process.env.TOOLS_DIR, "legacy-flat")),
127
+ "legacy-flat",
128
+ "a direct child of TOOLS_DIR still resolves (legacy layout)"
77
129
  );
78
130
  assert.strictEqual(tools.toolNameFromPath("/etc/passwd"), null, "a path outside TOOLS_DIR → null");
79
131
  assert.strictEqual(
80
- tools.toolNameFromPath(path.join(process.env.TOOLS_DIR, "sub", "deep")),
132
+ tools.toolNameFromPath(path.join(process.env.TOOLS_DIR, "sub", "deep", "deeper")),
81
133
  null,
82
- "a nested path under TOOLS_DIR → null"
134
+ "a 3-deep path under TOOLS_DIR → null"
83
135
  );
84
136
 
85
137
  // ── parseHeader returns null for a script with no marker line ──
@@ -95,15 +147,53 @@ assert.ok(!mFetch.some((m) => m.name === "hello"), "an unrelated tool does not m
95
147
  assert.deepStrictEqual(tools.matchTools("zzzznomatchatall"), [], "no terms hit → empty match");
96
148
  assert.deepStrictEqual(tools.matchTools(""), [], "empty query → empty match");
97
149
 
98
- // ── recordRun telemetry: bumps runCount + stamps lastUsed, visible via readUsage
99
- // and surfaced on the tool record itself ──
150
+ // ── recordRunResult telemetry: stamps state.json with run counts, exit codes,
151
+ // per-verb health, and a capped history ──
100
152
  assert.strictEqual(tools.findTool("hello").runCount, 0, "fresh tool has runCount 0");
101
- tools.recordRun("hello");
102
- assert.strictEqual(tools.readUsage().hello.runCount, 1, "recordRun bumps the usage sidecar");
103
- assert.ok(tools.readUsage().hello.lastUsed, "recordRun stamps lastUsed");
153
+ tools.recordRunResult("hello", { args: ["greet", "world"], exit: 0, ms: 42 });
154
+ let hState = tools.readState("hello");
155
+ assert.strictEqual(hState.runCount, 1, "recordRunResult bumps runCount in state.json");
156
+ assert.ok(hState.lastUsed, "recordRunResult stamps lastUsed");
157
+ assert.strictEqual(hState.lastExit, 0, "recordRunResult records the exit code");
158
+ assert.ok(hState.lastSuccess, "a zero exit stamps lastSuccess");
159
+ assert.strictEqual(hState.verbs.greet.runs, 1, "per-verb run count tracked (verb = first non-flag arg)");
160
+ assert.ok(hState.verbs.greet.lastSuccess, "per-verb lastSuccess tracked");
161
+ assert.strictEqual(hState.history[0].args, "greet world", "history records the arg line");
104
162
  assert.strictEqual(tools.findTool("hello").runCount, 1, "runCount surfaces on the tool record");
163
+
164
+ tools.recordRunResult("hello", { args: ["--yes", "assign"], exit: 2, ms: 10 });
165
+ hState = tools.readState("hello");
166
+ assert.strictEqual(hState.runCount, 2, "a second run increments again");
167
+ assert.strictEqual(hState.lastExit, 2, "failure exit recorded");
168
+ assert.ok(hState.lastFailure, "a non-zero exit stamps lastFailure");
169
+ assert.strictEqual(hState.verbs.assign.lastExit, 2, "flags are skipped when picking the verb");
170
+
171
+ for (let i = 0; i < 25; i++) tools.recordRunResult("hello", { args: ["spam"], exit: 0, ms: 1 });
172
+ assert.ok(tools.readState("hello").history.length <= 20, "run history is capped");
173
+
174
+ // ── recordRun (end-of-turn transcript parse) is a no-op for directory tools —
175
+ // the CLI already stamped the authoritative result; double-counting would lie ──
176
+ const beforeNoop = tools.readState("hello").runCount;
105
177
  tools.recordRun("hello");
106
- assert.strictEqual(tools.findTool("hello").runCount, 2, "a second run increments again");
178
+ assert.strictEqual(tools.readState("hello").runCount, beforeNoop, "recordRun no-ops on a dir-layout tool");
179
+
180
+ // ── legacy flat tools: recordRun still feeds the sidecar until migration ──
181
+ const legacyFile = path.join(process.env.TOOLS_DIR, "oldy");
182
+ fs.writeFileSync(legacyFile, "#!/usr/bin/env bash\n# open-claudia-tool: oldy\n# description: legacy widget probe\necho old\n", { mode: 0o700 });
183
+ assert.strictEqual(tools.findTool("oldy").layout, "file", "flat file reads as legacy layout");
184
+ tools.recordRun("oldy");
185
+ assert.strictEqual(tools.readUsage().oldy.runCount, 1, "recordRun bumps the legacy sidecar");
186
+ assert.strictEqual(tools.findTool("oldy").runCount, 1, "legacy runCount surfaces on the tool record");
187
+
188
+ // ── migration: flat file → directory, telemetry carried over, sidecar cleaned ──
189
+ const mig = repo.migrateLegacyTools();
190
+ assert.ok(mig.migrated.includes("oldy"), "legacy tool is migrated");
191
+ const oldy = tools.findTool("oldy");
192
+ assert.strictEqual(oldy.layout, "dir", "migrated tool now uses the directory layout");
193
+ assert.strictEqual(oldy.runCount, 1, "telemetry carried over from the sidecar");
194
+ assert.ok(fs.existsSync(path.join(process.env.TOOLS_DIR, "oldy", "TOOL.md")), "migration generates TOOL.md");
195
+ assert.ok(!tools.readUsage().oldy, "sidecar entry cleaned after migration");
196
+ assert.ok(!repo.hasLegacyTools(), "no legacy tools remain after migration");
107
197
 
108
198
  // ── matchTools tie-break: equal score → more-run tool ranks first ──
109
199
  for (const n of ["alpha", "beta"]) {
@@ -111,7 +201,7 @@ for (const n of ["alpha", "beta"]) {
111
201
  fs.writeFileSync(s, "#!/usr/bin/env bash\necho x\n");
112
202
  tools.addTool(s, { name: n, description: "common widget gadget" });
113
203
  }
114
- tools.recordRun("beta");
204
+ tools.recordRunResult("beta", { args: [], exit: 0, ms: 1 });
115
205
  const ranked = tools.matchTools("common widget gadget").map((m) => m.name);
116
206
  assert.ok(ranked.indexOf("beta") < ranked.indexOf("alpha"), "on equal score, the more-run tool ranks first");
117
207
 
@@ -123,6 +213,25 @@ const bornHello = tools.findTool("hello").createdAt;
123
213
  tools.recordCreate("hello"); // a second create-record must NOT move the stamp
124
214
  assert.strictEqual(tools.findTool("hello").createdAt, bornHello, "createdAt is immutable once set");
125
215
 
216
+ // ── runGate: read-only runs freely; write demands --yes; destructive demands
217
+ // --yes-destructive (a plain --yes must NOT unlock it); undeclared → write ──
218
+ assert.ok(tools.runGate({ name: "t", risk: "read-only" }, []).ok, "read-only runs without flags");
219
+ assert.ok(!tools.runGate({ name: "t", risk: "write" }, []).ok, "write without --yes is refused");
220
+ assert.ok(tools.runGate({ name: "t", risk: "write" }, ["--yes"]).ok, "write with --yes runs");
221
+ assert.ok(!tools.runGate({ name: "t", risk: "destructive" }, ["--yes"]).ok, "plain --yes does not unlock destructive");
222
+ assert.ok(tools.runGate({ name: "t", risk: "destructive" }, ["--yes-destructive"]).ok, "destructive with --yes-destructive runs");
223
+ const undeclared = tools.runGate({ name: "t", risk: "" }, []);
224
+ assert.ok(!undeclared.ok && undeclared.tier === "write", "undeclared tier is treated as write (fail-safe)");
225
+ assert.ok(/destructive/i.test(tools.runGate({ name: "t", risk: "destructive" }, []).message), "destructive refusal spells out the warning");
226
+
227
+ // ── scaffold: creates dir + stub + TOOL.md, registered and runnable ──
228
+ const sc = repo.scaffoldTool("kwidget", { description: "widget system CLI", risk: "read-only", pack: "widgets", usage: "kwidget <verb>" });
229
+ assert.strictEqual(sc.name, "kwidget", "scaffold registers the tool");
230
+ assert.strictEqual(sc.risk, "read-only", "scaffold honours the risk tier");
231
+ assert.ok(sc.executable, "scaffolded stub is executable");
232
+ assert.ok(/## Journal/.test(tools.readToolDoc("kwidget")), "scaffolded TOOL.md has the sections");
233
+ assert.throws(() => repo.scaffoldTool("kwidget", {}), /already exists/, "re-scaffolding an existing tool is refused");
234
+
126
235
  // ── envRefsIn: pulls every shape of env read out of a script ──
127
236
  const refs = tools.envRefsIn('echo $FOO ${BAR}\nx=process.env.BAZ\ny=process.env["QUX"]\n');
128
237
  for (const k of ["FOO", "BAR", "BAZ", "QUX"]) assert.ok(refs.has(k), `envRefsIn finds ${k}`);
@@ -152,7 +261,7 @@ assert.strictEqual(tools.checkSyntax("/tmp/x.txt", "plain text, no shebang\n"),
152
261
 
153
262
  // ── updateToolHeader (the 'fix' verb): refreshes recognised field lines in place,
154
263
  // preserves the marker (exactly once), hand-written comments, and createdAt;
155
- // leaves the body untouched ──
264
+ // leaves the body untouched; sets risk; refuses an invalid tier ──
156
265
  const fixSrc = path.join(tmp, "fixme.sh");
157
266
  fs.writeFileSync(
158
267
  fixSrc,
@@ -160,22 +269,33 @@ fs.writeFileSync(
160
269
  );
161
270
  const fixed0 = tools.addTool(fixSrc, {});
162
271
  const bornFix = fixed0.createdAt;
163
- const fixed = tools.updateToolHeader("fixme", { description: "new desc", requires: ["WIDGET_KEY"], usage: "fixme <x>" });
272
+ const fixed = tools.updateToolHeader("fixme", { description: "new desc", requires: ["WIDGET_KEY"], usage: "fixme <x>", risk: "read-only" });
164
273
  assert.strictEqual(fixed.description, "new desc", "updateToolHeader rewrites the description");
165
274
  assert.deepStrictEqual(fixed.requires, ["WIDGET_KEY"], "updateToolHeader sets requires");
166
275
  assert.strictEqual(fixed.usage, "fixme <x>", "updateToolHeader sets usage");
276
+ assert.strictEqual(fixed.risk, "read-only", "updateToolHeader sets the risk tier");
167
277
  assert.strictEqual(fixed.createdAt, bornFix, "updateToolHeader preserves createdAt");
168
278
  assert.ok(/a hand note worth keeping/.test(fixed.content), "updateToolHeader keeps hand-written comments");
169
279
  assert.strictEqual((fixed.content.match(/open-claudia-tool: fixme/g) || []).length, 1, "marker survives exactly once");
170
280
  assert.ok(/echo "\$WIDGET_KEY"/.test(fixed.content), "updateToolHeader leaves the body untouched");
281
+ assert.throws(() => tools.updateToolHeader("fixme", { risk: "yolo" }), /invalid risk/i, "an invalid tier is refused");
171
282
  // requires-string form is normalised to a list
172
283
  const fixed2 = tools.updateToolHeader("fixme", { requires: "WIDGET_KEY, OTHER_KEY" });
173
284
  assert.deepStrictEqual(fixed2.requires, ["WIDGET_KEY", "OTHER_KEY"], "string requires split into a list");
174
- // a body that now reads OTHER_KEY would no longer be 'unused' — lint stays in sync
175
285
  assert.strictEqual(tools.updateToolHeader("does-not-exist", { description: "x" }), null, "updateToolHeader on a missing tool → null");
176
286
 
287
+ // ── git history: the tools dir is a repo and changes commit (skipped without git) ──
288
+ try {
289
+ require("child_process").execFileSync("git", ["--version"], { stdio: "pipe" });
290
+ assert.ok(fs.existsSync(path.join(process.env.TOOLS_DIR, ".git")), "tools dir is git-initialised");
291
+ const log = repo.toolLog("hello", 10);
292
+ assert.ok(log.length >= 1, "tool changes are committed (git log has entries)");
293
+ assert.ok(fs.readFileSync(path.join(process.env.TOOLS_DIR, ".gitignore"), "utf-8").includes("state.json"), "state.json is gitignored");
294
+ } catch (e) { console.log(" (git not available — history assertions skipped)"); }
295
+
177
296
  // ── remove ──
178
297
  assert.ok(tools.removeTool("hello"), "removeTool returns the removed tool");
179
298
  assert.strictEqual(tools.findTool("hello"), null, "removed tool is gone");
299
+ assert.ok(!fs.existsSync(path.join(process.env.TOOLS_DIR, "hello")), "removed tool's directory is gone");
180
300
 
181
301
  console.log("tools OK");