@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.
- package/LICENSE +21 -0
- package/README.md +112 -0
- package/dist/cli.js +55 -0
- package/dist/gem/agentcorePublish.js +91 -0
- package/dist/gem/agentcoreRun.js +85 -0
- package/dist/gem/archive.js +185 -0
- package/dist/gem/archiveFs.js +28 -0
- package/dist/gem/archiveTar.js +66 -0
- package/dist/gem/buildGem.js +88 -0
- package/dist/gem/checks.js +28 -0
- package/dist/gem/credentials.js +34 -0
- package/dist/gem/deploy.js +35 -0
- package/dist/gem/deployRecord.js +24 -0
- package/dist/gem/introspect.js +247 -0
- package/dist/gem/mcpProxy.js +53 -0
- package/dist/gem/publish.js +58 -0
- package/dist/gem/recents.js +39 -0
- package/dist/gem/redact.js +42 -0
- package/dist/gem/registry.js +233 -0
- package/dist/gem/registryGithub.js +74 -0
- package/dist/gem/run.js +322 -0
- package/dist/gem/targets.js +578 -0
- package/dist/gem/testbed.js +103 -0
- package/dist/gem/testbedFlavors.js +287 -0
- package/dist/gem/toml.js +120 -0
- package/dist/gem/types.js +1 -0
- package/dist/gem/workspaces.js +93 -0
- package/dist/gem.controller.js +518 -0
- package/dist/gem.tools.js +103 -0
- package/dist/index.js +59 -0
- package/dist/pickFolder.js +36 -0
- package/dist/public/index.html +1465 -0
- package/dist/publish.js +130 -0
- package/dist/resolveDir.js +26 -0
- package/dist/schemas.js +407 -0
- package/package.json +72 -0
|
@@ -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
|
+
}
|
package/dist/gem/toml.js
ADDED
|
@@ -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
|
+
}
|