@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.
- package/CHANGELOG.md +25 -0
- package/LICENSE +21 -0
- package/README.md +361 -0
- package/dist/cli/src/adapters/claude.js +31 -0
- package/dist/cli/src/adapters/codex.js +30 -0
- package/dist/cli/src/adapters/cursor.js +42 -0
- package/dist/cli/src/adapters/dsh.js +46 -0
- package/dist/cli/src/adapters/generic.js +23 -0
- package/dist/cli/src/adapters/index.js +30 -0
- package/dist/cli/src/adapters/opencode.js +41 -0
- package/dist/cli/src/adapters/types.js +140 -0
- package/dist/cli/src/adapters/windsurf.js +43 -0
- package/dist/cli/src/commands/audit.js +62 -0
- package/dist/cli/src/commands/init.js +104 -0
- package/dist/cli/src/commands/install.js +212 -0
- package/dist/cli/src/commands/update.js +119 -0
- package/dist/cli/src/core/config.js +47 -0
- package/dist/cli/src/core/installer.js +279 -0
- package/dist/cli/src/core/lock.js +41 -0
- package/dist/cli/src/core/permissions.js +29 -0
- package/dist/cli/src/core/plugin.js +34 -0
- package/dist/cli/src/core/project.js +60 -0
- package/dist/cli/src/core/registry.js +137 -0
- package/dist/cli/src/core/semver.js +162 -0
- package/dist/cli/src/core/sign.js +92 -0
- package/dist/cli/src/core/store.js +71 -0
- package/dist/cli/src/index.js +484 -0
- package/dist/scripts/build-registry.js +145 -0
- package/dist/scripts/publish-verified.js +82 -0
- package/dist/scripts/seed-registry-13lite.js +208 -0
- package/dist/scripts/seed-registry.js +144 -0
- package/dist/scripts/verify-npm-mcps.js +60 -0
- package/forge.toml +18 -0
- package/install.ps1 +59 -0
- package/install.sh +81 -0
- package/package.json +64 -0
- package/registry/index.json +336 -0
- package/registry/packages/agent-changelog-writer.json +27 -0
- package/registry/packages/agent-debugger.json +27 -0
- package/registry/packages/agent-pr-reviewer.json +27 -0
- package/registry/packages/agent-researcher.json +27 -0
- package/registry/packages/agent-security-auditor.json +27 -0
- package/registry/packages/cmd-plan.json +26 -0
- package/registry/packages/cmd-review.json +26 -0
- package/registry/packages/mcp-filesystem.json +34 -0
- package/registry/packages/mcp-github.json +33 -0
- package/registry/packages/mcp-memory.json +33 -0
- package/registry/packages/mcp-postgres.json +34 -0
- package/registry/packages/mcp-sequential-thinking.json +33 -0
- package/registry/packages/obra-superpowers.json +29 -0
- package/registry/packages/pdf-compress.json +27 -0
- package/registry/packages/pdf-convert.json +27 -0
- package/registry/packages/pdf-extract.json +27 -0
- package/registry/packages/pdf-forms.json +27 -0
- package/registry/packages/pdf-merge.json +27 -0
- package/registry/packages/pdf-ocr.json +27 -0
- package/registry/packages/pdf-split.json +27 -0
- package/registry/packages/pdf-tables.json +27 -0
- package/registry/search.json +248 -0
- 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
|
+
}
|