@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,88 @@
|
|
|
1
|
+
import { redactMcpConfig } from "./redact.js";
|
|
2
|
+
export function buildGem(inventory, selection, opts = {}) {
|
|
3
|
+
const artifacts = [];
|
|
4
|
+
const projects = inventory.projects ?? [];
|
|
5
|
+
if ("all" in selection && selection.all) {
|
|
6
|
+
artifacts.push(...inventory.skills, ...inventory.mcpServers, ...inventory.instructions, ...inventory.hooks);
|
|
7
|
+
for (const p of projects)
|
|
8
|
+
artifacts.push(...p.skills, ...p.mcpServers, ...p.instructions, ...p.hooks);
|
|
9
|
+
}
|
|
10
|
+
else {
|
|
11
|
+
const sel = selection;
|
|
12
|
+
for (const n of sel.skills ?? []) {
|
|
13
|
+
const a = inventory.skills.find((s) => s.name === n);
|
|
14
|
+
if (!a)
|
|
15
|
+
throw new Error(`No skill '${n}'. Available: ${inventory.skills.map((s) => s.name).join(", ") || "(none)"}`);
|
|
16
|
+
artifacts.push(a);
|
|
17
|
+
}
|
|
18
|
+
for (const n of sel.mcpServers ?? []) {
|
|
19
|
+
const a = inventory.mcpServers.find((s) => s.name === n);
|
|
20
|
+
if (!a)
|
|
21
|
+
throw new Error(`No MCP server '${n}'. Available: ${inventory.mcpServers.map((s) => s.name).join(", ") || "(none)"}`);
|
|
22
|
+
artifacts.push(a);
|
|
23
|
+
}
|
|
24
|
+
if (sel.includeInstructions)
|
|
25
|
+
artifacts.push(...inventory.instructions);
|
|
26
|
+
for (const n of sel.hooks ?? []) {
|
|
27
|
+
const a = inventory.hooks.find((h) => h.name === n);
|
|
28
|
+
if (!a)
|
|
29
|
+
throw new Error(`No hook '${n}'. Available: ${inventory.hooks.map((h) => h.name).join(", ") || "(none)"}`);
|
|
30
|
+
artifacts.push(a);
|
|
31
|
+
}
|
|
32
|
+
for (const [root, ps] of Object.entries(sel.projects ?? {})) {
|
|
33
|
+
const proj = projects.find((p) => p.root === root);
|
|
34
|
+
if (!proj)
|
|
35
|
+
throw new Error(`No project '${root}'. Loaded: ${projects.map((p) => p.root).join(", ") || "(none)"}`);
|
|
36
|
+
for (const n of ps.skills ?? []) {
|
|
37
|
+
const a = proj.skills.find((s) => s.name === n);
|
|
38
|
+
if (!a)
|
|
39
|
+
throw new Error(`No skill '${n}' in project '${proj.name}'. Available: ${proj.skills.map((s) => s.name).join(", ") || "(none)"}`);
|
|
40
|
+
artifacts.push(a);
|
|
41
|
+
}
|
|
42
|
+
for (const n of ps.mcpServers ?? []) {
|
|
43
|
+
const a = proj.mcpServers.find((s) => s.name === n);
|
|
44
|
+
if (!a)
|
|
45
|
+
throw new Error(`No MCP server '${n}' in project '${proj.name}'. Available: ${proj.mcpServers.map((s) => s.name).join(", ") || "(none)"}`);
|
|
46
|
+
artifacts.push(a);
|
|
47
|
+
}
|
|
48
|
+
if (ps.includeInstructions)
|
|
49
|
+
artifacts.push(...proj.instructions);
|
|
50
|
+
for (const n of ps.hooks ?? []) {
|
|
51
|
+
const a = proj.hooks.find((h) => h.name === n);
|
|
52
|
+
if (!a)
|
|
53
|
+
throw new Error(`No hook '${n}' in project '${proj.name}'. Available: ${proj.hooks.map((h) => h.name).join(", ") || "(none)"}`);
|
|
54
|
+
artifacts.push(a);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
// Defense in depth: an mcp/hook artifact missing `secretRefs` was never redacted (e.g. it came
|
|
59
|
+
// from introspectConfig({redact:false}), which the import path uses). Re-redact it before it can
|
|
60
|
+
// enter the gem. Already-redacted artifacts carry a secretRefs array (possibly empty) and are
|
|
61
|
+
// left untouched, so this never double-redacts or corrupts existing refs.
|
|
62
|
+
const guarded = artifacts.map((a) => {
|
|
63
|
+
if ((a.type === "mcp_server" || a.type === "hook") && a.secretRefs === undefined) {
|
|
64
|
+
const { config, secrets } = redactMcpConfig(a.config);
|
|
65
|
+
return { ...a, config, secretRefs: secrets };
|
|
66
|
+
}
|
|
67
|
+
return a;
|
|
68
|
+
});
|
|
69
|
+
artifacts.length = 0;
|
|
70
|
+
artifacts.push(...guarded);
|
|
71
|
+
const requiredSecrets = [];
|
|
72
|
+
for (const a of artifacts) {
|
|
73
|
+
if ((a.type === "mcp_server" || a.type === "hook") && a.secretRefs) {
|
|
74
|
+
for (const ref of a.secretRefs)
|
|
75
|
+
requiredSecrets.push({ name: ref.name, artifact: a.name, location: ref.location });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
// Embed operator checks, but run each through redaction first: a check's task/setup is
|
|
79
|
+
// operator-authored test data and must not smuggle a raw secret into the shared gem.
|
|
80
|
+
const checks = (opts.checks ?? []).map((c) => redactMcpConfig(c).config);
|
|
81
|
+
return {
|
|
82
|
+
name: opts.name ?? "gem",
|
|
83
|
+
createdFrom: opts.createdFrom ?? "unknown",
|
|
84
|
+
artifacts,
|
|
85
|
+
checks,
|
|
86
|
+
requiredSecrets,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export const RUNNER_REGISTRY = {
|
|
2
|
+
skillspector: {
|
|
3
|
+
id: "skillspector",
|
|
4
|
+
consumes: "gem-as-directory", // Gem materializes to a dir of SKILL.md + config
|
|
5
|
+
resultShape: "score+findings",
|
|
6
|
+
defaultWith: { failAboveRisk: 40 },
|
|
7
|
+
},
|
|
8
|
+
};
|
|
9
|
+
export function scaffoldChecks(gem) {
|
|
10
|
+
const skills = gem.artifacts.filter((a) => a.type === "skill");
|
|
11
|
+
const lead = skills[0];
|
|
12
|
+
const intent = lead?.description ?? lead?.name ?? "the bundled capability";
|
|
13
|
+
const checks = [
|
|
14
|
+
{
|
|
15
|
+
kind: "behavioral",
|
|
16
|
+
name: "smoke",
|
|
17
|
+
description: "Draft — edit the task and add assertions before relying on this check.",
|
|
18
|
+
task: `Using this gem, ${intent}. Then report what you did.`,
|
|
19
|
+
assertions: [], // stubs: meaningful deterministic assertions are operator-authored
|
|
20
|
+
timeoutSec: 300,
|
|
21
|
+
},
|
|
22
|
+
];
|
|
23
|
+
if (skills.length) {
|
|
24
|
+
const reg = RUNNER_REGISTRY.skillspector;
|
|
25
|
+
checks.push({ kind: "external", name: "security-scan", runner: reg.id, with: { ...reg.defaultWith } });
|
|
26
|
+
}
|
|
27
|
+
return checks;
|
|
28
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// src/gem/credentials.ts
|
|
2
|
+
// Server-side credentials the deploy/publish backends gate on (Claude Managed → ANTHROPIC_API_KEY,
|
|
3
|
+
// eve → VERCEL_TOKEN, flue → CLOUDFLARE_API_TOKEN). These are agentgem-SERVER secrets — the machine's
|
|
4
|
+
// own auth — NOT Gem artifacts. They never enter a Gem; they are set in the running process and
|
|
5
|
+
// persisted (plaintext, 0600) to ~/.agentgem/.env so they survive a restart.
|
|
6
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { dirname, join } from "node:path";
|
|
8
|
+
import { agentgemHome } from "../resolveDir.js";
|
|
9
|
+
// Only these keys may be set/persisted via the API — never arbitrary env vars.
|
|
10
|
+
export const CREDENTIAL_KEYS = ["ANTHROPIC_API_KEY", "VERCEL_TOKEN", "CLOUDFLARE_API_TOKEN"];
|
|
11
|
+
export function credentialsEnvPath(home = agentgemHome()) {
|
|
12
|
+
return join(home, ".agentgem", ".env");
|
|
13
|
+
}
|
|
14
|
+
// Upsert KEY=value in ~/.agentgem/.env (created 0600) and set it in the running process so the next
|
|
15
|
+
// deploy picks it up without a restart. Rejects empty/multi-line values (would corrupt the .env).
|
|
16
|
+
export function setCredential(key, value, home = agentgemHome()) {
|
|
17
|
+
const v = value.trim();
|
|
18
|
+
if (!v)
|
|
19
|
+
throw new Error("credential value is empty");
|
|
20
|
+
if (/[\r\n]/.test(v))
|
|
21
|
+
throw new Error("credential value must be a single line");
|
|
22
|
+
process.env[key] = v;
|
|
23
|
+
const abs = credentialsEnvPath(home);
|
|
24
|
+
const kept = (existsSync(abs) ? readFileSync(abs, "utf8") : "")
|
|
25
|
+
.split("\n")
|
|
26
|
+
.filter((l) => l.trim().length > 0 && !l.startsWith(`${key}=`));
|
|
27
|
+
kept.push(`${key}=${v}`);
|
|
28
|
+
mkdirSync(dirname(abs), { recursive: true });
|
|
29
|
+
writeFileSync(abs, kept.join("\n") + "\n", "utf8");
|
|
30
|
+
try {
|
|
31
|
+
chmodSync(abs, 0o600);
|
|
32
|
+
}
|
|
33
|
+
catch { /* best-effort on platforms without POSIX modes */ }
|
|
34
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { renderManagedAgent } from "./publish.js";
|
|
2
|
+
import { publishManagedAgent, publishManagedAgentOnce, anthropicPublishClient } from "../publish.js";
|
|
3
|
+
import { previewAgentcorePublish, deployAgentcorePublish, agentcorePublishReady } from "./agentcorePublish.js";
|
|
4
|
+
const managedAgentPreview = (gem) => {
|
|
5
|
+
const r = renderManagedAgent(gem);
|
|
6
|
+
return { kind: "managed-agent", payload: r.payload, skillsToRegister: r.skillsToRegister.map((s) => s.name), skipped: r.skipped, vaultSecrets: r.vaultSecrets };
|
|
7
|
+
};
|
|
8
|
+
export const DEPLOY_REGISTRY = {
|
|
9
|
+
"claude-managed": {
|
|
10
|
+
id: "claude-managed",
|
|
11
|
+
label: "Claude Managed Agents",
|
|
12
|
+
preview: managedAgentPreview,
|
|
13
|
+
ready: () => !!process.env.ANTHROPIC_API_KEY,
|
|
14
|
+
deploy: async (gem, requestId) => {
|
|
15
|
+
const key = process.env.ANTHROPIC_API_KEY;
|
|
16
|
+
if (!key)
|
|
17
|
+
throw new Error("ANTHROPIC_API_KEY is not set on the server — cannot deploy to Claude Managed Agents.");
|
|
18
|
+
// The idempotency fingerprint relies on buildGem's stable ordering: identical retries must
|
|
19
|
+
// serialize to the same string, so don't make buildGem ordering non-deterministic.
|
|
20
|
+
const r = await publishManagedAgentOnce(requestId, JSON.stringify(gem), () => publishManagedAgent(gem, anthropicPublishClient(key)));
|
|
21
|
+
return { kind: "managed-agent", ...r };
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
"agentcore-managed": {
|
|
25
|
+
id: "agentcore-managed",
|
|
26
|
+
label: "AgentCore Harness",
|
|
27
|
+
preview: previewAgentcorePublish,
|
|
28
|
+
ready: agentcorePublishReady,
|
|
29
|
+
deploy: (gem, requestId) => deployAgentcorePublish(gem, requestId),
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
export const deployTargetIds = Object.keys(DEPLOY_REGISTRY);
|
|
33
|
+
export function deployTargetList() {
|
|
34
|
+
return deployTargetIds.map((id) => ({ id, label: DEPLOY_REGISTRY[id].label, ready: DEPLOY_REGISTRY[id].ready() }));
|
|
35
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// src/gem/deployRecord.ts
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { workspaceDir } from "./workspaces.js";
|
|
5
|
+
function recPath(name, backend) {
|
|
6
|
+
return join(workspaceDir(name), ".deploy", `${backend}.json`);
|
|
7
|
+
}
|
|
8
|
+
export function writeDeployRecord(name, rec) {
|
|
9
|
+
const abs = recPath(name, rec.backend);
|
|
10
|
+
mkdirSync(dirname(abs), { recursive: true });
|
|
11
|
+
writeFileSync(abs, JSON.stringify(rec, null, 2) + "\n", "utf8");
|
|
12
|
+
}
|
|
13
|
+
export function readDeployRecord(name, backend) {
|
|
14
|
+
const abs = recPath(name, backend);
|
|
15
|
+
try {
|
|
16
|
+
return existsSync(abs) ? JSON.parse(readFileSync(abs, "utf8")) : null;
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export function clearDeployRecord(name, backend) {
|
|
23
|
+
rmSync(recPath(name, backend), { force: true });
|
|
24
|
+
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
// src/gem/introspect.ts
|
|
2
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
3
|
+
import { basename, join } from "node:path";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { redactMcpConfig } from "./redact.js";
|
|
6
|
+
import { parseTomlMcpServers } from "./toml.js";
|
|
7
|
+
function isObj(v) {
|
|
8
|
+
return !!v && typeof v === "object" && !Array.isArray(v);
|
|
9
|
+
}
|
|
10
|
+
function readJson(path) {
|
|
11
|
+
try {
|
|
12
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
function parseFrontmatter(content) {
|
|
19
|
+
const m = content.match(/^---\n([\s\S]*?)\n---/);
|
|
20
|
+
if (!m)
|
|
21
|
+
return { internal: false };
|
|
22
|
+
const fm = m[1];
|
|
23
|
+
const description = fm.match(/^description:\s*(.+)$/m)?.[1]?.trim();
|
|
24
|
+
const internal = /^\s*internal:\s*true\s*$/m.test(fm);
|
|
25
|
+
return { description, internal };
|
|
26
|
+
}
|
|
27
|
+
function inferTransport(config) {
|
|
28
|
+
if (typeof config.url === "string")
|
|
29
|
+
return config.type === "sse" ? "sse" : "http";
|
|
30
|
+
return "stdio";
|
|
31
|
+
}
|
|
32
|
+
// Read <skillsRoot>/<name>/<file> skills. `files` lists the candidate body filenames to try
|
|
33
|
+
// in order (Claude/Codex/Agents use SKILL.md; Hermes uses DESCRIPTION.md).
|
|
34
|
+
function readSkillsDir(skillsRoot, source, files = ["SKILL.md"]) {
|
|
35
|
+
const out = [];
|
|
36
|
+
if (!existsSync(skillsRoot))
|
|
37
|
+
return out;
|
|
38
|
+
let names;
|
|
39
|
+
try {
|
|
40
|
+
names = readdirSync(skillsRoot);
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
for (const name of names) {
|
|
46
|
+
const skillMd = files.map((f) => join(skillsRoot, name, f)).find((p) => existsSync(p));
|
|
47
|
+
if (!skillMd)
|
|
48
|
+
continue;
|
|
49
|
+
try {
|
|
50
|
+
const content = readFileSync(skillMd, "utf8");
|
|
51
|
+
const { description, internal } = parseFrontmatter(content);
|
|
52
|
+
if (internal)
|
|
53
|
+
continue;
|
|
54
|
+
out.push({ type: "skill", name, description, source, content });
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
// skip unreadable skill
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
// Read each file directly under <rulesRoot> as an instructions artifact (Codex keeps its
|
|
63
|
+
// global instructions as rules files, e.g. ~/.codex/rules/default.rules).
|
|
64
|
+
function readRulesDir(rulesRoot) {
|
|
65
|
+
const out = [];
|
|
66
|
+
if (!existsSync(rulesRoot))
|
|
67
|
+
return out;
|
|
68
|
+
let names;
|
|
69
|
+
try {
|
|
70
|
+
names = readdirSync(rulesRoot);
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
for (const file of names) {
|
|
76
|
+
try {
|
|
77
|
+
out.push({ type: "instructions", name: `codex:rules/${file}`, content: readFileSync(join(rulesRoot, file), "utf8") });
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
// skip subdirectories / unreadable files
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
function serversToArtifacts(servers, source, redact = true) {
|
|
86
|
+
return Object.entries(servers).map(([name, cfg]) => {
|
|
87
|
+
const config = isObj(cfg) ? cfg : {};
|
|
88
|
+
if (!redact)
|
|
89
|
+
return { type: "mcp_server", name, transport: inferTransport(config), config, source };
|
|
90
|
+
const { config: redacted, secrets } = redactMcpConfig(config);
|
|
91
|
+
return { type: "mcp_server", name, transport: inferTransport(config), config: redacted, source, secretRefs: secrets };
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
function serversFromMcpJson(parsed) {
|
|
95
|
+
if (!isObj(parsed))
|
|
96
|
+
return {};
|
|
97
|
+
if (isObj(parsed.mcpServers))
|
|
98
|
+
return parsed.mcpServers;
|
|
99
|
+
return parsed;
|
|
100
|
+
}
|
|
101
|
+
// Turn a config's `.hooks` event map into per-(event, matcher) artifacts. Works for both
|
|
102
|
+
// settings.json (user/project) and a plugin's hooks/hooks.json — both nest under `.hooks`.
|
|
103
|
+
// Each group object is redacted (defense; near-no-op for command strings).
|
|
104
|
+
function hooksFromConfig(parsed, source, redact = true) {
|
|
105
|
+
const out = [];
|
|
106
|
+
if (!isObj(parsed) || !isObj(parsed.hooks))
|
|
107
|
+
return out;
|
|
108
|
+
for (const [event, groups] of Object.entries(parsed.hooks)) {
|
|
109
|
+
if (!Array.isArray(groups))
|
|
110
|
+
continue;
|
|
111
|
+
for (const g of groups) {
|
|
112
|
+
if (!isObj(g))
|
|
113
|
+
continue;
|
|
114
|
+
const matcher = typeof g.matcher === "string" && g.matcher.length ? g.matcher : undefined;
|
|
115
|
+
if (!redact) {
|
|
116
|
+
out.push({ type: "hook", name: `${event}${matcher ? ` · ${matcher}` : ""}`, event, matcher, config: g, source });
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
const { config: redacted, secrets } = redactMcpConfig(g);
|
|
120
|
+
out.push({ type: "hook", name: `${event}${matcher ? ` · ${matcher}` : ""}`, event, matcher, config: redacted, source, secretRefs: secrets });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
// Hooks are selected by name, so make names unique across sources: a collided name gets its
|
|
126
|
+
// source appended (and an index if the source collides too).
|
|
127
|
+
function uniqueHookNames(hooks) {
|
|
128
|
+
const counts = {};
|
|
129
|
+
hooks.forEach((h) => (counts[h.name] = (counts[h.name] || 0) + 1));
|
|
130
|
+
const idx = {};
|
|
131
|
+
return hooks.map((h) => {
|
|
132
|
+
if (counts[h.name] === 1)
|
|
133
|
+
return h;
|
|
134
|
+
idx[h.name] = (idx[h.name] || 0) + 1;
|
|
135
|
+
return { ...h, name: `${h.name} (${h.source})${idx[h.name] > 1 ? ` #${idx[h.name]}` : ""}` };
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
function dedupByName(items) {
|
|
139
|
+
const seen = new Set();
|
|
140
|
+
const out = [];
|
|
141
|
+
for (const it of items) {
|
|
142
|
+
if (seen.has(it.name))
|
|
143
|
+
continue;
|
|
144
|
+
seen.add(it.name);
|
|
145
|
+
out.push(it);
|
|
146
|
+
}
|
|
147
|
+
return out;
|
|
148
|
+
}
|
|
149
|
+
export function introspectConfig(opts = {}) {
|
|
150
|
+
const redact = opts.redact ?? true;
|
|
151
|
+
const claudeDir = opts.claudeDir ?? join(homedir(), ".claude");
|
|
152
|
+
const agentDir = opts.agentDir ?? join(homedir(), ".agents", "skills");
|
|
153
|
+
const codexDir = opts.codexDir ?? join(homedir(), ".codex");
|
|
154
|
+
const hermesDir = opts.hermesDir ?? join(homedir(), ".hermes");
|
|
155
|
+
const skillList = [];
|
|
156
|
+
const mcpList = [];
|
|
157
|
+
const hookList = [];
|
|
158
|
+
skillList.push(...readSkillsDir(join(claudeDir, "skills"), "standalone"));
|
|
159
|
+
const settings = readJson(join(claudeDir, "settings.json"));
|
|
160
|
+
if (isObj(settings) && isObj(settings.mcpServers)) {
|
|
161
|
+
mcpList.push(...serversToArtifacts(settings.mcpServers, "user", redact));
|
|
162
|
+
}
|
|
163
|
+
mcpList.push(...serversToArtifacts(serversFromMcpJson(readJson(join(claudeDir, ".mcp.json"))), "user", redact));
|
|
164
|
+
hookList.push(...hooksFromConfig(settings, "user", redact));
|
|
165
|
+
const enabled = isObj(settings) && isObj(settings.enabledPlugins) ? settings.enabledPlugins : {};
|
|
166
|
+
const installed = readJson(join(claudeDir, "plugins", "installed_plugins.json"));
|
|
167
|
+
const pluginsMap = isObj(installed) && isObj(installed.plugins) ? installed.plugins : {};
|
|
168
|
+
for (const [key, entry] of Object.entries(pluginsMap)) {
|
|
169
|
+
if (enabled[key] !== true)
|
|
170
|
+
continue;
|
|
171
|
+
const installPath = Array.isArray(entry) && isObj(entry[0]) ? entry[0].installPath : undefined;
|
|
172
|
+
if (!installPath || typeof installPath !== "string")
|
|
173
|
+
continue;
|
|
174
|
+
const source = `plugin:${key}`;
|
|
175
|
+
mcpList.push(...serversToArtifacts(serversFromMcpJson(readJson(join(installPath, ".mcp.json"))), source, redact));
|
|
176
|
+
skillList.push(...readSkillsDir(join(installPath, "skills"), source));
|
|
177
|
+
hookList.push(...hooksFromConfig(readJson(join(installPath, "hooks", "hooks.json")), source, redact));
|
|
178
|
+
}
|
|
179
|
+
skillList.push(...readSkillsDir(agentDir, "agent"));
|
|
180
|
+
// Source 5: Codex skills (~/.codex/skills)
|
|
181
|
+
skillList.push(...readSkillsDir(join(codexDir, "skills"), "codex"));
|
|
182
|
+
// Source 6: Hermes skills (~/.hermes/skills/<name>/DESCRIPTION.md, some SKILL.md).
|
|
183
|
+
// Hermes secrets (.env, auth.json, config.yaml) are never read.
|
|
184
|
+
skillList.push(...readSkillsDir(join(hermesDir, "skills"), "hermes", ["SKILL.md", "DESCRIPTION.md"]));
|
|
185
|
+
const instructions = [];
|
|
186
|
+
const claudeMd = join(claudeDir, "CLAUDE.md");
|
|
187
|
+
if (existsSync(claudeMd)) {
|
|
188
|
+
try {
|
|
189
|
+
instructions.push({ type: "instructions", name: "CLAUDE.md", content: readFileSync(claudeMd, "utf8") });
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
// skip unreadable CLAUDE.md
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
// Codex rules (~/.codex/rules/*) as instructions
|
|
196
|
+
instructions.push(...readRulesDir(join(codexDir, "rules")));
|
|
197
|
+
// Hermes persona (~/.hermes/SOUL.md) as instructions
|
|
198
|
+
const soul = join(hermesDir, "SOUL.md");
|
|
199
|
+
if (existsSync(soul)) {
|
|
200
|
+
try {
|
|
201
|
+
instructions.push({ type: "instructions", name: "SOUL.md", content: readFileSync(soul, "utf8") });
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
// skip unreadable SOUL.md
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return { skills: dedupByName(skillList), mcpServers: dedupByName(mcpList), instructions, hooks: uniqueHookNames(hookList) };
|
|
208
|
+
}
|
|
209
|
+
// Discover PROJECT-level artifacts under a chosen project root, tagged source "project".
|
|
210
|
+
// Kept separate from the global inventory (its own group, its own selection namespace) so a
|
|
211
|
+
// project artifact never collides with a same-named global one.
|
|
212
|
+
export function introspectProject(root) {
|
|
213
|
+
const skills = [];
|
|
214
|
+
const mcp = [];
|
|
215
|
+
const instructions = [];
|
|
216
|
+
const hooks = [];
|
|
217
|
+
skills.push(...readSkillsDir(join(root, ".claude", "skills"), "project"));
|
|
218
|
+
skills.push(...readSkillsDir(join(root, ".agents", "skills"), "project"));
|
|
219
|
+
const settings = readJson(join(root, ".claude", "settings.json"));
|
|
220
|
+
if (isObj(settings) && isObj(settings.mcpServers))
|
|
221
|
+
mcp.push(...serversToArtifacts(settings.mcpServers, "project"));
|
|
222
|
+
mcp.push(...serversToArtifacts(serversFromMcpJson(readJson(join(root, ".mcp.json"))), "project"));
|
|
223
|
+
hooks.push(...hooksFromConfig(settings, "project"));
|
|
224
|
+
hooks.push(...hooksFromConfig(readJson(join(root, ".claude", "hooks", "hooks.json")), "project"));
|
|
225
|
+
// Hermes project skills (nested-flat: .hermes/skills/<n>/DESCRIPTION.md|SKILL.md)
|
|
226
|
+
skills.push(...readSkillsDir(join(root, ".hermes", "skills"), "project", ["DESCRIPTION.md", "SKILL.md"]));
|
|
227
|
+
// Codex project MCP (.codex/config.toml [mcp_servers])
|
|
228
|
+
const codexToml = join(root, ".codex", "config.toml");
|
|
229
|
+
if (existsSync(codexToml)) {
|
|
230
|
+
try {
|
|
231
|
+
mcp.push(...serversToArtifacts(parseTomlMcpServers(readFileSync(codexToml, "utf8")), "project"));
|
|
232
|
+
}
|
|
233
|
+
catch { /* skip unparseable codex config */ }
|
|
234
|
+
}
|
|
235
|
+
for (const rel of ["CLAUDE.md", "AGENTS.md", join(".hermes", "SOUL.md")]) {
|
|
236
|
+
const p = join(root, rel);
|
|
237
|
+
if (existsSync(p)) {
|
|
238
|
+
try {
|
|
239
|
+
instructions.push({ type: "instructions", name: basename(rel), content: readFileSync(p, "utf8") });
|
|
240
|
+
}
|
|
241
|
+
catch {
|
|
242
|
+
// skip unreadable instructions file
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return { root, name: basename(root), skills: dedupByName(skills), mcpServers: dedupByName(mcp), instructions, hooks: uniqueHookNames(hooks) };
|
|
247
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// src/gem/mcpProxy.ts
|
|
2
|
+
// Shared, target-agnostic generator for a stdio->Streamable-HTTP MCP proxy runner. A url-only
|
|
3
|
+
// target (Eve today) can't connect to a stdio (command) MCP server directly; instead we emit a
|
|
4
|
+
// self-contained runner the operator launches where the agent runs. It spawns the stdio server and
|
|
5
|
+
// re-serves it over HTTP, so the agent connects to http://localhost:<port>/mcp.
|
|
6
|
+
//
|
|
7
|
+
// agentgem only RENDERS the script (pure text — no @modelcontextprotocol/sdk dependency here); the
|
|
8
|
+
// operator installs the SDK where they run it. Secrets are never embedded: the proxy passes the
|
|
9
|
+
// operator's environment through to the spawned server, and the file lists the env-var names needed.
|
|
10
|
+
export const PROXY_BASE_PORT = 7800;
|
|
11
|
+
export const PROXY_HOST = "127.0.0.1";
|
|
12
|
+
const commentText = (value) => value.replace(/[\r\n\u2028\u2029]/g, " ");
|
|
13
|
+
export function stdioProxyRunner(name, command, args, secretEnvs, port) {
|
|
14
|
+
const safeName = commentText(name);
|
|
15
|
+
const secretNote = secretEnvs.length
|
|
16
|
+
? `// Set these env vars before running (the underlying server reads them): ${commentText(secretEnvs.join(", "))}\n`
|
|
17
|
+
: "";
|
|
18
|
+
return `#!/usr/bin/env node
|
|
19
|
+
// Auto-generated by agentgem — stdio->HTTP MCP proxy for ${JSON.stringify(name)}.
|
|
20
|
+
// Run it alongside your agent: node agent/proxies/${safeName}.mjs
|
|
21
|
+
// Install where you run it: npm i @modelcontextprotocol/sdk express
|
|
22
|
+
${secretNote}import express from "express";
|
|
23
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
24
|
+
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
25
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
26
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
27
|
+
import {
|
|
28
|
+
ListToolsRequestSchema, CallToolRequestSchema,
|
|
29
|
+
ListPromptsRequestSchema, GetPromptRequestSchema,
|
|
30
|
+
ListResourcesRequestSchema, ReadResourceRequestSchema,
|
|
31
|
+
} from "@modelcontextprotocol/sdk/types.js";
|
|
32
|
+
|
|
33
|
+
// Connect to the real stdio MCP server (inherits this process's env for any secrets it needs).
|
|
34
|
+
const upstream = new Client({ name: "agentgem-proxy", version: "1.0.0" }, { capabilities: {} });
|
|
35
|
+
await upstream.connect(new StdioClientTransport({ command: ${JSON.stringify(command)}, args: ${JSON.stringify(args)}, env: process.env }));
|
|
36
|
+
|
|
37
|
+
// Re-serve it over Streamable HTTP, forwarding the standard MCP methods to the upstream client.
|
|
38
|
+
const server = new Server({ name: ${JSON.stringify(name)}, version: "1.0.0" }, { capabilities: { tools: {}, prompts: {}, resources: {} } });
|
|
39
|
+
server.setRequestHandler(ListToolsRequestSchema, () => upstream.listTools());
|
|
40
|
+
server.setRequestHandler(CallToolRequestSchema, (r) => upstream.callTool(r.params));
|
|
41
|
+
server.setRequestHandler(ListPromptsRequestSchema, () => upstream.listPrompts());
|
|
42
|
+
server.setRequestHandler(GetPromptRequestSchema, (r) => upstream.getPrompt(r.params));
|
|
43
|
+
server.setRequestHandler(ListResourcesRequestSchema, () => upstream.listResources());
|
|
44
|
+
server.setRequestHandler(ReadResourceRequestSchema, (r) => upstream.readResource(r.params));
|
|
45
|
+
|
|
46
|
+
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
|
|
47
|
+
await server.connect(transport);
|
|
48
|
+
const app = express();
|
|
49
|
+
app.use(express.json());
|
|
50
|
+
app.all("/mcp", (req, res) => transport.handleRequest(req, res, req.body));
|
|
51
|
+
app.listen(${port}, ${JSON.stringify(PROXY_HOST)}, () => console.error(${JSON.stringify(`[agentgem proxy] ${name} -> http://${PROXY_HOST}:${port}/mcp`)}));
|
|
52
|
+
`;
|
|
53
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
export const MANAGED_AGENTS_MODEL = "claude-opus-4-8";
|
|
2
|
+
const MAX_SKILLS = 20;
|
|
3
|
+
const MAX_MCP = 20;
|
|
4
|
+
export function renderManagedAgent(gem) {
|
|
5
|
+
const skipped = [];
|
|
6
|
+
const skills = gem.artifacts.filter((a) => a.type === "skill");
|
|
7
|
+
const mcp = gem.artifacts.filter((a) => a.type === "mcp_server");
|
|
8
|
+
const instr = gem.artifacts.filter((a) => a.type === "instructions");
|
|
9
|
+
const hooks = gem.artifacts.filter((a) => a.type === "hook");
|
|
10
|
+
// instructions -> system prompt (## <name>); skills are NOT inlined — they become Agent Skills.
|
|
11
|
+
const system = instr.map((i) => `## ${i.name}\n\n${i.content}`).join("\n\n---\n\n");
|
|
12
|
+
// skills -> custom skills to register (cap 20)
|
|
13
|
+
const skillsToRegister = [];
|
|
14
|
+
for (const s of skills) {
|
|
15
|
+
if (skillsToRegister.length >= MAX_SKILLS) {
|
|
16
|
+
skipped.push({ artifact: s.name, type: "skill", reason: "exceeds the Managed Agents 20-skill cap" });
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
skillsToRegister.push({ name: s.name, content: s.content });
|
|
20
|
+
}
|
|
21
|
+
// mcp -> mcp_servers (URL transport only; stdio has no endpoint), cap 20
|
|
22
|
+
const mcp_servers = [];
|
|
23
|
+
const mappedMcpNames = new Set();
|
|
24
|
+
for (const m of mcp) {
|
|
25
|
+
if (m.transport === "stdio") {
|
|
26
|
+
skipped.push({ artifact: m.name, type: "mcp_server", reason: "stdio MCP unsupported on Managed Agents (needs a URL endpoint)" });
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
const url = typeof m.config.url === "string" ? m.config.url : "";
|
|
30
|
+
// Require a real http(s) endpoint. A redaction-stripped or malformed url (e.g. "<redacted>"
|
|
31
|
+
// from a token-bearing query string) must not ship a broken server entry.
|
|
32
|
+
if (!/^https?:\/\//.test(url)) {
|
|
33
|
+
skipped.push({ artifact: m.name, type: "mcp_server", reason: url ? `${m.transport} MCP url is not a usable https endpoint (redacted or malformed)` : `${m.transport} MCP has no url` });
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
if (mappedMcpNames.has(m.name)) {
|
|
37
|
+
skipped.push({ artifact: m.name, type: "mcp_server", reason: "duplicate Managed Agents MCP server name" });
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (mcp_servers.length >= MAX_MCP) {
|
|
41
|
+
skipped.push({ artifact: m.name, type: "mcp_server", reason: "exceeds Managed Agents 20-server cap" });
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
mcp_servers.push({ type: "url", name: m.name, url });
|
|
45
|
+
mappedMcpNames.add(m.name);
|
|
46
|
+
}
|
|
47
|
+
// hooks -> no Managed Agents equivalent
|
|
48
|
+
for (const h of hooks)
|
|
49
|
+
skipped.push({ artifact: h.name, type: "hook", reason: "hooks have no Managed Agents equivalent" });
|
|
50
|
+
const tools = [
|
|
51
|
+
{ type: "agent_toolset_20260401" },
|
|
52
|
+
...mcp_servers.map((s) => ({ type: "mcp_toolset", mcp_server_name: s.name })),
|
|
53
|
+
];
|
|
54
|
+
// Only surface vault secrets for MCP servers that actually mapped.
|
|
55
|
+
const mappedNames = new Set(mcp_servers.map((m) => m.name));
|
|
56
|
+
const vaultSecrets = gem.requiredSecrets.filter((s) => mappedNames.has(s.artifact));
|
|
57
|
+
return { payload: { name: gem.name, model: MANAGED_AGENTS_MODEL, system, mcp_servers, tools }, skillsToRegister, skipped, vaultSecrets };
|
|
58
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// src/gem/recents.ts
|
|
2
|
+
// Persisted "testbeds you've opened in agentgem" — a small JSON list under ~/.agentgem.
|
|
3
|
+
// Pure store: takes an explicit home dir, computes no fs-existence (the endpoint adds that).
|
|
4
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
const CAP = 10;
|
|
7
|
+
function recentsFile(home) {
|
|
8
|
+
return join(home, ".agentgem", "recents.json");
|
|
9
|
+
}
|
|
10
|
+
function isEntry(v) {
|
|
11
|
+
const e = v;
|
|
12
|
+
return !!e && typeof e.path === "string" && typeof e.flavor === "string"
|
|
13
|
+
&& typeof e.name === "string" && typeof e.lastUsed === "string";
|
|
14
|
+
}
|
|
15
|
+
export function readRecents(home) {
|
|
16
|
+
try {
|
|
17
|
+
const parsed = JSON.parse(readFileSync(recentsFile(home), "utf8"));
|
|
18
|
+
return Array.isArray(parsed) ? parsed.filter(isEntry) : [];
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return [];
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
// Move/insert `e` at the front (deduped by path), stamp lastUsed, cap, persist.
|
|
25
|
+
// Best-effort write: a non-writable ~/.agentgem must not break opening a testbed.
|
|
26
|
+
export function upsertRecent(home, e) {
|
|
27
|
+
const entry = { ...e, lastUsed: new Date().toISOString() };
|
|
28
|
+
const rest = readRecents(home).filter((r) => r.path !== entry.path);
|
|
29
|
+
const next = [entry, ...rest].slice(0, CAP);
|
|
30
|
+
try {
|
|
31
|
+
const abs = recentsFile(home);
|
|
32
|
+
mkdirSync(dirname(abs), { recursive: true });
|
|
33
|
+
writeFileSync(abs, JSON.stringify(next, null, 2) + "\n", "utf8");
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
console.warn(`agentgem: could not write recents to ${recentsFile(home)}`);
|
|
37
|
+
}
|
|
38
|
+
return next;
|
|
39
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
const SECRET_RE = /(api[_-]?key|token|secret|password|passwd|bearer|sk-|ghp_|gho_|xox[a-z]-|credential)/i;
|
|
2
|
+
// A long, special-char-free token (no spaces, slashes, or dots) is almost
|
|
3
|
+
// certainly a secret; long sentences/paths/urls contain those characters.
|
|
4
|
+
function isHighEntropyToken(s) {
|
|
5
|
+
return s.length >= 32 && /^[A-Za-z0-9_-]+$/.test(s);
|
|
6
|
+
}
|
|
7
|
+
function redactNode(node, underSecretMap, path, key, secrets) {
|
|
8
|
+
if (typeof node === "string") {
|
|
9
|
+
const keyIsSecret = key !== undefined && SECRET_RE.test(key);
|
|
10
|
+
// Value-content heuristics are prose-safe: prose uses words like "bearer" or "token"
|
|
11
|
+
// legitimately (e.g. "test bearer authentication flow"), so SECRET_RE on the whole string
|
|
12
|
+
// would produce false positives. Instead:
|
|
13
|
+
// • Whitespace-free string: treat the whole value as a single token (existing behaviour).
|
|
14
|
+
// • Multi-word string: only flag it if ANY individual whitespace-free token is high-entropy
|
|
15
|
+
// (e.g. "use token ghp_abcdefghijklmnopqrstuvwxyz0123" → ghp_… triggers isHighEntropyToken).
|
|
16
|
+
const isWhitespaceFree = !/\s/.test(node);
|
|
17
|
+
const looksLikeToken = isWhitespaceFree
|
|
18
|
+
? SECRET_RE.test(node) || isHighEntropyToken(node)
|
|
19
|
+
: node.split(/\s+/).some((t) => t.length > 0 && isHighEntropyToken(t));
|
|
20
|
+
if (underSecretMap || keyIsSecret || looksLikeToken) {
|
|
21
|
+
secrets.push({ name: key ?? path, location: path });
|
|
22
|
+
return "<redacted>";
|
|
23
|
+
}
|
|
24
|
+
return node;
|
|
25
|
+
}
|
|
26
|
+
if (Array.isArray(node))
|
|
27
|
+
return node.map((x, i) => redactNode(x, underSecretMap, `${path}[${i}]`, key, secrets));
|
|
28
|
+
if (node && typeof node === "object") {
|
|
29
|
+
const out = {};
|
|
30
|
+
for (const [k, v] of Object.entries(node)) {
|
|
31
|
+
const secretMap = underSecretMap || k === "env" || k === "headers";
|
|
32
|
+
out[k] = redactNode(v, secretMap, path ? `${path}.${k}` : k, k, secrets);
|
|
33
|
+
}
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
return node;
|
|
37
|
+
}
|
|
38
|
+
export function redactMcpConfig(config) {
|
|
39
|
+
const secrets = [];
|
|
40
|
+
const redacted = redactNode(config, false, "", undefined, secrets);
|
|
41
|
+
return { config: redacted, secrets };
|
|
42
|
+
}
|