@oomerevren/tryforge 0.1.2

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.
Files changed (60) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/LICENSE +21 -0
  3. package/README.md +361 -0
  4. package/dist/cli/src/adapters/claude.js +31 -0
  5. package/dist/cli/src/adapters/codex.js +30 -0
  6. package/dist/cli/src/adapters/cursor.js +42 -0
  7. package/dist/cli/src/adapters/dsh.js +46 -0
  8. package/dist/cli/src/adapters/generic.js +23 -0
  9. package/dist/cli/src/adapters/index.js +30 -0
  10. package/dist/cli/src/adapters/opencode.js +41 -0
  11. package/dist/cli/src/adapters/types.js +140 -0
  12. package/dist/cli/src/adapters/windsurf.js +43 -0
  13. package/dist/cli/src/commands/audit.js +62 -0
  14. package/dist/cli/src/commands/init.js +104 -0
  15. package/dist/cli/src/commands/install.js +212 -0
  16. package/dist/cli/src/commands/update.js +119 -0
  17. package/dist/cli/src/core/config.js +47 -0
  18. package/dist/cli/src/core/installer.js +279 -0
  19. package/dist/cli/src/core/lock.js +41 -0
  20. package/dist/cli/src/core/permissions.js +29 -0
  21. package/dist/cli/src/core/plugin.js +34 -0
  22. package/dist/cli/src/core/project.js +60 -0
  23. package/dist/cli/src/core/registry.js +137 -0
  24. package/dist/cli/src/core/semver.js +162 -0
  25. package/dist/cli/src/core/sign.js +92 -0
  26. package/dist/cli/src/core/store.js +71 -0
  27. package/dist/cli/src/index.js +484 -0
  28. package/dist/scripts/build-registry.js +145 -0
  29. package/dist/scripts/publish-verified.js +82 -0
  30. package/dist/scripts/seed-registry-13lite.js +208 -0
  31. package/dist/scripts/seed-registry.js +144 -0
  32. package/dist/scripts/verify-npm-mcps.js +60 -0
  33. package/forge.toml +18 -0
  34. package/install.ps1 +59 -0
  35. package/install.sh +81 -0
  36. package/package.json +64 -0
  37. package/registry/index.json +336 -0
  38. package/registry/packages/agent-changelog-writer.json +27 -0
  39. package/registry/packages/agent-debugger.json +27 -0
  40. package/registry/packages/agent-pr-reviewer.json +27 -0
  41. package/registry/packages/agent-researcher.json +27 -0
  42. package/registry/packages/agent-security-auditor.json +27 -0
  43. package/registry/packages/cmd-plan.json +26 -0
  44. package/registry/packages/cmd-review.json +26 -0
  45. package/registry/packages/mcp-filesystem.json +34 -0
  46. package/registry/packages/mcp-github.json +33 -0
  47. package/registry/packages/mcp-memory.json +33 -0
  48. package/registry/packages/mcp-postgres.json +34 -0
  49. package/registry/packages/mcp-sequential-thinking.json +33 -0
  50. package/registry/packages/obra-superpowers.json +29 -0
  51. package/registry/packages/pdf-compress.json +27 -0
  52. package/registry/packages/pdf-convert.json +27 -0
  53. package/registry/packages/pdf-extract.json +27 -0
  54. package/registry/packages/pdf-forms.json +27 -0
  55. package/registry/packages/pdf-merge.json +27 -0
  56. package/registry/packages/pdf-ocr.json +27 -0
  57. package/registry/packages/pdf-split.json +27 -0
  58. package/registry/packages/pdf-tables.json +27 -0
  59. package/registry/search.json +248 -0
  60. package/registry/stats.json +13 -0
@@ -0,0 +1,140 @@
1
+ // cli/src/adapters/types.ts — Epoch 1c: fail-closed MCP config, Windows junction, skill files
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync, cpSync, copyFileSync, symlinkSync, lstatSync, unlinkSync, renameSync } from "fs";
3
+ import { join, dirname } from "path";
4
+ export { forgeHome, packagesDir, packageDir, toSlug } from "../core/store.js";
5
+ export function ensureDir(dir) {
6
+ if (!existsSync(dir))
7
+ mkdirSync(dir, { recursive: true });
8
+ }
9
+ export function trySymlinkOrCopy(src, dest) {
10
+ ensureDir(dirname(dest));
11
+ if (existsSync(dest)) {
12
+ try {
13
+ const stat = lstatSync(dest);
14
+ if (stat.isSymbolicLink())
15
+ unlinkSync(dest);
16
+ else
17
+ rmSync(dest, { recursive: true, force: true });
18
+ }
19
+ catch {
20
+ rmSync(dest, { recursive: true, force: true });
21
+ }
22
+ }
23
+ try {
24
+ const isWin = process.platform === "win32";
25
+ symlinkSync(src, dest, isWin ? "junction" : "dir");
26
+ return;
27
+ }
28
+ catch {
29
+ cpSync(src, dest, { recursive: true });
30
+ }
31
+ }
32
+ export function removeLinkOrDir(target) {
33
+ if (!existsSync(target))
34
+ return;
35
+ try {
36
+ const stat = lstatSync(target);
37
+ if (stat.isSymbolicLink())
38
+ unlinkSync(target);
39
+ else
40
+ rmSync(target, { recursive: true, force: true });
41
+ }
42
+ catch {
43
+ rmSync(target, { recursive: true, force: true });
44
+ }
45
+ }
46
+ /**
47
+ * Read MCP config — FAIL-CLOSED.
48
+ * Returns null if file doesn't exist.
49
+ * Returns parsed JSON if file is valid JSON.
50
+ * THROWS on parse error (invalid JSON/JSONC) — caller must NOT silently overwrite.
51
+ * This protects user configs with comments (JSONC) or trailing commas from being destroyed.
52
+ */
53
+ export function readMcpConfig(configPath) {
54
+ if (!existsSync(configPath))
55
+ return null;
56
+ const raw = readFileSync(configPath, "utf-8");
57
+ try {
58
+ return JSON.parse(raw);
59
+ }
60
+ catch (e) {
61
+ throw new Error(`[forge] MCP config at ${configPath} contains invalid JSON: ${e.message}\n` +
62
+ `[forge] Refusing to modify. Fix the config manually or rename it, then retry.`);
63
+ }
64
+ }
65
+ export function writeMcpConfig(configPath, data, opts = {}) {
66
+ ensureDir(dirname(configPath));
67
+ backupFileIfExists(configPath);
68
+ // Epoch 1d: Atomic write — temp dosyaya yaz, sonra rename (crash-safe)
69
+ const tmp = configPath + ".tmp." + Date.now();
70
+ writeFileSync(tmp, JSON.stringify(data, null, 2) + "\n");
71
+ renameSync(tmp, configPath);
72
+ }
73
+ /** Snapshot a user-owned config before we overwrite it (single `.bak`, like `sed -i.bak`).
74
+ * Returns the backup path, or null when there was nothing to back up. */
75
+ export function backupFileIfExists(configPath) {
76
+ if (!existsSync(configPath))
77
+ return null;
78
+ const bak = configPath + ".bak";
79
+ try {
80
+ copyFileSync(configPath, bak);
81
+ console.log(`[forge] backup: ${configPath} → ${bak}`);
82
+ return bak;
83
+ }
84
+ catch (e) {
85
+ console.warn(`[forge] warning: could not back up ${configPath}: ${e.message}`);
86
+ return null;
87
+ }
88
+ }
89
+ export function addMcpServerToConfig(configPath, name, mcp) {
90
+ // Epoch 1e: dosya yoksa oluştur (taze makine desteği)
91
+ let cfg = readMcpConfig(configPath);
92
+ if (cfg === null) {
93
+ // Dosya yok veya boş — yeni config oluştur
94
+ cfg = {};
95
+ }
96
+ if (!cfg["mcpServers"])
97
+ cfg["mcpServers"] = {};
98
+ const servers = cfg["mcpServers"];
99
+ servers[name] = {
100
+ command: mcp.command,
101
+ args: mcp.args ?? [],
102
+ ...(mcp.env ? { env: mcp.env } : {}),
103
+ };
104
+ writeMcpConfig(configPath, cfg);
105
+ }
106
+ export function removeMcpServerFromConfig(configPath, name) {
107
+ const cfg = readMcpConfig(configPath);
108
+ if (cfg === null)
109
+ return; // file doesn't exist or invalid — nothing to remove
110
+ const servers = cfg["mcpServers"];
111
+ if (servers && name in servers) {
112
+ delete servers[name];
113
+ writeMcpConfig(configPath, cfg);
114
+ }
115
+ }
116
+ // Epoch 1d: type-aware hedef dizin
117
+ export function targetDirFor(base, type) {
118
+ const dirMap = {
119
+ skill: "skills",
120
+ agent: "agents",
121
+ command: "commands",
122
+ hook: "hooks",
123
+ plugin: "plugins",
124
+ };
125
+ return join(base, dirMap[type] || "skills");
126
+ }
127
+ export function installSkillFiles(_adapterName, pkgSlug, srcDir, destBase) {
128
+ const dest = join(destBase, pkgSlug);
129
+ trySymlinkOrCopy(srcDir, dest);
130
+ }
131
+ export function uninstallSkillFiles(pkgSlug, destBase) {
132
+ const dest = join(destBase, pkgSlug);
133
+ removeLinkOrDir(dest);
134
+ }
135
+ export async function listDirNames(dir) {
136
+ if (!existsSync(dir))
137
+ return [];
138
+ const { readdirSync } = await import("fs");
139
+ return readdirSync(dir, { withFileTypes: true }).filter((d) => d.isDirectory() || d.isSymbolicLink()).map((d) => d.name);
140
+ }
@@ -0,0 +1,43 @@
1
+ // cli/src/adapters/windsurf.ts — Windsurf harness (Faz 12 yeni)
2
+ import { existsSync } from "fs";
3
+ import { join } from "path";
4
+ import { homedir } from "os";
5
+ import { installSkillFiles, uninstallSkillFiles, listDirNames } from "./types.js";
6
+ export const windsurfAdapter = {
7
+ name: "windsurf",
8
+ displayName: "Windsurf",
9
+ version: "0.1.0",
10
+ detect: () => existsSync(join(process.cwd(), ".windsurf")) || existsSync(join(homedir(), ".windsurf")) || existsSync(join(homedir(), ".codeium", "windsurf")),
11
+ skillDir: (slug) => {
12
+ if (existsSync(join(process.cwd(), ".windsurf")))
13
+ return join(process.cwd(), ".windsurf", "skills", slug);
14
+ return join(homedir(), ".windsurf", "skills", slug);
15
+ },
16
+ mcpConfigPath: () => {
17
+ if (existsSync(join(process.cwd(), ".windsurf")))
18
+ return join(process.cwd(), ".windsurf", "mcp_config.json");
19
+ return join(homedir(), ".codeium", "windsurf", "mcp_config.json");
20
+ },
21
+ async install(pkgSlug, srcDir, type) {
22
+ void type;
23
+ const base = existsSync(join(process.cwd(), ".windsurf")) ? join(process.cwd(), ".windsurf", "skills") : join(homedir(), ".windsurf", "skills");
24
+ installSkillFiles("windsurf", pkgSlug, srcDir, base);
25
+ },
26
+ async uninstall(pkgSlug, _type) {
27
+ const bases = [join(process.cwd(), ".windsurf", "skills"), join(homedir(), ".windsurf", "skills")];
28
+ for (const b of bases)
29
+ uninstallSkillFiles(pkgSlug, b);
30
+ },
31
+ async list() {
32
+ const bases = [join(process.cwd(), ".windsurf", "skills"), join(homedir(), ".windsurf", "skills")];
33
+ for (const b of bases) {
34
+ if (existsSync(b))
35
+ return listDirNames(b);
36
+ }
37
+ return [];
38
+ },
39
+ async isInstalled(pkgSlug) {
40
+ const bases = [join(process.cwd(), ".windsurf", "skills"), join(homedir(), ".windsurf", "skills")];
41
+ return bases.some((b) => existsSync(join(b, pkgSlug)));
42
+ },
43
+ };
@@ -0,0 +1,62 @@
1
+ // cli/src/commands/audit.ts — trust-tier audit (verified vs community vs mock).
2
+ // Full vulnerability DB in Faz 22; tier flags are accurate as of the verified-core sprint.
3
+ import { existsSync } from "fs";
4
+ import { join } from "path";
5
+ import { readLinks, packageDir } from "../core/store.js";
6
+ import { loadPackageDetail } from "../core/registry.js";
7
+ import { isPlaceholderSha } from "../core/installer.js";
8
+ export async function runAudit(opts = {}) {
9
+ const links = readLinks();
10
+ const entries = Object.values(links);
11
+ const findings = [];
12
+ if (entries.length === 0) {
13
+ console.log("[forge] audit: no installed packages — nothing to check");
14
+ console.log("[forge] audit: full vulnerability DB in Faz 22");
15
+ return;
16
+ }
17
+ for (const rec of entries) {
18
+ try {
19
+ const detail = await loadPackageDetail(rec.pkg);
20
+ const meta = detail.versions[rec.version];
21
+ if (!meta) {
22
+ findings.push({ pkg: rec.pkg, level: "warn", message: `version ${rec.version} not in registry` });
23
+ continue;
24
+ }
25
+ if (meta.sha256 && isPlaceholderSha(meta.sha256)) {
26
+ findings.push({ pkg: rec.pkg, level: 'info', message: 'community tier (unverified tarball) — install needs --mock' });
27
+ }
28
+ else if (!meta.verified) {
29
+ findings.push({ pkg: rec.pkg, level: 'warn', message: 'sha256 not verified against tarball content' });
30
+ }
31
+ if (!meta.tarball || meta.tarball.includes("placeholder")) {
32
+ findings.push({ pkg: rec.pkg, level: "warn", message: `tarball URL placeholder` });
33
+ }
34
+ // On-disk truth: .forge-mock marker means this install is mock content (--mock).
35
+ try {
36
+ if (existsSync(join(packageDir(rec.slug, rec.version), ".forge-mock"))) {
37
+ findings.push({ pkg: rec.pkg, level: "info", message: `installed from MOCK content (--mock was used)` });
38
+ }
39
+ }
40
+ catch { }
41
+ }
42
+ catch (e) {
43
+ findings.push({ pkg: rec.pkg, level: "warn", message: `registry read failed: ${e.message}` });
44
+ }
45
+ }
46
+ if (opts.json) {
47
+ console.log(JSON.stringify({ packages: entries.length, findings }, null, 2));
48
+ return;
49
+ }
50
+ console.log(`[forge] audit — ${entries.length} package(s) checked (skeleton, full DB Faz 22):\n`);
51
+ if (findings.length === 0) {
52
+ console.log("[forge] ✓ no issues");
53
+ }
54
+ else {
55
+ for (const f of findings.slice(0, 30)) {
56
+ console.log(` [${f.level}] ${f.pkg}: ${f.message}`);
57
+ }
58
+ if (findings.length > 30)
59
+ console.log(` ... and ${findings.length - 30} more`);
60
+ }
61
+ console.log(`\n[forge] audit done: ${findings.length} finding(s)`);
62
+ }
@@ -0,0 +1,104 @@
1
+ import { existsSync, mkdirSync, writeFileSync } from "fs";
2
+ import { join, resolve, basename, sep } from "path";
3
+ const TEMPLATES = {
4
+ skill: {
5
+ forgeToml: (name) => {
6
+ const short = name.split("/")[1] ?? name;
7
+ return `[package]\nname = "${name}"\nversion = "0.1.0"\ntype = "skill"\ndescription = "A new Forge skill — describe what it does"\nlicense = "MIT"\n\n[engines]\nclaude-code = "*"\n\n[skill]\nname = "${short}"\ninvocation = "/${short}"\nallowed-tools = ["Read", "Write", "Bash"]\n`;
8
+ },
9
+ extraFiles: (name) => ({
10
+ "SKILL.md": `# ${name}\n\nA Forge skill. Replace this with your skill prompt.\n\n## Usage\n\nDescribe when Muse should use this skill.\n\n## Instructions\n\n- Step 1: ...\n- Step 2: ...\n`,
11
+ }),
12
+ },
13
+ mcp: {
14
+ forgeToml: (name) => `[package]\nname = "${name}"\nversion = "0.1.0"\ntype = "mcp"\ndescription = "A new MCP server"\nlicense = "MIT"\n\n[mcp]\ncommand = "node"\nargs = ["./dist/index.js"]\n`,
15
+ extraFiles: (name) => ({
16
+ "src/index.ts": `#!/usr/bin/env node\n// ${name} — MCP server scaffold\nconsole.log("MCP server: ${name}");\n`,
17
+ "mcp.json": JSON.stringify({ mcpServers: { [name.split("/")[1] ?? name]: { command: "node", args: ["./dist/index.js"] } } }, null, 2) + "\n",
18
+ "README.md": `# ${name}\n\nMCP server scaffold generated by \`forge init\`.\n`,
19
+ }),
20
+ },
21
+ agent: {
22
+ forgeToml: (name) => `[package]\nname = "${name}"\nversion = "0.1.0"\ntype = "agent"\ndescription = "A new Forge agent"\nlicense = "MIT"\n\n[agent]\nmodel = "claude-sonnet-4"\ntools = ["Read", "Write", "Bash"]\nprompt = "agent.md"\n`,
23
+ extraFiles: (name) => ({
24
+ "agent.md": `# ${name} — Agent\n\nDefine your agent's system prompt here.\n`,
25
+ }),
26
+ },
27
+ command: {
28
+ forgeToml: (name) => {
29
+ const short = name.split("/")[1] ?? name;
30
+ return `[package]\nname = "${name}"\nversion = "0.1.0"\ntype = "command"\ndescription = "A new Forge command"\nlicense = "MIT"\n\n[command]\nname = "${short}"\ninvocation = "/${short}"\n`;
31
+ },
32
+ extraFiles: (name) => ({
33
+ "command.md": `# ${name} — Command\n\nDefine the slash command here.\n`,
34
+ }),
35
+ },
36
+ hook: {
37
+ forgeToml: (name) => `[package]\nname = "${name}"\nversion = "0.1.0"\ntype = "hook"\ndescription = "A new Forge hook"\nlicense = "MIT"\n\n[hook]\nevent = "pre-tool"\nentry = "hook.json"\n`,
38
+ extraFiles: (name) => ({
39
+ "hook.json": JSON.stringify({ name, hooks: { "pre-tool": { entry: "./hook.js" } } }, null, 2) + "\n",
40
+ }),
41
+ },
42
+ plugin: {
43
+ forgeToml: (name) => `[package]\nname = "${name}"\nversion = "0.1.0"\ntype = "plugin"\ndescription = "A new Forge plugin"\nlicense = "MIT"\n\n[plugin]\nentry = "index.js"\nhooks = ["pre-tool", "post-tool"]\n`,
44
+ extraFiles: () => ({
45
+ "plugin.json": JSON.stringify({ name: "plugin", entry: "index.js", hooks: ["pre-tool", "post-tool"] }, null, 2) + "\n",
46
+ "index.js": `// Forge plugin entry\nmodule.exports = {};\n`,
47
+ "README.md": "# Plugin scaffold\n",
48
+ }),
49
+ },
50
+ };
51
+ export async function runInit(opts) {
52
+ const cwd = resolve(opts.cwd ?? process.cwd());
53
+ const rawName = opts.name ?? basename(cwd);
54
+ const pkgName = normalizeName(rawName);
55
+ const type = (opts.type ?? "skill");
56
+ if (!TEMPLATES[type]) {
57
+ console.error(`[forge] unknown type "${type}" — choose: ${Object.keys(TEMPLATES).join(", ")}`);
58
+ process.exit(1);
59
+ }
60
+ // Epoch 1c: prevent path traversal — targetDir must be inside cwd
61
+ const targetDir = opts.name ? resolve(cwd, opts.name) : cwd;
62
+ const resolvedCwd = resolve(cwd);
63
+ if (!targetDir.startsWith(resolvedCwd + sep) && targetDir !== resolvedCwd) {
64
+ console.error(`[forge] invalid path "${opts.name}" — must be within the current directory`);
65
+ process.exit(1);
66
+ }
67
+ const forgeTomlPath = join(targetDir, "forge.toml");
68
+ if (existsSync(forgeTomlPath) && !opts.force) {
69
+ console.error(`[forge] ${forgeTomlPath} already exists — use --force to overwrite`);
70
+ process.exit(1);
71
+ }
72
+ if (!existsSync(targetDir))
73
+ mkdirSync(targetDir, { recursive: true });
74
+ const tpl = TEMPLATES[type];
75
+ const tomlContent = tpl.forgeToml(pkgName);
76
+ writeFileSync(forgeTomlPath, tomlContent);
77
+ const extras = tpl.extraFiles(pkgName);
78
+ for (const [rel, content] of Object.entries(extras)) {
79
+ const full = join(targetDir, rel);
80
+ // ensure parent
81
+ mkdirSync(join(targetDir, ...rel.split("/").slice(0, -1)), { recursive: true });
82
+ if (!existsSync(full) || opts.force)
83
+ writeFileSync(full, content);
84
+ }
85
+ const relDir = targetDir === cwd ? "." : opts.name;
86
+ console.log(`[forge] ✓ created ${relDir}/forge.toml [${type}]`);
87
+ if (Object.keys(extras).length)
88
+ console.log(`[forge] + ${Object.keys(extras).join(", ")}`);
89
+ console.log(`[forge] next: edit forge.toml & SKILL.md, then 'forge publish' (Faz 8)`);
90
+ }
91
+ function normalizeName(input) {
92
+ // allow my-skill -> my-skill/my-skill ? We expect scope/name
93
+ // If input contains /, keep; otherwise treat as unscoped and prefix with local scope?
94
+ // For scaffold we use "local/<name>" if no slash? But spec expects scope/name — use "local/<name>" fallback?
95
+ // Simpler: if no slash, use "<name>/<name>" is weird. Use input as-is if it has /, else input (consumer scaffold may be folder name)
96
+ // Forge init in a project folder without scope — keep as input; for package scaffold we need scope/name.
97
+ // We'll produce scope/name: if input has /, use it; else `local/${input}`
98
+ if (input.includes("/"))
99
+ return input;
100
+ // If it's a simple name like my-skill, make it local/my-skill
101
+ if (/^[a-z0-9-]+$/.test(input))
102
+ return `local/${input}`;
103
+ return input;
104
+ }
@@ -0,0 +1,212 @@
1
+ import { existsSync } from "fs";
2
+ import { resolve, join } from "path";
3
+ import { homedir } from "os";
4
+ import { resolveVersion, satisfiesRange } from "../core/registry.js";
5
+ import { ensurePackageContent } from "../core/installer.js";
6
+ import { ensureForgeDirs, readLinks, writeLinks, toSlug } from "../core/store.js";
7
+ import { allAdapters, detectAdapters, addMcpServerToConfig } from "../adapters/index.js";
8
+ import { findProjectToml, loadProjectToml, validateProjectToml } from "../core/project.js";
9
+ import { readLock, writeLock } from "../core/lock.js";
10
+ import { loadConfig } from "../core/config.js";
11
+ function pickAdapters(projectHarnesses) {
12
+ if (projectHarnesses && projectHarnesses.length > 0) {
13
+ const filtered = allAdapters.filter((a) => projectHarnesses.includes(a.name));
14
+ if (filtered.length === 0) {
15
+ console.warn(`[forge] warning: [forge].harnesses has no known adapter, falling back to auto-detect`);
16
+ return detectAdapters();
17
+ }
18
+ return filtered;
19
+ }
20
+ const cfg = loadConfig();
21
+ if (cfg.defaultHarnesses.length > 0) {
22
+ const filtered = allAdapters.filter((a) => cfg.defaultHarnesses.includes(a.name));
23
+ if (filtered.length > 0)
24
+ return filtered;
25
+ }
26
+ return detectAdapters();
27
+ }
28
+ export async function runInstall(opts = {}) {
29
+ const cwd = resolve(opts.cwd ?? process.cwd());
30
+ const tomlPath = findProjectToml(cwd);
31
+ if (!tomlPath) {
32
+ console.error(`[forge] forge.toml not found in ${cwd}`);
33
+ console.error(`[forge] run 'forge init' to create a project, or create forge.toml with [dependencies]`);
34
+ process.exit(1);
35
+ }
36
+ let project;
37
+ try {
38
+ project = loadProjectToml(tomlPath);
39
+ }
40
+ catch (e) {
41
+ console.error(`[forge] ${e.message}`);
42
+ process.exit(1);
43
+ }
44
+ const errs = validateProjectToml(project, tomlPath);
45
+ if (errs.length > 0) {
46
+ for (const er of errs)
47
+ console.error(`[forge] ${er}`);
48
+ process.exit(1);
49
+ }
50
+ const deps = project.dependencies;
51
+ if (Object.keys(deps).length === 0) {
52
+ console.log(`[forge] no dependencies in ${tomlPath}`);
53
+ return;
54
+ }
55
+ // --frozen: install exactly from lock
56
+ if (opts.frozen) {
57
+ const lock = readLock(cwd);
58
+ if (!lock) {
59
+ console.error(`[forge] --frozen requires forge.lock, but none found in ${cwd}`);
60
+ process.exit(1);
61
+ }
62
+ // verify lock matches dependencies keys (at least every dep has an entry)
63
+ // AND that each locked version still satisfies forge.toml's range —
64
+ // a hand-aged lock must never silently downgrade under --frozen.
65
+ const lockMap = new Map(lock.packages.map((p) => [p.name, p]));
66
+ for (const [depName, depRange] of Object.entries(deps)) {
67
+ const locked = lockMap.get(depName);
68
+ if (!locked) {
69
+ console.error(`[forge] --frozen: lock missing ${depName} (run 'forge install' without --frozen to update lock)`);
70
+ process.exit(1);
71
+ }
72
+ if (!satisfiesRange(locked.version, depRange)) {
73
+ console.error(`[forge] --frozen: locked ${depName}@${locked.version} does not satisfy forge.toml range "${depRange}" (run 'forge install' without --frozen to update lock)`);
74
+ process.exit(1);
75
+ }
76
+ }
77
+ console.log(`[forge] installing ${lock.packages.length} package(s) from forge.lock (frozen)...`);
78
+ const adapters = pickAdapters(project.forge?.harnesses);
79
+ console.log(`[forge] harnesses: ${adapters.map((a) => a.displayName).join(", ")}`);
80
+ const t0 = Date.now();
81
+ let ok = 0;
82
+ for (const entry of lock.packages) {
83
+ try {
84
+ const { detail, version, versionMeta } = await resolveVersion(entry.name, entry.version);
85
+ const src = await ensurePackageContent(entry.name, version, detail, versionMeta, { allowMock: opts.mock });
86
+ for (const adapter of adapters) {
87
+ await adapter.install(toSlug(entry.name), src, detail.type);
88
+ if (detail.type === "mcp" && versionMeta.mcp) {
89
+ const cfgPath = adapter.mcpConfigPath();
90
+ if (cfgPath)
91
+ addMcpServerToConfig(cfgPath, toSlug(entry.name), versionMeta.mcp);
92
+ }
93
+ }
94
+ // update links
95
+ const links = readLinks();
96
+ links[entry.name] = {
97
+ pkg: entry.name,
98
+ version,
99
+ slug: toSlug(entry.name),
100
+ type: detail.type,
101
+ adapters: adapters.map((a) => a.name),
102
+ installedAt: new Date().toISOString(),
103
+ };
104
+ writeLinks(links);
105
+ ok++;
106
+ console.log(` ✓ ${entry.name}@${version}`);
107
+ }
108
+ catch (e) {
109
+ console.warn(` ✗ ${entry.name} failed: ${e.message}`);
110
+ }
111
+ }
112
+ const dt = ((Date.now() - t0) / 1000).toFixed(1);
113
+ console.log(`\n[forge] ✓ installed ${ok}/${lock.packages.length} from lock in ${dt}s`);
114
+ return;
115
+ }
116
+ // Normal: resolve from dependencies
117
+ console.log(`[forge] installing ${Object.keys(deps).length} package(s) from ${tomlPath}...`);
118
+ const adapters = pickAdapters(project.forge?.harnesses);
119
+ console.log(`[forge] harnesses: ${adapters.map((a) => a.displayName).join(", ")}`);
120
+ const t0 = Date.now();
121
+ const lockEntries = [];
122
+ let ok = 0;
123
+ let skipped = 0;
124
+ // Check existing links to skip already-installed exact version? We reinstall if range resolves same version already installed? For idempotency allow skip? We'll still ensure content but count skipped.
125
+ const existingLinks = readLinks();
126
+ for (const [depName, depRange] of Object.entries(deps)) {
127
+ try {
128
+ const { detail, version, versionMeta } = await resolveVersion(depName, depRange);
129
+ const already = existingLinks[depName];
130
+ const isSameVersionInstalled = already?.version === version && existsSyncForSlug(toSlug(depName), version);
131
+ if (isSameVersionInstalled) {
132
+ // verify adapters still have it? For speed skip re-install? But ensure adapters have it
133
+ // For mcp packages isInstalled checks skillDir which is not created — treat store existence as enough
134
+ if (detail.type === "mcp") {
135
+ lockEntries.push({ name: depName, version, type: detail.type });
136
+ skipped++;
137
+ console.log(` = ${depName}@${version} already installed`);
138
+ continue;
139
+ }
140
+ // We do lightweight check: if every adapter reports installed, skip heavy install
141
+ let allPresent = true;
142
+ for (const a of adapters) {
143
+ if (!(await a.isInstalled(toSlug(depName))))
144
+ allPresent = false;
145
+ }
146
+ if (allPresent) {
147
+ lockEntries.push({ name: depName, version, type: detail.type });
148
+ skipped++;
149
+ console.log(` = ${depName}@${version} already installed`);
150
+ continue;
151
+ }
152
+ }
153
+ const src = await ensurePackageContent(depName, version, detail, versionMeta, { allowMock: opts.mock });
154
+ for (const adapter of adapters) {
155
+ await adapter.install(toSlug(depName), src, detail.type);
156
+ if (detail.type === "mcp" && versionMeta.mcp) {
157
+ const cfgPath = adapter.mcpConfigPath();
158
+ if (cfgPath)
159
+ addMcpServerToConfig(cfgPath, toSlug(depName), versionMeta.mcp);
160
+ }
161
+ }
162
+ // deps of dep (one level)
163
+ const subDeps = versionMeta.dependencies ?? {};
164
+ for (const [subName, subRange] of Object.entries(subDeps)) {
165
+ try {
166
+ const sub = await resolveVersion(subName, subRange);
167
+ const subSrc = await ensurePackageContent(subName, sub.version, sub.detail, sub.versionMeta, { allowMock: opts.mock });
168
+ for (const adapter of adapters)
169
+ await adapter.install(toSlug(subName), subSrc, sub.detail.type);
170
+ console.log(` dep ${subName}@${sub.version}`);
171
+ }
172
+ catch (e) {
173
+ console.warn(` ! dep ${subName} failed: ${e.message}`);
174
+ }
175
+ }
176
+ ensureForgeDirs();
177
+ const links = readLinks();
178
+ links[depName] = {
179
+ pkg: depName,
180
+ version,
181
+ slug: toSlug(depName),
182
+ type: detail.type,
183
+ adapters: adapters.map((a) => a.name),
184
+ installedAt: new Date().toISOString(),
185
+ };
186
+ writeLinks(links);
187
+ lockEntries.push({ name: depName, version, type: detail.type });
188
+ ok++;
189
+ console.log(` ✓ ${depName}@${version}`);
190
+ }
191
+ catch (e) {
192
+ console.error(` ✗ ${depName} failed: ${e.message}`);
193
+ }
194
+ }
195
+ // Write lock (deterministic order)
196
+ lockEntries.sort((a, b) => a.name.localeCompare(b.name));
197
+ writeLock(lockEntries, cwd);
198
+ const dt = ((Date.now() - t0) / 1000).toFixed(1);
199
+ const total = ok + skipped;
200
+ const failed = Object.keys(deps).length - total;
201
+ if (failed > 0) {
202
+ console.log(`\n[forge] ✗ ${failed} package(s) failed — installed ${ok} new, ${skipped} cached (${total}/${Object.keys(deps).length}) on ${adapters.length} harness(es) in ${dt}s`);
203
+ process.exitCode = 1;
204
+ }
205
+ else {
206
+ console.log(`\n[forge] ✓ installed ${ok} new, ${skipped} cached (${total}/${Object.keys(deps).length}) on ${adapters.length} harness(es) in ${dt}s`);
207
+ }
208
+ console.log(`[forge] lock written to ${cwd}/forge.lock`);
209
+ }
210
+ function existsSyncForSlug(slug, version) {
211
+ return existsSync(join(homedir(), ".forge", "packages", `${slug}@${version}`));
212
+ }