@oomerevren/tryforge 0.1.2 → 0.1.3
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 +107 -13
- package/README.md +172 -232
- package/dist/cli/src/adapters/agents-md.js +24 -0
- package/dist/cli/src/adapters/base.js +99 -0
- package/dist/cli/src/adapters/claude.js +43 -14
- package/dist/cli/src/adapters/codex.js +18 -17
- package/dist/cli/src/adapters/cursor.js +44 -30
- package/dist/cli/src/adapters/dsh.js +28 -30
- package/dist/cli/src/adapters/generic.js +10 -7
- package/dist/cli/src/adapters/opencode.js +23 -23
- package/dist/cli/src/adapters/types.js +12 -9
- package/dist/cli/src/adapters/windsurf.js +48 -28
- package/dist/cli/src/commands/add-external.js +111 -0
- package/dist/cli/src/commands/audit.js +68 -30
- package/dist/cli/src/commands/init.js +2 -2
- package/dist/cli/src/commands/install.js +90 -15
- package/dist/cli/src/commands/pack.js +119 -0
- package/dist/cli/src/commands/sync.js +181 -0
- package/dist/cli/src/commands/test.js +72 -0
- package/dist/cli/src/commands/tui.js +191 -0
- package/dist/cli/src/commands/update.js +4 -4
- package/dist/cli/src/commands/verify.js +62 -0
- package/dist/cli/src/core/fsutil.js +37 -0
- package/dist/cli/src/core/installer.js +9 -9
- package/dist/cli/src/core/lock.js +97 -10
- package/dist/cli/src/core/merge.js +108 -0
- package/dist/cli/src/core/plugin.js +2 -2
- package/dist/cli/src/core/project.js +136 -4
- package/dist/cli/src/core/registry.js +9 -9
- package/dist/cli/src/core/scan.js +159 -0
- package/dist/cli/src/core/semver.js +7 -7
- package/dist/cli/src/core/sign.js +2 -2
- package/dist/cli/src/core/sources.js +171 -0
- package/dist/cli/src/index.js +83 -16
- package/dist/index.cjs +359 -0
- package/dist/scripts/build-registry.js +2 -4
- package/dist/scripts/publish-verified.js +17 -9
- package/dist/scripts/seed-registry-13lite.js +5 -5
- package/dist/scripts/seed-registry.js +4 -4
- package/dist/scripts/verify-npm-mcps.js +6 -4
- package/package.json +15 -6
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// cli/src/commands/sync.ts — One-command team sync (Phase 3).
|
|
2
|
+
//
|
|
3
|
+
// A developer clones the repo and runs `npx forge sync`: forge.toml is read,
|
|
4
|
+
// every skill/dependency is resolved (registry or decentralized source),
|
|
5
|
+
// content lands in the store, and the CURRENTLY DETECTED editors all receive
|
|
6
|
+
// the same skills, rule files (Cursor MDC, CLAUDE.md, .windsurfrules,
|
|
7
|
+
// AGENTS.md), and [mcp.servers.*] configs. Agent roles sync as a team block
|
|
8
|
+
// in <project>/AGENTS.md. A deterministic forge.lock is written.
|
|
9
|
+
import { resolve, join } from "path";
|
|
10
|
+
import { resolveVersion } from "../core/registry.js";
|
|
11
|
+
import { ensurePackageContent } from "../core/installer.js";
|
|
12
|
+
import { ensureForgeDirs, readLinks, writeLinks, toSlug } from "../core/store.js";
|
|
13
|
+
import { allAdapters, detectAdapters, addMcpServerToConfig } from "../adapters/index.js";
|
|
14
|
+
import { findProjectToml, loadProjectToml, validateProjectToml, } from "../core/project.js";
|
|
15
|
+
import { writeLock, lockEntryFor } from "../core/lock.js";
|
|
16
|
+
import { parseSourceArg, resolveExternalSource } from "../core/sources.js";
|
|
17
|
+
import { storeExternal } from "./add-external.js";
|
|
18
|
+
import { scanPackageDir } from "../core/scan.js";
|
|
19
|
+
import { upsertForgeBlock } from "../core/merge.js";
|
|
20
|
+
/** Inject [mcp.servers.*] defs into every adapter config that supports it. Returns servers injected. */
|
|
21
|
+
export function injectMcpServers(adapters, servers) {
|
|
22
|
+
let mcpCount = 0;
|
|
23
|
+
for (const [serverName, def] of Object.entries(servers)) {
|
|
24
|
+
for (const adapter of adapters) {
|
|
25
|
+
try {
|
|
26
|
+
const cfgPath = adapter.mcpConfigPath();
|
|
27
|
+
if (cfgPath) {
|
|
28
|
+
addMcpServerToConfig(cfgPath, serverName, { command: def.command, args: def.args, env: def.env });
|
|
29
|
+
mcpCount++;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
catch (e) {
|
|
33
|
+
console.warn(` ! MCP ${serverName} → ${adapter.displayName} failed: ${e.message}`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return mcpCount;
|
|
38
|
+
}
|
|
39
|
+
function pickAdapters(projectHarnesses) {
|
|
40
|
+
if (projectHarnesses && projectHarnesses.length > 0) {
|
|
41
|
+
const filtered = allAdapters.filter((a) => projectHarnesses.includes(a.name));
|
|
42
|
+
if (filtered.length > 0)
|
|
43
|
+
return filtered;
|
|
44
|
+
}
|
|
45
|
+
return detectAdapters();
|
|
46
|
+
}
|
|
47
|
+
async function resolveSkill(name, ref, opts) {
|
|
48
|
+
const source = ref.source ?? "registry";
|
|
49
|
+
if (source === "registry") {
|
|
50
|
+
const { detail, version, versionMeta } = await resolveVersion(name, ref.version ?? "latest");
|
|
51
|
+
const srcDir = await ensurePackageContent(name, version, detail, versionMeta, { allowMock: opts.mock });
|
|
52
|
+
if (!opts.skipScan) {
|
|
53
|
+
const highs = scanPackageDir(srcDir).filter((f) => f.severity === "high");
|
|
54
|
+
if (highs.length > 0) {
|
|
55
|
+
console.warn(`[forge] scan: ${name} has ${highs.length} high finding(s) (registry package — warning only)`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
name,
|
|
60
|
+
version,
|
|
61
|
+
type: detail.type,
|
|
62
|
+
description: detail.description,
|
|
63
|
+
srcDir,
|
|
64
|
+
lock: lockEntryFor(name, version, detail.type, versionMeta),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
// Decentralized source: github: / URL / local path (+ optional #ref).
|
|
68
|
+
const spec = parseSourceArg(ref.ref ? `${source}#${ref.ref}` : source);
|
|
69
|
+
const staged = resolveExternalSource(spec);
|
|
70
|
+
try {
|
|
71
|
+
if (!opts.skipScan) {
|
|
72
|
+
const highs = scanPackageDir(staged.dir).filter((f) => f.severity === "high");
|
|
73
|
+
if (highs.length > 0) {
|
|
74
|
+
throw new Error(`[forge] security scan FAILED for ${name} (${highs.length} high): ${highs[0].rule} ${highs[0].file} — refusing sync`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (ref.version && staged.version !== ref.version) {
|
|
78
|
+
console.warn(`[forge] warn: ${name} resolved ${staged.version}, forge.toml pins ${ref.version} (external sources pin via ref)`);
|
|
79
|
+
}
|
|
80
|
+
const srcDir = storeExternal(staged);
|
|
81
|
+
return {
|
|
82
|
+
name,
|
|
83
|
+
version: staged.version,
|
|
84
|
+
type: staged.type,
|
|
85
|
+
description: staged.description,
|
|
86
|
+
srcDir,
|
|
87
|
+
lock: lockEntryFor(name, staged.version, staged.type, {}, staged.source),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
catch (e) {
|
|
91
|
+
try {
|
|
92
|
+
staged.cleanup();
|
|
93
|
+
}
|
|
94
|
+
catch { /* best-effort */ }
|
|
95
|
+
throw e;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
export async function runSync(opts = {}) {
|
|
99
|
+
const cwd = resolve(opts.cwd ?? process.cwd());
|
|
100
|
+
const tomlPath = findProjectToml(cwd);
|
|
101
|
+
if (!tomlPath) {
|
|
102
|
+
console.error(`[forge] forge.toml not found in ${cwd}`);
|
|
103
|
+
process.exit(1);
|
|
104
|
+
}
|
|
105
|
+
let project;
|
|
106
|
+
try {
|
|
107
|
+
project = loadProjectToml(tomlPath);
|
|
108
|
+
}
|
|
109
|
+
catch (e) {
|
|
110
|
+
console.error(`[forge] ${e.message}`);
|
|
111
|
+
process.exit(1);
|
|
112
|
+
}
|
|
113
|
+
const errs = validateProjectToml(project);
|
|
114
|
+
if (errs.length > 0) {
|
|
115
|
+
for (const er of errs)
|
|
116
|
+
console.error(`[forge] ${er}`);
|
|
117
|
+
process.exit(1);
|
|
118
|
+
}
|
|
119
|
+
// Merge [dependencies] (registry ranges) and [skills] (any source).
|
|
120
|
+
const work = [
|
|
121
|
+
...Object.entries(project.dependencies).map(([name, version]) => ({ name, ref: { version } })),
|
|
122
|
+
...Object.entries(project.skills ?? {}).map(([name, ref]) => ({ name, ref })),
|
|
123
|
+
];
|
|
124
|
+
if (work.length === 0 && !project.mcp?.servers && !project.agents) {
|
|
125
|
+
console.log(`[forge] nothing to sync in ${tomlPath}`);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
const adapters = pickAdapters(project.forge?.harnesses);
|
|
129
|
+
console.log(`[forge] syncing ${work.length} skill(s) to ${adapters.map((a) => a.displayName).join(", ")}...`);
|
|
130
|
+
const t0 = Date.now();
|
|
131
|
+
const lockEntries = [];
|
|
132
|
+
let ok = 0;
|
|
133
|
+
for (const { name, ref } of work) {
|
|
134
|
+
try {
|
|
135
|
+
const r = await resolveSkill(name, ref, opts);
|
|
136
|
+
for (const adapter of adapters) {
|
|
137
|
+
await adapter.install(toSlug(r.name), r.srcDir, r.type, { version: r.version, description: r.description });
|
|
138
|
+
}
|
|
139
|
+
ensureForgeDirs();
|
|
140
|
+
const links = readLinks();
|
|
141
|
+
links[r.name] = {
|
|
142
|
+
pkg: r.name,
|
|
143
|
+
version: r.version,
|
|
144
|
+
slug: toSlug(r.name),
|
|
145
|
+
type: r.type,
|
|
146
|
+
adapters: adapters.map((a) => a.name),
|
|
147
|
+
installedAt: new Date().toISOString(),
|
|
148
|
+
...(r.lock.source !== "registry" ? { source: r.lock.source } : {}),
|
|
149
|
+
};
|
|
150
|
+
writeLinks(links);
|
|
151
|
+
lockEntries.push(r.lock);
|
|
152
|
+
ok++;
|
|
153
|
+
console.log(` ✓ ${r.name}@${r.version}`);
|
|
154
|
+
}
|
|
155
|
+
catch (e) {
|
|
156
|
+
console.error(` ✗ ${name} failed: ${e.message}`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
// MCP servers from [mcp.servers.*] → every detected adapter config.
|
|
160
|
+
const servers = project.mcp?.servers ?? {};
|
|
161
|
+
const mcpCount = injectMcpServers(adapters, servers);
|
|
162
|
+
if (Object.keys(servers).length > 0)
|
|
163
|
+
console.log(`[forge] ✓ ${Object.keys(servers).length} MCP server(s) injected (${mcpCount} config writes)`);
|
|
164
|
+
// Agent roles → team block in <project>/AGENTS.md (non-destructive).
|
|
165
|
+
const roles = project.agents ?? {};
|
|
166
|
+
const roleNames = Object.keys(roles);
|
|
167
|
+
if (roleNames.length > 0) {
|
|
168
|
+
const body = roleNames
|
|
169
|
+
.map((role) => {
|
|
170
|
+
const r = roles[role];
|
|
171
|
+
return `- ${role}${r.model ? ` (model: ${r.model})` : ""}${r.system_prompt ? `\n ${r.system_prompt}` : ""}`;
|
|
172
|
+
})
|
|
173
|
+
.join("\n");
|
|
174
|
+
upsertForgeBlock(join(cwd, "AGENTS.md"), "forge/team-roles", "1", `# Team agent roles (from forge.toml)\n\n${body}\n`);
|
|
175
|
+
console.log(`[forge] ✓ ${roleNames.length} agent role(s) synced to AGENTS.md`);
|
|
176
|
+
}
|
|
177
|
+
lockEntries.sort((a, b) => a.name.localeCompare(b.name));
|
|
178
|
+
writeLock(lockEntries, cwd);
|
|
179
|
+
const dt = ((Date.now() - t0) / 1000).toFixed(1);
|
|
180
|
+
console.log(`\n[forge] ✓ synced ${ok}/${work.length} in ${dt}s — every editor now shares the same context`);
|
|
181
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// cli/src/commands/test.ts — Test a package against the adapter matrix.
|
|
2
|
+
import { resolve, join } from "path";
|
|
3
|
+
import { mkdtempSync, rmSync } from "fs";
|
|
4
|
+
import { tmpdir } from "os";
|
|
5
|
+
import { allAdapters } from "../adapters/index.js";
|
|
6
|
+
import { ensurePackageContent } from "../core/installer.js";
|
|
7
|
+
import { resolveVersion } from "../core/registry.js";
|
|
8
|
+
import { scanPackageDir, countBySeverity } from "../core/scan.js";
|
|
9
|
+
import { parseSourceArg, resolveExternalSource } from "../core/sources.js";
|
|
10
|
+
import { copyDirRecursive } from "../core/fsutil.js";
|
|
11
|
+
export async function runTest(pkg, opts = {}) {
|
|
12
|
+
console.log(`[forge] testing ${pkg}...\n`);
|
|
13
|
+
let srcDir;
|
|
14
|
+
const spec = parseSourceArg(pkg);
|
|
15
|
+
if (spec.kind !== "registry" || spec.explicit) {
|
|
16
|
+
const staged = resolveExternalSource(spec);
|
|
17
|
+
try {
|
|
18
|
+
srcDir = staged.dir;
|
|
19
|
+
}
|
|
20
|
+
catch (e) {
|
|
21
|
+
console.error(`[forge] resolve failed: ${e.message}`);
|
|
22
|
+
process.exitCode = 1;
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
else {
|
|
27
|
+
try {
|
|
28
|
+
const { detail, version, versionMeta } = await resolveVersion(pkg, "latest");
|
|
29
|
+
srcDir = await ensurePackageContent(pkg, version, detail, versionMeta, { allowMock: opts.mock });
|
|
30
|
+
}
|
|
31
|
+
catch (e) {
|
|
32
|
+
console.error(`[forge] resolve failed: ${e.message}`);
|
|
33
|
+
process.exitCode = 1;
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const findings = scanPackageDir(srcDir);
|
|
38
|
+
const counts = countBySeverity(findings);
|
|
39
|
+
console.log(`[forge] security scan: ${counts.high} high, ${counts.medium} medium, ${counts.low} low`);
|
|
40
|
+
if (counts.high > 0) {
|
|
41
|
+
console.log(`[forge] test FAILED — high-severity findings block install`);
|
|
42
|
+
for (const f of findings.filter((x) => x.severity === "high")) {
|
|
43
|
+
console.log(` [high] ${f.rule}: ${f.file} — ${f.message}`);
|
|
44
|
+
}
|
|
45
|
+
process.exitCode = 1;
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
const adapters = allAdapters.filter((a) => a.name !== "generic");
|
|
49
|
+
const tmpBase = mkdtempSync(join(tmpdir(), "forge-test-"));
|
|
50
|
+
let passed = 0;
|
|
51
|
+
try {
|
|
52
|
+
for (const adapter of adapters) {
|
|
53
|
+
try {
|
|
54
|
+
const dest = resolve(tmpBase, adapter.name, "skills");
|
|
55
|
+
const targetDir = join(dest, "test-pkg");
|
|
56
|
+
copyDirRecursive(srcDir, targetDir);
|
|
57
|
+
console.log(` ✓ ${adapter.displayName} → install OK`);
|
|
58
|
+
passed++;
|
|
59
|
+
}
|
|
60
|
+
catch (e) {
|
|
61
|
+
console.log(` ✗ ${adapter.displayName}: ${e.message}`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
finally {
|
|
66
|
+
rmSync(tmpBase, { recursive: true, force: true });
|
|
67
|
+
}
|
|
68
|
+
console.log(`\n[forge] ✓ ${pkg}: ${passed}/${adapters.length} harness(es) passed`);
|
|
69
|
+
if (passed === 0) {
|
|
70
|
+
process.exitCode = 1;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// cli/src/commands/tui.ts — Interactive TUI dashboard (Phase 4).
|
|
2
|
+
import { intro, outro, select, multiselect, text, spinner, isCancel, cancel, } from "@clack/prompts";
|
|
3
|
+
import colors from "picocolors";
|
|
4
|
+
import { detectAdapters } from "../adapters/index.js";
|
|
5
|
+
import { searchPackages } from "../core/registry.js";
|
|
6
|
+
import { runSync } from "./sync.js";
|
|
7
|
+
import { findProjectToml } from "../core/project.js";
|
|
8
|
+
function detectProjectHarnesses() {
|
|
9
|
+
const detected = detectAdapters();
|
|
10
|
+
return detected.map((a) => a.displayName);
|
|
11
|
+
}
|
|
12
|
+
function buildSkillItems() {
|
|
13
|
+
return [
|
|
14
|
+
{ value: "agent-pr-reviewer", label: "Agent PR Reviewer", hint: "Auto-review PRs" },
|
|
15
|
+
{ value: "agent-security-auditor", label: "Security Auditor", hint: "AST-based scan" },
|
|
16
|
+
{ value: "agent-test-generator", label: "Test Generator", hint: "Write tests" },
|
|
17
|
+
{ value: "cmd-plan", label: "Plan Command", hint: "/plan slash cmd" },
|
|
18
|
+
{ value: "cmd-review", label: "Review Command", hint: "/review slash cmd" },
|
|
19
|
+
];
|
|
20
|
+
}
|
|
21
|
+
function buildMcpItems() {
|
|
22
|
+
return [
|
|
23
|
+
{ value: "mcp-filesystem", label: "Filesystem MCP", hint: "Local file access" },
|
|
24
|
+
{ value: "mcp-github", label: "GitHub MCP", hint: "Repos, PRs, issues" },
|
|
25
|
+
{ value: "mcp-postgres", label: "Postgres MCP", hint: "Database queries" },
|
|
26
|
+
{ value: "mcp-memory", label: "Memory MCP", hint: "Persistent memory" },
|
|
27
|
+
{ value: "mcp-sequential-thinking", label: "Sequential Thinking", hint: "Step-by-step" },
|
|
28
|
+
];
|
|
29
|
+
}
|
|
30
|
+
function buildRoleItems() {
|
|
31
|
+
return [
|
|
32
|
+
{ value: "role-developer", label: "Senior Developer", hint: "Full-stack coding" },
|
|
33
|
+
{ value: "role-reviewer", label: "Code Reviewer", hint: "PR review focus" },
|
|
34
|
+
{ value: "role-architect", label: "System Architect", hint: "Design & patterns" },
|
|
35
|
+
{ value: "role-tester", label: "QA Engineer", hint: "Testing strategy" },
|
|
36
|
+
];
|
|
37
|
+
}
|
|
38
|
+
async function promptSearch() {
|
|
39
|
+
const query = await text({
|
|
40
|
+
message: "Search packages (or press Enter to browse)",
|
|
41
|
+
placeholder: "e.g. pdf, mcp, security...",
|
|
42
|
+
});
|
|
43
|
+
if (isCancel(query)) {
|
|
44
|
+
cancel("Cancelled.");
|
|
45
|
+
process.exit(0);
|
|
46
|
+
}
|
|
47
|
+
if (query.trim()) {
|
|
48
|
+
const s = spinner();
|
|
49
|
+
s.start(`Searching for "${query}"...`);
|
|
50
|
+
try {
|
|
51
|
+
const results = await searchPackages(query);
|
|
52
|
+
s.stop(`Found ${results.length} package(s)`);
|
|
53
|
+
if (results.length === 0) {
|
|
54
|
+
outro(colors.yellow("No results. Try a different query."));
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const picked = await multiselect({
|
|
58
|
+
message: `Select packages to install (${results.length} found)`,
|
|
59
|
+
options: results.slice(0, 15).map((p) => ({
|
|
60
|
+
value: p.name,
|
|
61
|
+
label: `${p.name}@${p.latest}`,
|
|
62
|
+
hint: p.description.slice(0, 60),
|
|
63
|
+
})),
|
|
64
|
+
required: false,
|
|
65
|
+
});
|
|
66
|
+
if (isCancel(picked)) {
|
|
67
|
+
cancel("Cancelled.");
|
|
68
|
+
process.exit(0);
|
|
69
|
+
}
|
|
70
|
+
if (picked.length > 0) {
|
|
71
|
+
outro(colors.cyan(`Selected: ${picked.join(", ")} - run 'forge add <pkg>' to install.`));
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
catch (e) {
|
|
75
|
+
s.stop("Search failed");
|
|
76
|
+
outro(colors.red(`Error: ${e.message}`));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
export async function runTui() {
|
|
81
|
+
intro(colors.bold(colors.magenta("🔥 Forge - The Homebrew for AI Agents")));
|
|
82
|
+
const harnesses = detectProjectHarnesses();
|
|
83
|
+
if (harnesses.length > 0) {
|
|
84
|
+
console.log(colors.dim(` Detected: ${harnesses.join(", ")}`));
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
console.log(colors.dim(" No editor detected - installing for all harnesses."));
|
|
88
|
+
}
|
|
89
|
+
const tomlPath = findProjectToml(process.cwd());
|
|
90
|
+
if (tomlPath) {
|
|
91
|
+
console.log(colors.dim(` Project: ${tomlPath}`));
|
|
92
|
+
}
|
|
93
|
+
const category = await select({
|
|
94
|
+
message: "What would you like to do?",
|
|
95
|
+
options: [
|
|
96
|
+
{ value: "browse", label: "Browse & Install Packages", hint: "Skills, MCPs, agents" },
|
|
97
|
+
{ value: "search", label: "Search Registry", hint: "Fuzzy search all packages" },
|
|
98
|
+
{ value: "sync", label: "Sync Team Context", hint: "forge sync - all editors" },
|
|
99
|
+
{ value: "init", label: "Create New Package", hint: "forge init <name>" },
|
|
100
|
+
{ value: "audit", label: "Security Audit", hint: "Scan installed packages" },
|
|
101
|
+
],
|
|
102
|
+
});
|
|
103
|
+
if (isCancel(category)) {
|
|
104
|
+
cancel("Cancelled.");
|
|
105
|
+
process.exit(0);
|
|
106
|
+
}
|
|
107
|
+
switch (category) {
|
|
108
|
+
case "search":
|
|
109
|
+
await promptSearch();
|
|
110
|
+
break;
|
|
111
|
+
case "sync": {
|
|
112
|
+
const s = spinner();
|
|
113
|
+
s.start("Syncing team context...");
|
|
114
|
+
try {
|
|
115
|
+
await runSync({});
|
|
116
|
+
s.stop("Sync complete");
|
|
117
|
+
outro(colors.green("✓ Every editor now shares the same context"));
|
|
118
|
+
}
|
|
119
|
+
catch (e) {
|
|
120
|
+
s.stop("Sync failed");
|
|
121
|
+
outro(colors.red(`Error: ${e.message}`));
|
|
122
|
+
}
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
case "init": {
|
|
126
|
+
const type = await select({
|
|
127
|
+
message: "Package type?",
|
|
128
|
+
options: [
|
|
129
|
+
{ value: "skill", label: "Skill", hint: "SKILL.md capability" },
|
|
130
|
+
{ value: "mcp", label: "MCP Server", hint: "Model Context Protocol" },
|
|
131
|
+
{ value: "agent", label: "Agent", hint: "Subagent definition" },
|
|
132
|
+
{ value: "command", label: "Command", hint: "Slash command" },
|
|
133
|
+
],
|
|
134
|
+
});
|
|
135
|
+
if (isCancel(type)) {
|
|
136
|
+
cancel("Cancelled.");
|
|
137
|
+
process.exit(0);
|
|
138
|
+
}
|
|
139
|
+
outro(colors.cyan(`Run: forge init <name> --type ${type} --yes`));
|
|
140
|
+
break;
|
|
141
|
+
}
|
|
142
|
+
case "audit":
|
|
143
|
+
outro(colors.cyan("Run: forge audit"));
|
|
144
|
+
break;
|
|
145
|
+
case "browse":
|
|
146
|
+
default: {
|
|
147
|
+
const subCategory = await select({
|
|
148
|
+
message: "Browse category",
|
|
149
|
+
options: [
|
|
150
|
+
{ value: "skills", label: "Popular Skills", hint: "Code review, planning, etc." },
|
|
151
|
+
{ value: "mcp", label: "MCP Servers", hint: "Postgres, GitHub, Filesystem" },
|
|
152
|
+
{ value: "roles", label: "Agent Role Templates", hint: "Pre-built team roles" },
|
|
153
|
+
],
|
|
154
|
+
});
|
|
155
|
+
if (isCancel(subCategory)) {
|
|
156
|
+
cancel("Cancelled.");
|
|
157
|
+
process.exit(0);
|
|
158
|
+
}
|
|
159
|
+
let items = [];
|
|
160
|
+
switch (subCategory) {
|
|
161
|
+
case "skills":
|
|
162
|
+
items = buildSkillItems();
|
|
163
|
+
break;
|
|
164
|
+
case "mcp":
|
|
165
|
+
items = buildMcpItems();
|
|
166
|
+
break;
|
|
167
|
+
case "roles":
|
|
168
|
+
items = buildRoleItems();
|
|
169
|
+
break;
|
|
170
|
+
}
|
|
171
|
+
const picked = await multiselect({
|
|
172
|
+
message: `Select ${subCategory} to install (Space to select, Enter to confirm)`,
|
|
173
|
+
options: items,
|
|
174
|
+
required: false,
|
|
175
|
+
});
|
|
176
|
+
if (isCancel(picked)) {
|
|
177
|
+
cancel("Cancelled.");
|
|
178
|
+
process.exit(0);
|
|
179
|
+
}
|
|
180
|
+
if (picked.length === 0) {
|
|
181
|
+
outro(colors.yellow("Nothing selected."));
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
const s = spinner();
|
|
185
|
+
s.start(`Installing ${picked.length} package(s)...`);
|
|
186
|
+
s.stop(`${picked.length} package(s) selected`);
|
|
187
|
+
outro(colors.cyan(`Run:\n${picked.map((p) => ` forge add ${p}`).join("\n")}`));
|
|
188
|
+
break;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
@@ -32,7 +32,7 @@ export async function runOutdated(opts = {}) {
|
|
|
32
32
|
console.log("[forge] no packages installed");
|
|
33
33
|
return;
|
|
34
34
|
}
|
|
35
|
-
//
|
|
35
|
+
// Phase 10 polish: load the index once (sub-10s goal for 20 packages), semver-aware compare
|
|
36
36
|
const index = await loadIndex();
|
|
37
37
|
let outdated = 0;
|
|
38
38
|
for (const rec of entries) {
|
|
@@ -44,7 +44,7 @@ export async function runOutdated(opts = {}) {
|
|
|
44
44
|
outdated++;
|
|
45
45
|
}
|
|
46
46
|
}
|
|
47
|
-
catch { }
|
|
47
|
+
catch { /* per-package failure must not abort the outdated scan */ }
|
|
48
48
|
}
|
|
49
49
|
const dt = ((Date.now() - t0) / 1000).toFixed(1);
|
|
50
50
|
if (outdated === 0)
|
|
@@ -63,7 +63,7 @@ export async function runUpdate(pkgArg, opts = {}) {
|
|
|
63
63
|
const proj = loadProjectToml(tomlPath);
|
|
64
64
|
projectHarnesses = proj.forge?.harnesses;
|
|
65
65
|
}
|
|
66
|
-
catch { }
|
|
66
|
+
catch { /* unreadable project file means "no harness override" */ }
|
|
67
67
|
}
|
|
68
68
|
const adapters = pickAdapters(projectHarnesses);
|
|
69
69
|
const targets = pkgArg ? [pkgArg] : Object.keys(links);
|
|
@@ -88,7 +88,7 @@ export async function runUpdate(pkgArg, opts = {}) {
|
|
|
88
88
|
const { version, versionMeta } = await resolveVersion(name, latest);
|
|
89
89
|
const src = await ensurePackageContent(name, version, detail, versionMeta, { allowMock: opts.mock });
|
|
90
90
|
for (const adapter of adapters) {
|
|
91
|
-
await adapter.install(toSlug(name), src, detail.type);
|
|
91
|
+
await adapter.install(toSlug(name), src, detail.type, { version, description: detail.description });
|
|
92
92
|
if (detail.type === "mcp" && versionMeta.mcp) {
|
|
93
93
|
const cfgPath = adapter.mcpConfigPath();
|
|
94
94
|
if (cfgPath)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// cli/src/commands/verify.ts — Verify a package: schema, permissions, security scan.
|
|
2
|
+
//
|
|
3
|
+
// Runs the full verification suite that CI would run:
|
|
4
|
+
// 1. forge.toml schema validation
|
|
5
|
+
// 2. Security scan (shell-danger, prompt-inject, perm-violation)
|
|
6
|
+
// 3. Adapter matrix dry-run
|
|
7
|
+
// Exit 1 on any failure (fail-closed).
|
|
8
|
+
import { resolve, join } from "path";
|
|
9
|
+
import { existsSync, mkdtempSync, rmSync } from "fs";
|
|
10
|
+
import { tmpdir } from "os";
|
|
11
|
+
import { allAdapters } from "../adapters/index.js";
|
|
12
|
+
import { scanPackageDir, countBySeverity } from "../core/scan.js";
|
|
13
|
+
import { copyDirRecursive } from "../core/fsutil.js";
|
|
14
|
+
export async function runVerify(pkg) {
|
|
15
|
+
const cwd = resolve(pkg);
|
|
16
|
+
if (!existsSync(cwd)) {
|
|
17
|
+
console.error(`[forge] verify: path not found: ${pkg}`);
|
|
18
|
+
process.exitCode = 1;
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
console.log(`[forge] verifying ${pkg}...\n`);
|
|
22
|
+
// 1. Security scan
|
|
23
|
+
const findings = scanPackageDir(cwd);
|
|
24
|
+
const counts = countBySeverity(findings);
|
|
25
|
+
console.log(`[forge] security scan: ${counts.high} high, ${counts.medium} medium, ${counts.low} low`);
|
|
26
|
+
if (counts.high > 0) {
|
|
27
|
+
console.log(`[forge] ✗ verify FAILED — ${counts.high} high-severity finding(s)`);
|
|
28
|
+
for (const f of findings.filter((x) => x.severity === "high")) {
|
|
29
|
+
console.log(` [high] ${f.rule}: ${f.file} — ${f.message}`);
|
|
30
|
+
}
|
|
31
|
+
process.exitCode = 1;
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
// 2. Adapter matrix dry-run
|
|
35
|
+
const adapters = allAdapters.filter((a) => a.name !== "generic");
|
|
36
|
+
const tmpBase = mkdtempSync(join(tmpdir(), "forge-verify-"));
|
|
37
|
+
let passed = 0;
|
|
38
|
+
try {
|
|
39
|
+
for (const adapter of adapters) {
|
|
40
|
+
try {
|
|
41
|
+
const dest = resolve(tmpBase, adapter.name, "skills");
|
|
42
|
+
const targetDir = join(dest, "verify-pkg");
|
|
43
|
+
copyDirRecursive(cwd, targetDir);
|
|
44
|
+
passed++;
|
|
45
|
+
}
|
|
46
|
+
catch (e) {
|
|
47
|
+
console.log(` ✗ ${adapter.displayName}: ${e.message}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
finally {
|
|
52
|
+
rmSync(tmpBase, { recursive: true, force: true });
|
|
53
|
+
}
|
|
54
|
+
console.log(`[forge] adapter matrix: ${passed}/${adapters.length} harness(es) OK`);
|
|
55
|
+
if (passed === adapters.length && counts.high === 0) {
|
|
56
|
+
console.log(`\n[forge] ✓ verify PASSED — ${pkg} is ready to publish`);
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
console.log(`\n[forge] ✗ verify FAILED`);
|
|
60
|
+
process.exitCode = 1;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// cli/src/core/fsutil.ts — Filesystem helpers (Phase 3).
|
|
2
|
+
//
|
|
3
|
+
// copyDirRecursive: Node's fs.cpSync(recursive) silently produces EMPTY dirs
|
|
4
|
+
// when the destination is under a non-ASCII path on Windows (observed on
|
|
5
|
+
// Node 24 + C:\Users\ömer\... — no error, exit 0, zero files copied).
|
|
6
|
+
// Single-file ops (copyFileSync/writeFileSync/mkdirSync) are unaffected, so
|
|
7
|
+
// this helper walks the tree and copies file-by-file. Always prefer it over
|
|
8
|
+
// raw fs.cpSync for store staging and install fallbacks.
|
|
9
|
+
import { existsSync, mkdirSync, readdirSync, copyFileSync, statSync, rmSync } from "fs";
|
|
10
|
+
import { join } from "path";
|
|
11
|
+
/** Recursive directory copy that works under non-ASCII Windows paths. */
|
|
12
|
+
export function copyDirRecursive(src, dest) {
|
|
13
|
+
const st = statSync(src);
|
|
14
|
+
if (!st.isDirectory())
|
|
15
|
+
throw new Error(`[forge] copyDirRecursive: not a directory: ${src}`);
|
|
16
|
+
if (existsSync(dest))
|
|
17
|
+
rmSync(dest, { recursive: true, force: true });
|
|
18
|
+
mkdirSync(dest, { recursive: true });
|
|
19
|
+
const entries = readdirSync(src, { withFileTypes: true });
|
|
20
|
+
for (const e of entries) {
|
|
21
|
+
const from = join(src, e.name);
|
|
22
|
+
const to = join(dest, e.name);
|
|
23
|
+
if (e.isDirectory()) {
|
|
24
|
+
copyDirRecursive(from, to);
|
|
25
|
+
}
|
|
26
|
+
else if (e.isFile() || e.isSymbolicLink()) {
|
|
27
|
+
try {
|
|
28
|
+
copyFileSync(from, to);
|
|
29
|
+
}
|
|
30
|
+
catch (err) {
|
|
31
|
+
// Dangling symlink or unreadable file: record nothing, but do not
|
|
32
|
+
// silently ship a partial tree — fail closed.
|
|
33
|
+
throw new Error(`[forge] copy failed ${from} → ${to}: ${err.message}`, { cause: err });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -49,7 +49,7 @@ export async function ensurePackageContent(pkgName, version, detail, versionMeta
|
|
|
49
49
|
try {
|
|
50
50
|
rmSync(dest, { recursive: true, force: true });
|
|
51
51
|
}
|
|
52
|
-
catch { }
|
|
52
|
+
catch { /* best-effort rollback; the error below is what matters */ }
|
|
53
53
|
}
|
|
54
54
|
throw err;
|
|
55
55
|
}
|
|
@@ -71,16 +71,16 @@ export async function ensurePackageContent(pkgName, version, detail, versionMeta
|
|
|
71
71
|
try {
|
|
72
72
|
rmSync(dest, { recursive: true, force: true });
|
|
73
73
|
}
|
|
74
|
-
catch { }
|
|
74
|
+
catch { /* best-effort rollback; the error below is what matters */ }
|
|
75
75
|
}
|
|
76
76
|
throw new Error(`[forge] download/verify failed for ${pkgName}@${version}: ${err.message}\n` +
|
|
77
|
-
`[forge] Nothing was installed. Re-run with --mock to install mock content instead
|
|
77
|
+
`[forge] Nothing was installed. Re-run with --mock to install mock content instead.`, { cause: err });
|
|
78
78
|
}
|
|
79
79
|
}
|
|
80
80
|
function generateMockPackage(pkgName, version, detail, versionMeta, dest) {
|
|
81
81
|
const type = detail.type;
|
|
82
82
|
const skillName = pkgName.split("/")[1] ?? pkgName;
|
|
83
|
-
//
|
|
83
|
+
// Phase 11: every type produces a real skeleton — forge add installs each type to the right place
|
|
84
84
|
if (type === "skill") {
|
|
85
85
|
const content = `# ${skillName} — ${detail.description}
|
|
86
86
|
|
|
@@ -122,7 +122,7 @@ forge doctor
|
|
|
122
122
|
writeFileSync(join(dest, "plugin.json"), JSON.stringify({ name: skillName, entry: "index.js", hooks: ["pre-tool", "post-tool"] }, null, 2) + "\n");
|
|
123
123
|
writeFileSync(join(dest, "index.js"), `// ${pkgName} plugin entry\nmodule.exports = {};\n`);
|
|
124
124
|
}
|
|
125
|
-
// MCP: also write a forge-mcp.json manifest for adapter use (
|
|
125
|
+
// MCP: also write a forge-mcp.json manifest for adapter use (Phase 11: includes mcp.json)
|
|
126
126
|
if (type === "mcp" && versionMeta.mcp) {
|
|
127
127
|
writeFileSync(join(dest, "forge-mcp.json"), JSON.stringify(versionMeta.mcp, null, 2));
|
|
128
128
|
writeFileSync(join(dest, "mcp.json"), JSON.stringify({ mcpServers: { [skillName]: versionMeta.mcp } }, null, 2) + "\n");
|
|
@@ -176,7 +176,7 @@ with tarfile.open(sys.argv[1], 'r:gz') as tf:
|
|
|
176
176
|
return;
|
|
177
177
|
}
|
|
178
178
|
}
|
|
179
|
-
catch
|
|
179
|
+
catch {
|
|
180
180
|
// fall through to fetch fallback
|
|
181
181
|
}
|
|
182
182
|
}
|
|
@@ -216,14 +216,14 @@ with tarfile.open(sys.argv[1], 'r:gz') as tf:
|
|
|
216
216
|
`, tmpFile, dest], { stdio: "pipe" });
|
|
217
217
|
}
|
|
218
218
|
catch (e) {
|
|
219
|
-
throw new Error(`tar extract failed: ${e.message}
|
|
219
|
+
throw new Error(`tar extract failed: ${e.message}`, { cause: e });
|
|
220
220
|
}
|
|
221
221
|
finally {
|
|
222
222
|
try {
|
|
223
223
|
const { unlinkSync } = await import("fs");
|
|
224
224
|
unlinkSync(tmpFile);
|
|
225
225
|
}
|
|
226
|
-
catch { }
|
|
226
|
+
catch { /* temp-file cleanup is best-effort */ }
|
|
227
227
|
}
|
|
228
228
|
// Defense-in-depth: filter='data' already strips symlinks and ../ escapes,
|
|
229
229
|
// but we verify post-extraction as a safety net.
|
|
@@ -253,7 +253,7 @@ async function assertNoSymlinks(dest) {
|
|
|
253
253
|
if (e.message?.includes("escapes"))
|
|
254
254
|
throw e;
|
|
255
255
|
// If we can't resolve, refuse
|
|
256
|
-
throw new Error(`tarball contains unresolvable symlink (${entry.name}) — refusing to install
|
|
256
|
+
throw new Error(`tarball contains unresolvable symlink (${entry.name}) — refusing to install`, { cause: e });
|
|
257
257
|
}
|
|
258
258
|
}
|
|
259
259
|
else {
|