@inetafrica/open-claudia 2.6.59 → 2.7.1

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.
@@ -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 };