javi-forge 1.28.0 → 1.28.1

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.
@@ -8,7 +8,7 @@
8
8
  * Help banner shown by meow when `--help` is passed or invalid args are supplied.
9
9
  * Multi-line template literal — preserve exact formatting (whitespace is significant).
10
10
  */
11
- export declare const HELP_TEXT = "\n Usage\n $ javi-forge [command] [options]\n\n Commands\n init Bootstrap a new project (default)\n ci Run CI simulation (lint + compile + test + security + ghagga)\n ci validate Validate .javi-forge/ci.yaml without running anything\n ci init Install git hooks that call javi-forge ci\n tdd init Enable the TDD pre-commit section + install managed hooks\n tdd pipeline Enable the TDD pre-push section (--mode strict|warn)\n hooks run Run a git hook's composed sections (pre-commit | pre-push)\n analyze Run repoforge skills analysis\n doctor Show health report\n workflow show Render a workflow graph as ASCII (--template <name> or file path)\n workflow validate Validate project state against a workflow graph\n workflow list List available workflows and built-in templates\n plugin add Install a plugin from GitHub (org/repo)\n plugin remove Remove an installed plugin\n plugin list List installed plugins\n plugin search Search the plugin registry\n plugin validate Validate a local plugin directory\n plugin sync Auto-detect and wire installed plugins\n plugin export Export plugin to Agent Skills spec format (skills.json)\n plugin export --codex: Export plugin to Codex-compatible TOML subagent files\n plugin export-skills Generate aggregated skills.json from all installed plugins\n plugin export-skills global Generate global skills.json from all globally installed plugins\n plugin import Import an Agent Skills spec package as a javi-forge plugin\n skills doctor Show skills health report (add --deep for conflict detection)\n skills budget Show token cost of loaded skills (add -b N for custom budget)\n skills score Score a skill on quality dimensions (completeness, clarity, testability, token-efficiency)\n skills benchmark Benchmark a skill with structural quality checks\n skills auto Auto-detect project stack and suggest/install matching AI skills\n skills auto-install Alias for skills auto\n skill publish Package a skill directory for marketplace distribution (generates plugin.json)\n security baseline Create security baseline from current audit findings\n security check Check for regressions against baseline (exits non-zero if found)\n security update Re-snapshot baseline (acknowledge current vulns)\n security allowlist Add all current findings to the allowlist (suppress in future checks)\n llms-txt Generate AI-friendly llms.txt for current project\n\n Options\n --dry-run Preview changes without writing files\n --stack Project stack (node, python, go, rust, java-gradle, java-maven, elixir)\n --ci CI provider (github, gitlab, woodpecker)\n --memory Memory module (engram, obsidian-brain, memory-simple, none)\n --project-name Project name (skips name prompt)\n --ghagga Enable GHAGGA review system\n --mock Enable mock-first mode (no real API keys needed)\n --local-ai Include local AI dev stack (Ollama + Docker Compose)\n --batch Non-interactive mode (auto-proceed, no keyboard input)\n --deep Enable deep analysis (conflict + duplicate detection)\n --budget, -b Token budget limit for skills (default: 8000)\n --skills-dir Custom skills directory path\n --author Author name for skill publish\n --repo Repository URL for skill publish\n --version Show version\n --help Show this help\n\n CI options (javi-forge ci)\n --quick Lint + compile only (fast, for pre-commit)\n --shell Open interactive shell in CI container\n --detect Show detected stack and exit\n --config PATH Load ordered CI runners from a versioned config file\n (default discovery: .javi-forge/ci.yaml)\n --stack STACK Force a single explicit stack (single-stack repos only \u2014\n insufficient for hybrid repos; use --config instead)\n --no-docker Run commands natively (no Docker)\n --no-ci-ghagga Skip GHAGGA review\n --no-security Skip Semgrep security scan\n --timeout N Per-step timeout in seconds (default: 600)\n\n CI hooks (javi-forge ci init)\n Install git hooks that call javi-forge ci.\n No files copied \u2014 hooks reference the global CLI.\n Existing hooks javi-forge did not write are refused, never clobbered.\n --force Overwrite a foreign or locally modified hook. The previous\n content is copied to a .bak sibling first; if that backup\n cannot be written, the hook is left untouched. Symlinked\n hook paths are refused even with --force.\n\n SkillGuard install gate (plugin add / plugin import / skills auto)\n Every install is scanned before anything is written. Refusals are\n fail-closed and name the offending files:\n - SKILL.md files that block (critical threats) are refused \u2014 always.\n - Unscannable files (binary, oversized, unreadable) are refused unless\n --force is given.\n - Symlinks anywhere in the tree and SKILL.md files outside the declared\n set are manifest-integrity refusals \u2014 they are refused even with --force.\n - Empty or missing skills.json `skills` array on import is refused.\n\n Examples\n $ javi-forge\n $ javi-forge init --dry-run\n $ javi-forge init --stack node --ci github\n $ javi-forge ci\n $ javi-forge ci init\n $ javi-forge ci init --force\n $ javi-forge plugin add org/repo\n $ javi-forge plugin add org/repo --force\n $ javi-forge tdd init\n $ javi-forge ci --quick\n $ javi-forge ci --no-ci-ghagga --no-security\n $ javi-forge ci --no-docker\n $ javi-forge ci --shell\n $ javi-forge ci --config .javi-forge/ci.yaml\n $ javi-forge ci validate\n $ javi-forge ci --help\n $ javi-forge analyze\n $ javi-forge doctor\n $ javi-forge plugin add mapbox/agent-skills\n $ javi-forge plugin list\n";
11
+ export declare const HELP_TEXT = "\n Usage\n $ javi-forge [command] [options]\n\n Commands\n init Bootstrap a new project (default)\n ci Run CI simulation (lint + compile + test + security + ghagga)\n ci validate Validate .javi-forge/ci.yaml without running anything\n ci init Install git hooks that call javi-forge ci\n tdd init Enable the TDD pre-commit section + install managed hooks\n tdd pipeline Enable the TDD pre-push section (--mode strict|warn)\n hooks run Run a git hook's composed sections (pre-commit | pre-push)\n analyze Run repoforge skills analysis\n doctor Show health report\n workflow show Render a workflow graph as ASCII (--template <name> or file path)\n workflow validate Validate project state against a workflow graph\n workflow list List available workflows and built-in templates\n plugin add Install a plugin from GitHub (org/repo)\n plugin remove Remove an installed plugin\n plugin list List installed plugins\n plugin search Search the plugin registry\n plugin validate Validate a local plugin directory\n plugin sync Auto-detect and wire installed plugins\n plugin export Export plugin to Agent Skills spec format (skills.json)\n plugin export --codex: Export plugin to Codex-compatible TOML subagent files\n plugin export-skills Generate aggregated skills.json from all installed plugins\n plugin export-skills global Generate global skills.json from all globally installed plugins\n plugin import Import an Agent Skills spec package as a javi-forge plugin\n skills doctor Show skills health report (add --deep for conflict detection)\n skills budget Show token cost of loaded skills (add -b N for custom budget)\n skills score Score a skill on quality dimensions (completeness, clarity, testability, token-efficiency)\n skills benchmark Benchmark a skill with structural quality checks\n skills auto Auto-detect project stack and suggest/install matching AI skills\n skills auto-install Alias for skills auto\n skill publish Package a skill directory for marketplace distribution (generates plugin.json)\n security baseline Create security baseline from current audit findings\n security check Check for regressions against baseline (exits non-zero if found)\n security update Re-snapshot baseline (acknowledge current vulns)\n security allowlist Add all current findings to the allowlist (suppress in future checks)\n llms-txt Generate AI-friendly llms.txt for current project\n\n Options\n --dry-run Preview changes without writing files\n --stack Project stack (node, python, go, rust, java-gradle, java-maven, elixir)\n --ci CI provider (github, gitlab, woodpecker)\n --memory Memory module (engram, obsidian-brain, memory-simple, none)\n --project-name Project name (skips name prompt)\n --ghagga Enable GHAGGA review system\n --mock Enable mock-first mode (no real API keys needed)\n --local-ai Include local AI dev stack (Ollama + Docker Compose)\n --batch Non-interactive mode (auto-proceed, no keyboard input)\n --deep Enable deep analysis (conflict + duplicate detection)\n --budget, -b Token budget limit for skills (default: 8000)\n --skills-dir Custom skills directory path\n --author Author name for skill publish\n --repo Repository URL for skill publish\n --version Show version\n --help Show this help\n\n CI options (javi-forge ci)\n --quick Lint + compile only (fast, for pre-commit)\n --shell Open interactive shell in CI container\n --detect Show detected stack and exit\n --config PATH Load ordered CI runners from a versioned config file\n (default discovery: .javi-forge/ci.yaml)\n --stack STACK Force a single explicit stack (single-stack repos only \u2014\n insufficient for hybrid repos; use --config instead)\n --no-docker Run commands natively (no Docker)\n --no-ci-ghagga Skip GHAGGA review\n --no-security Skip Semgrep security scan\n --timeout N Per-step timeout in seconds (default: 600)\n\n CI hooks (javi-forge ci init)\n Install git hooks that call javi-forge ci.\n No files copied \u2014 hooks reference the global CLI.\n Existing hooks javi-forge did not write are refused, never clobbered.\n --force Overwrite a foreign or locally modified hook. The previous\n content is copied to a .bak sibling first; if that backup\n cannot be written, the hook is left untouched. Symlinked\n hook paths are refused even with --force.\n\n SkillGuard install gate (plugin add / plugin import / skills auto)\n Every install is scanned before anything is written. Refusals are\n fail-closed and name the offending files:\n - SKILL.md files that block (critical threats) are refused \u2014 always.\n - Unscannable files (binary, oversized, unreadable) are refused unless\n --force is given.\n - Symlinks anywhere in the tree and SKILL.md files outside the declared\n set are manifest-integrity refusals \u2014 they are refused even with --force.\n - Empty or missing skills.json `skills` array on import is refused.\n A refused install/auto-install exits non-zero (exit 1) so scripts and CI\n can tell a refusal apart from success; clean installs \u2014 including\n --force-lifted unscannable ones \u2014 exit 0.\n\n Examples\n $ javi-forge\n $ javi-forge init --dry-run\n $ javi-forge init --stack node --ci github\n $ javi-forge ci\n $ javi-forge ci init\n $ javi-forge ci init --force\n $ javi-forge plugin add org/repo\n $ javi-forge plugin add org/repo --force\n $ javi-forge tdd init\n $ javi-forge ci --quick\n $ javi-forge ci --no-ci-ghagga --no-security\n $ javi-forge ci --no-docker\n $ javi-forge ci --shell\n $ javi-forge ci --config .javi-forge/ci.yaml\n $ javi-forge ci validate\n $ javi-forge ci --help\n $ javi-forge analyze\n $ javi-forge doctor\n $ javi-forge plugin add mapbox/agent-skills\n $ javi-forge plugin list\n";
12
12
  /**
13
13
  * Per-command help for `ci`, shown by `javi-forge ci --help` (or when `ci` is
14
14
  * given an unknown subcommand). Kept consistent with the global HELP_TEXT
package/dist/cli/help.js CHANGED
@@ -98,6 +98,9 @@ export const HELP_TEXT = `
98
98
  - Symlinks anywhere in the tree and SKILL.md files outside the declared
99
99
  set are manifest-integrity refusals — they are refused even with --force.
100
100
  - Empty or missing skills.json \`skills\` array on import is refused.
101
+ A refused install/auto-install exits non-zero (exit 1) so scripts and CI
102
+ can tell a refusal apart from success; clean installs — including
103
+ --force-lifted unscannable ones — exit 0.
101
104
 
102
105
  Examples
103
106
  $ javi-forge
@@ -37,7 +37,9 @@ export declare function runPluginExportCodex(name: string, onStep: StepCallback)
37
37
  /**
38
38
  * Import an Agent Skills spec package and convert to javi-forge plugin format.
39
39
  */
40
- export declare function runPluginImport(sourceDir: string, dryRun: boolean, onStep: StepCallback, force?: boolean): Promise<void>;
40
+ export declare function runPluginImport(sourceDir: string, dryRun: boolean, onStep: StepCallback, options?: {
41
+ force?: boolean;
42
+ }): Promise<void>;
41
43
  /**
42
44
  * Generate a project-level skills.json from all installed plugins.
43
45
  * Makes the project discoverable by `npx skills add` and 40+ AI agents.
@@ -18,6 +18,12 @@ export async function runPluginAdd(source, dryRun, onStep, options = {}) {
18
18
  }
19
19
  else {
20
20
  report(onStep, stepId, `Install plugin: ${source}`, "error", result.error);
21
+ // FU-1 (R4-002): a skillguard refusal must be distinguishable from
22
+ // success by scripted consumers — exit non-zero. `process.exitCode`
23
+ // (not `process.exit`) so the Ink tree keeps rendering/unmounting
24
+ // normally. Non-gate failures (validation, clone errors) keep exit 0.
25
+ if (result.refused)
26
+ process.exitCode = 1;
21
27
  }
22
28
  }
23
29
  /**
@@ -145,10 +151,13 @@ export async function runPluginExportCodex(name, onStep) {
145
151
  /**
146
152
  * Import an Agent Skills spec package and convert to javi-forge plugin format.
147
153
  */
148
- export async function runPluginImport(sourceDir, dryRun, onStep, force = false) {
154
+ export async function runPluginImport(sourceDir, dryRun, onStep, options = {}) {
149
155
  const stepId = "plugin-import";
150
156
  report(onStep, stepId, `Import agent-skills package: ${sourceDir}`, "running");
151
- const result = await importAgentSkillsPackage(sourceDir, { dryRun, force });
157
+ const result = await importAgentSkillsPackage(sourceDir, {
158
+ dryRun,
159
+ force: options.force,
160
+ });
152
161
  if (result.success) {
153
162
  report(onStep, stepId, `Import agent-skills package: ${sourceDir}`, "done", dryRun
154
163
  ? `dry-run: would import ${result.name}`
@@ -156,6 +165,11 @@ export async function runPluginImport(sourceDir, dryRun, onStep, force = false)
156
165
  }
157
166
  else {
158
167
  report(onStep, stepId, `Import agent-skills package: ${sourceDir}`, "error", result.error);
168
+ // FU-1 (R4-002): same exit-code contract as runPluginAdd — a skillguard
169
+ // refusal (manifest-integrity or verdict) exits non-zero; plain input
170
+ // errors (skills.json missing/invalid) keep exit 0.
171
+ if (result.refused)
172
+ process.exitCode = 1;
159
173
  }
160
174
  }
161
175
  /**
@@ -35,6 +35,14 @@ export declare function importAgentSkillsPackage(sourceDir: string, options?: {
35
35
  success: boolean;
36
36
  name?: string;
37
37
  error?: string;
38
+ /**
39
+ * FU-1 (R4-002): true when the failure is a skillguard gate refusal
40
+ * (manifest-integrity — invalid name, empty/missing skills, containment
41
+ * escape, walk errors/symlinks/undeclared — or verdict refusal, incl. a
42
+ * scanner-error deny). The CLI layer turns this into a non-zero exit
43
+ * code. Plain input errors (skills.json missing/invalid) leave it unset.
44
+ */
45
+ refused?: boolean;
38
46
  }>;
39
47
  /**
40
48
  * Aggregate multiple installed plugins into a single Agent Skills spec manifest.
@@ -1,8 +1,8 @@
1
1
  import path from "node:path";
2
2
  import fs from "fs-extra";
3
3
  import { AGENT_SKILLS_MANIFEST_FILE, PLUGIN_MANIFEST_FILE, PLUGINS_DIR, } from "../constants.js";
4
- import { evaluateInstallGate } from "./skill-install-gate.js";
5
- import { formatBatchReport, scanSkillsWithCoverage } from "./skill-scanner.js";
4
+ import { evaluateCoverageGate, scanFailureMessage, } from "./skill-install-gate.js";
5
+ import { checkPathContained, scanSkillsWithCoverage } from "./skill-scanner.js";
6
6
  // ── Conversion ─────────────────────────────────────────────────────────────
7
7
  /**
8
8
  * Convert a javi-forge PluginManifest to an Agent Skills spec manifest.
@@ -111,6 +111,7 @@ export async function importAgentSkillsPackage(sourceDir, options = {}) {
111
111
  if (typeof pluginName !== "string") {
112
112
  return {
113
113
  success: false,
114
+ refused: true,
114
115
  error: `skillguard: install refused — invalid manifest name "${pluginName}" (manifest-integrity, force never lifts)`,
115
116
  };
116
117
  }
@@ -122,6 +123,7 @@ export async function importAgentSkillsPackage(sourceDir, options = {}) {
122
123
  path.isAbsolute(pluginName)) {
123
124
  return {
124
125
  success: false,
126
+ refused: true,
125
127
  error: `skillguard: install refused — invalid manifest name "${pluginName}" (manifest-integrity, force never lifts)`,
126
128
  };
127
129
  }
@@ -133,6 +135,7 @@ export async function importAgentSkillsPackage(sourceDir, options = {}) {
133
135
  agentManifest.skills.length === 0) {
134
136
  return {
135
137
  success: false,
138
+ refused: true,
136
139
  error: "skills.json must declare a non-empty skills array (every skill-shaped file must be declared)",
137
140
  };
138
141
  }
@@ -143,12 +146,14 @@ export async function importAgentSkillsPackage(sourceDir, options = {}) {
143
146
  if (!entry || typeof entry.name !== "string" || !entry.name) {
144
147
  return {
145
148
  success: false,
149
+ refused: true,
146
150
  error: "skills.json skills entry missing name",
147
151
  };
148
152
  }
149
153
  if (typeof entry.path !== "string" || !entry.path) {
150
154
  return {
151
155
  success: false,
156
+ refused: true,
152
157
  error: `skills.json skills entry "${entry.name}" missing path`,
153
158
  };
154
159
  }
@@ -156,6 +161,7 @@ export async function importAgentSkillsPackage(sourceDir, options = {}) {
156
161
  if (!contained.ok) {
157
162
  return {
158
163
  success: false,
164
+ refused: true,
159
165
  error: `skills.json skills entry "${entry.name}" path escapes package root (${contained.reason}) — refusing`,
160
166
  };
161
167
  }
@@ -169,49 +175,25 @@ export async function importAgentSkillsPackage(sourceDir, options = {}) {
169
175
  // Runs BEFORE the existing-install remove and fs.copy: a refusal preserves
170
176
  // an existing install and installs nothing. dryRun early-returns above, so
171
177
  // no scan happens on dry-run. Scanner/eval errors deny unconditionally (D7).
178
+ // The refusal policy + message-building is shared with plugin add via
179
+ // evaluateCoverageGate (R2-001).
180
+ let coverage;
172
181
  try {
173
- const coverage = await scanSkillsWithCoverage(sourceDir, declaredPaths);
174
- // Manifest-integrity refusals — block-level, force NEVER lifts
175
- // (JD-007: ANY symlink; JD-006: undeclared SKILL.md incl. node_modules).
176
- // A walk with I/O errors cannot certify the copied footprint — refuse
177
- // first, because the broken subtree may hide symlinks or undeclared
178
- // files (JD-013).
179
- if (coverage.errors.length > 0) {
180
- return {
181
- success: false,
182
- error: `skillguard: install refused — ${coverage.errors.length} path(s) could not be read (walk incomplete; manifest-integrity, force never lifts):\n${coverage.errors.map((p) => ` ${p}`).join("\n")}`,
183
- };
184
- }
185
- if (coverage.symlinks.length > 0) {
186
- return {
187
- success: false,
188
- error: `skillguard: install refused — symlink(s) in tree (manifest-integrity, force never lifts):\n${coverage.symlinks.map((p) => ` ${p}`).join("\n")}`,
189
- };
190
- }
191
- if (coverage.undeclared.length > 0) {
192
- return {
193
- success: false,
194
- error: `skillguard: install refused — undeclared SKILL.md(s) in tree (every skill-shaped file must be declared; force never lifts):\n${coverage.undeclared.map((p) => ` ${p}`).join("\n")}`,
195
- };
196
- }
197
- const gate = evaluateInstallGate(coverage.declared, { force });
198
- if (!gate.allowed) {
199
- const blocked = gate.rejected.filter((r) => r.verdict === "block").length;
200
- const unscannable = gate.rejected.filter((r) => r.verdict === "unscannable").length;
201
- return {
202
- success: false,
203
- // Lead line names the rejected count; the batch report renders
204
- // the FULL declared set so the header/rows reflect every scanned
205
- // skill (D6, JD-014).
206
- error: `skillguard: install refused — ${gate.rejected.length} rejected (${blocked} blocked, ${unscannable} unscannable)\n${formatBatchReport(coverage.declared)}`,
207
- };
208
- }
182
+ coverage = await scanSkillsWithCoverage(sourceDir, declaredPaths);
209
183
  }
210
184
  catch (scanError) {
211
- const msg = scanError instanceof Error ? scanError.message : String(scanError);
212
185
  return {
213
186
  success: false,
214
- error: `skillguard scan failed — ${msg}`,
187
+ refused: true,
188
+ error: scanFailureMessage(scanError),
189
+ };
190
+ }
191
+ const decision = evaluateCoverageGate(coverage, { force });
192
+ if (decision.refusalError) {
193
+ return {
194
+ success: false,
195
+ refused: true,
196
+ error: decision.refusalError,
215
197
  };
216
198
  }
217
199
  // Remove existing version if present
@@ -242,33 +224,18 @@ export async function importAgentSkillsPackage(sourceDir, options = {}) {
242
224
  * Verify a declared skill entry path stays inside the package root — both
243
225
  * lexically (`../../x`, absolute paths) and by realpath, so an in-tree symlink
244
226
  * cannot redirect the import read outside the staged clone (JD-003/JD-006).
245
- * Realpath resolution is best-effort: a missing declared dir has no realpath
246
- * yet, in which case lexical containment is the whole guard (the coverage walk
247
- * will later report it as a missing/unscannable declared skill).
227
+ * Non-throwing `{ ok, reason }` surface over the shared containment core
228
+ * (`checkPathContained`, R2-002) the import gate refuses gracefully instead
229
+ * of throwing.
248
230
  */
249
231
  async function skillPathContained(rootAbs, rootReal, entryPath) {
250
- const entryAbs = path.resolve(rootAbs, entryPath);
251
- const rel = path.relative(rootAbs, entryAbs);
252
- if (rel.startsWith("..") || path.isAbsolute(rel)) {
232
+ const check = await checkPathContained(rootAbs, rootReal, entryPath);
233
+ if (!check.ok) {
253
234
  return {
254
235
  ok: false,
255
- reason: `path "${entryPath}" resolves outside the package root`,
236
+ reason: `path "${entryPath}" resolves outside the package root${check.violation === "realpath" ? " (realpath)" : ""}`,
256
237
  };
257
238
  }
258
- try {
259
- const entryReal = await fs.realpath(entryAbs);
260
- const relReal = path.relative(rootReal, entryReal);
261
- if (relReal.startsWith("..") || path.isAbsolute(relReal)) {
262
- return {
263
- ok: false,
264
- reason: `path "${entryPath}" resolves outside the package root (realpath)`,
265
- };
266
- }
267
- }
268
- catch {
269
- // Declared dir does not exist yet — lexical containment stands; the
270
- // coverage walk reports it as a missing declared skill later.
271
- }
272
239
  return { ok: true };
273
240
  }
274
241
  // ── Aggregation ──────────────────────────────────────────────────────────
@@ -14,6 +14,14 @@ export declare function installPlugin(source: string, options?: {
14
14
  success: boolean;
15
15
  name?: string;
16
16
  error?: string;
17
+ /**
18
+ * FU-1 (R4-002): true when the failure is a skillguard gate refusal
19
+ * (manifest-integrity or verdict refusal, incl. a scanner-error deny).
20
+ * The CLI layer turns this into a non-zero exit code so scripted
21
+ * consumers can tell a refusal apart from success. Plain usage errors
22
+ * (invalid source, validation failed) leave it unset.
23
+ */
24
+ refused?: boolean;
17
25
  }>;
18
26
  /**
19
27
  * Remove an installed plugin by name.
@@ -4,8 +4,8 @@ import { PLUGIN_ASSET_DIRS, PLUGIN_MANIFEST_FILE, PLUGIN_REGISTRY_URL, PLUGINS_D
4
4
  import { generateAgentSkillsManifest } from "./agent-skills.js";
5
5
  import { autoWirePlugins } from "./auto-wire.js";
6
6
  import { execFileAsync } from "./exec.js";
7
- import { evaluateInstallGate } from "./skill-install-gate.js";
8
- import { formatBatchReport, scanSkillsWithCoverage } from "./skill-scanner.js";
7
+ import { evaluateCoverageGate, scanFailureMessage, } from "./skill-install-gate.js";
8
+ import { scanSkillsWithCoverage } from "./skill-scanner.js";
9
9
  const KEBAB_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
10
10
  const SEMVER_RE = /^\d+\.\d+\.\d+$/;
11
11
  // ── Validation ──────────────────────────────────────────────────────────────
@@ -158,54 +158,27 @@ export async function installPlugin(source, options = {}) {
158
158
  // leaves staging intact (removed by `finally`) and never destroys a
159
159
  // prior install. dryRun skips the gate entirely (no staged clone).
160
160
  // Scanner/eval errors deny unconditionally (D7 — a throw is not a
161
- // verdict, so no force branch consults it).
162
- let gate;
163
- // Declared results, hoisted for the refusal report: the batch report
164
- // renders the FULL declared set (header "Scanned: N" + per-skill rows,
165
- // D6) while the lead line still names the rejected count (JD-014).
166
- let declaredResults = [];
161
+ // verdict, so no force branch consults it). The refusal policy +
162
+ // message-building is shared with plugin import via
163
+ // evaluateCoverageGate (R2-001).
164
+ let coverage;
167
165
  try {
168
166
  const declaredPaths = (validation.manifest.skills ?? []).map((skill) => path.join("skills", skill));
169
- const coverage = await scanSkillsWithCoverage(tmpDir, declaredPaths);
170
- // Manifest-integrity refusals — block-level, force NEVER lifts
171
- // (JD-007: ANY symlink; JD-006: undeclared SKILL.md incl.
172
- // node_modules/.git). A walk with I/O errors cannot certify the
173
- // installed footprint — refuse first, before symlink/undeclared
174
- // checks, because the broken subtree may hide either (JD-013).
175
- if (coverage.errors.length > 0) {
176
- return {
177
- success: false,
178
- error: `skillguard: install refused — ${coverage.errors.length} path(s) could not be read (walk incomplete; manifest-integrity, force never lifts):\n${coverage.errors.map((p) => ` ${p}`).join("\n")}`,
179
- };
180
- }
181
- if (coverage.symlinks.length > 0) {
182
- return {
183
- success: false,
184
- error: `skillguard: install refused — symlink(s) in tree (manifest-integrity, force never lifts):\n${coverage.symlinks.map((p) => ` ${p}`).join("\n")}`,
185
- };
186
- }
187
- if (coverage.undeclared.length > 0) {
188
- return {
189
- success: false,
190
- error: `skillguard: install refused — undeclared SKILL.md(s) in tree (every skill-shaped file must be declared; force never lifts):\n${coverage.undeclared.map((p) => ` ${p}`).join("\n")}`,
191
- };
192
- }
193
- declaredResults = coverage.declared;
194
- gate = evaluateInstallGate(coverage.declared, { force });
167
+ coverage = await scanSkillsWithCoverage(tmpDir, declaredPaths);
195
168
  }
196
169
  catch (scanError) {
197
- const msg = scanError instanceof Error ? scanError.message : String(scanError);
198
170
  return {
199
171
  success: false,
200
- error: `skillguard scan failed — ${msg}`,
172
+ refused: true,
173
+ error: scanFailureMessage(scanError),
201
174
  };
202
175
  }
203
- if (!gate.allowed) {
204
- const blocked = gate.rejected.filter((r) => r.verdict === "block").length;
205
- const unscannable = gate.rejected.filter((r) => r.verdict === "unscannable").length;
176
+ const decision = evaluateCoverageGate(coverage, { force });
177
+ if (decision.refusalError) {
206
178
  return {
207
179
  success: false,
208
- error: `skillguard: install refused — ${gate.rejected.length} rejected (${blocked} blocked, ${unscannable} unscannable)\n${formatBatchReport(declaredResults)}`,
180
+ refused: true,
181
+ error: decision.refusalError,
209
182
  };
210
183
  }
211
184
  // Remove existing version if present
@@ -1,18 +1,22 @@
1
1
  /**
2
2
  * Shared install-gate evaluation for the skillguard runtime gate (D2).
3
3
  *
4
- * Pure helper: verdict evaluation for a set of declared skill scans lives in
5
- * exactly one place so all three entrypoints (plugin add, plugin import, skills
6
- * auto-install) share identical force semantics. Scanning + try/catch stay at
7
- * the call sites (a pure helper cannot own the I/O); this module imports no fs.
4
+ * Pure helpers: verdict evaluation ({@link evaluateInstallGate}) and the
5
+ * coverage refusal policy + message-building ({@link evaluateCoverageGate},
6
+ * {@link scanFailureMessage}) live in exactly one place so all three
7
+ * entrypoints (plugin add, plugin import, skills auto-install) share
8
+ * identical force semantics and byte-identical refusal messages (R2-001).
9
+ * Scanning + try/catch stay at the call sites (a pure helper cannot own the
10
+ * I/O); this module imports no fs.
8
11
  *
9
12
  * Force rule (fail-closed): `block` always refuses; `--force` lifts ONLY
10
13
  * `unscannable`. Manifest-integrity refusals (undeclared SKILL.md in the tree,
11
- * any symlink, empty/missing `skills` on import, declared paths escaping the
12
- * source dir) are NOT verdicts — call sites enforce them BEFORE this helper
13
- * runs, so `force` can never lift them either.
14
+ * any symlink, case-colliding declared dirs, empty/missing `skills` on import,
15
+ * declared paths escaping the source dir) are NOT verdicts —
16
+ * `evaluateCoverageGate` enforces the walk-derived ones BEFORE the verdict
17
+ * gate runs, so `force` can never lift them either.
14
18
  */
15
- import type { SkillScanResult } from "./skill-scanner.js";
19
+ import type { SkillCoverageScan, SkillScanResult } from "./skill-scanner.js";
16
20
  export interface InstallGateDecision {
17
21
  allowed: boolean;
18
22
  /** Rejected results when `!allowed`, else `[]`. */
@@ -28,4 +32,36 @@ export interface InstallGateDecision {
28
32
  export declare function evaluateInstallGate(results: SkillScanResult[], options?: {
29
33
  force?: boolean;
30
34
  }): InstallGateDecision;
35
+ export interface CoverageGateDecision {
36
+ /** Verdict-gate evaluation of the declared results. */
37
+ gate: InstallGateDecision;
38
+ /**
39
+ * Refusal message when the install must be refused, else `null`
40
+ * (⇔ the install proceeds). Manifest-integrity refusals come first
41
+ * (errors → symlinks → undeclared — block-level, force never lifts),
42
+ * then the verdict refusal (`!gate.allowed`). Messages are the
43
+ * byte-identical UX contract both package entrypoints share (spec:
44
+ * "refusal output reuses scanner reports"; R2-001: the refusal policy +
45
+ * message-building lives in exactly one place).
46
+ */
47
+ refusalError: string | null;
48
+ }
49
+ /**
50
+ * Shared refusal policy for the coverage walk + verdict gate (R2-001) — the
51
+ * chain both `plugin add` and `plugin import` run between
52
+ * `scanSkillsWithCoverage` and placement. A `null` refusalError means the
53
+ * install may proceed; any non-null message is a block-level refusal the
54
+ * caller returns verbatim (`force` never lifts integrity refusals; the
55
+ * verdict branch already encodes the force rule via {@link evaluateInstallGate}).
56
+ */
57
+ export declare function evaluateCoverageGate(coverage: SkillCoverageScan, options?: {
58
+ force?: boolean;
59
+ }): CoverageGateDecision;
60
+ /**
61
+ * Byte-identical message for the scanner-error deny (D7): a throw from the
62
+ * coverage walk or verdict evaluation is not a verdict, so no force branch
63
+ * consults it — the install is denied unconditionally (R2-001: shared by
64
+ * both package entrypoints).
65
+ */
66
+ export declare function scanFailureMessage(error: unknown): string;
31
67
  //# sourceMappingURL=skill-install-gate.d.ts.map
@@ -1,18 +1,22 @@
1
1
  /**
2
2
  * Shared install-gate evaluation for the skillguard runtime gate (D2).
3
3
  *
4
- * Pure helper: verdict evaluation for a set of declared skill scans lives in
5
- * exactly one place so all three entrypoints (plugin add, plugin import, skills
6
- * auto-install) share identical force semantics. Scanning + try/catch stay at
7
- * the call sites (a pure helper cannot own the I/O); this module imports no fs.
4
+ * Pure helpers: verdict evaluation ({@link evaluateInstallGate}) and the
5
+ * coverage refusal policy + message-building ({@link evaluateCoverageGate},
6
+ * {@link scanFailureMessage}) live in exactly one place so all three
7
+ * entrypoints (plugin add, plugin import, skills auto-install) share
8
+ * identical force semantics and byte-identical refusal messages (R2-001).
9
+ * Scanning + try/catch stay at the call sites (a pure helper cannot own the
10
+ * I/O); this module imports no fs.
8
11
  *
9
12
  * Force rule (fail-closed): `block` always refuses; `--force` lifts ONLY
10
13
  * `unscannable`. Manifest-integrity refusals (undeclared SKILL.md in the tree,
11
- * any symlink, empty/missing `skills` on import, declared paths escaping the
12
- * source dir) are NOT verdicts — call sites enforce them BEFORE this helper
13
- * runs, so `force` can never lift them either.
14
+ * any symlink, case-colliding declared dirs, empty/missing `skills` on import,
15
+ * declared paths escaping the source dir) are NOT verdicts —
16
+ * `evaluateCoverageGate` enforces the walk-derived ones BEFORE the verdict
17
+ * gate runs, so `force` can never lift them either.
14
18
  */
15
- import { isRejectedVerdict } from "./skill-scanner.js";
19
+ import { formatBatchReport, isRejectedVerdict } from "./skill-scanner.js";
16
20
  /**
17
21
  * Evaluate a set of declared-skill scan results against the install gate.
18
22
  *
@@ -27,4 +31,69 @@ export function evaluateInstallGate(results, options) {
27
31
  const allowed = !hasBlock && (rejected.length === 0 || force);
28
32
  return { allowed, rejected: allowed ? [] : rejected };
29
33
  }
34
+ /**
35
+ * Shared refusal policy for the coverage walk + verdict gate (R2-001) — the
36
+ * chain both `plugin add` and `plugin import` run between
37
+ * `scanSkillsWithCoverage` and placement. A `null` refusalError means the
38
+ * install may proceed; any non-null message is a block-level refusal the
39
+ * caller returns verbatim (`force` never lifts integrity refusals; the
40
+ * verdict branch already encodes the force rule via {@link evaluateInstallGate}).
41
+ */
42
+ export function evaluateCoverageGate(coverage, options) {
43
+ // Manifest-integrity refusals — block-level, force NEVER lifts
44
+ // (JD-007: ANY symlink; JD-006: undeclared SKILL.md incl.
45
+ // node_modules/.git). A walk with I/O errors cannot certify the
46
+ // installed footprint — refuse first, before symlink/undeclared
47
+ // checks, because the broken subtree may hide either (JD-013).
48
+ if (coverage.errors.length > 0) {
49
+ return {
50
+ gate: { allowed: false, rejected: [] },
51
+ refusalError: `skillguard: install refused — ${coverage.errors.length} path(s) could not be read (walk incomplete; manifest-integrity, force never lifts):\n${coverage.errors.map((p) => ` ${p}`).join("\n")}`,
52
+ };
53
+ }
54
+ // FU-5 (F3 residual): a declared dir with case-colliding on-disk twins
55
+ // is ambiguous — the declared scan read one twin while both install.
56
+ const ambiguous = coverage.ambiguousDeclaredDirs ?? [];
57
+ if (ambiguous.length > 0) {
58
+ return {
59
+ gate: { allowed: false, rejected: [] },
60
+ refusalError: `skillguard: install refused — ambiguous declared skill dir(s) (case-colliding on-disk dirs; manifest-integrity, force never lifts):\n${ambiguous.map((p) => ` ${p}`).join("\n")}`,
61
+ };
62
+ }
63
+ if (coverage.symlinks.length > 0) {
64
+ return {
65
+ gate: { allowed: false, rejected: [] },
66
+ refusalError: `skillguard: install refused — symlink(s) in tree (manifest-integrity, force never lifts):\n${coverage.symlinks.map((p) => ` ${p}`).join("\n")}`,
67
+ };
68
+ }
69
+ if (coverage.undeclared.length > 0) {
70
+ return {
71
+ gate: { allowed: false, rejected: [] },
72
+ refusalError: `skillguard: install refused — undeclared SKILL.md(s) in tree (every skill-shaped file must be declared; force never lifts):\n${coverage.undeclared.map((p) => ` ${p}`).join("\n")}`,
73
+ };
74
+ }
75
+ const gate = evaluateInstallGate(coverage.declared, options);
76
+ if (!gate.allowed) {
77
+ const blocked = gate.rejected.filter((r) => r.verdict === "block").length;
78
+ const unscannable = gate.rejected.filter((r) => r.verdict === "unscannable").length;
79
+ return {
80
+ gate,
81
+ // Lead line names the rejected count; the batch report renders
82
+ // the FULL declared set so the header/rows reflect every scanned
83
+ // skill (D6, JD-014).
84
+ refusalError: `skillguard: install refused — ${gate.rejected.length} rejected (${blocked} blocked, ${unscannable} unscannable)\n${formatBatchReport(coverage.declared)}`,
85
+ };
86
+ }
87
+ return { gate, refusalError: null };
88
+ }
89
+ /**
90
+ * Byte-identical message for the scanner-error deny (D7): a throw from the
91
+ * coverage walk or verdict evaluation is not a verdict, so no force branch
92
+ * consults it — the install is denied unconditionally (R2-001: shared by
93
+ * both package entrypoints).
94
+ */
95
+ export function scanFailureMessage(error) {
96
+ const msg = error instanceof Error ? error.message : String(error);
97
+ return `skillguard scan failed — ${msg}`;
98
+ }
30
99
  //# sourceMappingURL=skill-install-gate.js.map
@@ -107,6 +107,20 @@ export interface SkillCoverageScan {
107
107
  * proceed un-scanned.
108
108
  */
109
109
  errors: string[];
110
+ /**
111
+ * Distinct real on-disk dirs that case-collide with a DECLARED dir (FU-5,
112
+ * F3 residual): two or more on-disk dirs whose lowercased paths are equal
113
+ * AND match a declared dir — e.g. declared `skills/alpha` with on-disk
114
+ * siblings `skills/alpha` + `skills/Alpha` on a case-sensitive FS.
115
+ * Lowercased declared-dir membership (R1-F2-N1) cannot tell the twins
116
+ * apart: both pass membership (neither SKILL.md lands in `undeclared`)
117
+ * while the declared scan reads only one of them — the other installs
118
+ * un-scanned. The caller refuses on this (manifest-integrity, block-
119
+ * level, force never lifts). Absent or empty when no declared dir
120
+ * case-collides; a single case-folded on-disk dir (no twin) is NOT
121
+ * ambiguous — it resolves via the normal case-fold lookup.
122
+ */
123
+ ambiguousDeclaredDirs?: string[];
110
124
  }
111
125
  /**
112
126
  * SKILL.md-only coverage walk for the install gates (JD-006/JD-007).
@@ -121,6 +135,29 @@ export interface SkillCoverageScan {
121
135
  * {@link scanSkillFile} afterwards (JD-005).
122
136
  */
123
137
  export declare function scanSkillsWithCoverage(dir: string, declaredPaths: string[]): Promise<SkillCoverageScan>;
138
+ export interface PathContainmentCheck {
139
+ /** True when the entry stays inside the root lexically AND by realpath. */
140
+ ok: boolean;
141
+ /** Absolute lexical resolution of `entry` under `rootAbs`. */
142
+ entryAbs: string;
143
+ /** Which containment check failed when `!ok`. */
144
+ violation?: "lexical" | "realpath";
145
+ }
146
+ /**
147
+ * Containment core (R2-002) — the ONE implementation of the lexical +
148
+ * realpath containment policy (JD-003): an entry must stay inside the root
149
+ * both lexically (`../../x`, absolute paths) and by realpath, so an in-tree
150
+ * symlink cannot redirect a read outside the staged clone. Realpath
151
+ * resolution is best-effort: a missing declared dir has no realpath yet, in
152
+ * which case lexical containment is the whole guard (the coverage walk later
153
+ * reports it as a missing/unscannable declared skill).
154
+ *
155
+ * Two surfaces wrap this core with their own error shapes (both bound by
156
+ * tests — keep the messages distinct): {@link resolveContained} (throwing;
157
+ * used by the coverage walk) and `skillPathContained` in agent-skills.ts
158
+ * (non-throwing `{ ok, reason }`; the import gate refuses gracefully).
159
+ */
160
+ export declare function checkPathContained(rootAbs: string, rootReal: string, entry: string): Promise<PathContainmentCheck>;
124
161
  export declare function formatScanReport(result: SkillScanResult): string;
125
162
  export declare function formatBatchReport(results: SkillScanResult[]): string;
126
163
  export {};
@@ -528,6 +528,34 @@ export async function scanSkillsWithCoverage(dir, declaredPaths) {
528
528
  // Starting from `rootAbs` also keeps undeclared/symlinks/errors absolute
529
529
  // and consistent with the gate's realpath expectations.
530
530
  await walk(rootAbs);
531
+ // FU-5 (F3 residual): a declared dir whose lowercased path maps to MORE
532
+ // THAN ONE distinct real on-disk dir is ambiguous. Declared-dir
533
+ // membership is lowercased on both sides (R1-F2-N1), so a case-colliding
534
+ // TWIN (declared `skills/alpha` + on-disk siblings `skills/alpha` and
535
+ // `skills/Alpha` on a case-sensitive FS) passes membership for BOTH
536
+ // dirs — neither SKILL.md lands in `undeclared` — while the declared
537
+ // scan below reads only one of them; the other would install un-scanned.
538
+ // Surface the colliding dirs as a refusal-class condition (the gate
539
+ // refuses, manifest-integrity, force never lifts). A single case-folded
540
+ // dir (no twin) is unaffected — the two-tier lookup resolves it.
541
+ const dirsByLower = new Map();
542
+ for (const d of walkDirs) {
543
+ const key = d.toLowerCase();
544
+ const group = dirsByLower.get(key);
545
+ if (group) {
546
+ group.push(d);
547
+ }
548
+ else {
549
+ dirsByLower.set(key, [d]);
550
+ }
551
+ }
552
+ const ambiguousDeclaredDirs = [];
553
+ for (const [key, group] of dirsByLower) {
554
+ if (group.length > 1 && declaredDirAbsLower.has(key)) {
555
+ // Sorted for deterministic output (readdir order is OS-dependent).
556
+ ambiguousDeclaredDirs.push(...[...group].sort());
557
+ }
558
+ }
531
559
  // Content-scanned results for declared entries only (JD-005), in declared
532
560
  // order so reports are deterministic. A declared file that is a symlink is
533
561
  // already in `symlinks` — reading through it would escape the tree (JD-003),
@@ -561,7 +589,7 @@ export async function scanSkillsWithCoverage(dir, declaredPaths) {
561
589
  continue;
562
590
  declared.push(await scanSkillFile(file));
563
591
  }
564
- return { declared, undeclared, symlinks, errors };
592
+ return { declared, undeclared, symlinks, errors, ambiguousDeclaredDirs };
565
593
  }
566
594
  /**
567
595
  * Resolve the on-disk skill file for a declared skill directory. The coverage
@@ -597,17 +625,25 @@ async function declaredSkillFileOnDisk(absDir) {
597
625
  return canonical;
598
626
  }
599
627
  /**
600
- * Resolve a declared skill entry to an absolute directory and verify it stays
601
- * inside the scan root both lexically (`../../x`, absolute paths) and by
602
- * realpath, so an in-tree symlink cannot redirect the declared read outside the
603
- * staged clone (JD-003). Throws when the entry escapes; the caller denies.
628
+ * Containment core (R2-002) the ONE implementation of the lexical +
629
+ * realpath containment policy (JD-003): an entry must stay inside the root
630
+ * both lexically (`../../x`, absolute paths) and by realpath, so an in-tree
631
+ * symlink cannot redirect a read outside the staged clone. Realpath
632
+ * resolution is best-effort: a missing declared dir has no realpath yet, in
633
+ * which case lexical containment is the whole guard (the coverage walk later
634
+ * reports it as a missing/unscannable declared skill).
635
+ *
636
+ * Two surfaces wrap this core with their own error shapes (both bound by
637
+ * tests — keep the messages distinct): {@link resolveContained} (throwing;
638
+ * used by the coverage walk) and `skillPathContained` in agent-skills.ts
639
+ * (non-throwing `{ ok, reason }`; the import gate refuses gracefully).
604
640
  */
605
- async function resolveContained(rootAbs, rootReal, entry) {
641
+ export async function checkPathContained(rootAbs, rootReal, entry) {
606
642
  const entryAbs = path.resolve(rootAbs, entry);
607
643
  // Lexical containment — catches `../outside` and absolute entries.
608
644
  const rel = path.relative(rootAbs, entryAbs);
609
645
  if (rel.startsWith("..") || path.isAbsolute(rel)) {
610
- throw new Error(`skillguard: declared skill path escapes scan root — ${entry}`);
646
+ return { ok: false, entryAbs, violation: "lexical" };
611
647
  }
612
648
  // Realpath containment — catches a directory inside the tree whose real
613
649
  // location is outside it. A missing declared dir (later `unscannable`) has
@@ -617,13 +653,27 @@ async function resolveContained(rootAbs, rootReal, entry) {
617
653
  real = await fs.realpath(entryAbs);
618
654
  }
619
655
  catch {
620
- return entryAbs;
656
+ return { ok: true, entryAbs };
621
657
  }
622
658
  const relReal = path.relative(rootReal, real);
623
659
  if (relReal.startsWith("..") || path.isAbsolute(relReal)) {
660
+ return { ok: false, entryAbs, violation: "realpath" };
661
+ }
662
+ return { ok: true, entryAbs };
663
+ }
664
+ /**
665
+ * Resolve a declared skill entry to an absolute directory and verify it stays
666
+ * inside the scan root — both lexically (`../../x`, absolute paths) and by
667
+ * realpath, so an in-tree symlink cannot redirect the declared read outside the
668
+ * staged clone (JD-003). Throws when the entry escapes; the caller denies.
669
+ * Thin throwing surface over the shared containment core (R2-002).
670
+ */
671
+ async function resolveContained(rootAbs, rootReal, entry) {
672
+ const check = await checkPathContained(rootAbs, rootReal, entry);
673
+ if (!check.ok) {
624
674
  throw new Error(`skillguard: declared skill path escapes scan root — ${entry}`);
625
675
  }
626
- return entryAbs;
676
+ return check.entryAbs;
627
677
  }
628
678
  // =============================================================================
629
679
  // Report formatting
@@ -24,10 +24,20 @@ export default function AutoSkills({ projectDir, skillsDir, dryRun, force = fals
24
24
  force,
25
25
  })
26
26
  .then((r) => {
27
+ // FU-1 (R4-002): a refused batch (gate-blocked skills) must exit
28
+ // non-zero so scripted consumers can tell a refusal from success;
29
+ // `process.exitCode` (not `process.exit`) keeps Ink rendering.
30
+ // A clean run resets to 0 so an interactive re-scan (r) after a
31
+ // refusal can still exit 0. Force-lifted unscannable installs are
32
+ // NOT blocked — they keep exit 0.
33
+ process.exitCode = r.blocked.length > 0 ? 1 : 0;
27
34
  setResult(r);
28
35
  setLoading(false);
29
36
  })
30
37
  .catch((e) => {
38
+ // A scan throw is a gate deny (D7) — nothing was copied; exit
39
+ // non-zero like any other refusal.
40
+ process.exitCode = 1;
31
41
  setError(String(e));
32
42
  setLoading(false);
33
43
  });
package/dist/ui/Plugin.js CHANGED
@@ -106,7 +106,7 @@ export default function Plugin({ action, target, dryRun, codex = false, force =
106
106
  });
107
107
  break;
108
108
  }
109
- await runPluginImport(target, dryRun, onStep, force);
109
+ await runPluginImport(target, dryRun, onStep, { force });
110
110
  break;
111
111
  case "export-skills":
112
112
  if (target === "global") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "javi-forge",
3
- "version": "1.28.0",
3
+ "version": "1.28.1",
4
4
  "description": "Project scaffolding and AI-ready CI bootstrap",
5
5
  "type": "module",
6
6
  "bin": {