@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.
Files changed (41) hide show
  1. package/CHANGELOG.md +107 -13
  2. package/README.md +172 -232
  3. package/dist/cli/src/adapters/agents-md.js +24 -0
  4. package/dist/cli/src/adapters/base.js +99 -0
  5. package/dist/cli/src/adapters/claude.js +43 -14
  6. package/dist/cli/src/adapters/codex.js +18 -17
  7. package/dist/cli/src/adapters/cursor.js +44 -30
  8. package/dist/cli/src/adapters/dsh.js +28 -30
  9. package/dist/cli/src/adapters/generic.js +10 -7
  10. package/dist/cli/src/adapters/opencode.js +23 -23
  11. package/dist/cli/src/adapters/types.js +12 -9
  12. package/dist/cli/src/adapters/windsurf.js +48 -28
  13. package/dist/cli/src/commands/add-external.js +111 -0
  14. package/dist/cli/src/commands/audit.js +68 -30
  15. package/dist/cli/src/commands/init.js +2 -2
  16. package/dist/cli/src/commands/install.js +90 -15
  17. package/dist/cli/src/commands/pack.js +119 -0
  18. package/dist/cli/src/commands/sync.js +181 -0
  19. package/dist/cli/src/commands/test.js +72 -0
  20. package/dist/cli/src/commands/tui.js +191 -0
  21. package/dist/cli/src/commands/update.js +4 -4
  22. package/dist/cli/src/commands/verify.js +62 -0
  23. package/dist/cli/src/core/fsutil.js +37 -0
  24. package/dist/cli/src/core/installer.js +9 -9
  25. package/dist/cli/src/core/lock.js +97 -10
  26. package/dist/cli/src/core/merge.js +108 -0
  27. package/dist/cli/src/core/plugin.js +2 -2
  28. package/dist/cli/src/core/project.js +136 -4
  29. package/dist/cli/src/core/registry.js +9 -9
  30. package/dist/cli/src/core/scan.js +159 -0
  31. package/dist/cli/src/core/semver.js +7 -7
  32. package/dist/cli/src/core/sign.js +2 -2
  33. package/dist/cli/src/core/sources.js +171 -0
  34. package/dist/cli/src/index.js +83 -16
  35. package/dist/index.cjs +359 -0
  36. package/dist/scripts/build-registry.js +2 -4
  37. package/dist/scripts/publish-verified.js +17 -9
  38. package/dist/scripts/seed-registry-13lite.js +5 -5
  39. package/dist/scripts/seed-registry.js +4 -4
  40. package/dist/scripts/verify-npm-mcps.js +6 -4
  41. package/package.json +15 -6
@@ -0,0 +1,111 @@
1
+ // cli/src/commands/add-external.ts — Install from decentralized sources (Phase 3).
2
+ //
3
+ // Handles github:/git-URL/local-path packages: resolve → stage → security
4
+ // gate → copy into the forge store → install to adapters → record links.
5
+ // Fail-closed: HIGH scan findings refuse the install (exit 1) unless the
6
+ // package comes from the verified registry path (which only warns).
7
+ import { resolveExternalSource, } from "../core/sources.js";
8
+ import { copyDirRecursive } from "../core/fsutil.js";
9
+ import { ensureForgeDirs, readLinks, writeLinks, packageDir, toSlug, } from "../core/store.js";
10
+ import { detectAdapters } from "../adapters/index.js";
11
+ import { scanPackageDir } from "../core/scan.js";
12
+ import { findProjectToml, loadProjectToml } from "../core/project.js";
13
+ /**
14
+ * Copy staged external content into the forge store (adapters symlink the
15
+ * store, never tmp). Returns the store dir. Always cleans the stage dir.
16
+ */
17
+ export function storeExternal(staged) {
18
+ const dest = packageDir(toSlug(staged.name), staged.version);
19
+ try {
20
+ ensureForgeDirs();
21
+ // NOTE: raw fs.cpSync silently yields empty dirs under non-ASCII
22
+ // Windows home paths — always use copyDirRecursive (see fsutil.ts).
23
+ copyDirRecursive(staged.dir, dest);
24
+ return dest;
25
+ }
26
+ finally {
27
+ staged.cleanup();
28
+ }
29
+ }
30
+ export async function installExternal(spec, opts = {}) {
31
+ console.log(`[forge] resolving external source ${spec.source}...`);
32
+ let staged;
33
+ try {
34
+ staged = resolveExternalSource(spec);
35
+ }
36
+ catch (e) {
37
+ console.error(e.message);
38
+ process.exitCode = 1;
39
+ return;
40
+ }
41
+ const slug = toSlug(staged.name);
42
+ console.log(`[forge] found ${staged.name}@${staged.version} [${staged.type}] — ${staged.description}`);
43
+ console.log(`[forge] source: ${staged.source} (external — no registry pin)`);
44
+ if (opts.dryRun) {
45
+ console.log(`[forge] (dry-run) would install to ~/.forge/packages/${slug}@${staged.version}/`);
46
+ staged.cleanup();
47
+ return;
48
+ }
49
+ // Security gate: HIGH findings refuse external installs (fail-closed).
50
+ if (!opts.skipScan) {
51
+ let permissions;
52
+ try {
53
+ const tomlPath = findProjectToml(process.cwd());
54
+ if (tomlPath)
55
+ permissions = loadProjectToml(tomlPath).permissions;
56
+ }
57
+ catch { /* project parse is best-effort; scan still runs */ }
58
+ const findings = scanPackageDir(staged.dir, { permissions });
59
+ const highs = findings.filter((f) => f.severity === "high");
60
+ const mediums = findings.filter((f) => f.severity === "medium");
61
+ for (const f of mediums.slice(0, 10)) {
62
+ console.warn(`[forge] scan warn [${f.rule}] ${f.file}${f.line > 0 ? `:${f.line}` : ""} — ${f.message}`);
63
+ }
64
+ if (highs.length > 0) {
65
+ console.error(`[forge] ✗ security scan FAILED for ${staged.name}: ${highs.length} high-severity finding(s)`);
66
+ for (const f of highs.slice(0, 10)) {
67
+ console.error(`[forge] [high] [${f.rule}] ${f.file}${f.line > 0 ? `:${f.line}` : ""} — ${f.message}`);
68
+ }
69
+ console.error(`[forge] Refusing to install. Re-run with --skip-scan only if you trust this source.`);
70
+ staged.cleanup();
71
+ process.exitCode = 1;
72
+ return;
73
+ }
74
+ console.log(`[forge] scan clean (${findings.length} finding(s), 0 high)`);
75
+ }
76
+ // Copy staged content into the store (adapters symlink the store, never tmp).
77
+ const dest = storeExternal(staged);
78
+ console.log(`[forge] package content ready: ${dest}`);
79
+ const adapters = detectAdapters();
80
+ console.log(`[forge] detected harnesses: ${adapters.map((a) => `${a.displayName} (${a.name})`).join(", ")}`);
81
+ let failed = 0;
82
+ for (const adapter of adapters) {
83
+ try {
84
+ await adapter.install(slug, dest, staged.type, { version: staged.version, description: staged.description });
85
+ console.log(` ✓ ${adapter.displayName} → ${adapter.skillDir(slug)}`);
86
+ }
87
+ catch (e) {
88
+ console.warn(` ✗ ${adapter.displayName} failed: ${e.message}`);
89
+ failed++;
90
+ }
91
+ }
92
+ if (failed > 0) {
93
+ console.log(`\n[forge] ✗ ${failed} harness(es) failed — installed on ${adapters.length - failed}/${adapters.length}`);
94
+ process.exitCode = 1;
95
+ }
96
+ else {
97
+ console.log(`\n[forge] ✓ installed ${staged.name}@${staged.version} on ${adapters.length} harness(es)`);
98
+ }
99
+ ensureForgeDirs();
100
+ const links = readLinks();
101
+ links[staged.name] = {
102
+ pkg: staged.name,
103
+ version: staged.version,
104
+ slug,
105
+ type: staged.type,
106
+ adapters: adapters.map((a) => a.name),
107
+ installedAt: new Date().toISOString(),
108
+ source: staged.source,
109
+ };
110
+ writeLinks(links);
111
+ }
@@ -1,53 +1,85 @@
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.
1
+ // cli/src/commands/audit.ts — trust-tier audit + static security scan (Phase 3).
2
+ //
3
+ // Two layers:
4
+ // 1. Trust tier per installed package (verified vs community vs mock).
5
+ // 2. Static scan of on-disk package content (shell-danger, prompt-inject,
6
+ // perm-violation — see core/scan.ts).
7
+ // Any HIGH-severity scan finding fails the audit (exit 1, fail-closed).
3
8
  import { existsSync } from "fs";
4
9
  import { join } from "path";
5
10
  import { readLinks, packageDir } from "../core/store.js";
6
11
  import { loadPackageDetail } from "../core/registry.js";
7
12
  import { isPlaceholderSha } from "../core/installer.js";
13
+ import { scanPackageDir } from "../core/scan.js";
14
+ import { findProjectToml, loadProjectToml } from "../core/project.js";
8
15
  export async function runAudit(opts = {}) {
9
- const links = readLinks();
10
- const entries = Object.values(links);
16
+ // opts.dirs overrides the links store (tests + `forge audit <path>` flows).
17
+ const entries = opts.dirs
18
+ ? Object.entries(opts.dirs).map(([pkg, dir]) => ({ pkg, dir, version: "scan", slug: pkg }))
19
+ : Object.values(readLinks()).map((rec) => ({ pkg: rec.pkg, dir: packageDir(rec.slug, rec.version), version: rec.version, slug: rec.slug }));
11
20
  const findings = [];
12
21
  if (entries.length === 0) {
13
22
  console.log("[forge] audit: no installed packages — nothing to check");
14
- console.log("[forge] audit: full vulnerability DB in Faz 22");
15
23
  return;
16
24
  }
25
+ // Project permission boundaries apply to content checks when present.
26
+ let permissions;
27
+ try {
28
+ const tomlPath = findProjectToml(process.cwd());
29
+ if (tomlPath)
30
+ permissions = loadProjectToml(tomlPath).permissions;
31
+ }
32
+ catch { /* project parse is best-effort here; tier checks still run */ }
17
33
  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).
34
+ if (!opts.dirs) {
35
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)` });
36
+ const detail = await loadPackageDetail(rec.pkg);
37
+ const meta = detail.versions[rec.version];
38
+ if (!meta) {
39
+ findings.push({ pkg: rec.pkg, level: "warn", message: `version ${rec.version} not in registry` });
40
+ continue;
41
+ }
42
+ if (meta.sha256 && isPlaceholderSha(meta.sha256)) {
43
+ findings.push({ pkg: rec.pkg, level: "info", message: "community tier (unverified tarball) — install needs --mock" });
44
+ }
45
+ else if (!meta.verified) {
46
+ findings.push({ pkg: rec.pkg, level: "warn", message: "sha256 not verified against tarball content" });
38
47
  }
48
+ if (!meta.tarball || meta.tarball.includes("placeholder")) {
49
+ findings.push({ pkg: rec.pkg, level: "warn", message: `tarball URL placeholder` });
50
+ }
51
+ // On-disk truth: .forge-mock marker means this install is mock content (--mock).
52
+ try {
53
+ if (existsSync(join(packageDir(rec.slug, rec.version), ".forge-mock"))) {
54
+ findings.push({ pkg: rec.pkg, level: "info", message: `installed from MOCK content (--mock was used)` });
55
+ }
56
+ }
57
+ catch { /* marker check is best-effort; unreadable dir simply means "not mock" */ }
58
+ }
59
+ catch (e) {
60
+ findings.push({ pkg: rec.pkg, level: "warn", message: `registry read failed: ${e.message}` });
39
61
  }
40
- catch { }
41
62
  }
42
- catch (e) {
43
- findings.push({ pkg: rec.pkg, level: "warn", message: `registry read failed: ${e.message}` });
63
+ // Static scan of on-disk content (always runs when the dir exists).
64
+ if (existsSync(rec.dir)) {
65
+ const scan = scanPackageDir(rec.dir, { permissions });
66
+ for (const f of scan) {
67
+ findings.push({
68
+ pkg: rec.pkg,
69
+ level: f.severity === "high" ? "high" : f.severity === "medium" ? "warn" : "info",
70
+ message: `[${f.rule}] ${f.file}${f.line > 0 ? `:${f.line}` : ""} — ${f.message}`,
71
+ });
72
+ }
44
73
  }
45
74
  }
75
+ const highs = findings.filter((f) => f.level === "high").length;
46
76
  if (opts.json) {
47
- console.log(JSON.stringify({ packages: entries.length, findings }, null, 2));
77
+ console.log(JSON.stringify({ packages: entries.length, findings, highs }, null, 2));
78
+ if (highs > 0)
79
+ process.exitCode = 1;
48
80
  return;
49
81
  }
50
- console.log(`[forge] audit — ${entries.length} package(s) checked (skeleton, full DB Faz 22):\n`);
82
+ console.log(`[forge] audit — ${entries.length} package(s) checked:\n`);
51
83
  if (findings.length === 0) {
52
84
  console.log("[forge] ✓ no issues");
53
85
  }
@@ -58,5 +90,11 @@ export async function runAudit(opts = {}) {
58
90
  if (findings.length > 30)
59
91
  console.log(` ... and ${findings.length - 30} more`);
60
92
  }
61
- console.log(`\n[forge] audit done: ${findings.length} finding(s)`);
93
+ if (highs > 0) {
94
+ console.log(`\n[forge] ✗ audit FAILED: ${highs} high-severity finding(s) — refusing to pass`);
95
+ process.exitCode = 1;
96
+ }
97
+ else {
98
+ console.log(`\n[forge] audit done: ${findings.length} finding(s)`);
99
+ }
62
100
  }
@@ -82,11 +82,11 @@ export async function runInit(opts) {
82
82
  if (!existsSync(full) || opts.force)
83
83
  writeFileSync(full, content);
84
84
  }
85
- const relDir = targetDir === cwd ? "." : opts.name;
85
+ const relDir = targetDir === cwd ? "." : (opts.name ?? rawName);
86
86
  console.log(`[forge] ✓ created ${relDir}/forge.toml [${type}]`);
87
87
  if (Object.keys(extras).length)
88
88
  console.log(`[forge] + ${Object.keys(extras).join(", ")}`);
89
- console.log(`[forge] next: edit forge.toml & SKILL.md, then 'forge publish' (Faz 8)`);
89
+ console.log(`[forge] next: edit forge.toml & SKILL.md, then 'forge publish' (Phase 8)`);
90
90
  }
91
91
  function normalizeName(input) {
92
92
  // allow my-skill -> my-skill/my-skill ? We expect scope/name
@@ -6,8 +6,12 @@ import { ensurePackageContent } from "../core/installer.js";
6
6
  import { ensureForgeDirs, readLinks, writeLinks, toSlug } from "../core/store.js";
7
7
  import { allAdapters, detectAdapters, addMcpServerToConfig } from "../adapters/index.js";
8
8
  import { findProjectToml, loadProjectToml, validateProjectToml } from "../core/project.js";
9
- import { readLock, writeLock } from "../core/lock.js";
9
+ import { readLock, writeLock, verifyLockIntegrity, lockEntryFor } from "../core/lock.js";
10
10
  import { loadConfig } from "../core/config.js";
11
+ import { scanPackageDir } from "../core/scan.js";
12
+ import { parseSourceArg, resolveExternalSource } from "../core/sources.js";
13
+ import { storeExternal } from "./add-external.js";
14
+ import { injectMcpServers } from "./sync.js";
11
15
  function pickAdapters(projectHarnesses) {
12
16
  if (projectHarnesses && projectHarnesses.length > 0) {
13
17
  const filtered = allAdapters.filter((a) => projectHarnesses.includes(a.name));
@@ -41,14 +45,15 @@ export async function runInstall(opts = {}) {
41
45
  console.error(`[forge] ${e.message}`);
42
46
  process.exit(1);
43
47
  }
44
- const errs = validateProjectToml(project, tomlPath);
48
+ const errs = validateProjectToml(project);
45
49
  if (errs.length > 0) {
46
50
  for (const er of errs)
47
51
  console.error(`[forge] ${er}`);
48
52
  process.exit(1);
49
53
  }
50
54
  const deps = project.dependencies;
51
- if (Object.keys(deps).length === 0) {
55
+ const hasSkills = Object.keys(project.skills ?? {}).length > 0;
56
+ if (Object.keys(deps).length === 0 && !hasSkills) {
52
57
  console.log(`[forge] no dependencies in ${tomlPath}`);
53
58
  return;
54
59
  }
@@ -75,6 +80,13 @@ export async function runInstall(opts = {}) {
75
80
  }
76
81
  }
77
82
  console.log(`[forge] installing ${lock.packages.length} package(s) from forge.lock (frozen)...`);
83
+ // Security barrier: yanked versions and hash drift refuse the install.
84
+ const integrityIssues = await verifyLockIntegrity(lock, { allowMock: opts.mock });
85
+ if (integrityIssues.length > 0) {
86
+ for (const issue of integrityIssues)
87
+ console.error(`[forge] ✗ ${issue.message}`);
88
+ process.exit(1);
89
+ }
78
90
  const adapters = pickAdapters(project.forge?.harnesses);
79
91
  console.log(`[forge] harnesses: ${adapters.map((a) => a.displayName).join(", ")}`);
80
92
  const t0 = Date.now();
@@ -84,7 +96,10 @@ export async function runInstall(opts = {}) {
84
96
  const { detail, version, versionMeta } = await resolveVersion(entry.name, entry.version);
85
97
  const src = await ensurePackageContent(entry.name, version, detail, versionMeta, { allowMock: opts.mock });
86
98
  for (const adapter of adapters) {
87
- await adapter.install(toSlug(entry.name), src, detail.type);
99
+ await adapter.install(toSlug(entry.name), src, detail.type, {
100
+ version,
101
+ description: detail.description,
102
+ });
88
103
  if (detail.type === "mcp" && versionMeta.mcp) {
89
104
  const cfgPath = adapter.mcpConfigPath();
90
105
  if (cfgPath)
@@ -113,17 +128,71 @@ export async function runInstall(opts = {}) {
113
128
  console.log(`\n[forge] ✓ installed ${ok}/${lock.packages.length} from lock in ${dt}s`);
114
129
  return;
115
130
  }
116
- // Normal: resolve from dependencies
117
- console.log(`[forge] installing ${Object.keys(deps).length} package(s) from ${tomlPath}...`);
131
+ // Normal: resolve from dependencies (+ [skills] from the universal manifest)
132
+ const skillEntries = Object.entries(project.skills ?? {});
133
+ const totalWork = Object.keys(deps).length + skillEntries.length;
134
+ console.log(`[forge] installing ${totalWork} package(s) from ${tomlPath}...`);
118
135
  const adapters = pickAdapters(project.forge?.harnesses);
119
136
  console.log(`[forge] harnesses: ${adapters.map((a) => a.displayName).join(", ")}`);
120
137
  const t0 = Date.now();
121
138
  const lockEntries = [];
122
139
  let ok = 0;
123
140
  let skipped = 0;
141
+ // External [skills] (github:/URL/local) resolve outside the registry loop.
142
+ for (const [skillName, ref] of skillEntries) {
143
+ const source = ref.source ?? "registry";
144
+ if (source === "registry")
145
+ continue; // handled in the registry loop below
146
+ try {
147
+ const spec = parseSourceArg(ref.ref ? `${source}#${ref.ref}` : source);
148
+ const staged = resolveExternalSource(spec);
149
+ try {
150
+ const highs = scanPackageDir(staged.dir).filter((f) => f.severity === "high");
151
+ if (highs.length > 0) {
152
+ throw new Error(`security scan FAILED (${highs.length} high): ${highs[0].rule} ${highs[0].file} — refusing install`);
153
+ }
154
+ const srcDir = storeExternal(staged);
155
+ for (const adapter of adapters) {
156
+ await adapter.install(toSlug(skillName), srcDir, staged.type, { version: staged.version, description: staged.description });
157
+ }
158
+ ensureForgeDirs();
159
+ const links = readLinks();
160
+ links[skillName] = {
161
+ pkg: skillName,
162
+ version: staged.version,
163
+ slug: toSlug(skillName),
164
+ type: staged.type,
165
+ adapters: adapters.map((a) => a.name),
166
+ installedAt: new Date().toISOString(),
167
+ source: staged.source,
168
+ };
169
+ writeLinks(links);
170
+ lockEntries.push(lockEntryFor(skillName, staged.version, staged.type, {}, staged.source));
171
+ ok++;
172
+ console.log(` ✓ ${skillName}@${staged.version} (from ${staged.source})`);
173
+ }
174
+ catch (e) {
175
+ try {
176
+ staged.cleanup();
177
+ }
178
+ catch { /* best-effort */ }
179
+ throw e;
180
+ }
181
+ }
182
+ catch (e) {
183
+ console.error(` ✗ ${skillName} failed: ${e.message}`);
184
+ }
185
+ }
186
+ // Registry skills without explicit source join the semver loop.
187
+ const mergedDeps = { ...deps };
188
+ for (const [skillName, ref] of skillEntries) {
189
+ if ((ref.source ?? "registry") === "registry" && ref.version && mergedDeps[skillName] === undefined) {
190
+ mergedDeps[skillName] = ref.version;
191
+ }
192
+ }
124
193
  // 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
194
  const existingLinks = readLinks();
126
- for (const [depName, depRange] of Object.entries(deps)) {
195
+ for (const [depName, depRange] of Object.entries(mergedDeps)) {
127
196
  try {
128
197
  const { detail, version, versionMeta } = await resolveVersion(depName, depRange);
129
198
  const already = existingLinks[depName];
@@ -132,7 +201,7 @@ export async function runInstall(opts = {}) {
132
201
  // verify adapters still have it? For speed skip re-install? But ensure adapters have it
133
202
  // For mcp packages isInstalled checks skillDir which is not created — treat store existence as enough
134
203
  if (detail.type === "mcp") {
135
- lockEntries.push({ name: depName, version, type: detail.type });
204
+ lockEntries.push(lockEntryFor(depName, version, detail.type, versionMeta));
136
205
  skipped++;
137
206
  console.log(` = ${depName}@${version} already installed`);
138
207
  continue;
@@ -144,7 +213,7 @@ export async function runInstall(opts = {}) {
144
213
  allPresent = false;
145
214
  }
146
215
  if (allPresent) {
147
- lockEntries.push({ name: depName, version, type: detail.type });
216
+ lockEntries.push(lockEntryFor(depName, version, detail.type, versionMeta));
148
217
  skipped++;
149
218
  console.log(` = ${depName}@${version} already installed`);
150
219
  continue;
@@ -152,7 +221,7 @@ export async function runInstall(opts = {}) {
152
221
  }
153
222
  const src = await ensurePackageContent(depName, version, detail, versionMeta, { allowMock: opts.mock });
154
223
  for (const adapter of adapters) {
155
- await adapter.install(toSlug(depName), src, detail.type);
224
+ await adapter.install(toSlug(depName), src, detail.type, { version, description: detail.description });
156
225
  if (detail.type === "mcp" && versionMeta.mcp) {
157
226
  const cfgPath = adapter.mcpConfigPath();
158
227
  if (cfgPath)
@@ -166,7 +235,7 @@ export async function runInstall(opts = {}) {
166
235
  const sub = await resolveVersion(subName, subRange);
167
236
  const subSrc = await ensurePackageContent(subName, sub.version, sub.detail, sub.versionMeta, { allowMock: opts.mock });
168
237
  for (const adapter of adapters)
169
- await adapter.install(toSlug(subName), subSrc, sub.detail.type);
238
+ await adapter.install(toSlug(subName), subSrc, sub.detail.type, { version: sub.version, description: sub.detail.description });
170
239
  console.log(` dep ${subName}@${sub.version}`);
171
240
  }
172
241
  catch (e) {
@@ -184,7 +253,7 @@ export async function runInstall(opts = {}) {
184
253
  installedAt: new Date().toISOString(),
185
254
  };
186
255
  writeLinks(links);
187
- lockEntries.push({ name: depName, version, type: detail.type });
256
+ lockEntries.push(lockEntryFor(depName, version, detail.type, versionMeta));
188
257
  ok++;
189
258
  console.log(` ✓ ${depName}@${version}`);
190
259
  }
@@ -195,15 +264,21 @@ export async function runInstall(opts = {}) {
195
264
  // Write lock (deterministic order)
196
265
  lockEntries.sort((a, b) => a.name.localeCompare(b.name));
197
266
  writeLock(lockEntries, cwd);
267
+ // Project MCP servers → every adapter config.
268
+ const mcpServers = project.mcp?.servers ?? {};
269
+ if (Object.keys(mcpServers).length > 0) {
270
+ const writes = injectMcpServers(adapters, mcpServers);
271
+ console.log(`[forge] ✓ ${Object.keys(mcpServers).length} MCP server(s) injected (${writes} config writes)`);
272
+ }
198
273
  const dt = ((Date.now() - t0) / 1000).toFixed(1);
199
274
  const total = ok + skipped;
200
- const failed = Object.keys(deps).length - total;
275
+ const failed = totalWork - total;
201
276
  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`);
277
+ console.log(`\n[forge] ✗ ${failed} package(s) failed — installed ${ok} new, ${skipped} cached (${total}/${totalWork}) on ${adapters.length} harness(es) in ${dt}s`);
203
278
  process.exitCode = 1;
204
279
  }
205
280
  else {
206
- console.log(`\n[forge] ✓ installed ${ok} new, ${skipped} cached (${total}/${Object.keys(deps).length}) on ${adapters.length} harness(es) in ${dt}s`);
281
+ console.log(`\n[forge] ✓ installed ${ok} new, ${skipped} cached (${total}/${totalWork}) on ${adapters.length} harness(es) in ${dt}s`);
207
282
  }
208
283
  console.log(`[forge] lock written to ${cwd}/forge.lock`);
209
284
  }
@@ -0,0 +1,119 @@
1
+ // cli/src/commands/pack.ts — Package the current directory into a verified tarball.
2
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, statSync } from "fs";
3
+ import { join } from "path";
4
+ import { createHash } from "crypto";
5
+ import { execFileSync } from "child_process";
6
+ import { findProjectToml, loadProjectToml } from "../core/project.js";
7
+ import { scanPackageDir, countBySeverity } from "../core/scan.js";
8
+ const SKIP_DIRS = new Set(["node_modules", ".git", ".hg", ".svn", "__pycache__", "dist", "build", ".cache", ".forge"]);
9
+ async function listFiles(dir, out = []) {
10
+ const { readdirSync, statSync: stat } = await import("fs");
11
+ let entries;
12
+ try {
13
+ entries = readdirSync(dir, { withFileTypes: true });
14
+ }
15
+ catch {
16
+ return out;
17
+ }
18
+ for (const e of entries) {
19
+ if (SKIP_DIRS.has(e.name) || e.name.startsWith(".forge-"))
20
+ continue;
21
+ const full = join(dir, e.name);
22
+ try {
23
+ const st = stat(full);
24
+ if (st.isDirectory())
25
+ await listFiles(full, out);
26
+ else if (st.isFile())
27
+ out.push(full);
28
+ }
29
+ catch { /* skip */ }
30
+ }
31
+ return out;
32
+ }
33
+ export async function runPack(opts = {}) {
34
+ const cwd = process.cwd();
35
+ const tomlPath = findProjectToml(cwd);
36
+ if (!tomlPath) {
37
+ console.error("[forge] pack requires a forge.toml in the current directory");
38
+ process.exit(1);
39
+ }
40
+ let name, version;
41
+ try {
42
+ const parsed = loadProjectToml(tomlPath);
43
+ if (!parsed.package) {
44
+ console.error("[forge] pack requires [package] table in forge.toml");
45
+ process.exit(1);
46
+ }
47
+ name = parsed.package.name;
48
+ version = parsed.package.version;
49
+ if (!name || !version) {
50
+ console.error("[forge] [package] must have name and version");
51
+ process.exit(1);
52
+ }
53
+ }
54
+ catch (e) {
55
+ console.error(`[forge] forge.toml parse error: ${e.message}`);
56
+ process.exit(1);
57
+ }
58
+ console.log(`[forge] packing ${name}@${version}...`);
59
+ const findings = scanPackageDir(cwd);
60
+ const counts = countBySeverity(findings);
61
+ console.log(`[forge] security: ${counts.high} high, ${counts.medium} medium, ${counts.low} low`);
62
+ if (counts.high > 0) {
63
+ console.log("[forge] pack FAILED — high-severity findings must be resolved first");
64
+ for (const f of findings.filter((x) => x.severity === "high")) {
65
+ console.log(` [high] ${f.rule}: ${f.file} — ${f.message}`);
66
+ }
67
+ process.exit(1);
68
+ }
69
+ if (opts.check) {
70
+ console.log("[forge] check passed");
71
+ return;
72
+ }
73
+ const files = (await listFiles(cwd)).sort();
74
+ if (files.length === 0) {
75
+ console.error("[forge] pack: no files to pack (empty directory?)");
76
+ process.exit(1);
77
+ }
78
+ const outDir = join(cwd, "dist");
79
+ if (!existsSync(outDir))
80
+ mkdirSync(outDir, { recursive: true });
81
+ const slug = name.replace("/", "-");
82
+ const tarball = join(outDir, `${slug}-${version}.tgz`);
83
+ try {
84
+ const fileList = files.map((f) => `"${f}"`).join("\n");
85
+ const listPath = join(cwd, ".forge-pack-list.txt");
86
+ writeFileSync(listPath, fileList + "\n");
87
+ execFileSync("python", ["-c", `
88
+ import tarfile, sys, os
89
+ cwd = os.getcwd()
90
+ with tarfile.open(sys.argv[1], 'w:gz') as tf:
91
+ for line in open('.forge-pack-list.txt'):
92
+ f = line.strip().strip('"')
93
+ if f and os.path.isfile(f):
94
+ tf.add(f, arcname=os.path.relpath(f, cwd))
95
+ `], { stdio: "pipe" });
96
+ const { unlinkSync } = await import("fs");
97
+ try {
98
+ unlinkSync(listPath);
99
+ }
100
+ catch { /* best-effort */ }
101
+ }
102
+ catch (e) {
103
+ console.error(`[forge] tarball creation failed: ${e.message}`);
104
+ process.exit(1);
105
+ }
106
+ let sha256;
107
+ try {
108
+ const buf = readFileSync(tarball);
109
+ sha256 = createHash("sha256").update(buf).digest("hex");
110
+ }
111
+ catch (e) {
112
+ console.error(`[forge] sha256 failed: ${e.message}`);
113
+ process.exit(1);
114
+ }
115
+ console.log(`[forge] packed ${tarball}`);
116
+ console.log(`[forge] sha256: ${sha256}`);
117
+ console.log(`[forge] files: ${files.length}`);
118
+ console.log(`[forge] size: ${statSync(tarball).size} bytes`);
119
+ }