@fabricorg/cli 0.3.2 → 0.3.4

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.2",
60
+ version: "0.3.4",
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;
@@ -2785,7 +2863,7 @@ function buildInstructionsCommand() {
2785
2863
  "Write a SessionStart hook for a coding tool and take the first copy"
2786
2864
  ).requiredOption("--project <id>", "Project ID").requiredOption(
2787
2865
  "--tool <tool>",
2788
- "Coding tool to configure: claude-code"
2866
+ "Coding tool to configure: claude-code|codex"
2789
2867
  ).option("--dest <dir>", "Destination directory (default: cwd)").option("--org <slug>", "Organization context").option(
2790
2868
  "--apply",
2791
2869
  "Let the hook apply changes instead of only reporting them"
@@ -2885,7 +2963,7 @@ function destinationOf(opts) {
2885
2963
  var _a2;
2886
2964
  return path4.resolve((_a2 = opts.dest) != null ? _a2 : process.cwd());
2887
2965
  }
2888
- function instructionsClient(opts, timeoutMs) {
2966
+ function instructionsClient(opts, timeoutMs, { neverRetry = false } = {}) {
2889
2967
  if (!getApiKey()) {
2890
2968
  throw new CliFailure(
2891
2969
  "Not authenticated. Run: fabric auth login --key <api-key>",
@@ -2900,9 +2978,16 @@ function instructionsClient(opts, timeoutMs) {
2900
2978
  // one version stale.
2901
2979
  timeoutMs: Math.min(timeoutMs, HOOK_DEADLINE_MS),
2902
2980
  retry: { maxRetries: 0 }
2903
- } : { timeoutMs }
2981
+ } : neverRetry ? { timeoutMs, retry: { maxRetries: 0 } } : { timeoutMs }
2904
2982
  ).withoutContext();
2905
2983
  }
2984
+ function downloadUrlClient(opts) {
2985
+ return instructionsClient(
2986
+ opts,
2987
+ opts.hook ? HOOK_DEADLINE_MS : BUNDLE_TIMEOUT_MS,
2988
+ { neverRetry: true }
2989
+ );
2990
+ }
2906
2991
  function orgSlugFor(opts) {
2907
2992
  return opts.org;
2908
2993
  }
@@ -3113,10 +3198,9 @@ function syncOnce(opts) {
3113
3198
  const org = orgSlugFor(opts);
3114
3199
  let download;
3115
3200
  try {
3116
- download = yield client.instructions.createDownloadUrl(
3117
- opts.project,
3118
- { org }
3119
- );
3201
+ download = yield downloadUrlClient(
3202
+ opts
3203
+ ).instructions.createDownloadUrl(opts.project, { org });
3120
3204
  } catch (error) {
3121
3205
  throw asCliFailure(error);
3122
3206
  }
@@ -3361,12 +3445,13 @@ function reportPush(outcome) {
3361
3445
  }
3362
3446
  function runInit(opts, format) {
3363
3447
  return __async(this, null, function* () {
3364
- if (opts.tool !== "claude-code") {
3448
+ if (!isInstructionsHookTool(opts.tool)) {
3365
3449
  throw new CliFailure(
3366
- `Unsupported tool "${opts.tool}". Only --tool claude-code is supported today; other tools follow.`,
3450
+ `Unsupported tool "${opts.tool}". Use --tool claude-code or --tool codex.`,
3367
3451
  2
3368
3452
  );
3369
3453
  }
3454
+ const tool = opts.tool;
3370
3455
  const root = yield resolveDestinationRoot(destinationOf(opts));
3371
3456
  yield readLockForProject(root, opts.project);
3372
3457
  try {
@@ -3397,7 +3482,8 @@ function runInit(opts, format) {
3397
3482
  const merged = yield mergeSessionStartHook({
3398
3483
  root,
3399
3484
  projectId: opts.project,
3400
- command
3485
+ command,
3486
+ tool
3401
3487
  });
3402
3488
  if (format === "json") {
3403
3489
  printOutput(
@@ -3421,6 +3507,11 @@ function runInit(opts, format) {
3421
3507
  line(
3422
3508
  opts.apply ? " It applies changes at session start." : " It only reports changes; add --apply to init to have it apply them."
3423
3509
  );
3510
+ if (tool === "codex") {
3511
+ line(
3512
+ " Start Codex in this checkout, then use `/hooks` to review and trust the project hook."
3513
+ );
3514
+ }
3424
3515
  if (outcome === null) {
3425
3516
  line(
3426
3517
  "This project has nothing published yet, so there is nothing to copy \u2014 the hook will pick it up once there is."
@@ -3431,10 +3522,16 @@ function runInit(opts, format) {
3431
3522
  }
3432
3523
  line("");
3433
3524
  line(
3434
- `${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.`
3525
+ `${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.`
3435
3526
  );
3436
3527
  });
3437
3528
  }
3529
+ function isInstructionsHookTool(tool) {
3530
+ return tool === "claude-code" || tool === "codex";
3531
+ }
3532
+ function hookPathFor(tool) {
3533
+ return tool === "codex" ? CODEX_HOOKS_RELATIVE_PATH : CLAUDE_SETTINGS_RELATIVE_PATH;
3534
+ }
3438
3535
 
3439
3536
  // src/commands/mcp/index.ts
3440
3537
  import { Command as Command12 } from "commander";