@ninemind/agentgem 0.1.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.
@@ -0,0 +1,103 @@
1
+ // src/gem/testbed.ts
2
+ // The inverse of introspect.ts: scaffold a runnable .claude/ testbed and merge selected
3
+ // GLOBAL artifacts into it. MCP/hook secrets are copied verbatim from raw global config into
4
+ // the LOCAL testbed only (never into a Gem). Owns its own read-merge-write disk I/O.
5
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
6
+ import { dirname, join } from "node:path";
7
+ import { TESTBED_FLAVORS } from "./testbedFlavors.js";
8
+ function readJson(abs) {
9
+ try {
10
+ const v = JSON.parse(readFileSync(abs, "utf8"));
11
+ return v && typeof v === "object" && !Array.isArray(v) ? v : {};
12
+ }
13
+ catch {
14
+ return {};
15
+ }
16
+ }
17
+ function writeJson(abs, obj) {
18
+ mkdirSync(dirname(abs), { recursive: true });
19
+ writeFileSync(abs, JSON.stringify(obj, null, 2) + "\n", "utf8");
20
+ }
21
+ export function scaffoldTestbed(root, name, flavor = "claude") {
22
+ const { created } = TESTBED_FLAVORS[flavor].scaffold(root, name);
23
+ return { root, created };
24
+ }
25
+ function marker(name) {
26
+ return { open: `<!-- agentgem:imported ${name} -->`, close: `<!-- /agentgem:end ${name} -->` };
27
+ }
28
+ function escapeRegex(s) {
29
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
30
+ }
31
+ // Replace an existing marked block, else append one. Keeps re-import idempotent.
32
+ // Returns true when an existing block was replaced (so the caller can report `overwritten`).
33
+ function upsertMarkedBlock(root, rel, name, content) {
34
+ const abs = join(root, rel);
35
+ const existing = existsSync(abs) ? readFileSync(abs, "utf8") : "";
36
+ const { open, close } = marker(name);
37
+ const block = `${open}\n${content}\n${close}`;
38
+ const re = new RegExp(`${escapeRegex(open)}[\\s\\S]*?${escapeRegex(close)}`);
39
+ const replaced = re.test(existing);
40
+ const next = replaced ? existing.replace(re, block) : `${existing}${existing && !existing.endsWith("\n") ? "\n" : ""}${block}\n`;
41
+ mkdirSync(dirname(abs), { recursive: true });
42
+ writeFileSync(abs, next, "utf8");
43
+ return replaced;
44
+ }
45
+ export function importArtifacts(root, selection, rawInv, flavor = "claude") {
46
+ const written = [];
47
+ const skipped = [];
48
+ const { import: imp, label } = TESTBED_FLAVORS[flavor];
49
+ for (const name of selection.skills ?? []) {
50
+ const sk = rawInv.skills.find((s) => s.name === name);
51
+ if (!sk) {
52
+ skipped.push({ artifact: name, reason: "not found in global inventory" });
53
+ continue;
54
+ }
55
+ const rel = imp.skillRel(name);
56
+ const overwritten = existsSync(join(root, rel));
57
+ mkdirSync(dirname(join(root, rel)), { recursive: true });
58
+ writeFileSync(join(root, rel), sk.content, "utf8");
59
+ written.push({ type: "skill", name, overwritten });
60
+ }
61
+ if (selection.includeInstructions) {
62
+ for (const ins of rawInv.instructions) {
63
+ const overwritten = upsertMarkedBlock(root, imp.instructionsFile, ins.name, ins.content);
64
+ written.push({ type: "instructions", name: ins.name, overwritten });
65
+ }
66
+ }
67
+ for (const name of selection.mcpServers ?? []) {
68
+ const m = rawInv.mcpServers.find((s) => s.name === name);
69
+ if (!m) {
70
+ skipped.push({ artifact: name, reason: "not found in global inventory" });
71
+ continue;
72
+ }
73
+ if (!imp.writeMcp) {
74
+ skipped.push({ artifact: name, reason: `${label} has no MCP-server config` });
75
+ continue;
76
+ }
77
+ const overwritten = imp.writeMcp(root, name, m.config); // raw config — local testbed only
78
+ written.push({ type: "mcp_server", name, overwritten });
79
+ }
80
+ for (const name of selection.hooks ?? []) {
81
+ const h = rawInv.hooks.find((x) => x.name === name);
82
+ if (!h) {
83
+ skipped.push({ artifact: name, reason: "not found in global inventory" });
84
+ continue;
85
+ }
86
+ if (!imp.supportsHooks) {
87
+ skipped.push({ artifact: name, reason: `${label} has no hooks` });
88
+ continue;
89
+ }
90
+ const abs = join(root, ".claude", "settings.json");
91
+ const doc = readJson(abs);
92
+ const hooks = (doc.hooks && typeof doc.hooks === "object" ? doc.hooks : {});
93
+ const groups = Array.isArray(hooks[h.event]) ? hooks[h.event] : [];
94
+ const exists = groups.some((g) => JSON.stringify(g) === JSON.stringify(h.config));
95
+ if (!exists)
96
+ groups.push(h.config);
97
+ hooks[h.event] = groups;
98
+ doc.hooks = hooks;
99
+ writeJson(abs, doc);
100
+ written.push({ type: "hook", name, overwritten: false });
101
+ }
102
+ return { written, skipped };
103
+ }
@@ -0,0 +1,287 @@
1
+ // src/gem/testbedFlavors.ts
2
+ // The set of harness "flavors" a testbed can be authored/test-driven as. Flavors drive the
3
+ // flavor-specific bits — detection, scaffold skeleton, test-drive run command, and import support.
4
+ // Introspection is flavor-agnostic (introspectProject reads whatever project config is present).
5
+ import { closeSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, statSync, writeFileSync } from "node:fs";
6
+ import { dirname, join } from "node:path";
7
+ import { parseTomlMcpServers, tomlMcpServers } from "./toml.js";
8
+ function writeIfAbsent(root, rel, content, created) {
9
+ const abs = join(root, rel);
10
+ if (existsSync(abs))
11
+ return;
12
+ mkdirSync(dirname(abs), { recursive: true });
13
+ writeFileSync(abs, content, "utf8");
14
+ created.push(rel);
15
+ }
16
+ // Remove every [mcp_servers...] section (header through to the next top-level table or EOF),
17
+ // preserving all other content. Lets writeMcpCodexToml regenerate just the MCP block.
18
+ function stripMcpServerBlocks(toml) {
19
+ const out = [];
20
+ let skipping = false;
21
+ for (const line of toml.split("\n")) {
22
+ if (/^\s*\[/.test(line))
23
+ skipping = /^\s*\[mcp_servers(\.|\])/.test(line); // a table header (re)sets the mode
24
+ if (!skipping)
25
+ out.push(line);
26
+ }
27
+ return out.join("\n");
28
+ }
29
+ export function writeMcpCodexToml(root, name, rawConfig) {
30
+ const abs = join(root, ".codex", "config.toml");
31
+ const text = existsSync(abs) ? readFileSync(abs, "utf8") : "";
32
+ const servers = parseTomlMcpServers(text);
33
+ const overwritten = name in servers;
34
+ servers[name] = rawConfig; // raw config — local testbed only
35
+ const nonMcp = stripMcpServerBlocks(text).trimEnd();
36
+ const arts = Object.entries(servers).map(([n, config]) => ({ type: "mcp_server", name: n, transport: "stdio", config }));
37
+ const block = tomlMcpServers(arts); // regenerated [mcp_servers...] section
38
+ mkdirSync(dirname(abs), { recursive: true });
39
+ writeFileSync(abs, (nonMcp ? nonMcp + "\n\n" : "") + block, "utf8");
40
+ return overwritten;
41
+ }
42
+ function readJsonFile(abs) {
43
+ try {
44
+ const v = JSON.parse(readFileSync(abs, "utf8"));
45
+ return v && typeof v === "object" && !Array.isArray(v) ? v : {};
46
+ }
47
+ catch {
48
+ return {};
49
+ }
50
+ }
51
+ export function writeMcpJson(root, name, rawConfig) {
52
+ const abs = join(root, ".mcp.json");
53
+ const doc = readJsonFile(abs);
54
+ const servers = (doc.mcpServers && typeof doc.mcpServers === "object" ? doc.mcpServers : {});
55
+ const overwritten = name in servers;
56
+ servers[name] = rawConfig; // raw config — local testbed only
57
+ doc.mcpServers = servers;
58
+ mkdirSync(dirname(abs), { recursive: true });
59
+ writeFileSync(abs, JSON.stringify(doc, null, 2) + "\n", "utf8");
60
+ return overwritten;
61
+ }
62
+ export const TESTBED_FLAVORS = {
63
+ claude: {
64
+ id: "claude", label: "Claude Code", runCommand: "claude", importSupported: true,
65
+ detect: (root) => existsSync(join(root, ".claude")) || existsSync(join(root, "CLAUDE.md")),
66
+ // ~/.claude/projects/<path-encoded>/<uuid>.jsonl — folder name is lossy, so read
67
+ // the real cwd out of the newest session in each folder.
68
+ discoverProjects: (dirs) => discoverClaudeProjects(dirs.claudeDir),
69
+ scaffold: (root, name) => {
70
+ const created = [];
71
+ mkdirSync(join(root, ".claude", "skills"), { recursive: true });
72
+ writeIfAbsent(root, ".claude/settings.json", "{}\n", created);
73
+ writeIfAbsent(root, "CLAUDE.md", `# ${name}\n`, created);
74
+ writeIfAbsent(root, ".gitignore", ".mcp.json\n.claude/settings.json\n.env\n.targets/\n", created);
75
+ return { created };
76
+ },
77
+ import: { skillRel: (n) => `.claude/skills/${n}/SKILL.md`, instructionsFile: "CLAUDE.md", writeMcp: writeMcpJson, supportsHooks: true },
78
+ },
79
+ codex: {
80
+ id: "codex", label: "Codex", runCommand: "codex", importSupported: true,
81
+ detect: (root) => existsSync(join(root, ".codex")) || existsSync(join(root, "AGENTS.md")),
82
+ // ~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl — date-partitioned, so walk the
83
+ // tree and pull payload.cwd from each session_meta header line.
84
+ discoverProjects: (dirs) => discoverCodexProjects(dirs.codexDir),
85
+ scaffold: (root, name) => {
86
+ const created = [];
87
+ mkdirSync(join(root, ".agents", "skills"), { recursive: true });
88
+ writeIfAbsent(root, "AGENTS.md", `# ${name}\n`, created);
89
+ writeIfAbsent(root, ".gitignore", ".codex/config.toml\n.env\n.targets/\n", created);
90
+ return { created };
91
+ },
92
+ import: { skillRel: (n) => `.agents/skills/${n}/SKILL.md`, instructionsFile: "AGENTS.md", writeMcp: writeMcpCodexToml, supportsHooks: false },
93
+ },
94
+ hermes: {
95
+ id: "hermes", label: "Hermes", runCommand: "hermes", importSupported: true,
96
+ detect: (root) => existsSync(join(root, ".hermes")),
97
+ // Hermes sessions are Slack/agent threads (~/.hermes/sessions/sessions.json),
98
+ // not filesystem repos — there is no project cwd to harvest.
99
+ discoverProjects: () => [],
100
+ scaffold: (root, name) => {
101
+ const created = [];
102
+ mkdirSync(join(root, ".hermes", "skills"), { recursive: true });
103
+ writeIfAbsent(root, ".hermes/SOUL.md", `# ${name}\n`, created);
104
+ writeIfAbsent(root, ".gitignore", ".hermes/config.yaml\n.env\n.targets/\n", created);
105
+ return { created };
106
+ },
107
+ import: { skillRel: (n) => `.hermes/skills/${n}/DESCRIPTION.md`, instructionsFile: ".hermes/SOUL.md", writeMcp: undefined, supportsHooks: false },
108
+ },
109
+ };
110
+ export function flavorIds() {
111
+ return Object.keys(TESTBED_FLAVORS);
112
+ }
113
+ // Single marker match -> that flavor; none or several -> null (caller asks).
114
+ export function detectFlavor(root) {
115
+ const hits = flavorIds().filter((id) => TESTBED_FLAVORS[id].detect(root));
116
+ return hits.length === 1 ? hits[0] : null;
117
+ }
118
+ export function suggestTestbed(root) {
119
+ const anyMarker = flavorIds().some((id) => TESTBED_FLAVORS[id].detect(root));
120
+ const looksLikeProject = anyMarker || existsSync(join(root, ".git"));
121
+ return { looksLikeProject, flavor: detectFlavor(root) };
122
+ }
123
+ // Union of every flavor's discovered projects: dedup per (flavor, path) keeping
124
+ // the most recent hit, validate the path still exists, sort newest-first.
125
+ export function discoverProjects(dirs) {
126
+ const best = new Map();
127
+ for (const id of flavorIds()) {
128
+ for (const proj of TESTBED_FLAVORS[id].discoverProjects(dirs)) {
129
+ const key = `${id} ${proj.path}`;
130
+ const prev = best.get(key);
131
+ if (!prev || proj.lastUsedMs > prev.lastUsedMs)
132
+ best.set(key, { ...proj, flavor: id });
133
+ }
134
+ }
135
+ return [...best.values()]
136
+ .sort((a, b) => b.lastUsedMs - a.lastUsedMs)
137
+ .map((p) => ({
138
+ path: p.path,
139
+ flavor: p.flavor,
140
+ lastUsed: new Date(p.lastUsedMs).toISOString(),
141
+ exists: existsSync(p.path),
142
+ }));
143
+ }
144
+ // Read up to maxBytes from the front of a file. Session .jsonl files can be many
145
+ // MB; cwd lives near the top, so a bounded read avoids slurping whole transcripts.
146
+ function readHead(file, maxBytes = 1 << 16) {
147
+ let fd;
148
+ try {
149
+ fd = openSync(file, "r");
150
+ }
151
+ catch {
152
+ return null;
153
+ }
154
+ try {
155
+ const buf = Buffer.alloc(maxBytes);
156
+ const n = readSync(fd, buf, 0, maxBytes, 0);
157
+ return buf.toString("utf8", 0, n);
158
+ }
159
+ catch {
160
+ return null;
161
+ }
162
+ finally {
163
+ closeSync(fd);
164
+ }
165
+ }
166
+ function firstLine(text) {
167
+ const nl = text.indexOf("\n");
168
+ return nl === -1 ? text : text.slice(0, nl);
169
+ }
170
+ function safeMtime(p) {
171
+ try {
172
+ return statSync(p).mtimeMs;
173
+ }
174
+ catch {
175
+ return 0;
176
+ }
177
+ }
178
+ const CWD_RE = /"cwd":"((?:[^"\\]|\\.)*)"/;
179
+ // A session file's cwd never changes, so memoize the expensive head-read+parse by
180
+ // path. Recency (statSync) stays live and uncached. Bounded by sessions on disk.
181
+ const cwdByFile = new Map();
182
+ function cachedCwd(file, extract) {
183
+ const hit = cwdByFile.get(file);
184
+ if (hit !== undefined)
185
+ return hit;
186
+ const cwd = extract(file);
187
+ cwdByFile.set(file, cwd);
188
+ return cwd;
189
+ }
190
+ function readClaudeCwd(file) {
191
+ const head = readHead(file);
192
+ const match = head ? CWD_RE.exec(head) : null;
193
+ if (!match)
194
+ return null;
195
+ try {
196
+ return JSON.parse(`"${match[1]}"`); // unescape \\ and friends
197
+ }
198
+ catch {
199
+ return match[1];
200
+ }
201
+ }
202
+ // Claude writes one folder per project under ~/.claude/projects, each holding
203
+ // <uuid>.jsonl sessions. We take the newest session per folder and regex its cwd
204
+ // (the first session line is sometimes a summary record with no cwd).
205
+ function discoverClaudeProjects(claudeDir) {
206
+ const projectsDir = join(claudeDir, "projects");
207
+ let entries;
208
+ try {
209
+ entries = readdirSync(projectsDir, { withFileTypes: true });
210
+ }
211
+ catch {
212
+ return [];
213
+ }
214
+ const out = [];
215
+ for (const entry of entries) {
216
+ if (!entry.isDirectory())
217
+ continue;
218
+ const session = newestJsonl(join(projectsDir, entry.name));
219
+ if (!session)
220
+ continue;
221
+ const cwd = cachedCwd(session, readClaudeCwd);
222
+ if (cwd)
223
+ out.push({ path: cwd, lastUsedMs: safeMtime(session) });
224
+ }
225
+ return out;
226
+ }
227
+ function newestJsonl(dir) {
228
+ let entries;
229
+ try {
230
+ entries = readdirSync(dir, { withFileTypes: true });
231
+ }
232
+ catch {
233
+ return null;
234
+ }
235
+ let newest = null;
236
+ for (const entry of entries) {
237
+ if (!entry.isFile() || !entry.name.endsWith(".jsonl"))
238
+ continue;
239
+ const file = join(dir, entry.name);
240
+ const mtimeMs = safeMtime(file);
241
+ if (!newest || mtimeMs > newest.mtimeMs)
242
+ newest = { file, mtimeMs };
243
+ }
244
+ return newest?.file ?? null;
245
+ }
246
+ function readCodexMetaCwd(file) {
247
+ const head = readHead(file);
248
+ if (!head)
249
+ return null;
250
+ try {
251
+ const rec = JSON.parse(firstLine(head));
252
+ const cwd = rec?.payload?.cwd;
253
+ return typeof cwd === "string" ? cwd : null;
254
+ }
255
+ catch {
256
+ return null; // malformed header
257
+ }
258
+ }
259
+ // Codex partitions sessions by date, so we walk the whole tree. Each rollout file
260
+ // opens with a {"type":"session_meta","payload":{"cwd":...}} header line.
261
+ function discoverCodexProjects(codexDir) {
262
+ const files = [];
263
+ walkJsonl(join(codexDir, "sessions"), files);
264
+ const out = [];
265
+ for (const file of files) {
266
+ const cwd = cachedCwd(file, readCodexMetaCwd);
267
+ if (cwd)
268
+ out.push({ path: cwd, lastUsedMs: safeMtime(file) });
269
+ }
270
+ return out;
271
+ }
272
+ function walkJsonl(dir, out) {
273
+ let entries;
274
+ try {
275
+ entries = readdirSync(dir, { withFileTypes: true });
276
+ }
277
+ catch {
278
+ return;
279
+ }
280
+ for (const entry of entries) {
281
+ const p = join(dir, entry.name);
282
+ if (entry.isDirectory())
283
+ walkJsonl(p, out);
284
+ else if (entry.isFile() && entry.name.endsWith(".jsonl"))
285
+ out.push(p);
286
+ }
287
+ }
@@ -0,0 +1,120 @@
1
+ const BARE = /^[A-Za-z0-9_-]+$/;
2
+ function escapeStr(s) {
3
+ return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\t/g, "\\t");
4
+ }
5
+ function key(k) {
6
+ return BARE.test(k) ? k : `"${escapeStr(k)}"`;
7
+ }
8
+ function scalar(v) {
9
+ if (typeof v === "string")
10
+ return `"${escapeStr(v)}"`;
11
+ if (typeof v === "number" || typeof v === "boolean")
12
+ return String(v);
13
+ return `"${escapeStr(String(v))}"`;
14
+ }
15
+ function isScalar(v) {
16
+ return v === null || typeof v !== "object";
17
+ }
18
+ export function tomlMcpServers(servers) {
19
+ const blocks = [];
20
+ for (const s of servers) {
21
+ const lines = [`[mcp_servers.${key(s.name)}]`];
22
+ const subTables = [];
23
+ for (const [k, v] of Object.entries(s.config)) {
24
+ if (isScalar(v))
25
+ lines.push(`${key(k)} = ${scalar(v)}`);
26
+ else if (Array.isArray(v))
27
+ lines.push(`${key(k)} = [${v.map(scalar).join(", ")}]`);
28
+ else if (v && typeof v === "object") {
29
+ const sub = [`[mcp_servers.${key(s.name)}.${key(k)}]`];
30
+ for (const [k2, v2] of Object.entries(v))
31
+ sub.push(`${key(k2)} = ${scalar(v2)}`);
32
+ subTables.push(sub.join("\n"));
33
+ }
34
+ }
35
+ blocks.push([lines.join("\n"), ...subTables].join("\n\n"));
36
+ }
37
+ return blocks.length ? blocks.join("\n\n") + "\n" : "";
38
+ }
39
+ // Inverse of tomlMcpServers for the [mcp_servers.*] subset only (scalars, scalar arrays, one level of
40
+ // sub-tables). Not a general TOML parser. Unknown/other top-level tables are ignored.
41
+ function parseScalar(raw) {
42
+ const s = raw.trim();
43
+ if (s.startsWith('"') && s.endsWith('"')) {
44
+ return s.slice(1, -1).replace(/\\n/g, "\n").replace(/\\t/g, "\t").replace(/\\"/g, '"').replace(/\\\\/g, "\\");
45
+ }
46
+ if (s === "true")
47
+ return true;
48
+ if (s === "false")
49
+ return false;
50
+ if (/^-?\d+(\.\d+)?$/.test(s))
51
+ return Number(s);
52
+ return s;
53
+ }
54
+ function parseArray(raw) {
55
+ const inner = raw.trim().replace(/^\[/, "").replace(/\]$/, "").trim();
56
+ if (!inner)
57
+ return [];
58
+ // split on commas not inside quotes
59
+ const parts = [];
60
+ let cur = "";
61
+ let inStr = false;
62
+ for (let i = 0; i < inner.length; i++) {
63
+ const ch = inner[i];
64
+ if (ch === '"' && inner[i - 1] !== "\\")
65
+ inStr = !inStr;
66
+ if (ch === "," && !inStr) {
67
+ parts.push(cur);
68
+ cur = "";
69
+ }
70
+ else
71
+ cur += ch;
72
+ }
73
+ if (cur.trim())
74
+ parts.push(cur);
75
+ return parts.map((p) => parseScalar(p));
76
+ }
77
+ function unquoteKey(k) {
78
+ const s = k.trim();
79
+ return s.startsWith('"') && s.endsWith('"') ? s.slice(1, -1) : s;
80
+ }
81
+ export function parseTomlMcpServers(toml) {
82
+ const out = {};
83
+ let server = null;
84
+ let sub = null; // sub-table key (e.g. "env") within the current server
85
+ for (const rawLine of toml.split("\n")) {
86
+ const line = rawLine.trim();
87
+ if (!line || line.startsWith("#"))
88
+ continue;
89
+ const header = line.match(/^\[mcp_servers\.([^\]]+)\]$/);
90
+ if (header) {
91
+ // split "name" or "name.sub" on the first dot outside quotes
92
+ const segs = header[1].match(/("(?:[^"\\]|\\.)*"|[^.]+)/g) ?? [];
93
+ const name = unquoteKey(segs[0] ?? "");
94
+ server = name;
95
+ sub = segs[1] ? unquoteKey(segs[1]) : null;
96
+ out[server] ??= {};
97
+ if (sub)
98
+ (out[server][sub] ??= {});
99
+ continue;
100
+ }
101
+ if (line.startsWith("[")) {
102
+ server = null;
103
+ sub = null;
104
+ continue;
105
+ } // some other table
106
+ if (!server)
107
+ continue;
108
+ const eq = line.indexOf("=");
109
+ if (eq < 0)
110
+ continue;
111
+ const key = unquoteKey(line.slice(0, eq));
112
+ const valRaw = line.slice(eq + 1).trim();
113
+ const val = valRaw.startsWith("[") ? parseArray(valRaw) : parseScalar(valRaw);
114
+ if (sub)
115
+ out[server][sub][key] = val;
116
+ else
117
+ out[server][key] = val;
118
+ }
119
+ return out;
120
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,93 @@
1
+ // src/gem/workspaces.ts
2
+ // A gem's persistent local home: the canonical archive at the workspace root (source of truth) plus
3
+ // .targets/<target>/ rendered harness layouts (derived). Orchestration over the pure archive/materialize
4
+ // core; this module owns all workspace filesystem I/O.
5
+ import { homedir } from "node:os";
6
+ import { join } from "node:path";
7
+ import { mkdirSync, rmSync, readdirSync, statSync, existsSync, readFileSync } from "node:fs";
8
+ import { materialize, compatibility, TARGET_REGISTRY, safePathSegment } from "./targets.js";
9
+ import { writeGemArchive, readGemArchive } from "./archive.js";
10
+ import { writeArchiveDir, readArchiveDir } from "./archiveFs.js";
11
+ const TARGETS_DIR = ".targets";
12
+ export function workspacesRoot() {
13
+ const home = process.env.AGENTGEM_HOME ?? join(homedir(), ".agentgem");
14
+ return join(home, "workspaces");
15
+ }
16
+ // A workspace name must already be a safe single path segment — reject anything else (no separators,
17
+ // no `.`/`..`), so two distinct requests never collide to one directory and nothing escapes the root.
18
+ export function workspaceName(name) {
19
+ const seg = safePathSegment(name);
20
+ if (seg !== name)
21
+ throw new Error(`invalid workspace name '${name}' — use only [A-Za-z0-9._-], no separators`);
22
+ return seg;
23
+ }
24
+ export function workspaceDir(name) {
25
+ return join(workspacesRoot(), workspaceName(name));
26
+ }
27
+ function countArtifacts(entries) {
28
+ const c = { skill: 0, mcp_server: 0, instructions: 0, hook: 0 };
29
+ for (const e of entries)
30
+ if (e.type in c)
31
+ c[e.type]++;
32
+ return c;
33
+ }
34
+ function renderedTargets(dir) {
35
+ const t = join(dir, TARGETS_DIR);
36
+ if (!existsSync(t))
37
+ return [];
38
+ return readdirSync(t).filter((n) => statSync(join(t, n)).isDirectory() && n in TARGET_REGISTRY);
39
+ }
40
+ function summary(name, manifestJson, dir) {
41
+ const m = JSON.parse(manifestJson);
42
+ return {
43
+ name,
44
+ gemName: m.name,
45
+ version: m.version,
46
+ artifactCounts: countArtifacts(m.artifacts),
47
+ checks: m.checks.length,
48
+ renderedTargets: renderedTargets(dir),
49
+ };
50
+ }
51
+ export function createWorkspace(name, gem, opts = {}) {
52
+ const dir = workspaceDir(name);
53
+ if (existsSync(dir))
54
+ throw new Error(`workspace '${name}' already exists`);
55
+ const { files } = writeGemArchive(gem, { version: opts.version });
56
+ mkdirSync(dir, { recursive: true });
57
+ writeArchiveDir(dir, files);
58
+ return summary(workspaceName(name), files["gem.json"], dir);
59
+ }
60
+ export function listWorkspaces() {
61
+ const root = workspacesRoot();
62
+ if (!existsSync(root))
63
+ return [];
64
+ return readdirSync(root)
65
+ .filter((n) => statSync(join(root, n)).isDirectory() && existsSync(join(root, n, "gem.json")))
66
+ .map((n) => summary(n, readFileSync(join(root, n, "gem.json"), "utf8"), join(root, n)));
67
+ }
68
+ export function readWorkspace(name) {
69
+ const dir = workspaceDir(name);
70
+ if (!existsSync(join(dir, "gem.json")))
71
+ throw new Error(`no workspace '${name}'`);
72
+ const files = readArchiveDir(dir); // skips .targets/ (Task 2)
73
+ const gem = readGemArchive(files); // verifies the lock
74
+ return { ...summary(workspaceName(name), files["gem.json"], dir), files, compatibility: compatibility(gem) };
75
+ }
76
+ export function renderTarget(name, target) {
77
+ const dir = workspaceDir(name);
78
+ if (!existsSync(join(dir, "gem.json")))
79
+ throw new Error(`no workspace '${name}'`);
80
+ const gem = readGemArchive(readArchiveDir(dir));
81
+ const { files, skipped } = materialize(gem, target);
82
+ const out = join(dir, TARGETS_DIR, target);
83
+ rmSync(out, { recursive: true, force: true }); // clear stale renders
84
+ mkdirSync(out, { recursive: true });
85
+ writeArchiveDir(out, files);
86
+ return { target, files, skipped, path: out };
87
+ }
88
+ export function deleteWorkspace(name) {
89
+ const dir = workspaceDir(name);
90
+ if (!existsSync(dir))
91
+ throw new Error(`no workspace '${name}'`);
92
+ rmSync(dir, { recursive: true, force: true });
93
+ }