javi-forge 1.39.0 → 1.39.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -792,12 +792,63 @@ function bashSubstitutions(command) {
792
792
  }
793
793
  return bodies;
794
794
  }
795
+ // This deliberately recognizes only a direct, quoted Python stdin heredoc on
796
+ // the first command line. It removes the opaque body before shell inspection;
797
+ // it does not parse Python or permit its execution.
798
+ function pythonQuotedHeredoc(command) {
799
+ const newline = command.indexOf("\n");
800
+ const header = newline < 0 ? command : command.slice(0, newline);
801
+ const starts = [0];
802
+ let quote = "", escaped = false, heredocs = 0;
803
+ for (let index = 0; index < header.length; index++) {
804
+ const char = header[index];
805
+ if (escaped) { escaped = false; continue; }
806
+ if (char === "\\" && quote !== "'") { escaped = true; continue; }
807
+ if (quote) { if (char === quote) quote = ""; continue; }
808
+ if (char === "'" || char === '"' || char === "`") { quote = char; continue; }
809
+ if (char === "<" && header[index + 1] === "<") { heredocs++; index++; }
810
+ if (char === ";" || char === "|" || char === "&") starts.push(index + 1);
811
+ }
812
+ const literalPath = String.raw`(?:[A-Za-z0-9_./~-]+|'[A-Za-z0-9_./~ -]+'|"[A-Za-z0-9_./~ -]+")`;
813
+ const redirection = String.raw`(?:[0-9]*>>?|[0-9]*<)[ \t]*${literalPath}[ \t]*`;
814
+ const python = String.raw`[ \t]*(?:python3?|/(?:[A-Za-z0-9_.-]+/)*python3?)(?:[ \t]+-)?[ \t]*`;
815
+ const pattern = new RegExp(String.raw`^(${python}(?:${redirection})*)<<(-?)[ \t]*(['"])([A-Za-z_][A-Za-z0-9_]*)\3([ \t]*(?:${redirection})*)([;|&].*)?$`);
816
+ for (const start of starts) {
817
+ const match = pattern.exec(header.slice(start));
818
+ if (!match) continue;
819
+ // Multiple documents and tab-stripping need a shell grammar this guard
820
+ // intentionally does not implement, so retain fail-closed behavior.
821
+ if (newline < 0 || heredocs !== 1 || match[2]) fail("unlexable-command");
822
+ if (match[6]?.trimStart().startsWith("|")) fail("unlexable-command");
823
+ let offset = newline + 1;
824
+ while (offset <= command.length) {
825
+ const end = command.indexOf("\n", offset);
826
+ const lineEnd = end < 0 ? command.length : end;
827
+ if (command.slice(offset, lineEnd) === match[4]) {
828
+ const outerHeader = header.slice(0, start) + match[1] + match[5] + (match[6] ?? "");
829
+ return { command: `${outerHeader}\n${command.slice(lineEnd + 1)}`, unsupported: true };
830
+ }
831
+ if (end < 0) break;
832
+ offset = end + 1;
833
+ }
834
+ fail("unlexable-command");
835
+ }
836
+ return { command, unsupported: false };
837
+ }
795
838
  function evaluateBash(command, cwd, config = AGENT_CONFIGS.claude, projectRoot = PROJECT_ROOT, depth = 0) {
796
839
  if (depth > 4) return { allowed: false, ruleId: "shell.obfuscated-interpreter" };
840
+ let unsupported = false;
841
+ try { ({ command, unsupported } = pythonQuotedHeredoc(command)); } catch { return { allowed: false, ruleId: "shell.obfuscated-interpreter" }; }
797
842
  const headerOnlyCommand = opaqueInertCatHereDoc(command, cwd, config, projectRoot);
798
843
  if (headerOnlyCommand === null) return { allowed: false, ruleId: "shell.obfuscated-interpreter" };
799
844
  command = headerOnlyCommand;
800
- try { for (const body of bashSubstitutions(command)) { const nested = evaluateBash(body, cwd, config, projectRoot, depth + 1); if (!nested.allowed) return nested; } } catch { return { allowed: false, ruleId: "shell.obfuscated-interpreter" }; }
845
+ try {
846
+ for (const body of bashSubstitutions(command)) {
847
+ const nested = evaluateBash(body, cwd, config, projectRoot, depth + 1);
848
+ if (nested.ruleId === "shell.unsupported-interpreter") unsupported = true;
849
+ else if (!nested.allowed) return nested;
850
+ }
851
+ } catch { return { allowed: false, ruleId: "shell.obfuscated-interpreter" }; }
801
852
  if (/^\s*:\s*\(\s*\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:\s*$/.test(command)) return { allowed: false, ruleId: "shell.destructive-root" };
802
853
  let parsed;
803
854
  try { parsed = lex(command); } catch { return { allowed: false, ruleId: "shell.obfuscated-interpreter" }; }
@@ -834,10 +885,16 @@ function evaluateBash(command, cwd, config = AGENT_CONFIGS.claude, projectRoot =
834
885
  if (/^(?:powershell|pwsh)(?:\.exe)?$/i.test(executable) && tokens.some((token) => /^-(?:enc|encodedcommand)$/i.test(token))) return { allowed: false, ruleId: "shell.obfuscated-interpreter" };
835
886
  if (/^(?:bash|sh|zsh|dash|ksh)$/.test(executable)) {
836
887
  const flag = tokens.findIndex((token) => /^-[^-]*c[^-]*$/.test(token));
837
- if (flag >= 0) { const body = tokens[flag + 1]; if (!body || /\$(?!\()/.test(body)) return { allowed: false, ruleId: "shell.obfuscated-interpreter" }; const nested = evaluateBash(body, cwd, config, projectRoot, depth + 1); if (!nested.allowed) return nested; }
888
+ if (flag >= 0) {
889
+ const body = tokens[flag + 1];
890
+ if (!body || /\$(?!\()/.test(body)) return { allowed: false, ruleId: "shell.obfuscated-interpreter" };
891
+ const nested = evaluateBash(body, cwd, config, projectRoot, depth + 1);
892
+ if (nested.ruleId === "shell.unsupported-interpreter") unsupported = true;
893
+ else if (!nested.allowed) return nested;
894
+ }
838
895
  }
839
896
  }
840
- return { allowed: true };
897
+ return unsupported ? { allowed: false, ruleId: "shell.unsupported-interpreter" } : { allowed: true };
841
898
  }
842
899
  function evaluatePowerShell(command, cwd, config = AGENT_CONFIGS.claude, projectRoot = PROJECT_ROOT) {
843
900
  const parsed = lex(command, true);
@@ -939,6 +996,7 @@ function diagnostic(error) {
939
996
  }
940
997
  function denialDiagnostic(toolName, decision) {
941
998
  const tool = SUPPORTED_TOOLS.includes(toolName) ? toolName : "supported tool";
999
+ if (decision.ruleId === "shell.unsupported-interpreter") return "javi-forge PreToolUse denied Bash [shell.unsupported-interpreter]: quoted Python heredoc execution is unsupported";
942
1000
  if (decision.ambiguity) return `javi-forge PreToolUse denied ${tool} [${decision.ruleId}]: ${decision.ambiguity.utility} ${decision.ambiguity.profile} ${decision.ambiguity.sink} semantics denied as ambiguous`;
943
1001
  return `javi-forge PreToolUse denied ${tool} [${decision.ruleId}]: global guard policy denied the invocation`;
944
1002
  }
@@ -4,7 +4,7 @@
4
4
  "name": "javi-forge-skillguard-pre-tool-use.mjs",
5
5
  "version": 1,
6
6
  "policyVersion": 2,
7
- "sha256": "6edfbb31ce0551b27e38ae1ffd1daf9cc4bea54f2c86687c5124132af5b8c0af",
7
+ "sha256": "507a57a15f1b967103bb1eefe701a74813d5a9603fa7bcc15579a215b03621e3",
8
8
  "historical": [
9
9
  "78be7e6613c012280b7ad17886462ba166b63ebd031e34565d757b3a0796d7cc",
10
10
  "5dc2a5c31131f4ac7d8657c78b950de52776aad6eaefe78ea0d764a9963c4425",
@@ -12,7 +12,8 @@
12
12
  "3581862f0567cce75a58b693c9ade80d39ee7d58add11537a34a8461c47c1ed4",
13
13
  "54a270f28b068450b79547a88ec6f2d4854514392fd5f38ed1d6174ea093d7aa",
14
14
  "9a565cec31d9e091e3fb9420b86685f824733bc1ebe479f086b2b955aba6ef3e",
15
- "59fc4224975ad64cfc85bab50ec60d9bd4948070e9d41f42ab43e6d8231c19a1"
15
+ "59fc4224975ad64cfc85bab50ec60d9bd4948070e9d41f42ab43e6d8231c19a1",
16
+ "6edfbb31ce0551b27e38ae1ffd1daf9cc4bea54f2c86687c5124132af5b8c0af"
16
17
  ]
17
18
  },
18
19
  "settingsEntries": {
@@ -0,0 +1,9 @@
1
+ import { type ReactElement } from "react";
2
+ import { type PluginCommandRequest, type PluginCommandResult } from "../../commands/plugin.js";
3
+ /** Preserve an earlier CLI failure, even when this request succeeds. */
4
+ export declare function pluginCommandExitCode(result: PluginCommandResult, previous?: typeof process.exitCode): typeof process.exitCode;
5
+ /** One mounted controller is one CLI invocation, never a retry or retarget. */
6
+ export default function PluginController({ request, }: {
7
+ request: PluginCommandRequest;
8
+ }): ReactElement;
9
+ //# sourceMappingURL=plugin.d.ts.map
@@ -0,0 +1,68 @@
1
+ import { createElement, useEffect, useRef, useState, } from "react";
2
+ import { PLUGIN_COMMAND_STATUS, runPluginCommand, } from "../../commands/plugin.js";
3
+ import Plugin from "../../ui/Plugin.js";
4
+ /** Preserve an earlier CLI failure, even when this request succeeds. */
5
+ export function pluginCommandExitCode(result, previous = undefined) {
6
+ if (previous !== undefined && previous !== 0 && previous !== "0")
7
+ return previous;
8
+ return result.status === PLUGIN_COMMAND_STATUS.SUCCESS ? 0 : 1;
9
+ }
10
+ function abortSearch(signal, controller) {
11
+ if (controller.signal.aborted)
12
+ return;
13
+ controller.abort();
14
+ if (signal === "SIGINT")
15
+ process.exitCode = 130;
16
+ if (signal === "SIGTERM")
17
+ process.exitCode = 143;
18
+ }
19
+ /** One mounted controller is one CLI invocation, never a retry or retarget. */
20
+ export default function PluginController({ request, }) {
21
+ const initialRequest = useRef({ ...request }).current;
22
+ const started = useRef(false);
23
+ const [steps, setSteps] = useState([]);
24
+ const [result, setResult] = useState(null);
25
+ useEffect(() => {
26
+ if (started.current)
27
+ return;
28
+ started.current = true;
29
+ const controller = initialRequest.action === "search" ? new AbortController() : undefined;
30
+ const sigintHandler = controller === undefined
31
+ ? undefined
32
+ : () => abortSearch("SIGINT", controller);
33
+ const sigtermHandler = controller === undefined
34
+ ? undefined
35
+ : () => abortSearch("SIGTERM", controller);
36
+ if (sigintHandler)
37
+ process.on("SIGINT", sigintHandler);
38
+ if (sigtermHandler)
39
+ process.on("SIGTERM", sigtermHandler);
40
+ const onStep = (step) => {
41
+ setSteps((previous) => {
42
+ const index = previous.findIndex((item) => item.id === step.id);
43
+ if (index < 0)
44
+ return [...previous, step];
45
+ const next = [...previous];
46
+ next[index] = step;
47
+ return next;
48
+ });
49
+ };
50
+ void runPluginCommand({ ...initialRequest, signal: controller?.signal }, onStep).then((outcome) => {
51
+ process.exitCode = pluginCommandExitCode(outcome, process.exitCode);
52
+ setResult(outcome);
53
+ });
54
+ return () => {
55
+ if (sigintHandler)
56
+ process.removeListener("SIGINT", sigintHandler);
57
+ if (sigtermHandler)
58
+ process.removeListener("SIGTERM", sigtermHandler);
59
+ };
60
+ }, [initialRequest]);
61
+ return createElement(Plugin, {
62
+ action: initialRequest.action ?? "list",
63
+ dryRun: initialRequest.dryRun,
64
+ steps,
65
+ result,
66
+ });
67
+ }
68
+ //# sourceMappingURL=plugin.js.map
@@ -14,9 +14,9 @@ import AnalyzeUI from "../../ui/AnalyzeUI.js";
14
14
  import App from "../../ui/App.js";
15
15
  import { CIProvider as CIContextProvider } from "../../ui/CIContext.js";
16
16
  import LlmsTxt from "../../ui/LlmsTxt.js";
17
- import Plugin from "../../ui/Plugin.js";
18
17
  import { VALID_CI, VALID_MEMORY, VALID_STACKS } from "../validators.js";
19
18
  import DoctorController from "./doctor.js";
19
+ import PluginController from "./plugin.js";
20
20
  export function handleDoctor(cli, ctx, deps = {}) {
21
21
  const platformSupport = resolvePlatformSupport(deps.platform ?? process.platform);
22
22
  if (platformSupport) {
@@ -39,24 +39,15 @@ export function handleLlmsTxt(cli, ctx) {
39
39
  React.createElement(LlmsTxt, { projectDir: process.cwd(), dryRun: cli.flags.dryRun })), { stdin: ctx.inkStdin });
40
40
  }
41
41
  export function handlePlugin(cli, ctx) {
42
- const pluginAction = cli.input[1];
43
- const VALID_PLUGIN_ACTIONS = [
44
- "add",
45
- "remove",
46
- "list",
47
- "search",
48
- "validate",
49
- "sync",
50
- "export",
51
- "import",
52
- "export-skills",
53
- ];
54
- const action = pluginAction && VALID_PLUGIN_ACTIONS.includes(pluginAction)
55
- ? pluginAction
56
- : "list";
57
- const target = cli.input[2];
58
42
  render(React.createElement(CIContextProvider, { isCI: ctx.isCI },
59
- React.createElement(Plugin, { action: action, target: target, dryRun: cli.flags.dryRun, codex: cli.flags.codex, force: cli.flags.force })), { stdin: ctx.inkStdin });
43
+ React.createElement(PluginController, { request: {
44
+ action: cli.input[1],
45
+ target: cli.input[2],
46
+ projectDir: process.cwd(),
47
+ dryRun: cli.flags.dryRun,
48
+ codex: cli.flags.codex,
49
+ force: cli.flags.force,
50
+ } })), { stdin: ctx.inkStdin });
60
51
  }
61
52
  export function handleInitDefault(cli, ctx) {
62
53
  const platformSupport = resolvePlatformSupport(process.platform);
@@ -1,55 +1,87 @@
1
1
  import type { InitStep } from "../types/index.js";
2
2
  type StepCallback = (step: InitStep) => void;
3
+ export declare const PLUGIN_COMMAND_STATUS: {
4
+ readonly SUCCESS: "success";
5
+ readonly FAILURE: "failure";
6
+ readonly REFUSED: "refused";
7
+ };
8
+ export type PluginCommandStatus = (typeof PLUGIN_COMMAND_STATUS)[keyof typeof PLUGIN_COMMAND_STATUS];
9
+ export declare const PLUGIN_COMMAND_ACTION: {
10
+ readonly ADD: "add";
11
+ readonly REMOVE: "remove";
12
+ readonly LIST: "list";
13
+ readonly SEARCH: "search";
14
+ readonly VALIDATE: "validate";
15
+ readonly SYNC: "sync";
16
+ readonly EXPORT: "export";
17
+ readonly IMPORT: "import";
18
+ readonly EXPORT_SKILLS: "export-skills";
19
+ };
20
+ export interface PluginCommandResult {
21
+ status: PluginCommandStatus;
22
+ }
23
+ export interface PluginCommandRequest {
24
+ action?: string;
25
+ target?: string;
26
+ projectDir: string;
27
+ dryRun: boolean;
28
+ codex?: boolean;
29
+ /** Force only unscannable sources; never override a guard block. */
30
+ force?: boolean;
31
+ signal?: AbortSignal;
32
+ }
3
33
  /**
4
34
  * Add (install) a plugin from a GitHub source.
5
35
  */
6
36
  export declare function runPluginAdd(source: string, dryRun: boolean, onStep: StepCallback, options?: {
7
37
  force?: boolean;
8
- }): Promise<void>;
38
+ }): Promise<PluginCommandResult>;
9
39
  /**
10
40
  * Remove an installed plugin by name.
11
41
  */
12
- export declare function runPluginRemove(name: string, dryRun: boolean, onStep: StepCallback): Promise<void>;
42
+ export declare function runPluginRemove(name: string, dryRun: boolean, onStep: StepCallback): Promise<PluginCommandResult>;
13
43
  /**
14
44
  * List all installed plugins.
15
45
  */
16
- export declare function runPluginList(onStep: StepCallback): Promise<void>;
46
+ export declare function runPluginList(onStep: StepCallback): Promise<PluginCommandResult>;
17
47
  /**
18
48
  * Search the remote plugin registry.
19
49
  */
20
50
  export declare function runPluginSearch(query: string | undefined, onStep: StepCallback, options?: {
21
51
  signal?: AbortSignal;
22
- }): Promise<void>;
52
+ }): Promise<PluginCommandResult>;
23
53
  /**
24
54
  * Validate a local plugin directory.
25
55
  */
26
- export declare function runPluginValidate(pluginDir: string, onStep: StepCallback): Promise<void>;
56
+ export declare function runPluginValidate(pluginDir: string, onStep: StepCallback): Promise<PluginCommandResult>;
27
57
  /**
28
58
  * Sync detected plugins into the project manifest.
29
59
  */
30
- export declare function runPluginSync(projectDir: string, dryRun: boolean, onStep: StepCallback): Promise<void>;
60
+ export declare function runPluginSync(projectDir: string, dryRun: boolean, onStep: StepCallback): Promise<PluginCommandResult>;
31
61
  /**
32
62
  * Export an installed plugin to Agent Skills spec format.
33
63
  */
34
- export declare function runPluginExport(name: string, onStep: StepCallback): Promise<void>;
64
+ export declare function runPluginExport(name: string, onStep: StepCallback): Promise<PluginCommandResult>;
35
65
  /**
36
66
  * Export an installed plugin to Codex-compatible TOML subagent files.
37
67
  */
38
- export declare function runPluginExportCodex(name: string, onStep: StepCallback): Promise<void>;
68
+ export declare function runPluginExportCodex(name: string, onStep: StepCallback): Promise<PluginCommandResult>;
39
69
  /**
40
70
  * Import an Agent Skills spec package and convert to javi-forge plugin format.
41
71
  */
42
72
  export declare function runPluginImport(sourceDir: string, dryRun: boolean, onStep: StepCallback, options?: {
43
73
  force?: boolean;
44
- }): Promise<void>;
74
+ }): Promise<PluginCommandResult>;
45
75
  /**
46
76
  * Generate a project-level skills.json from all installed plugins.
47
77
  * Makes the project discoverable by `npx skills add` and 40+ AI agents.
48
78
  */
49
- export declare function runPluginExportSkillsJson(projectDir: string, dryRun: boolean, onStep: StepCallback): Promise<void>;
79
+ export declare function runPluginExportSkillsJson(projectDir: string, dryRun: boolean, onStep: StepCallback): Promise<PluginCommandResult>;
50
80
  /**
51
81
  * Generate a global skills.json from all globally installed plugins.
52
82
  */
53
- export declare function runPluginExportGlobalSkillsJson(dryRun: boolean, onStep: StepCallback): Promise<void>;
83
+ export declare function runPluginExportGlobalSkillsJson(dryRun: boolean, onStep: StepCallback): Promise<PluginCommandResult>;
84
+ /** Execute one plugin request; the CLI dispatcher owns process exit status. */
85
+ export declare function runPluginCommand(request: PluginCommandRequest, onStep: StepCallback): Promise<PluginCommandResult>;
54
86
  export {};
55
87
  //# sourceMappingURL=plugin.d.ts.map
@@ -1,6 +1,22 @@
1
1
  import { exportPluginAsAgentSkills, generateGlobalSkillsJson, generateProjectSkillsJson, importAgentSkillsPackage, } from "../lib/agent-skills.js";
2
2
  import { exportPluginAsCodexToml } from "../lib/codex-export.js";
3
3
  import { installPlugin, listInstalledPlugins, removePlugin, searchRegistry, syncPlugins, validatePlugin, } from "../lib/plugin.js";
4
+ export const PLUGIN_COMMAND_STATUS = {
5
+ SUCCESS: "success",
6
+ FAILURE: "failure",
7
+ REFUSED: "refused",
8
+ };
9
+ export const PLUGIN_COMMAND_ACTION = {
10
+ ADD: "add",
11
+ REMOVE: "remove",
12
+ LIST: "list",
13
+ SEARCH: "search",
14
+ VALIDATE: "validate",
15
+ SYNC: "sync",
16
+ EXPORT: "export",
17
+ IMPORT: "import",
18
+ EXPORT_SKILLS: "export-skills",
19
+ };
4
20
  function report(onStep, id, label, status, detail) {
5
21
  onStep({ id, label, status, detail });
6
22
  }
@@ -32,13 +48,14 @@ export async function runPluginAdd(source, dryRun, onStep, options = {}) {
32
48
  }
33
49
  else {
34
50
  report(onStep, stepId, `Install plugin: ${source}`, "error", result.error);
35
- // FU-1 (R4-002): a skillguard refusal must be distinguishable from
36
- // success by scripted consumers — exit non-zero. `process.exitCode`
37
- // (not `process.exit`) so the Ink tree keeps rendering/unmounting
38
- // normally. Non-gate failures (validation, clone errors) keep exit 0.
39
- if (result.refused)
40
- process.exitCode = 1;
41
51
  }
52
+ return {
53
+ status: result.success
54
+ ? PLUGIN_COMMAND_STATUS.SUCCESS
55
+ : result.refused
56
+ ? PLUGIN_COMMAND_STATUS.REFUSED
57
+ : PLUGIN_COMMAND_STATUS.FAILURE,
58
+ };
42
59
  }
43
60
  /**
44
61
  * Remove an installed plugin by name.
@@ -53,6 +70,11 @@ export async function runPluginRemove(name, dryRun, onStep) {
53
70
  else {
54
71
  report(onStep, stepId, `Remove plugin: ${name}`, "error", result.error);
55
72
  }
73
+ return {
74
+ status: result.success
75
+ ? PLUGIN_COMMAND_STATUS.SUCCESS
76
+ : PLUGIN_COMMAND_STATUS.FAILURE,
77
+ };
56
78
  }
57
79
  /**
58
80
  * List all installed plugins.
@@ -68,6 +90,7 @@ export async function runPluginList(onStep) {
68
90
  const summary = plugins.map((p) => `${p.name}@${p.version}`).join(", ");
69
91
  report(onStep, stepId, "List installed plugins", "done", `${plugins.length} plugins: ${summary}`);
70
92
  }
93
+ return { status: PLUGIN_COMMAND_STATUS.SUCCESS };
71
94
  }
72
95
  /**
73
96
  * Search the remote plugin registry.
@@ -78,9 +101,11 @@ export async function runPluginSearch(query, onStep, options = {}) {
78
101
  const results = await searchRegistry(query, options);
79
102
  if (results.status === "cancelled") {
80
103
  report(onStep, stepId, `Search plugins${query ? `: ${query}` : ""}`, "error", "registry search cancelled");
104
+ return { status: PLUGIN_COMMAND_STATUS.FAILURE };
81
105
  }
82
106
  else if (results.status === "unavailable") {
83
107
  report(onStep, stepId, `Search plugins${query ? `: ${query}` : ""}`, "error", "registry unavailable");
108
+ return { status: PLUGIN_COMMAND_STATUS.FAILURE };
84
109
  }
85
110
  else if (results.entries.length === 0) {
86
111
  report(onStep, stepId, `Search plugins${query ? `: ${query}` : ""}`, "done", query ? `no plugins matching "${query}"` : "registry empty");
@@ -91,6 +116,7 @@ export async function runPluginSearch(query, onStep, options = {}) {
91
116
  .join("\n ");
92
117
  report(onStep, stepId, `Search plugins${query ? `: ${query}` : ""}`, "done", `${results.entries.length} results:\n ${summary}`);
93
118
  }
119
+ return { status: PLUGIN_COMMAND_STATUS.SUCCESS };
94
120
  }
95
121
  /**
96
122
  * Validate a local plugin directory.
@@ -108,6 +134,11 @@ export async function runPluginValidate(pluginDir, onStep) {
108
134
  .join("\n");
109
135
  report(onStep, stepId, `Validate plugin: ${pluginDir}`, "error", `${result.errors.length} errors:\n${msgs}`);
110
136
  }
137
+ return {
138
+ status: result.valid
139
+ ? PLUGIN_COMMAND_STATUS.SUCCESS
140
+ : PLUGIN_COMMAND_STATUS.FAILURE,
141
+ };
111
142
  }
112
143
  /**
113
144
  * Sync detected plugins into the project manifest.
@@ -132,10 +163,12 @@ export async function runPluginSync(projectDir, dryRun, onStep) {
132
163
  parts.push("no plugins detected");
133
164
  const prefix = dryRun ? "dry-run: " : "";
134
165
  report(onStep, stepId, "Sync plugins", "done", `${prefix}${parts.join(" | ")}`);
166
+ return { status: PLUGIN_COMMAND_STATUS.SUCCESS };
135
167
  }
136
168
  catch (e) {
137
169
  const msg = e instanceof Error ? e.message : String(e);
138
170
  report(onStep, stepId, "Sync plugins", "error", msg);
171
+ return { status: PLUGIN_COMMAND_STATUS.FAILURE };
139
172
  }
140
173
  }
141
174
  /**
@@ -151,6 +184,11 @@ export async function runPluginExport(name, onStep) {
151
184
  else {
152
185
  report(onStep, stepId, `Export plugin: ${name}`, "error", result.error);
153
186
  }
187
+ return {
188
+ status: result.success
189
+ ? PLUGIN_COMMAND_STATUS.SUCCESS
190
+ : PLUGIN_COMMAND_STATUS.FAILURE,
191
+ };
154
192
  }
155
193
  /**
156
194
  * Export an installed plugin to Codex-compatible TOML subagent files.
@@ -165,6 +203,11 @@ export async function runPluginExportCodex(name, onStep) {
165
203
  else {
166
204
  report(onStep, stepId, `Export plugin as Codex TOML: ${name}`, "error", result.error);
167
205
  }
206
+ return {
207
+ status: result.success
208
+ ? PLUGIN_COMMAND_STATUS.SUCCESS
209
+ : PLUGIN_COMMAND_STATUS.FAILURE,
210
+ };
168
211
  }
169
212
  /**
170
213
  * Import an Agent Skills spec package and convert to javi-forge plugin format.
@@ -181,12 +224,14 @@ export async function runPluginImport(sourceDir, dryRun, onStep, options = {}) {
181
224
  }
182
225
  else {
183
226
  report(onStep, stepId, `Import agent-skills package: ${sourceDir}`, "error", result.error);
184
- // FU-1 (R4-002): same exit-code contract as runPluginAdd — a skillguard
185
- // refusal (manifest-integrity or verdict) exits non-zero; plain input
186
- // errors (skills.json missing/invalid) keep exit 0.
187
- if (result.refused)
188
- process.exitCode = 1;
189
227
  }
228
+ return {
229
+ status: result.success
230
+ ? PLUGIN_COMMAND_STATUS.SUCCESS
231
+ : result.refused
232
+ ? PLUGIN_COMMAND_STATUS.REFUSED
233
+ : PLUGIN_COMMAND_STATUS.FAILURE,
234
+ };
190
235
  }
191
236
  /**
192
237
  * Generate a project-level skills.json from all installed plugins.
@@ -203,6 +248,11 @@ export async function runPluginExportSkillsJson(projectDir, dryRun, onStep) {
203
248
  else {
204
249
  report(onStep, stepId, "Generate project skills.json", "error", result.error);
205
250
  }
251
+ return {
252
+ status: result.success
253
+ ? PLUGIN_COMMAND_STATUS.SUCCESS
254
+ : PLUGIN_COMMAND_STATUS.FAILURE,
255
+ };
206
256
  }
207
257
  /**
208
258
  * Generate a global skills.json from all globally installed plugins.
@@ -218,5 +268,66 @@ export async function runPluginExportGlobalSkillsJson(dryRun, onStep) {
218
268
  else {
219
269
  report(onStep, stepId, "Generate global skills.json", "error", result.error);
220
270
  }
271
+ return {
272
+ status: result.success
273
+ ? PLUGIN_COMMAND_STATUS.SUCCESS
274
+ : PLUGIN_COMMAND_STATUS.FAILURE,
275
+ };
276
+ }
277
+ function requiredTargetLabel(action) {
278
+ if (action === PLUGIN_COMMAND_ACTION.ADD)
279
+ return "source";
280
+ if (action === PLUGIN_COMMAND_ACTION.REMOVE ||
281
+ action === PLUGIN_COMMAND_ACTION.EXPORT)
282
+ return "name";
283
+ if (action === PLUGIN_COMMAND_ACTION.VALIDATE ||
284
+ action === PLUGIN_COMMAND_ACTION.IMPORT)
285
+ return "path";
286
+ return undefined;
287
+ }
288
+ /** Execute one plugin request; the CLI dispatcher owns process exit status. */
289
+ export async function runPluginCommand(request, onStep) {
290
+ const { action = PLUGIN_COMMAND_ACTION.LIST, dryRun, projectDir, codex = false, force = false, } = request;
291
+ const target = request.target ?? "";
292
+ const requiredLabel = requiredTargetLabel(action);
293
+ if (requiredLabel && !target) {
294
+ report(onStep, "err", "Error", "error", `${requiredLabel} required: javi-forge plugin ${action} <${requiredLabel}>`);
295
+ return { status: PLUGIN_COMMAND_STATUS.FAILURE };
296
+ }
297
+ try {
298
+ switch (action) {
299
+ case PLUGIN_COMMAND_ACTION.ADD:
300
+ return await runPluginAdd(target, dryRun, onStep, { force });
301
+ case PLUGIN_COMMAND_ACTION.REMOVE:
302
+ return await runPluginRemove(target, dryRun, onStep);
303
+ case PLUGIN_COMMAND_ACTION.LIST:
304
+ return await runPluginList(onStep);
305
+ case PLUGIN_COMMAND_ACTION.SEARCH:
306
+ return await runPluginSearch(request.target, onStep, {
307
+ signal: request.signal,
308
+ });
309
+ case PLUGIN_COMMAND_ACTION.VALIDATE:
310
+ return await runPluginValidate(target, onStep);
311
+ case PLUGIN_COMMAND_ACTION.SYNC:
312
+ return await runPluginSync(projectDir, dryRun, onStep);
313
+ case PLUGIN_COMMAND_ACTION.EXPORT:
314
+ return codex
315
+ ? await runPluginExportCodex(target, onStep)
316
+ : await runPluginExport(target, onStep);
317
+ case PLUGIN_COMMAND_ACTION.IMPORT:
318
+ return await runPluginImport(target, dryRun, onStep, { force });
319
+ case PLUGIN_COMMAND_ACTION.EXPORT_SKILLS:
320
+ return target === "global"
321
+ ? await runPluginExportGlobalSkillsJson(dryRun, onStep)
322
+ : await runPluginExportSkillsJson(request.target ?? projectDir, dryRun, onStep);
323
+ default:
324
+ report(onStep, "err", "Error", "error", `unknown plugin action: ${action}`);
325
+ return { status: PLUGIN_COMMAND_STATUS.FAILURE };
326
+ }
327
+ }
328
+ catch (error) {
329
+ report(onStep, "fatal", "Fatal error", "error", String(error));
330
+ return { status: PLUGIN_COMMAND_STATUS.FAILURE };
331
+ }
221
332
  }
222
333
  //# sourceMappingURL=plugin.js.map
@@ -1,12 +1,12 @@
1
1
  import React from "react";
2
+ import type { PluginCommandResult } from "../commands/plugin.js";
3
+ import type { InitStep } from "../types/index.js";
2
4
  interface PluginProps {
3
- action: "add" | "remove" | "list" | "search" | "validate" | "sync" | "export" | "import" | "export-skills";
4
- target?: string;
5
+ action: string;
5
6
  dryRun: boolean;
6
- codex?: boolean;
7
- /** Bypass the skillguard gate for unscannable sources ONLY — block always refuses (D5) */
8
- force?: boolean;
7
+ steps: readonly InitStep[];
8
+ result: PluginCommandResult | null;
9
9
  }
10
- export default function Plugin({ action, target, dryRun, codex, force, }: PluginProps): React.JSX.Element;
10
+ export default function Plugin({ action, dryRun, steps, result }: PluginProps): React.JSX.Element;
11
11
  export {};
12
12
  //# sourceMappingURL=Plugin.d.ts.map
package/dist/ui/Plugin.js CHANGED
@@ -1,7 +1,6 @@
1
1
  import { Box, Text } from "ink";
2
2
  import Spinner from "ink-spinner";
3
- import React, { useEffect, useState } from "react";
4
- import { runPluginAdd, runPluginExport, runPluginExportCodex, runPluginExportGlobalSkillsJson, runPluginExportSkillsJson, runPluginImport, runPluginList, runPluginRemove, runPluginSearch, runPluginSync, runPluginValidate, } from "../commands/plugin.js";
3
+ import React from "react";
5
4
  import { theme } from "./theme.js";
6
5
  const STATUS_ICON = {
7
6
  pending: "\u25cb",
@@ -16,150 +15,7 @@ const STATUS_COLOR = {
16
15
  error: theme.error,
17
16
  skipped: theme.muted,
18
17
  };
19
- export default function Plugin({ action, target, dryRun, codex = false, force = false, }) {
20
- const [steps, setSteps] = useState([]);
21
- const [done, setDone] = useState(false);
22
- const handleTerminalSignal = (signal, signalController) => {
23
- if (signalController.signal.aborted)
24
- return;
25
- signalController.abort();
26
- if (signal === "SIGINT")
27
- process.exitCode = 130;
28
- if (signal === "SIGTERM")
29
- process.exitCode = 143;
30
- };
31
- const onStep = (step) => {
32
- setSteps((prev) => {
33
- const idx = prev.findIndex((s) => s.id === step.id);
34
- if (idx >= 0) {
35
- const next = [...prev];
36
- next[idx] = step;
37
- return next;
38
- }
39
- return [...prev, step];
40
- });
41
- };
42
- useEffect(() => {
43
- const controller = action === "search" ? new AbortController() : undefined;
44
- const sigintHandler = controller === undefined
45
- ? undefined
46
- : () => handleTerminalSignal("SIGINT", controller);
47
- const sigtermHandler = controller === undefined
48
- ? undefined
49
- : () => handleTerminalSignal("SIGTERM", controller);
50
- if (action === "search" && sigintHandler && sigtermHandler) {
51
- process.on("SIGINT", sigintHandler);
52
- process.on("SIGTERM", sigtermHandler);
53
- }
54
- const run = async () => {
55
- try {
56
- switch (action) {
57
- case "add":
58
- if (!target) {
59
- onStep({
60
- id: "err",
61
- label: "Error",
62
- status: "error",
63
- detail: "source required: javi-forge plugin add <org/repo>",
64
- });
65
- break;
66
- }
67
- await runPluginAdd(target, dryRun, onStep, { force });
68
- break;
69
- case "remove":
70
- if (!target) {
71
- onStep({
72
- id: "err",
73
- label: "Error",
74
- status: "error",
75
- detail: "name required: javi-forge plugin remove <name>",
76
- });
77
- break;
78
- }
79
- await runPluginRemove(target, dryRun, onStep);
80
- break;
81
- case "list":
82
- await runPluginList(onStep);
83
- break;
84
- case "search":
85
- await runPluginSearch(target, onStep, {
86
- signal: controller?.signal,
87
- });
88
- break;
89
- case "validate":
90
- if (!target) {
91
- onStep({
92
- id: "err",
93
- label: "Error",
94
- status: "error",
95
- detail: "path required: javi-forge plugin validate <dir>",
96
- });
97
- break;
98
- }
99
- await runPluginValidate(target, onStep);
100
- break;
101
- case "sync":
102
- await runPluginSync(process.cwd(), dryRun, onStep);
103
- break;
104
- case "export":
105
- if (!target) {
106
- onStep({
107
- id: "err",
108
- label: "Error",
109
- status: "error",
110
- detail: "name required: javi-forge plugin export <name>",
111
- });
112
- break;
113
- }
114
- if (codex) {
115
- await runPluginExportCodex(target, onStep);
116
- }
117
- else {
118
- await runPluginExport(target, onStep);
119
- }
120
- break;
121
- case "import":
122
- if (!target) {
123
- onStep({
124
- id: "err",
125
- label: "Error",
126
- status: "error",
127
- detail: "path required: javi-forge plugin import <dir>",
128
- });
129
- break;
130
- }
131
- await runPluginImport(target, dryRun, onStep, { force });
132
- break;
133
- case "export-skills":
134
- if (target === "global") {
135
- await runPluginExportGlobalSkillsJson(dryRun, onStep);
136
- }
137
- else {
138
- await runPluginExportSkillsJson(target ?? process.cwd(), dryRun, onStep);
139
- }
140
- break;
141
- }
142
- }
143
- catch (e) {
144
- onStep({
145
- id: "fatal",
146
- label: "Fatal error",
147
- status: "error",
148
- detail: String(e),
149
- });
150
- }
151
- setDone(true);
152
- };
153
- run();
154
- return () => {
155
- if (sigintHandler) {
156
- process.removeListener("SIGINT", sigintHandler);
157
- }
158
- if (sigtermHandler) {
159
- process.removeListener("SIGTERM", sigtermHandler);
160
- }
161
- };
162
- }, [action, target, dryRun, force]);
18
+ export default function Plugin({ action, dryRun, steps, result }) {
163
19
  return (React.createElement(Box, { flexDirection: "column", padding: 1 },
164
20
  React.createElement(Box, { marginBottom: 1 },
165
21
  React.createElement(Text, { bold: true, color: theme.primary }, "javi-forge"),
@@ -180,7 +36,11 @@ export default function Plugin({ action, target, dryRun, codex = false, force =
180
36
  step.detail ? (React.createElement(Text, { color: theme.muted, dimColor: true },
181
37
  " ",
182
38
  step.detail)) : null))))),
183
- done && (React.createElement(Box, { marginTop: 1 },
184
- React.createElement(Text, { color: theme.muted }, "Done.")))));
39
+ result && (React.createElement(Box, { marginTop: 1 },
40
+ React.createElement(Text, { color: result.status === "success" ? theme.success : theme.error }, result.status === "success"
41
+ ? "Done."
42
+ : result.status === "refused"
43
+ ? "Refused."
44
+ : "Failed.")))));
185
45
  }
186
46
  //# sourceMappingURL=Plugin.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "javi-forge",
3
- "version": "1.39.0",
3
+ "version": "1.39.2",
4
4
  "description": "Project scaffolding and AI-ready CI bootstrap",
5
5
  "type": "module",
6
6
  "bin": {