javi-forge 1.38.3 → 1.38.5

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.
@@ -19,7 +19,7 @@ export declare const CI_HELP_TEXT = "\n Usage\n $ javi-forge ci [subcommand]
19
19
  * Per-command help for `hooks`, shown by `javi-forge hooks --help` (or when
20
20
  * `hooks` is given an unknown subcommand). Whitespace is significant.
21
21
  */
22
- export declare const HOOKS_HELP_TEXT = "\n Usage\n $ javi-forge hooks run <pre-commit|pre-push>\n $ javi-forge hooks <install|doctor|repair> claude [--force]\n\n Run the sections enabled under hooks: in .javi-forge/ci.yaml, in a fixed\n cheap\u2192expensive order, fail-fast. With no hooks: config the default is the\n quick native CI gate (setup + lint + compile + gates \u2014 no tests, no coverage).\n\n Subcommands\n run pre-commit Run the composed pre-commit sections\n run pre-push Run the composed pre-push sections\n install claude Install the managed Claude PreToolUse guard (.claude/)\n doctor claude Report Claude PreToolUse guard health (informational)\n repair claude Repair the managed guard; --force overwrites edited assets\n\n Notes\n A blocking section failure exits non-zero and blocks the commit/push.\n A broken .javi-forge/ci.yaml exits 1 (fail-closed \u2014 never skips a gate).\n To skip: git commit --no-verify (pre-push: git push --no-verify)\n doctor claude is informational (always exits 0); install/repair exit 0 on\n success, non-zero on refusal/failure. Use repair claude --force to overwrite\n a locally edited managed asset.\n Linux: install/repair claude need the acl package (getfacl) to prove the\n parent chain \u2014 apt install acl / apk add acl / dnf install acl. Without it\n they refuse fail-closed; an already-installed guard keeps firing, and\n doctor claude reports the acl capability as its own row.\n Claude Code spawns the guard with node from ITS path, so node must resolve\n there, not only inside javi-forge.\n\n Examples\n $ javi-forge hooks run pre-commit\n $ javi-forge hooks run pre-push\n $ javi-forge hooks install claude\n $ javi-forge hooks doctor claude\n $ javi-forge hooks repair claude --force\n";
22
+ export declare const HOOKS_HELP_TEXT = "\n Usage\n $ javi-forge hooks run <pre-commit|pre-push>\n $ javi-forge hooks <install|doctor|repair> <claude|codex> [--force]\n\n Run the sections enabled under hooks: in .javi-forge/ci.yaml, in a fixed\n cheap\u2192expensive order, fail-fast. With no hooks: config the default is the\n quick native CI gate (setup + lint + compile + gates \u2014 no tests, no coverage).\n\n Subcommands\n run pre-commit Run the composed pre-commit sections\n run pre-push Run the composed pre-push sections\n install claude Install the managed Claude PreToolUse guard (.claude/)\n doctor claude Report Claude PreToolUse guard health (informational)\n repair claude Repair the managed guard; --force overwrites edited assets\n install codex Install the managed Codex PreToolUse guard (~/.codex/)\n doctor codex Report Codex hook execution readiness and trust boundary\n repair codex Repair the managed Codex guard; --force overwrites edits\n\n Notes\n A blocking section failure exits non-zero and blocks the commit/push.\n A broken .javi-forge/ci.yaml exits 1 (fail-closed \u2014 never skips a gate).\n To skip: git commit --no-verify (pre-push: git push --no-verify)\n doctor claude is informational (always exits 0); install/repair exit 0 on\n success, non-zero on refusal/failure. Use repair claude --force to overwrite\n a locally edited managed asset.\n doctor codex exits 0 when runnable, 1 when blocked, and 2 when inconclusive;\n it does not prove provider trust or runtime execution. Use repair codex\n --force only to overwrite an edited managed asset.\n Linux: install/repair claude need the acl package (getfacl) to prove the\n parent chain \u2014 apt install acl / apk add acl / dnf install acl. Without it\n they refuse fail-closed; an already-installed guard keeps firing, and\n doctor claude reports the acl capability as its own row.\n Claude Code spawns the guard with node from ITS path, so node must resolve\n there, not only inside javi-forge.\n\n Examples\n $ javi-forge hooks run pre-commit\n $ javi-forge hooks run pre-push\n $ javi-forge hooks install claude\n $ javi-forge hooks doctor claude\n $ javi-forge hooks repair claude --force\n $ javi-forge hooks install codex\n $ javi-forge hooks doctor codex\n $ javi-forge hooks repair codex --force\n";
23
23
  export declare const FLAGS_SCHEMA: {
24
24
  readonly help: {
25
25
  readonly type: "boolean";
package/dist/cli/help.js CHANGED
@@ -166,7 +166,7 @@ export const CI_HELP_TEXT = `
166
166
  export const HOOKS_HELP_TEXT = `
167
167
  Usage
168
168
  $ javi-forge hooks run <pre-commit|pre-push>
169
- $ javi-forge hooks <install|doctor|repair> claude [--force]
169
+ $ javi-forge hooks <install|doctor|repair> <claude|codex> [--force]
170
170
 
171
171
  Run the sections enabled under hooks: in .javi-forge/ci.yaml, in a fixed
172
172
  cheap→expensive order, fail-fast. With no hooks: config the default is the
@@ -178,6 +178,9 @@ export const HOOKS_HELP_TEXT = `
178
178
  install claude Install the managed Claude PreToolUse guard (.claude/)
179
179
  doctor claude Report Claude PreToolUse guard health (informational)
180
180
  repair claude Repair the managed guard; --force overwrites edited assets
181
+ install codex Install the managed Codex PreToolUse guard (~/.codex/)
182
+ doctor codex Report Codex hook execution readiness and trust boundary
183
+ repair codex Repair the managed Codex guard; --force overwrites edits
181
184
 
182
185
  Notes
183
186
  A blocking section failure exits non-zero and blocks the commit/push.
@@ -186,6 +189,9 @@ export const HOOKS_HELP_TEXT = `
186
189
  doctor claude is informational (always exits 0); install/repair exit 0 on
187
190
  success, non-zero on refusal/failure. Use repair claude --force to overwrite
188
191
  a locally edited managed asset.
192
+ doctor codex exits 0 when runnable, 1 when blocked, and 2 when inconclusive;
193
+ it does not prove provider trust or runtime execution. Use repair codex
194
+ --force only to overwrite an edited managed asset.
189
195
  Linux: install/repair claude need the acl package (getfacl) to prove the
190
196
  parent chain — apt install acl / apk add acl / dnf install acl. Without it
191
197
  they refuse fail-closed; an already-installed guard keeps firing, and
@@ -199,6 +205,9 @@ export const HOOKS_HELP_TEXT = `
199
205
  $ javi-forge hooks install claude
200
206
  $ javi-forge hooks doctor claude
201
207
  $ javi-forge hooks repair claude --force
208
+ $ javi-forge hooks install codex
209
+ $ javi-forge hooks doctor codex
210
+ $ javi-forge hooks repair codex --force
202
211
  `;
203
212
  export const FLAGS_SCHEMA = {
204
213
  // `--help` is handled manually (autoHelp is disabled at the entrypoint so
@@ -62,16 +62,20 @@ export async function runPluginSearch(query, onStep) {
62
62
  const stepId = "plugin-search";
63
63
  report(onStep, stepId, `Search plugins${query ? `: ${query}` : ""}`, "running");
64
64
  const results = await searchRegistry(query);
65
- if (results.length === 0) {
66
- report(onStep, stepId, `Search plugins${query ? `: ${query}` : ""}`, "done", query
67
- ? `no plugins matching "${query}"`
68
- : "registry empty or unreachable");
65
+ if (results.status === "cancelled") {
66
+ report(onStep, stepId, `Search plugins${query ? `: ${query}` : ""}`, "error", "registry search cancelled");
67
+ }
68
+ else if (results.status === "unavailable") {
69
+ report(onStep, stepId, `Search plugins${query ? `: ${query}` : ""}`, "error", "registry unavailable");
70
+ }
71
+ else if (results.entries.length === 0) {
72
+ report(onStep, stepId, `Search plugins${query ? `: ${query}` : ""}`, "done", query ? `no plugins matching "${query}"` : "registry empty");
69
73
  }
70
74
  else {
71
- const summary = results
75
+ const summary = results.entries
72
76
  .map((p) => `${p.id} — ${p.description}`)
73
77
  .join("\n ");
74
- report(onStep, stepId, `Search plugins${query ? `: ${query}` : ""}`, "done", `${results.length} results:\n ${summary}`);
78
+ report(onStep, stepId, `Search plugins${query ? `: ${query}` : ""}`, "done", `${results.entries.length} results:\n ${summary}`);
75
79
  }
76
80
  }
77
81
  /**
@@ -254,9 +254,10 @@ function buildCodexHooksContainer(assetPath) {
254
254
  };
255
255
  }
256
256
  /**
257
- * Merge our managed group into an existing container: drop any prior managed
258
- * groups (ours, by command regex) and append a fresh one, preserving every
259
- * foreign group. A fresh install (no container) yields the clean container.
257
+ * Merge our managed group into an existing container: remove only prior managed
258
+ * handlers (ours, by command regex), preserve foreign handlers in their groups,
259
+ * and append one fresh canonical managed group. A fresh install (no container)
260
+ * yields the clean container.
260
261
  */
261
262
  function mergeCodexHooks(existing, assetPath) {
262
263
  if (!isPlainObject(existing))
@@ -266,14 +267,24 @@ function mergeCodexHooks(existing, assetPath) {
266
267
  container.hooks = {};
267
268
  const hooks = container.hooks;
268
269
  const groups = Array.isArray(hooks.PreToolUse) ? hooks.PreToolUse : [];
269
- const kept = groups.filter((group) => {
270
- const list = isPlainObject(group) && Array.isArray(group.hooks) ? group.hooks : [];
271
- const isOurs = list.some((h) => isPlainObject(h) &&
270
+ const kept = [];
271
+ for (const group of groups) {
272
+ if (!isPlainObject(group) || !Array.isArray(group.hooks)) {
273
+ kept.push(group);
274
+ continue;
275
+ }
276
+ const foreign = group.hooks.filter((h) => !(isPlainObject(h) &&
272
277
  h.type === "command" &&
273
278
  typeof h.command === "string" &&
274
- CODEX_CMD_RE.test(h.command));
275
- return !isOurs;
276
- });
279
+ CODEX_CMD_RE.test(h.command)));
280
+ if (foreign.length === 0)
281
+ continue;
282
+ if (foreign.length === group.hooks.length) {
283
+ kept.push(group);
284
+ continue;
285
+ }
286
+ kept.push({ ...group, hooks: foreign });
287
+ }
277
288
  const fresh = buildCodexHooksContainer(assetPath).hooks;
278
289
  hooks.PreToolUse = [...kept, ...fresh.PreToolUse];
279
290
  return container;
@@ -372,7 +383,9 @@ export async function doctorCodexPreToolUse(homeDir, options = {}) {
372
383
  ? "inconclusive"
373
384
  : "runnable";
374
385
  const remediation = [];
375
- if (hooksJson.state === "absent" || asset.state !== "managed-current") {
386
+ if (hooksJson.state === "absent" ||
387
+ hooksJson.state === "released-outdated" ||
388
+ asset.state !== "managed-current") {
376
389
  remediation.push("install the codex guard with: javi-forge hooks install codex");
377
390
  }
378
391
  if (!trusted)
@@ -1,4 +1,12 @@
1
1
  import type { InstalledPlugin, PluginRegistryEntry, PluginSyncResult, PluginValidationResult } from "../types/index.js";
2
+ export type RegistrySearchResult = {
3
+ status: "success";
4
+ entries: PluginRegistryEntry[];
5
+ } | {
6
+ status: "unavailable";
7
+ } | {
8
+ status: "cancelled";
9
+ };
2
10
  /**
3
11
  * Validate a plugin directory structure and manifest.
4
12
  */
@@ -38,8 +46,14 @@ export declare function removePlugin(name: string, options?: {
38
46
  export declare function listInstalledPlugins(): Promise<InstalledPlugin[]>;
39
47
  /**
40
48
  * Fetch the remote plugin registry and optionally filter by query.
49
+ *
50
+ * A registry that cannot be read or validated is deliberately distinct from a
51
+ * valid registry with no matches. Callers need that distinction to avoid
52
+ * presenting a network failure as a successful empty search.
41
53
  */
42
- export declare function searchRegistry(query?: string): Promise<PluginRegistryEntry[]>;
54
+ export declare function searchRegistry(query?: string, options?: {
55
+ signal?: AbortSignal;
56
+ }): Promise<RegistrySearchResult>;
43
57
  /**
44
58
  * Detect installed plugins in a project's .javi-forge/plugins/ directory.
45
59
  * Returns an array of plugin names (sorted alphabetically).
@@ -8,6 +8,7 @@ import { evaluateCoverageGate, scanFailureMessage, } from "./skill-install-gate.
8
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
+ const REGISTRY_REQUEST_TIMEOUT_MS = 10_000;
11
12
  // ── Validation ──────────────────────────────────────────────────────────────
12
13
  /**
13
14
  * Validate a plugin directory structure and manifest.
@@ -251,26 +252,90 @@ export async function listInstalledPlugins() {
251
252
  }
252
253
  /**
253
254
  * Fetch the remote plugin registry and optionally filter by query.
255
+ *
256
+ * A registry that cannot be read or validated is deliberately distinct from a
257
+ * valid registry with no matches. Callers need that distinction to avoid
258
+ * presenting a network failure as a successful empty search.
254
259
  */
255
- export async function searchRegistry(query) {
260
+ export async function searchRegistry(query, options = {}) {
261
+ const { signal: callerSignal } = options;
262
+ if (callerSignal?.aborted)
263
+ return { status: "cancelled" };
264
+ const controller = new AbortController();
265
+ let timedOut = false;
266
+ let callerCancelled = false;
267
+ let resolveDeadline;
268
+ const deadline = new Promise((resolve) => {
269
+ resolveDeadline = resolve;
270
+ });
271
+ const timeout = setTimeout(() => {
272
+ timedOut = true;
273
+ controller.abort();
274
+ resolveDeadline();
275
+ }, REGISTRY_REQUEST_TIMEOUT_MS);
276
+ const cancelFromCaller = () => {
277
+ callerCancelled = true;
278
+ controller.abort();
279
+ resolveDeadline();
280
+ };
281
+ callerSignal?.addEventListener("abort", cancelFromCaller, { once: true });
256
282
  try {
257
- const response = await fetch(PLUGIN_REGISTRY_URL);
258
- if (!response.ok) {
259
- return [];
260
- }
261
- const registry = (await response.json());
262
- let plugins = registry.plugins ?? [];
263
- if (query) {
264
- const q = query.toLowerCase();
265
- plugins = plugins.filter((p) => p.id.toLowerCase().includes(q) ||
266
- p.description.toLowerCase().includes(q) ||
267
- p.tags.some((t) => t.toLowerCase().includes(q)));
268
- }
269
- return plugins;
283
+ const response = await Promise.race([
284
+ Promise.resolve().then(() => fetch(PLUGIN_REGISTRY_URL, { signal: controller.signal })),
285
+ deadline,
286
+ ]);
287
+ if (callerCancelled || callerSignal?.aborted)
288
+ return { status: "cancelled" };
289
+ if (timedOut || !response || !response.ok)
290
+ return { status: "unavailable" };
291
+ const body = await Promise.race([response.json(), deadline]);
292
+ if (callerCancelled || callerSignal?.aborted)
293
+ return { status: "cancelled" };
294
+ if (timedOut || !isPluginRegistry(body))
295
+ return { status: "unavailable" };
296
+ const entries = query
297
+ ? filterRegistryEntries(body.plugins, query)
298
+ : body.plugins;
299
+ return { status: "success", entries };
270
300
  }
271
301
  catch {
272
- return [];
302
+ return callerCancelled || callerSignal?.aborted
303
+ ? { status: "cancelled" }
304
+ : { status: "unavailable" };
273
305
  }
306
+ finally {
307
+ clearTimeout(timeout);
308
+ callerSignal?.removeEventListener("abort", cancelFromCaller);
309
+ }
310
+ }
311
+ function filterRegistryEntries(plugins, query) {
312
+ const normalizedQuery = query.toLowerCase();
313
+ return plugins.filter((plugin) => plugin.id.toLowerCase().includes(normalizedQuery) ||
314
+ plugin.description.toLowerCase().includes(normalizedQuery) ||
315
+ plugin.tags.some((tag) => tag.toLowerCase().includes(normalizedQuery)));
316
+ }
317
+ function isPluginRegistry(value) {
318
+ if (!isRecord(value))
319
+ return false;
320
+ return (typeof value.version === "string" &&
321
+ typeof value.updatedAt === "string" &&
322
+ Array.isArray(value.plugins) &&
323
+ value.plugins.every(isPluginRegistryEntry));
324
+ }
325
+ function isPluginRegistryEntry(value) {
326
+ if (!isRecord(value))
327
+ return false;
328
+ return (typeof value.id === "string" &&
329
+ typeof value.repository === "string" &&
330
+ typeof value.description === "string" &&
331
+ Array.isArray(value.tags) &&
332
+ value.tags.every((tag) => typeof tag === "string") &&
333
+ (value.stars === undefined ||
334
+ (typeof value.stars === "number" && Number.isFinite(value.stars))) &&
335
+ (value.updatedAt === undefined || typeof value.updatedAt === "string"));
336
+ }
337
+ function isRecord(value) {
338
+ return typeof value === "object" && value !== null;
274
339
  }
275
340
  // ── Sync ───────────────────────────────────────────────────────────────
276
341
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "javi-forge",
3
- "version": "1.38.3",
3
+ "version": "1.38.5",
4
4
  "description": "Project scaffolding and AI-ready CI bootstrap",
5
5
  "type": "module",
6
6
  "bin": {