@fabricorg/cli 0.3.3 → 0.3.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.
package/dist/fabric.js CHANGED
@@ -57,7 +57,7 @@ import { Command as Command20 } from "commander";
57
57
  // package.json
58
58
  var package_default = {
59
59
  name: "@fabricorg/cli",
60
- version: "0.3.3",
60
+ version: "0.3.5",
61
61
  description: "CLI for the Fabric AI platform",
62
62
  keywords: [
63
63
  "fabric",
@@ -1371,7 +1371,10 @@ var DRIVE_LETTER = /^[A-Za-z]:/;
1371
1371
  var RESERVED_DEVICE_NAME = /^(?:CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(?:\.|$)/i;
1372
1372
  var FORBIDDEN_NAME_CHARACTER = /[<>:"|?*]/;
1373
1373
  var RESERVED_ROOTS = [".git", ".fabric"];
1374
- var RESERVED_EXACT_PATHS = [".claude/settings.local.json"];
1374
+ var RESERVED_EXACT_PATHS = [
1375
+ ".claude/settings.local.json",
1376
+ ".codex/hooks.json"
1377
+ ];
1375
1378
  function hasControlCharacter(input) {
1376
1379
  for (let index = 0; index < input.length; index++) {
1377
1380
  const code = input.charCodeAt(index);
@@ -1492,6 +1495,7 @@ function describeRejection(rejection) {
1492
1495
 
1493
1496
  // src/lib/instructions/safe-write.ts
1494
1497
  import { createHash, randomBytes } from "crypto";
1498
+ import { constants } from "fs";
1495
1499
  import {
1496
1500
  lstat,
1497
1501
  mkdir,
@@ -1587,24 +1591,79 @@ function assertWritableTarget(root, relativePath) {
1587
1591
  return check;
1588
1592
  });
1589
1593
  }
1590
- function readFileSafely(root, relativePath) {
1591
- return __async(this, null, function* () {
1594
+ function readFileSafely(_0, _1) {
1595
+ return __async(this, arguments, function* (root, relativePath, options = {}) {
1592
1596
  const check = yield assertWritableTarget(root, relativePath);
1593
1597
  if (!check.ok) {
1594
1598
  throw new Error(`Refusing to sync: ${describeRejection(check)}.`);
1595
1599
  }
1596
- const stats = yield lstatOrNull(check.path);
1597
- if (stats === null) {
1598
- return null;
1599
- }
1600
+ let handle;
1600
1601
  try {
1601
- return { bytes: yield readFile(check.path), mode: stats.mode };
1602
+ handle = yield open(
1603
+ check.path,
1604
+ constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK
1605
+ );
1602
1606
  } catch (error) {
1603
1607
  if (error.code === "ENOENT") {
1604
1608
  return null;
1605
1609
  }
1606
1610
  throw error;
1607
1611
  }
1612
+ try {
1613
+ const opened = yield handle.stat();
1614
+ if (!opened.isFile()) {
1615
+ throw new Error(
1616
+ `Refusing to sync: ${relativePath} is not a regular file.`
1617
+ );
1618
+ }
1619
+ const afterOpen = yield assertWritableTarget(root, relativePath);
1620
+ if (!afterOpen.ok) {
1621
+ throw new Error(
1622
+ `Refusing to sync: ${describeRejection(afterOpen)}.`
1623
+ );
1624
+ }
1625
+ const current = yield lstatOrNull(afterOpen.path);
1626
+ if (current === null || !current.isFile() || current.dev !== opened.dev || current.ino !== opened.ino) {
1627
+ throw new Error(
1628
+ `Refusing to sync: ${relativePath} changed while it was being opened.`
1629
+ );
1630
+ }
1631
+ if (options.maxBytes === void 0) {
1632
+ return { bytes: yield handle.readFile(), mode: opened.mode };
1633
+ }
1634
+ if (!Number.isSafeInteger(options.maxBytes) || options.maxBytes < 0) {
1635
+ throw new Error(
1636
+ "A safe file-read limit must be a non-negative integer."
1637
+ );
1638
+ }
1639
+ if (opened.size > options.maxBytes) {
1640
+ throw new Error(
1641
+ `${relativePath} is too large to read safely (maximum ${options.maxBytes} bytes).`
1642
+ );
1643
+ }
1644
+ const buffer = Buffer.allocUnsafe(options.maxBytes + 1);
1645
+ let total = 0;
1646
+ while (total <= options.maxBytes) {
1647
+ const { bytesRead } = yield handle.read(
1648
+ buffer,
1649
+ total,
1650
+ options.maxBytes + 1 - total,
1651
+ total
1652
+ );
1653
+ if (bytesRead === 0) {
1654
+ break;
1655
+ }
1656
+ total += bytesRead;
1657
+ }
1658
+ if (total > options.maxBytes) {
1659
+ throw new Error(
1660
+ `${relativePath} is too large to read safely (maximum ${options.maxBytes} bytes).`
1661
+ );
1662
+ }
1663
+ return { bytes: buffer.subarray(0, total), mode: opened.mode };
1664
+ } finally {
1665
+ yield handle.close();
1666
+ }
1608
1667
  });
1609
1668
  }
1610
1669
  function resolveExistingRoot(destination) {
@@ -2022,14 +2081,16 @@ function extractBundle(archive, wanted) {
2022
2081
  }
2023
2082
 
2024
2083
  // src/lib/instructions/hook.ts
2025
- import { readFile as readFile2 } from "fs/promises";
2026
2084
  import path2 from "path";
2027
2085
  var CLAUDE_SETTINGS_RELATIVE_PATH = path2.join(
2028
2086
  ".claude",
2029
2087
  "settings.local.json"
2030
2088
  );
2089
+ var CODEX_HOOKS_RELATIVE_PATH = path2.join(".codex", "hooks.json");
2031
2090
  var CLAUDE_SETTINGS_POSIX_PATH = ".claude/settings.local.json";
2091
+ var CODEX_HOOKS_POSIX_PATH = ".codex/hooks.json";
2032
2092
  var HOOK_TIMEOUT_SECONDS = 15;
2093
+ var MAX_HOOK_CONFIG_BYTES = 1024 * 1024;
2033
2094
  var HOOK_SUBCOMMANDS = /* @__PURE__ */ new Set(["check", "sync"]);
2034
2095
  function buildHookCommand(projectId, apply, org) {
2035
2096
  const verb = apply ? "sync" : "check";
@@ -2047,17 +2108,25 @@ function assertKeyStaysOutside(destination, configPath) {
2047
2108
  }
2048
2109
  function mergeSessionStartHook(input) {
2049
2110
  return __async(this, null, function* () {
2050
- const settingsPath = path2.join(input.root, CLAUDE_SETTINGS_RELATIVE_PATH);
2051
- let existingRaw = null;
2111
+ var _a2;
2112
+ const hookTarget = hookTargetFor((_a2 = input.tool) != null ? _a2 : "claude-code");
2113
+ const settingsPath = path2.join(input.root, hookTarget.relativePath);
2114
+ let existingRaw;
2052
2115
  try {
2053
- existingRaw = yield readFile2(settingsPath, "utf8");
2116
+ const existing = yield readFileSafely(
2117
+ input.root,
2118
+ hookTarget.posixPath,
2119
+ { maxBytes: MAX_HOOK_CONFIG_BYTES }
2120
+ );
2121
+ existingRaw = existing === null ? null : new TextDecoder().decode(existing.bytes);
2054
2122
  } catch (error) {
2055
- if (error.code !== "ENOENT") {
2056
- throw error;
2057
- }
2123
+ const message = error instanceof Error ? error.message : String(error);
2124
+ throw new Error(
2125
+ `${settingsPath} could not be read safely: ${message}. It was left untouched.`
2126
+ );
2058
2127
  }
2059
2128
  let settings = {};
2060
- if (existingRaw !== null && existingRaw.trim().length > 0) {
2129
+ if (existingRaw !== null) {
2061
2130
  let parsed;
2062
2131
  try {
2063
2132
  parsed = JSON.parse(existingRaw);
@@ -2124,7 +2193,7 @@ function mergeSessionStartHook(input) {
2124
2193
  });
2125
2194
  yield writeFileSafely({
2126
2195
  root: input.root,
2127
- relativePath: CLAUDE_SETTINGS_POSIX_PATH,
2196
+ relativePath: hookTarget.posixPath,
2128
2197
  bytes: new TextEncoder().encode(
2129
2198
  `${JSON.stringify(next, null, 2).replace(/\r\n/g, "\n")}
2130
2199
  `
@@ -2138,6 +2207,15 @@ function mergeSessionStartHook(input) {
2138
2207
  };
2139
2208
  });
2140
2209
  }
2210
+ function hookTargetFor(tool) {
2211
+ return tool === "codex" ? {
2212
+ relativePath: CODEX_HOOKS_RELATIVE_PATH,
2213
+ posixPath: CODEX_HOOKS_POSIX_PATH
2214
+ } : {
2215
+ relativePath: CLAUDE_SETTINGS_RELATIVE_PATH,
2216
+ posixPath: CLAUDE_SETTINGS_POSIX_PATH
2217
+ };
2218
+ }
2141
2219
  function isFabricHookFor(hook, projectId) {
2142
2220
  if (!isRecord(hook) || typeof hook.command !== "string") {
2143
2221
  return false;
@@ -2179,7 +2257,7 @@ function isRecord(value) {
2179
2257
  }
2180
2258
 
2181
2259
  // src/lib/instructions/lock.ts
2182
- import { readFile as readFile3 } from "fs/promises";
2260
+ import { readFile as readFile2 } from "fs/promises";
2183
2261
  import path3 from "path";
2184
2262
  var LOCK_DIRECTORY = ".fabric";
2185
2263
  var LOCK_FILENAME = "instructions.lock";
@@ -2194,7 +2272,7 @@ function readLock(destination) {
2194
2272
  const file = lockPath(destination);
2195
2273
  let raw;
2196
2274
  try {
2197
- raw = yield readFile3(file, "utf8");
2275
+ raw = yield readFile2(file, "utf8");
2198
2276
  } catch (error) {
2199
2277
  if (error.code === "ENOENT") {
2200
2278
  return null;
@@ -2766,12 +2844,15 @@ function buildInstructionsCommand() {
2766
2844
  });
2767
2845
  });
2768
2846
  instructions.command("push").description(
2769
- "Suggest this checkout's edits to the project's coding instructions"
2847
+ "Propose this checkout's edits to the project's coding instructions, or publish them with --publish"
2770
2848
  ).requiredOption("--project <id>", "Project ID").option("--dest <dir>", "Destination directory (default: cwd)").option("--org <slug>", "Organization context").option(
2771
2849
  "--add <path>",
2772
2850
  "Also send a file the last sync did not write (repeatable)",
2773
2851
  collectAdded,
2774
2852
  []
2853
+ ).option(
2854
+ "--publish",
2855
+ "Publish the change as a new version instead of proposing it (needs a key with instructions:publish)"
2775
2856
  ).option("--dry-run", "Print the change set and send nothing").option("--format <format>", "Output format: text|json").action(function(opts) {
2776
2857
  return __async(this, null, function* () {
2777
2858
  yield run(
@@ -2785,7 +2866,7 @@ function buildInstructionsCommand() {
2785
2866
  "Write a SessionStart hook for a coding tool and take the first copy"
2786
2867
  ).requiredOption("--project <id>", "Project ID").requiredOption(
2787
2868
  "--tool <tool>",
2788
- "Coding tool to configure: claude-code"
2869
+ "Coding tool to configure: claude-code|codex"
2789
2870
  ).option("--dest <dir>", "Destination directory (default: cwd)").option("--org <slug>", "Organization context").option(
2790
2871
  "--apply",
2791
2872
  "Let the hook apply changes instead of only reporting them"
@@ -3269,6 +3350,7 @@ function runPush(opts, format) {
3269
3350
  projectId: opts.project,
3270
3351
  destination: root,
3271
3352
  dryRun: Boolean(opts.dryRun),
3353
+ publish: Boolean(opts.publish),
3272
3354
  baseSnapshotId: lock.snapshotId,
3273
3355
  baseVersion: lock.snapshotVersion,
3274
3356
  put: plan.entries.filter((entry) => entry.action === "put").map((entry) => entry.path),
@@ -3277,11 +3359,17 @@ function runPush(opts, format) {
3277
3359
  snapshotId: null,
3278
3360
  version: null,
3279
3361
  proposalStatus: null,
3280
- status: null
3362
+ status: null,
3363
+ published: null
3281
3364
  };
3282
3365
  if (!opts.dryRun) {
3283
3366
  try {
3284
- const submitted = yield client.instructions.submitChange(
3367
+ const submitted = opts.publish ? yield client.instructions.publishChange(
3368
+ opts.project,
3369
+ lock.snapshotId,
3370
+ plan.changes,
3371
+ { org: orgSlugFor(opts) }
3372
+ ) : yield client.instructions.submitChange(
3285
3373
  opts.project,
3286
3374
  lock.snapshotId,
3287
3375
  plan.changes,
@@ -3291,6 +3379,7 @@ function runPush(opts, format) {
3291
3379
  outcome.version = submitted.version;
3292
3380
  outcome.proposalStatus = submitted.proposalStatus;
3293
3381
  outcome.status = submitted.status;
3382
+ outcome.published = submitted.published;
3294
3383
  } catch (error) {
3295
3384
  throw asPushFailure(error);
3296
3385
  }
@@ -3330,6 +3419,16 @@ function asPushFailure(error) {
3330
3419
  }
3331
3420
  function pushVerdict(outcome) {
3332
3421
  var _a2;
3422
+ if (outcome.publish) {
3423
+ if (outcome.published) {
3424
+ return `Published version ${outcome.version}. Run \`fabric instructions sync\` to bring this checkout onto it.`;
3425
+ }
3426
+ if (outcome.status === "FAILED" || outcome.status === "REJECTED") {
3427
+ return `Version ${outcome.version} did not pass its checks (${outcome.status}), so nothing was published; open the project's Coding Instructions tab to see why.`;
3428
+ }
3429
+ const sent = `Sent as version ${outcome.version}. It publishes on its own once its checks pass (${outcome.status}) \u2014 then run \`fabric instructions sync\` to bring this checkout onto it.`;
3430
+ return outcome.status === "READY" ? `${sent} It has passed its checks; its publication state is shown in the project's Coding Instructions tab.` : sent;
3431
+ }
3333
3432
  if (outcome.proposalStatus === "PENDING" && outcome.status === "RECEIVING") {
3334
3433
  return `An earlier attempt is still sending this change; if version ${outcome.version} stays unfinished, cancel it in the project's Coding Instructions tab, or leave it and it is closed out automatically after six hours.`;
3335
3434
  }
@@ -3356,23 +3455,24 @@ function reportPush(outcome) {
3356
3455
  listPaths("deleted", outcome.deleted);
3357
3456
  if (outcome.dryRun) {
3358
3457
  line(
3359
- "Nothing was sent. Without --dry-run this would open a proposal for review."
3458
+ outcome.publish ? "Nothing was sent. Without --dry-run this would create a new version that publishes on its own once its checks pass, with no review." : "Nothing was sent. Without --dry-run this would open a proposal for review."
3360
3459
  );
3361
3460
  return;
3362
3461
  }
3363
3462
  line(pushVerdict(outcome));
3364
3463
  line(
3365
- "Your lock was not changed: it still names the published version, which is what `fabric instructions sync` compares against."
3464
+ outcome.publish ? `Your lock was not changed: it still names version ${outcome.baseVersion}, which is what \`fabric instructions sync\` compares against.` : "Your lock was not changed: it still names the published version, which is what `fabric instructions sync` compares against."
3366
3465
  );
3367
3466
  }
3368
3467
  function runInit(opts, format) {
3369
3468
  return __async(this, null, function* () {
3370
- if (opts.tool !== "claude-code") {
3469
+ if (!isInstructionsHookTool(opts.tool)) {
3371
3470
  throw new CliFailure(
3372
- `Unsupported tool "${opts.tool}". Only --tool claude-code is supported today; other tools follow.`,
3471
+ `Unsupported tool "${opts.tool}". Use --tool claude-code or --tool codex.`,
3373
3472
  2
3374
3473
  );
3375
3474
  }
3475
+ const tool = opts.tool;
3376
3476
  const root = yield resolveDestinationRoot(destinationOf(opts));
3377
3477
  yield readLockForProject(root, opts.project);
3378
3478
  try {
@@ -3403,7 +3503,8 @@ function runInit(opts, format) {
3403
3503
  const merged = yield mergeSessionStartHook({
3404
3504
  root,
3405
3505
  projectId: opts.project,
3406
- command
3506
+ command,
3507
+ tool
3407
3508
  });
3408
3509
  if (format === "json") {
3409
3510
  printOutput(
@@ -3427,6 +3528,11 @@ function runInit(opts, format) {
3427
3528
  line(
3428
3529
  opts.apply ? " It applies changes at session start." : " It only reports changes; add --apply to init to have it apply them."
3429
3530
  );
3531
+ if (tool === "codex") {
3532
+ line(
3533
+ " Start Codex in this checkout, then use `/hooks` to review and trust the project hook."
3534
+ );
3535
+ }
3430
3536
  if (outcome === null) {
3431
3537
  line(
3432
3538
  "This project has nothing published yet, so there is nothing to copy \u2014 the hook will pick it up once there is."
@@ -3437,10 +3543,16 @@ function runInit(opts, format) {
3437
3543
  }
3438
3544
  line("");
3439
3545
  line(
3440
- `${CLAUDE_SETTINGS_RELATIVE_PATH} and ${LOCK_DIRECTORY}/ are local to this machine. If this repository does not ignore them already, add them to your own ignore rules \u2014 this command does not edit .gitignore.`
3546
+ `${hookPathFor(tool)} and ${LOCK_DIRECTORY}/ are local to this machine. If this repository does not ignore them already, add them to your own ignore rules \u2014 this command does not edit .gitignore.`
3441
3547
  );
3442
3548
  });
3443
3549
  }
3550
+ function isInstructionsHookTool(tool) {
3551
+ return tool === "claude-code" || tool === "codex";
3552
+ }
3553
+ function hookPathFor(tool) {
3554
+ return tool === "codex" ? CODEX_HOOKS_RELATIVE_PATH : CLAUDE_SETTINGS_RELATIVE_PATH;
3555
+ }
3444
3556
 
3445
3557
  // src/commands/mcp/index.ts
3446
3558
  import { Command as Command12 } from "commander";