@fabricorg/cli 0.3.4 → 0.3.6

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
@@ -52,12 +52,12 @@ var __async = (__this, __arguments, generator) => {
52
52
  };
53
53
 
54
54
  // src/bin/fabric.ts
55
- import { Command as Command20 } from "commander";
55
+ import { Command as Command21 } from "commander";
56
56
 
57
57
  // package.json
58
58
  var package_default = {
59
59
  name: "@fabricorg/cli",
60
- version: "0.3.4",
60
+ version: "0.3.6",
61
61
  description: "CLI for the Fabric AI platform",
62
62
  keywords: [
63
63
  "fabric",
@@ -96,7 +96,8 @@ var package_default = {
96
96
  "cli-table3": "^0.6.5",
97
97
  commander: "^13.1.0",
98
98
  conf: "^13.1.0",
99
- fflate: "^0.8.3"
99
+ fflate: "^0.8.3",
100
+ ignore: "7.0.9"
100
101
  },
101
102
  devDependencies: {
102
103
  "@repo/tsconfig": "workspace:*",
@@ -1051,806 +1052,1903 @@ function buildCompletionCommand() {
1051
1052
  return completion;
1052
1053
  }
1053
1054
 
1054
- // src/commands/ctx/index.ts
1055
+ // src/commands/context/index.ts
1056
+ import { lstat as lstat3 } from "fs/promises";
1057
+ import path5 from "path";
1055
1058
  import { Command as Command8 } from "commander";
1056
- function buildCtxCommand() {
1057
- const ctx = new Command8("ctx").description(
1058
- "Manage the organization commands run in"
1059
- );
1060
- ctx.command("current").description("Show the active default context").action(() => {
1061
- const stored = getDefaultContext();
1062
- if (!stored) {
1063
- printWarning(
1064
- "No default context set. Use --org <slug> on each command, or run:\n fabric ctx use org <slug>"
1065
- );
1066
- return;
1067
- }
1068
- if (stored.type === "personal") {
1069
- printWarning(
1070
- "Context: personal \u2014 retired, and no command will accept it.\nReplace it with:\n fabric ctx use org <slug>"
1071
- );
1072
- return;
1059
+
1060
+ // src/lib/command-boundary.ts
1061
+ function outputFormatFor(command) {
1062
+ const resolved = command.optsWithGlobals();
1063
+ return resolved.format === "json" ? "json" : "text";
1064
+ }
1065
+ var CliFailure = class extends Error {
1066
+ constructor(message, exitCode) {
1067
+ super(message);
1068
+ this.exitCode = exitCode;
1069
+ this.name = "CliFailure";
1070
+ }
1071
+ };
1072
+ function withDeadline(totalMs, body) {
1073
+ return __async(this, null, function* () {
1074
+ const controller = new AbortController();
1075
+ let timer;
1076
+ const deadline = new Promise((_resolve, reject) => {
1077
+ timer = setTimeout(() => {
1078
+ controller.abort();
1079
+ reject(
1080
+ new Error(
1081
+ `gave up after ${totalMs}ms so the session is not held up`
1082
+ )
1083
+ );
1084
+ }, totalMs);
1085
+ });
1086
+ try {
1087
+ yield Promise.race([body(controller.signal), deadline]);
1088
+ } finally {
1089
+ if (timer) {
1090
+ clearTimeout(timer);
1091
+ }
1073
1092
  }
1074
- process.stdout.write(`Context: org:${stored.slug}
1075
- `);
1076
- });
1077
- const use = new Command8("use").description(
1078
- "Set the default organization for commands"
1079
- );
1080
- use.command("personal").description("Retired \u2014 context is organization-only").action(() => {
1081
- printError(
1082
- "Personal context no longer exists \u2014 every command runs inside an organization.\nSet one with:\n fabric ctx use org <slug>",
1083
- 2
1084
- );
1085
- });
1086
- use.command("org <slug>").description("Use an org context by default").action((slug) => {
1087
- saveDefaultContext({ type: "org", slug });
1088
- printSuccess(`Default context set to: org:${slug}`);
1089
1093
  });
1090
- ctx.addCommand(use);
1091
- return ctx;
1094
+ }
1095
+ function describeError(error) {
1096
+ if (error instanceof Error) {
1097
+ return error.message.replace(/\s*\n\s*/g, " ");
1098
+ }
1099
+ return String(error);
1100
+ }
1101
+ function asCliFailure(error) {
1102
+ if (error instanceof CliFailure) {
1103
+ return error;
1104
+ }
1105
+ const status = error.status;
1106
+ const message = error instanceof Error ? error.message : String(error);
1107
+ switch (status) {
1108
+ case 401:
1109
+ return new CliFailure(message, 3);
1110
+ case 403:
1111
+ return new CliFailure(message, 5);
1112
+ case 404:
1113
+ return new CliFailure(message, 4);
1114
+ case 429:
1115
+ return new CliFailure(message, 6);
1116
+ case 400:
1117
+ case 422:
1118
+ return new CliFailure(message, 7);
1119
+ default:
1120
+ return new CliFailure(message, 1);
1121
+ }
1092
1122
  }
1093
1123
 
1094
- // src/commands/features/index.ts
1095
- import { Command as Command9 } from "commander";
1096
- function buildFeaturesCommand() {
1097
- const features = new Command9("features").description(
1098
- "Manage project features"
1099
- );
1100
- features.command("list").description("List features in a project").requiredOption("-p, --project <id>", "Project ID").option("--org <slug>", "Organization context").option("--personal", "Retired \u2014 context is organization-only").option(
1101
- "--priority <priority>",
1102
- "Filter: P0_CRITICAL|P1_HIGH|P2_MEDIUM|P3_LOW"
1103
- ).option("--search <query>", "Search by title").option("--limit <n>", "Max results (default 50)", "50").option(
1104
- "--format <format>",
1105
- "Output format: table|json|yaml|csv",
1106
- "table"
1107
- ).option("-q, --quiet", "Print IDs only").action(
1108
- (opts) => __async(null, null, function* () {
1109
- const ctx = resolveContext(opts.personal, opts.org, false);
1110
- const client = getClient();
1111
- let list;
1112
- try {
1113
- list = yield client.projects.listFeatures(opts.project, {
1114
- org: (ctx == null ? void 0 : ctx.type) === "org" ? ctx.slug : void 0,
1115
- priority: opts.priority,
1116
- search: opts.search,
1117
- limit: Number(opts.limit)
1118
- });
1119
- } catch (err) {
1120
- printError(err.message, 1);
1121
- }
1122
- if (opts.quiet) {
1123
- for (const f of list) {
1124
- process.stdout.write(`${f.id}
1125
- `);
1126
- }
1127
- return;
1128
- }
1129
- printOutput(list, {
1130
- format: opts.format,
1131
- columns: ["identifier", "title", "priority", "createdAt"]
1132
- });
1133
- })
1124
+ // src/lib/context-sync/lock.ts
1125
+ import path2 from "path";
1126
+
1127
+ // src/lib/instructions/safe-write.ts
1128
+ import { createHash, randomBytes } from "crypto";
1129
+ import { constants } from "fs";
1130
+ import {
1131
+ lstat,
1132
+ mkdir,
1133
+ open,
1134
+ readFile,
1135
+ realpath,
1136
+ rename,
1137
+ unlink
1138
+ } from "fs/promises";
1139
+ import path from "path";
1140
+
1141
+ // src/lib/instructions/paths.ts
1142
+ var DRIVE_LETTER = /^[A-Za-z]:/;
1143
+ var RESERVED_DEVICE_NAME = /^(?:CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(?:\.|$)/i;
1144
+ var FORBIDDEN_NAME_CHARACTER = /[<>:"|?*]/;
1145
+ var RESERVED_ROOTS = [".git", ".fabric"];
1146
+ var RESERVED_EXACT_PATHS = [
1147
+ ".claude/settings.local.json",
1148
+ ".codex/hooks.json"
1149
+ ];
1150
+ function hasControlCharacter(input) {
1151
+ for (let index = 0; index < input.length; index++) {
1152
+ const code = input.charCodeAt(index);
1153
+ if (code <= 31 || code === 127) {
1154
+ return true;
1155
+ }
1156
+ }
1157
+ return false;
1158
+ }
1159
+ function checkRelativePath(input) {
1160
+ if (input.length === 0) {
1161
+ return { ok: false, reason: "empty", detail: input };
1162
+ }
1163
+ if (hasControlCharacter(input)) {
1164
+ return { ok: false, reason: "control_char", detail: input };
1165
+ }
1166
+ if (input.includes("\\")) {
1167
+ return { ok: false, reason: "backslash", detail: input };
1168
+ }
1169
+ if (input.startsWith("/") || DRIVE_LETTER.test(input)) {
1170
+ return { ok: false, reason: "absolute", detail: input };
1171
+ }
1172
+ if (input.endsWith("/")) {
1173
+ return { ok: false, reason: "trailing_separator", detail: input };
1174
+ }
1175
+ const segments = input.split("/");
1176
+ if (segments.some((s) => s.length === 0)) {
1177
+ return { ok: false, reason: "empty", detail: input };
1178
+ }
1179
+ if (segments.some((s) => s === "." || s === "..")) {
1180
+ return { ok: false, reason: "traversal", detail: input };
1181
+ }
1182
+ for (const segment of segments) {
1183
+ if (/[. ]$/.test(segment)) {
1184
+ return {
1185
+ ok: false,
1186
+ reason: "trailing_dot_or_space",
1187
+ detail: input
1188
+ };
1189
+ }
1190
+ if (RESERVED_DEVICE_NAME.test(segment)) {
1191
+ return {
1192
+ ok: false,
1193
+ reason: "reserved_device_name",
1194
+ detail: input
1195
+ };
1196
+ }
1197
+ if (FORBIDDEN_NAME_CHARACTER.test(segment)) {
1198
+ return {
1199
+ ok: false,
1200
+ reason: "forbidden_character",
1201
+ detail: input
1202
+ };
1203
+ }
1204
+ }
1205
+ return { ok: true, path: input };
1206
+ }
1207
+ function isReservedPath(input) {
1208
+ var _a2;
1209
+ const first = (_a2 = input.split("/")[0]) == null ? void 0 : _a2.toLowerCase();
1210
+ if (RESERVED_ROOTS.some((root) => first === root)) {
1211
+ return true;
1212
+ }
1213
+ const key = collisionKey(input);
1214
+ return RESERVED_EXACT_PATHS.some(
1215
+ (reserved) => collisionKey(reserved) === key
1134
1216
  );
1135
- features.command("show <id>").description("Show feature details").requiredOption("-p, --project <projectId>", "Project ID").option("--org <slug>", "Organization context").option("--personal", "Retired \u2014 context is organization-only").option("--format <format>", "Output format: table|json|yaml", "table").action(
1136
- (id, opts) => __async(null, null, function* () {
1137
- var _a2, _b, _c;
1138
- const ctx = resolveContext(opts.personal, opts.org, false);
1139
- const client = getClient();
1140
- let feature;
1141
- try {
1142
- feature = yield client.projects.getFeature(
1143
- opts.project,
1144
- id,
1145
- {
1146
- org: (ctx == null ? void 0 : ctx.type) === "org" ? ctx.slug : void 0
1147
- }
1148
- );
1149
- } catch (err) {
1150
- printError(err.message, 1);
1151
- }
1152
- const fmt = opts.format;
1153
- if (fmt === "json" || fmt === "yaml") {
1154
- printOutput(feature, { format: fmt });
1155
- return;
1156
- }
1157
- printRecord({
1158
- ID: feature.id,
1159
- Identifier: feature.identifier,
1160
- Title: feature.title,
1161
- Priority: feature.priority,
1162
- Description: (_a2 = feature.description) != null ? _a2 : "(none)",
1163
- "Acceptance Criteria": (_b = feature.acceptanceCriteria) != null ? _b : "(none)",
1164
- Project: feature.projectId,
1165
- Assignee: (_c = feature.assigneeId) != null ? _c : "(none)",
1166
- Created: feature.createdAt.slice(0, 10)
1167
- });
1168
- })
1169
- );
1170
- features.command("create <title>").description("Create a feature in a project").requiredOption("-p, --project <projectId>", "Project ID").option("--org <slug>", "Organization context").option("--personal", "Retired \u2014 context is organization-only").option("-d, --description <text>", "Feature description").option(
1171
- "--priority <priority>",
1172
- "Priority: P0_CRITICAL|P1_HIGH|P2_MEDIUM|P3_LOW (default: P2_MEDIUM)"
1173
- ).option("--format <format>", "Output format: table|json|yaml", "table").action(
1174
- (title, opts) => __async(null, null, function* () {
1175
- const ctx = resolveContext(opts.personal, opts.org, false);
1176
- const client = getClient();
1177
- let feature;
1178
- try {
1179
- feature = yield client.projects.createFeature(
1180
- opts.project,
1181
- {
1182
- title,
1183
- description: opts.description,
1184
- priority: opts.priority,
1185
- org: (ctx == null ? void 0 : ctx.type) === "org" ? ctx.slug : void 0
1186
- }
1217
+ }
1218
+ function collisionKey(input) {
1219
+ return input.normalize("NFC").toLowerCase();
1220
+ }
1221
+ function findCollision(paths) {
1222
+ const seen = /* @__PURE__ */ new Map();
1223
+ for (const candidate of paths) {
1224
+ const key = collisionKey(candidate);
1225
+ const previous = seen.get(key);
1226
+ if (previous !== void 0) {
1227
+ return { first: previous, second: candidate };
1228
+ }
1229
+ seen.set(key, candidate);
1230
+ }
1231
+ return null;
1232
+ }
1233
+ function describeRejection(rejection) {
1234
+ switch (rejection.reason) {
1235
+ case "empty":
1236
+ return `empty or malformed path (${JSON.stringify(rejection.detail)})`;
1237
+ case "absolute":
1238
+ return `absolute path refused: ${rejection.detail}`;
1239
+ case "traversal":
1240
+ return `path traversal refused: ${rejection.detail}`;
1241
+ case "backslash":
1242
+ return `backslash in path refused: ${rejection.detail}`;
1243
+ case "control_char":
1244
+ return `control character in path refused: ${JSON.stringify(rejection.detail)}`;
1245
+ case "trailing_separator":
1246
+ return `path names a directory, not a file: ${rejection.detail}`;
1247
+ case "trailing_dot_or_space":
1248
+ return `path segment ending in a dot or space refused: ${rejection.detail}`;
1249
+ case "reserved_device_name":
1250
+ return `reserved device name refused: ${rejection.detail}`;
1251
+ case "forbidden_character":
1252
+ return `path contains a character Windows will not put in a filename (< > : " | ? *): ${rejection.detail}`;
1253
+ case "reserved_path":
1254
+ return `this tool never writes or deletes ${RESERVED_ROOTS.join(", ")} or ${RESERVED_EXACT_PATHS.join(", ")}: ${rejection.detail}`;
1255
+ case "not_a_regular_file":
1256
+ return `path is not a regular file: ${rejection.detail}`;
1257
+ case "escapes_destination":
1258
+ return `path resolves outside the destination: ${rejection.detail}`;
1259
+ case "symlink":
1260
+ return `refusing to follow a symlink: ${rejection.detail}`;
1261
+ case "not_a_directory":
1262
+ return `a path component is not a directory: ${rejection.detail}`;
1263
+ case "collision":
1264
+ return `two paths name the same file on a case- or Unicode-insensitive filesystem: ${rejection.detail}`;
1265
+ }
1266
+ }
1267
+
1268
+ // src/lib/instructions/safe-write.ts
1269
+ var ALLOWED_MODES = /* @__PURE__ */ new Set([420, 493]);
1270
+ function isAllowedMode(mode) {
1271
+ if (mode === null || mode === void 0) {
1272
+ return true;
1273
+ }
1274
+ if (!Number.isInteger(mode) || mode < 0) {
1275
+ return false;
1276
+ }
1277
+ const type = mode & 61440;
1278
+ if (type !== 0 && type !== 32768) {
1279
+ return false;
1280
+ }
1281
+ return ALLOWED_MODES.has(mode & 4095);
1282
+ }
1283
+ function permissionBits(mode) {
1284
+ if (mode === null || mode === void 0) {
1285
+ return null;
1286
+ }
1287
+ return mode & 4095;
1288
+ }
1289
+ function resolveDestinationRoot(destination) {
1290
+ return __async(this, null, function* () {
1291
+ yield mkdir(destination, { recursive: true });
1292
+ return realpath(destination);
1293
+ });
1294
+ }
1295
+ function assertSafeTarget(root, relativePath) {
1296
+ return __async(this, null, function* () {
1297
+ const target = path.resolve(root, relativePath);
1298
+ if (target === root || !target.startsWith(root + path.sep)) {
1299
+ return {
1300
+ ok: false,
1301
+ reason: "escapes_destination",
1302
+ detail: relativePath
1303
+ };
1304
+ }
1305
+ const segments = relativePath.split("/");
1306
+ let current = root;
1307
+ let deepestExisting = root;
1308
+ for (const [index, segment] of segments.entries()) {
1309
+ current = path.join(current, segment);
1310
+ const stats = yield lstatOrNull(current);
1311
+ if (stats === null) {
1312
+ break;
1313
+ }
1314
+ if (stats.isSymbolicLink()) {
1315
+ return { ok: false, reason: "symlink", detail: current };
1316
+ }
1317
+ if (index < segments.length - 1 && !stats.isDirectory()) {
1318
+ return { ok: false, reason: "not_a_directory", detail: current };
1319
+ }
1320
+ deepestExisting = current;
1321
+ }
1322
+ if (deepestExisting !== root) {
1323
+ const canonical = yield realpath(deepestExisting).catch(() => null);
1324
+ if (canonical !== null && canonical !== root && !canonical.startsWith(root + path.sep)) {
1325
+ return {
1326
+ ok: false,
1327
+ reason: "escapes_destination",
1328
+ detail: relativePath
1329
+ };
1330
+ }
1331
+ }
1332
+ return { ok: true, path: target };
1333
+ });
1334
+ }
1335
+ function assertWritableTarget(root, relativePath) {
1336
+ return __async(this, null, function* () {
1337
+ const check = yield assertSafeTarget(root, relativePath);
1338
+ if (!check.ok) {
1339
+ return check;
1340
+ }
1341
+ const stats = yield lstatOrNull(check.path);
1342
+ if (stats === null) {
1343
+ return check;
1344
+ }
1345
+ if (stats.isSymbolicLink()) {
1346
+ return { ok: false, reason: "symlink", detail: check.path };
1347
+ }
1348
+ if (!stats.isFile()) {
1349
+ return { ok: false, reason: "not_a_regular_file", detail: check.path };
1350
+ }
1351
+ return check;
1352
+ });
1353
+ }
1354
+ function readFileSafely(_0, _1) {
1355
+ return __async(this, arguments, function* (root, relativePath, options = {}) {
1356
+ const check = yield assertWritableTarget(root, relativePath);
1357
+ if (!check.ok) {
1358
+ throw new Error(`Refusing to sync: ${describeRejection(check)}.`);
1359
+ }
1360
+ let handle;
1361
+ try {
1362
+ handle = yield open(
1363
+ check.path,
1364
+ constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK
1365
+ );
1366
+ } catch (error) {
1367
+ if (error.code === "ENOENT") {
1368
+ return null;
1369
+ }
1370
+ throw error;
1371
+ }
1372
+ try {
1373
+ const opened = yield handle.stat();
1374
+ if (!opened.isFile()) {
1375
+ throw new Error(
1376
+ `Refusing to sync: ${relativePath} is not a regular file.`
1187
1377
  );
1188
- } catch (err) {
1189
- printError(err.message, 1);
1190
1378
  }
1191
- const fmt = opts.format;
1192
- if (fmt === "json" || fmt === "yaml") {
1193
- printOutput(feature, { format: fmt });
1194
- return;
1379
+ const afterOpen = yield assertWritableTarget(root, relativePath);
1380
+ if (!afterOpen.ok) {
1381
+ throw new Error(
1382
+ `Refusing to sync: ${describeRejection(afterOpen)}.`
1383
+ );
1195
1384
  }
1196
- printSuccess(`Feature ${feature.identifier} created`);
1197
- printRecord({
1198
- ID: feature.id,
1199
- Identifier: feature.identifier,
1200
- Title: feature.title,
1201
- Priority: feature.priority,
1202
- Project: feature.projectId,
1203
- Created: feature.createdAt.slice(0, 10)
1204
- });
1205
- })
1206
- );
1207
- features.command("tasks").description("List tasks (sub-items) for a feature").requiredOption("-p, --project <projectId>", "Project ID").requiredOption("-f, --feature <featureId>", "Feature ID").option("--org <slug>", "Organization context").option("--personal", "Retired \u2014 context is organization-only").option(
1208
- "--format <format>",
1209
- "Output format: table|json|yaml|csv",
1210
- "table"
1211
- ).option("-q, --quiet", "Print IDs only").action(
1212
- (opts) => __async(null, null, function* () {
1213
- const ctx = resolveContext(opts.personal, opts.org, false);
1214
- const client = getClient();
1215
- let tasks;
1216
- try {
1217
- tasks = yield client.projects.listTasks(
1218
- opts.project,
1219
- opts.feature,
1220
- {
1221
- org: (ctx == null ? void 0 : ctx.type) === "org" ? ctx.slug : void 0
1385
+ const current = yield lstatOrNull(afterOpen.path);
1386
+ if (current === null || !current.isFile() || current.dev !== opened.dev || current.ino !== opened.ino) {
1387
+ throw new Error(
1388
+ `Refusing to sync: ${relativePath} changed while it was being opened.`
1389
+ );
1390
+ }
1391
+ if (options.maxBytes === void 0) {
1392
+ return { bytes: yield handle.readFile(), mode: opened.mode };
1393
+ }
1394
+ if (!Number.isSafeInteger(options.maxBytes) || options.maxBytes < 0) {
1395
+ throw new Error(
1396
+ "A safe file-read limit must be a non-negative integer."
1397
+ );
1398
+ }
1399
+ if (opened.size > options.maxBytes) {
1400
+ throw new Error(
1401
+ `${relativePath} is too large to read safely (maximum ${options.maxBytes} bytes).`
1402
+ );
1403
+ }
1404
+ const buffer = Buffer.allocUnsafe(options.maxBytes + 1);
1405
+ let total = 0;
1406
+ while (total <= options.maxBytes) {
1407
+ const { bytesRead } = yield handle.read(
1408
+ buffer,
1409
+ total,
1410
+ options.maxBytes + 1 - total,
1411
+ total
1412
+ );
1413
+ if (bytesRead === 0) {
1414
+ break;
1415
+ }
1416
+ total += bytesRead;
1417
+ }
1418
+ if (total > options.maxBytes) {
1419
+ throw new Error(
1420
+ `${relativePath} is too large to read safely (maximum ${options.maxBytes} bytes).`
1421
+ );
1422
+ }
1423
+ return { bytes: buffer.subarray(0, total), mode: opened.mode };
1424
+ } finally {
1425
+ yield handle.close();
1426
+ }
1427
+ });
1428
+ }
1429
+ function resolveExistingRoot(destination) {
1430
+ return __async(this, null, function* () {
1431
+ return realpath(destination).catch(() => path.resolve(destination));
1432
+ });
1433
+ }
1434
+ function makeParents(root, relativePath) {
1435
+ return __async(this, null, function* () {
1436
+ const segments = relativePath.split("/").slice(0, -1);
1437
+ let current = root;
1438
+ for (const segment of segments) {
1439
+ current = path.join(current, segment);
1440
+ const stats = yield lstatOrNull(current);
1441
+ if (stats === null) {
1442
+ yield mkdir(current).catch((error) => __async(null, null, function* () {
1443
+ if (error.code !== "EEXIST") {
1444
+ throw error;
1445
+ }
1446
+ const raced = yield lstatOrNull(current);
1447
+ if (raced == null ? void 0 : raced.isSymbolicLink()) {
1448
+ throw new Error(
1449
+ `Refusing to sync: ${describeRejection({ ok: false, reason: "symlink", detail: current })}.`
1450
+ );
1222
1451
  }
1452
+ }));
1453
+ continue;
1454
+ }
1455
+ if (stats.isSymbolicLink()) {
1456
+ throw new Error(
1457
+ `Refusing to sync: ${describeRejection({ ok: false, reason: "symlink", detail: current })}.`
1223
1458
  );
1224
- } catch (err) {
1225
- printError(err.message, 1);
1226
1459
  }
1227
- if (opts.quiet) {
1228
- for (const t of tasks) {
1229
- process.stdout.write(`${t.id}
1230
- `);
1231
- }
1232
- return;
1460
+ if (!stats.isDirectory()) {
1461
+ throw new Error(
1462
+ `Refusing to sync: ${describeRejection({ ok: false, reason: "not_a_directory", detail: current })}.`
1463
+ );
1464
+ }
1465
+ }
1466
+ });
1467
+ }
1468
+ function writeFileSafely(input) {
1469
+ return __async(this, null, function* () {
1470
+ var _a2;
1471
+ const check = yield assertSafeTarget(input.root, input.relativePath);
1472
+ if (!check.ok) {
1473
+ throw new Error(`Refusing to sync: ${describeRejection(check)}.`);
1474
+ }
1475
+ if (!isAllowedMode(input.mode)) {
1476
+ throw new Error(
1477
+ `Refusing to sync: ${input.relativePath} asks for file mode ${((_a2 = input.mode) != null ? _a2 : 0).toString(8)}, and only 644 and 755 are accepted.`
1478
+ );
1479
+ }
1480
+ yield makeParents(input.root, input.relativePath);
1481
+ const target = check.path;
1482
+ const directory = path.dirname(target);
1483
+ const directoryBefore = yield lstatOrNull(directory);
1484
+ const temporary = path.join(
1485
+ directory,
1486
+ `.${path.basename(target)}.${randomBytes(8).toString("hex")}.tmp`
1487
+ );
1488
+ let handle = null;
1489
+ try {
1490
+ handle = yield open(temporary, "wx", 384);
1491
+ yield handle.writeFile(input.bytes);
1492
+ const bits = permissionBits(input.mode);
1493
+ if (bits !== null && process.platform !== "win32") {
1494
+ yield handle.chmod(bits);
1495
+ }
1496
+ yield handle.close();
1497
+ handle = null;
1498
+ const parentStats = yield lstatOrNull(directory);
1499
+ if (parentStats === null || parentStats.isSymbolicLink()) {
1500
+ throw new Error(
1501
+ `Refusing to sync: ${describeRejection({ ok: false, reason: "symlink", detail: directory })}.`
1502
+ );
1503
+ }
1504
+ const targetStats = yield lstatOrNull(target);
1505
+ if (targetStats == null ? void 0 : targetStats.isSymbolicLink()) {
1506
+ throw new Error(
1507
+ `Refusing to sync: ${describeRejection({ ok: false, reason: "symlink", detail: target })}.`
1508
+ );
1509
+ }
1510
+ yield rename(temporary, target);
1511
+ } catch (error) {
1512
+ yield handle == null ? void 0 : handle.close().catch(() => {
1513
+ });
1514
+ if (yield isSameDirectory(directory, directoryBefore)) {
1515
+ yield unlink(temporary).catch(() => {
1516
+ });
1517
+ }
1518
+ throw error;
1519
+ }
1520
+ });
1521
+ }
1522
+ function isSameDirectory(directory, before) {
1523
+ return __async(this, null, function* () {
1524
+ if (before === null) {
1525
+ return false;
1526
+ }
1527
+ const now = yield lstatOrNull(directory);
1528
+ return now !== null && now.dev === before.dev && now.ino === before.ino;
1529
+ });
1530
+ }
1531
+ function deleteFileSafely(root, relativePath, expectedSha256) {
1532
+ return __async(this, null, function* () {
1533
+ const check = yield assertSafeTarget(root, relativePath);
1534
+ if (!check.ok) {
1535
+ throw new Error(`Refusing to sync: ${describeRejection(check)}.`);
1536
+ }
1537
+ const stats = yield lstatOrNull(check.path);
1538
+ if (stats === null) {
1539
+ return "missing";
1540
+ }
1541
+ if (stats.isSymbolicLink()) {
1542
+ throw new Error(
1543
+ `Refusing to sync: ${describeRejection({ ok: false, reason: "symlink", detail: check.path })}.`
1544
+ );
1545
+ }
1546
+ if (!stats.isFile()) {
1547
+ throw new Error(
1548
+ `Refusing to sync: ${describeRejection({ ok: false, reason: "not_a_regular_file", detail: check.path })}.`
1549
+ );
1550
+ }
1551
+ let actual;
1552
+ try {
1553
+ actual = createHash("sha256").update(yield readFile(check.path)).digest("hex");
1554
+ } catch (error) {
1555
+ if (error.code === "ENOENT") {
1556
+ return "missing";
1557
+ }
1558
+ throw error;
1559
+ }
1560
+ if (actual !== expectedSha256) {
1561
+ return "modified";
1562
+ }
1563
+ try {
1564
+ yield unlink(check.path);
1565
+ return "deleted";
1566
+ } catch (error) {
1567
+ if (error.code === "ENOENT") {
1568
+ return "missing";
1569
+ }
1570
+ throw error;
1571
+ }
1572
+ });
1573
+ }
1574
+ function lstatOrNull(target) {
1575
+ return __async(this, null, function* () {
1576
+ try {
1577
+ return yield lstat(target);
1578
+ } catch (error) {
1579
+ if (error.code === "ENOENT") {
1580
+ return null;
1581
+ }
1582
+ throw error;
1583
+ }
1584
+ });
1585
+ }
1586
+ function resolvesInside(root, candidate) {
1587
+ return __async(this, null, function* () {
1588
+ const canonicalRoot = yield realpath(root).catch(() => path.resolve(root));
1589
+ const canonicalCandidate = yield realpathDeepest(candidate);
1590
+ return canonicalCandidate === canonicalRoot || canonicalCandidate.startsWith(canonicalRoot + path.sep);
1591
+ });
1592
+ }
1593
+ function realpathDeepest(candidate) {
1594
+ return __async(this, null, function* () {
1595
+ const absolute = path.resolve(candidate);
1596
+ const tail = [];
1597
+ let current = absolute;
1598
+ for (; ; ) {
1599
+ const resolved = yield realpath(current).catch(() => null);
1600
+ if (resolved !== null) {
1601
+ return tail.length === 0 ? resolved : path.join(resolved, ...tail);
1602
+ }
1603
+ const parent = path.dirname(current);
1604
+ if (parent === current) {
1605
+ return absolute;
1233
1606
  }
1234
- printOutput(tasks, {
1235
- format: opts.format,
1236
- columns: [
1237
- "identifier",
1238
- "title",
1239
- "status",
1240
- "estimatedHours"
1241
- ]
1242
- });
1243
- })
1607
+ tail.unshift(path.basename(current));
1608
+ current = parent;
1609
+ }
1610
+ });
1611
+ }
1612
+
1613
+ // src/lib/context-sync/source-path.ts
1614
+ var MAX_CONTEXT_SOURCE_PATH_LENGTH = 512;
1615
+ var CONTROL_OR_FORMAT_CHARACTER = /[\u0000-\u001f\u007f-\u009f\p{Cf}]/u;
1616
+ var DRIVE_LETTER_PREFIX = /^[A-Za-z]:/;
1617
+ function hasControlOrFormatCharacter(value) {
1618
+ return CONTROL_OR_FORMAT_CHARACTER.test(value);
1619
+ }
1620
+ function normalizeContextSourcePath(input) {
1621
+ const slashed = input.normalize("NFC").replace(/\\/g, "/");
1622
+ if (hasControlOrFormatCharacter(slashed)) {
1623
+ return { ok: false, reason: "control-character" };
1624
+ }
1625
+ if (slashed.startsWith("/") || DRIVE_LETTER_PREFIX.test(slashed)) {
1626
+ return { ok: false, reason: "absolute" };
1627
+ }
1628
+ let path9 = slashed.replace(/\/{2,}/g, "/");
1629
+ while (path9.startsWith("./")) {
1630
+ path9 = path9.slice(2);
1631
+ }
1632
+ if (path9.length === 0) {
1633
+ return { ok: false, reason: "empty" };
1634
+ }
1635
+ if (path9.endsWith("/")) {
1636
+ return { ok: false, reason: "trailing-slash" };
1637
+ }
1638
+ if (path9.split("/").some((segment) => segment === "." || segment === "..")) {
1639
+ return { ok: false, reason: "dot-segment" };
1640
+ }
1641
+ if (path9.length > MAX_CONTEXT_SOURCE_PATH_LENGTH) {
1642
+ return { ok: false, reason: "too-long" };
1643
+ }
1644
+ return { ok: true, sourcePath: path9 };
1645
+ }
1646
+
1647
+ // src/lib/context-sync/lock.ts
1648
+ var CONTEXT_LOCK_DIRECTORY = ".fabric";
1649
+ var CONTEXT_LOCK_FILENAME = "context.lock";
1650
+ var CONTEXT_LOCK_VERSION = 1;
1651
+ var CONTEXT_LOCK_RELATIVE_PATH = `${CONTEXT_LOCK_DIRECTORY}/${CONTEXT_LOCK_FILENAME}`;
1652
+ var MAX_CONTEXT_LOCK_BYTES = 32 * 1024 * 1024;
1653
+ var SHA256_HEX = /^[0-9a-f]{64}$/;
1654
+ function contextLockPath(root) {
1655
+ return path2.join(root, CONTEXT_LOCK_DIRECTORY, CONTEXT_LOCK_FILENAME);
1656
+ }
1657
+ function readContextLock(root) {
1658
+ return __async(this, null, function* () {
1659
+ const file = contextLockPath(root);
1660
+ const read = yield readFileSafely(root, CONTEXT_LOCK_RELATIVE_PATH, {
1661
+ maxBytes: MAX_CONTEXT_LOCK_BYTES
1662
+ });
1663
+ if (read === null) {
1664
+ return null;
1665
+ }
1666
+ let parsed;
1667
+ try {
1668
+ parsed = JSON.parse(new TextDecoder().decode(read.bytes));
1669
+ } catch (e) {
1670
+ throw new Error(
1671
+ `${file} is unreadable: it is not valid JSON. Delete it to push every file again with no expected version.`
1672
+ );
1673
+ }
1674
+ const problem = lockProblem(parsed);
1675
+ if (problem !== null) {
1676
+ throw new Error(
1677
+ `${file} is unreadable: ${problem}. Delete it to push every file again with no expected version.`
1678
+ );
1679
+ }
1680
+ return parsed;
1681
+ });
1682
+ }
1683
+ function serializeLock(lock) {
1684
+ return `${JSON.stringify(lock, null, 2).replace(/\r\n/g, "\n")}
1685
+ `;
1686
+ }
1687
+ function writeContextLock(root, lock) {
1688
+ return __async(this, null, function* () {
1689
+ yield writeFileSafely({
1690
+ root,
1691
+ relativePath: CONTEXT_LOCK_RELATIVE_PATH,
1692
+ bytes: new TextEncoder().encode(serializeLock(lock))
1693
+ });
1694
+ });
1695
+ }
1696
+ function lockProblem(value) {
1697
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
1698
+ return "it is not a JSON object";
1699
+ }
1700
+ const candidate = value;
1701
+ if (candidate.version !== CONTEXT_LOCK_VERSION) {
1702
+ return `its version is ${JSON.stringify(candidate.version)} and this build writes version ${CONTEXT_LOCK_VERSION}`;
1703
+ }
1704
+ for (const field of ["projectId", "pushedAt"]) {
1705
+ const actual = candidate[field];
1706
+ if (typeof actual !== "string" || actual.length === 0) {
1707
+ return `its "${field}" is not a non-empty string`;
1708
+ }
1709
+ }
1710
+ if (typeof candidate.files !== "object" || candidate.files === null || Array.isArray(candidate.files)) {
1711
+ return 'its "files" is not an object';
1712
+ }
1713
+ for (const [filePath, entry] of Object.entries(
1714
+ candidate.files
1715
+ )) {
1716
+ const normalized = normalizeContextSourcePath(filePath);
1717
+ if (!normalized.ok || normalized.sourcePath !== filePath) {
1718
+ return `it names ${JSON.stringify(filePath)}, which is not a path the server stores`;
1719
+ }
1720
+ if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
1721
+ return `its entry for ${JSON.stringify(filePath)} is not an object`;
1722
+ }
1723
+ const record = entry;
1724
+ if (typeof record.sha256 !== "string" || !SHA256_HEX.test(record.sha256)) {
1725
+ return `its entry for ${JSON.stringify(filePath)} has no 64-character hex sha256`;
1726
+ }
1727
+ if (record.state === "duplicate") {
1728
+ if (record.contextId !== void 0) {
1729
+ return `its duplicate entry for ${JSON.stringify(filePath)} names a contextId, which a duplicate never has`;
1730
+ }
1731
+ continue;
1732
+ }
1733
+ if (record.state !== void 0) {
1734
+ return `its entry for ${JSON.stringify(filePath)} has an unknown state ${JSON.stringify(record.state)}`;
1735
+ }
1736
+ if (typeof record.contextId !== "string" || record.contextId.length === 0) {
1737
+ return `its entry for ${JSON.stringify(filePath)} has no contextId`;
1738
+ }
1739
+ }
1740
+ return null;
1741
+ }
1742
+
1743
+ // src/lib/context-sync/classify.ts
1744
+ import { createHash as createHash2 } from "crypto";
1745
+ import path3 from "path";
1746
+ var MAX_CONTEXT_FILE_BYTES = 2 * 1024 * 1024;
1747
+ var CONTEXT_TEXT_EXTENSIONS = /* @__PURE__ */ new Set([
1748
+ ".md",
1749
+ ".markdown",
1750
+ ".txt",
1751
+ ".json",
1752
+ ".yaml",
1753
+ ".yml"
1754
+ ]);
1755
+ var HAS_NON_WHITESPACE = /\S/;
1756
+ function hasTextExtension(relativePath) {
1757
+ return CONTEXT_TEXT_EXTENSIONS.has(
1758
+ path3.posix.extname(relativePath).toLowerCase()
1244
1759
  );
1245
- features.command("update <id>").description("Update a feature").requiredOption("-p, --project <projectId>", "Project ID").option("--org <slug>", "Organization context").option("--personal", "Retired \u2014 context is organization-only").option("--title <title>", "New title").option("-d, --description <text>", "New description").option(
1246
- "--priority <priority>",
1247
- "Priority: P0_CRITICAL|P1_HIGH|P2_MEDIUM|P3_LOW"
1248
- ).option("--format <format>", "Output format: table|json|yaml", "table").action(
1249
- (id, opts) => __async(null, null, function* () {
1250
- const ctx = resolveContext(opts.personal, opts.org, false);
1251
- const client = getClient();
1252
- let feature;
1253
- try {
1254
- feature = yield client.projects.updateFeature(
1255
- opts.project,
1256
- id,
1257
- {
1258
- title: opts.title,
1259
- description: opts.description,
1260
- priority: opts.priority,
1261
- org: (ctx == null ? void 0 : ctx.type) === "org" ? ctx.slug : void 0
1760
+ }
1761
+ function classifyContextBytes(bytes) {
1762
+ if (bytes.byteLength > MAX_CONTEXT_FILE_BYTES) {
1763
+ return { ok: false, reason: "too-large" };
1764
+ }
1765
+ let content;
1766
+ try {
1767
+ content = new TextDecoder("utf-8", {
1768
+ fatal: true,
1769
+ ignoreBOM: true
1770
+ }).decode(bytes);
1771
+ } catch (e) {
1772
+ return { ok: false, reason: "binary" };
1773
+ }
1774
+ if (content.includes("\0")) {
1775
+ return { ok: false, reason: "binary" };
1776
+ }
1777
+ if (!HAS_NON_WHITESPACE.test(content)) {
1778
+ return { ok: false, reason: "empty" };
1779
+ }
1780
+ return { ok: true, content };
1781
+ }
1782
+ function hashContextContent(content) {
1783
+ return createHash2("sha256").update(content, "utf8").digest("hex");
1784
+ }
1785
+
1786
+ // src/lib/context-sync/ignore.ts
1787
+ import ignore from "ignore";
1788
+ var CONTEXT_IGNORE_FILENAME = ".contextignore";
1789
+ var DEFAULT_CONTEXT_IGNORE_PATTERNS = [
1790
+ ".git/",
1791
+ ".fabric/",
1792
+ ".claude/",
1793
+ ".cursor/",
1794
+ ".codex/",
1795
+ "node_modules/",
1796
+ "CLAUDE.md",
1797
+ "AGENTS.md",
1798
+ "GEMINI.md",
1799
+ "skills/",
1800
+ "agents/",
1801
+ "hooks/",
1802
+ "rules/",
1803
+ "scripts/",
1804
+ CONTEXT_IGNORE_FILENAME
1805
+ ];
1806
+ function buildContextIgnoreRules(input = {}) {
1807
+ const defaults = ignore().add([...DEFAULT_CONTEXT_IGNORE_PATTERNS]);
1808
+ const user = ignore();
1809
+ if (input.contextIgnore) {
1810
+ user.add(input.contextIgnore);
1811
+ }
1812
+ if (input.excludes && input.excludes.length > 0) {
1813
+ user.add([...input.excludes]);
1814
+ }
1815
+ const ignores = (candidate) => defaults.ignores(candidate) || user.ignores(candidate);
1816
+ return {
1817
+ ignoresFile: (relativePath) => ignores(relativePath),
1818
+ // The trailing slash is what makes a directory-only pattern
1819
+ // (`drafts/`) match: gitignore semantics, as the package implements
1820
+ // them.
1821
+ ignoresDirectory: (relativePath) => ignores(`${relativePath}/`)
1822
+ };
1823
+ }
1824
+
1825
+ // src/lib/context-sync/walk.ts
1826
+ import { lstat as lstat2, readdir } from "fs/promises";
1827
+ import path4 from "path";
1828
+ function byPath(a, b) {
1829
+ return a < b ? -1 : a > b ? 1 : 0;
1830
+ }
1831
+ function walkContextDirectory(root, rules) {
1832
+ return __async(this, null, function* () {
1833
+ const walk = {
1834
+ files: [],
1835
+ symlinks: [],
1836
+ special: [],
1837
+ unmatchable: [],
1838
+ ignored: []
1839
+ };
1840
+ const pending = [""];
1841
+ while (pending.length > 0) {
1842
+ const relativeDirectory = pending.pop();
1843
+ const names = yield readdir(path4.join(root, relativeDirectory));
1844
+ for (const name of names) {
1845
+ const relative = relativeDirectory ? `${relativeDirectory}/${name}` : name;
1846
+ let stats;
1847
+ try {
1848
+ stats = yield lstat2(path4.join(root, relative));
1849
+ } catch (error) {
1850
+ if (error.code === "ENOENT") {
1851
+ continue;
1262
1852
  }
1263
- );
1264
- } catch (err) {
1265
- printError(err.message, 1);
1266
- }
1267
- const fmt = opts.format;
1268
- if (fmt === "json" || fmt === "yaml") {
1269
- printOutput(feature, { format: fmt });
1270
- return;
1853
+ throw error;
1854
+ }
1855
+ let ignored;
1856
+ try {
1857
+ ignored = stats.isDirectory() ? rules.ignoresDirectory(relative) : stats.isSymbolicLink() ? rules.ignoresFile(relative) || rules.ignoresDirectory(relative) : rules.ignoresFile(relative);
1858
+ } catch (e) {
1859
+ walk.unmatchable.push(relative);
1860
+ continue;
1861
+ }
1862
+ if (ignored) {
1863
+ walk.ignored.push({
1864
+ path: relative,
1865
+ // A link was matched as a directory too, so what would sit
1866
+ // beneath it counts as ignored with it.
1867
+ directory: stats.isDirectory() || stats.isSymbolicLink()
1868
+ });
1869
+ continue;
1870
+ }
1871
+ if (stats.isSymbolicLink()) {
1872
+ walk.symlinks.push(relative);
1873
+ } else if (stats.isDirectory()) {
1874
+ pending.push(relative);
1875
+ } else if (stats.isFile()) {
1876
+ walk.files.push({ path: relative, size: stats.size });
1877
+ } else {
1878
+ walk.special.push(relative);
1879
+ }
1271
1880
  }
1272
- printRecord({
1273
- ID: feature.id,
1274
- Identifier: feature.identifier,
1275
- Title: feature.title,
1276
- Priority: feature.priority,
1277
- Updated: feature.updatedAt.slice(0, 10)
1278
- });
1279
- })
1280
- );
1281
- return features;
1881
+ }
1882
+ walk.files.sort((a, b) => byPath(a.path, b.path));
1883
+ walk.symlinks.sort(byPath);
1884
+ walk.special.sort(byPath);
1885
+ walk.unmatchable.sort(byPath);
1886
+ walk.ignored.sort((a, b) => byPath(a.path, b.path));
1887
+ return walk;
1888
+ });
1282
1889
  }
1283
1890
 
1284
- // src/commands/frames/index.ts
1285
- import { Command as Command10 } from "commander";
1286
- function buildFramesCommand() {
1287
- const frames = new Command10("frames").description(
1288
- "Manage AI-generated frames and slideshows"
1289
- );
1290
- frames.command("list").description("List frames and slideshows").option("--org <slug>", "Organization context").option("--personal", "Retired \u2014 context is organization-only").option("--limit <n>", "Max results (default 50)", "50").option(
1291
- "--format <format>",
1292
- "Output format: table|json|yaml|csv",
1293
- "table"
1294
- ).option("-q, --quiet", "Print IDs only").action(
1295
- (opts) => __async(null, null, function* () {
1296
- const ctx = resolveContext(opts.personal, opts.org, false);
1297
- const client = getClient();
1298
- let list;
1299
- try {
1300
- list = yield client.frames.list({
1301
- org: (ctx == null ? void 0 : ctx.type) === "org" ? ctx.slug : void 0,
1302
- limit: Number(opts.limit)
1891
+ // src/lib/context-sync/plan.ts
1892
+ function byPath2(a, b) {
1893
+ return a < b ? -1 : a > b ? 1 : 0;
1894
+ }
1895
+ function readContextIgnore(root) {
1896
+ return __async(this, null, function* () {
1897
+ const read = yield readFileSafely(root, CONTEXT_IGNORE_FILENAME, {
1898
+ maxBytes: 1024 * 1024
1899
+ });
1900
+ return read === null ? null : new TextDecoder().decode(read.bytes);
1901
+ });
1902
+ }
1903
+ function ignoredByWalk(ignored) {
1904
+ const exact = new Set(ignored.map((entry) => entry.path.normalize("NFC")));
1905
+ const directories = ignored.filter((entry) => entry.directory).map((entry) => `${entry.path.normalize("NFC")}/`);
1906
+ return (sourcePath) => exact.has(sourcePath) || directories.some((prefix) => sourcePath.startsWith(prefix));
1907
+ }
1908
+ function sharedPathProblem(entry, group2) {
1909
+ const others = group2.filter((other) => other.diskPath !== entry.diskPath);
1910
+ if (others.length === 0) {
1911
+ return null;
1912
+ }
1913
+ const names = others.map((other) => other.diskPath).join(", ");
1914
+ return others.every((other) => other.sourcePath === entry.sourcePath) ? `names the same source as ${names}` : `case-only collision with ${names}`;
1915
+ }
1916
+ function computeContextPlan(input) {
1917
+ return __async(this, null, function* () {
1918
+ var _a2, _b, _c;
1919
+ const { root, lock } = input;
1920
+ const rules = buildContextIgnoreRules({
1921
+ contextIgnore: yield readContextIgnore(root),
1922
+ excludes: input.excludes
1923
+ });
1924
+ const walk = yield walkContextDirectory(root, rules);
1925
+ const skipped = [
1926
+ ...walk.symlinks.map((p) => ({ path: p, reason: "symlink" })),
1927
+ ...walk.special.map((p) => ({
1928
+ path: p,
1929
+ reason: "unsupported-type"
1930
+ })),
1931
+ ...walk.unmatchable.map((p) => ({
1932
+ path: p,
1933
+ reason: "invalid-path",
1934
+ detail: "cannot be matched against the ignore rules"
1935
+ }))
1936
+ ];
1937
+ const readable = [];
1938
+ for (const file of walk.files) {
1939
+ if (file.path.includes("\\")) {
1940
+ skipped.push({
1941
+ path: file.path,
1942
+ reason: "invalid-path",
1943
+ detail: "backslash"
1303
1944
  });
1304
- } catch (err) {
1305
- printError(err.message, 1);
1945
+ continue;
1306
1946
  }
1307
- if (opts.quiet) {
1308
- for (const f of list) {
1309
- process.stdout.write(`${f.id}
1310
- `);
1311
- }
1312
- return;
1947
+ const normalized = normalizeContextSourcePath(file.path);
1948
+ if (!normalized.ok) {
1949
+ skipped.push({
1950
+ path: file.path,
1951
+ reason: "invalid-path",
1952
+ detail: normalized.reason
1953
+ });
1954
+ continue;
1313
1955
  }
1314
- printOutput(list, {
1315
- format: opts.format,
1316
- columns: [
1317
- "id",
1318
- "title",
1319
- "fileType",
1320
- "status",
1321
- "isPublic",
1322
- "updatedAt"
1323
- ]
1956
+ if (!hasTextExtension(file.path)) {
1957
+ skipped.push({ path: file.path, reason: "unsupported-type" });
1958
+ continue;
1959
+ }
1960
+ if (file.size > MAX_CONTEXT_FILE_BYTES) {
1961
+ skipped.push({ path: file.path, reason: "too-large" });
1962
+ continue;
1963
+ }
1964
+ readable.push({
1965
+ diskPath: file.path,
1966
+ sourcePath: normalized.sourcePath
1324
1967
  });
1325
- })
1326
- );
1327
- frames.command("show <id>").description("Show frame details").option("--org <slug>", "Organization context").option("--personal", "Retired \u2014 context is organization-only").option("--format <format>", "Output format: table|json|yaml", "table").action(
1328
- (id, opts) => __async(null, null, function* () {
1329
- var _a2, _b, _c;
1330
- const ctx = resolveContext(opts.personal, opts.org, false);
1331
- const client = getClient();
1332
- let frame;
1333
- try {
1334
- frame = yield client.frames.get(id, {
1335
- org: (ctx == null ? void 0 : ctx.type) === "org" ? ctx.slug : void 0
1968
+ }
1969
+ const byFoldedPath = /* @__PURE__ */ new Map();
1970
+ for (const entry of readable) {
1971
+ const key = entry.sourcePath.toLowerCase();
1972
+ byFoldedPath.set(key, [...(_a2 = byFoldedPath.get(key)) != null ? _a2 : [], entry]);
1973
+ }
1974
+ const push = [];
1975
+ const unchangedLocal = [];
1976
+ const present = /* @__PURE__ */ new Set();
1977
+ for (const entry of readable) {
1978
+ const problem = sharedPathProblem(
1979
+ entry,
1980
+ (_b = byFoldedPath.get(entry.sourcePath.toLowerCase())) != null ? _b : []
1981
+ );
1982
+ if (problem !== null) {
1983
+ skipped.push({
1984
+ path: entry.diskPath,
1985
+ reason: "invalid-path",
1986
+ detail: problem
1336
1987
  });
1337
- } catch (err) {
1338
- printError(err.message, 1);
1339
- }
1340
- const fmt = opts.format;
1341
- if (fmt === "json" || fmt === "yaml") {
1342
- printOutput(frame, { format: fmt });
1343
- return;
1988
+ continue;
1344
1989
  }
1345
- printRecord({
1346
- ID: frame.id,
1347
- Title: frame.title,
1348
- Kind: (_a2 = frame.kind) != null ? _a2 : "(none)",
1349
- "File Type": frame.fileType,
1350
- Status: frame.status,
1351
- Public: frame.isPublic ? "yes" : "no",
1352
- "Share Token": (_b = frame.shareToken) != null ? _b : "(none)",
1353
- Description: (_c = frame.description) != null ? _c : "(none)",
1354
- Updated: frame.updatedAt.slice(0, 10)
1990
+ const read = yield readFileSafely(root, entry.diskPath, {
1991
+ maxBytes: MAX_CONTEXT_FILE_BYTES
1992
+ }).catch((error) => {
1993
+ if (error instanceof Error && /too large/.test(error.message)) {
1994
+ return "too-large";
1995
+ }
1996
+ throw error;
1355
1997
  });
1356
- })
1357
- );
1358
- return frames;
1359
- }
1360
-
1361
- // src/commands/instructions/index.ts
1362
- import path4 from "path";
1363
- import { Command as Command11 } from "commander";
1364
-
1365
- // src/lib/instructions/apply.ts
1366
- import { createHash as createHash2 } from "crypto";
1367
- import { chmod } from "fs/promises";
1368
-
1369
- // src/lib/instructions/paths.ts
1370
- var DRIVE_LETTER = /^[A-Za-z]:/;
1371
- var RESERVED_DEVICE_NAME = /^(?:CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(?:\.|$)/i;
1372
- var FORBIDDEN_NAME_CHARACTER = /[<>:"|?*]/;
1373
- var RESERVED_ROOTS = [".git", ".fabric"];
1374
- var RESERVED_EXACT_PATHS = [
1375
- ".claude/settings.local.json",
1376
- ".codex/hooks.json"
1377
- ];
1378
- function hasControlCharacter(input) {
1379
- for (let index = 0; index < input.length; index++) {
1380
- const code = input.charCodeAt(index);
1381
- if (code <= 31 || code === 127) {
1382
- return true;
1998
+ if (read === null) {
1999
+ continue;
2000
+ }
2001
+ if (read === "too-large") {
2002
+ skipped.push({ path: entry.diskPath, reason: "too-large" });
2003
+ continue;
2004
+ }
2005
+ const verdict = classifyContextBytes(read.bytes);
2006
+ if (!verdict.ok) {
2007
+ skipped.push({ path: entry.diskPath, reason: verdict.reason });
2008
+ continue;
2009
+ }
2010
+ const sha256 = hashContextContent(verdict.content);
2011
+ const locked = lock == null ? void 0 : lock.files[entry.sourcePath];
2012
+ const plannedFile = {
2013
+ sourcePath: entry.sourcePath,
2014
+ diskPath: entry.diskPath,
2015
+ sha256,
2016
+ bytes: read.bytes.byteLength
2017
+ };
2018
+ if ((locked == null ? void 0 : locked.sha256) === sha256) {
2019
+ unchangedLocal.push(entry.sourcePath);
2020
+ } else {
2021
+ push.push(
2022
+ // A duplicate never had a row of its own, so there is no
2023
+ // version to name: it is sent like a new path.
2024
+ locked && locked.state !== "duplicate" ? __spreadProps(__spreadValues({}, plannedFile), { expectedContentHash: locked.sha256 }) : plannedFile
2025
+ );
2026
+ }
2027
+ present.add(entry.sourcePath);
1383
2028
  }
1384
- }
1385
- return false;
2029
+ for (const skip of skipped) {
2030
+ const check = normalizeContextSourcePath(skip.path);
2031
+ if (check.ok) {
2032
+ present.add(check.sourcePath);
2033
+ }
2034
+ }
2035
+ const isIgnored = ignoredByWalk(walk.ignored);
2036
+ const removed = [];
2037
+ const forgotten = [];
2038
+ for (const [sourcePath, entry] of Object.entries((_c = lock == null ? void 0 : lock.files) != null ? _c : {})) {
2039
+ if (present.has(sourcePath)) {
2040
+ continue;
2041
+ }
2042
+ if (entry.state === "duplicate") {
2043
+ forgotten.push(sourcePath);
2044
+ } else if (isIgnored(sourcePath)) {
2045
+ skipped.push({ path: sourcePath, reason: "ignored" });
2046
+ } else {
2047
+ removed.push(sourcePath);
2048
+ }
2049
+ }
2050
+ push.sort((a, b) => byPath2(a.sourcePath, b.sourcePath));
2051
+ unchangedLocal.sort(byPath2);
2052
+ removed.sort(byPath2);
2053
+ forgotten.sort(byPath2);
2054
+ skipped.sort((a, b) => byPath2(a.path, b.path));
2055
+ return { push, unchangedLocal, removed, forgotten, skipped };
2056
+ });
1386
2057
  }
1387
- function checkRelativePath(input) {
1388
- if (input.length === 0) {
1389
- return { ok: false, reason: "empty", detail: input };
1390
- }
1391
- if (hasControlCharacter(input)) {
1392
- return { ok: false, reason: "control_char", detail: input };
1393
- }
1394
- if (input.includes("\\")) {
1395
- return { ok: false, reason: "backslash", detail: input };
1396
- }
1397
- if (input.startsWith("/") || DRIVE_LETTER.test(input)) {
1398
- return { ok: false, reason: "absolute", detail: input };
2058
+
2059
+ // src/lib/context-sync/push.ts
2060
+ var RUN_LEVEL_STATUSES = /* @__PURE__ */ new Set([401, 403, 404, 429]);
2061
+ var RUN_LEVEL_CODES = /* @__PURE__ */ new Set(["NETWORK_ERROR", "TIMEOUT"]);
2062
+ function stopsTheRun(error) {
2063
+ const { status, code } = error;
2064
+ return typeof status === "number" && RUN_LEVEL_STATUSES.has(status) || typeof code === "string" && RUN_LEVEL_CODES.has(code);
2065
+ }
2066
+ function conflictOf(error) {
2067
+ if (typeof error !== "object" || error === null) {
2068
+ return null;
1399
2069
  }
1400
- if (input.endsWith("/")) {
1401
- return { ok: false, reason: "trailing_separator", detail: input };
2070
+ const conflict = error.conflict;
2071
+ if (typeof conflict === "object" && conflict !== null && conflict.status === "conflict" && "current" in conflict && // An object, or `null` for a named version deleted on the server.
2072
+ typeof conflict.current === "object") {
2073
+ return conflict;
1402
2074
  }
1403
- const segments = input.split("/");
1404
- if (segments.some((s) => s.length === 0)) {
1405
- return { ok: false, reason: "empty", detail: input };
2075
+ return null;
2076
+ }
2077
+ function readPlannedContent(root, entry) {
2078
+ return __async(this, null, function* () {
2079
+ const read = yield readFileSafely(root, entry.diskPath, {
2080
+ maxBytes: MAX_CONTEXT_FILE_BYTES
2081
+ }).catch((error) => {
2082
+ if (error instanceof Error && /too large/.test(error.message)) {
2083
+ return null;
2084
+ }
2085
+ throw error;
2086
+ });
2087
+ if (read === null) {
2088
+ return null;
2089
+ }
2090
+ const verdict = classifyContextBytes(read.bytes);
2091
+ if (!verdict.ok || hashContextContent(verdict.content) !== entry.sha256) {
2092
+ return null;
2093
+ }
2094
+ return verdict.content;
2095
+ });
2096
+ }
2097
+ function fromResult(sourcePath, result) {
2098
+ if (result.status === "duplicate") {
2099
+ return {
2100
+ sourcePath,
2101
+ status: "duplicate",
2102
+ contentHash: result.contentHash,
2103
+ duplicateOfContextId: result.duplicateOfContextId,
2104
+ duplicateOfSourcePath: result.duplicateOfSourcePath
2105
+ };
1406
2106
  }
1407
- if (segments.some((s) => s === "." || s === "..")) {
1408
- return { ok: false, reason: "traversal", detail: input };
2107
+ return {
2108
+ sourcePath,
2109
+ status: result.status,
2110
+ contextId: result.contextId,
2111
+ contentHash: result.contentHash
2112
+ };
2113
+ }
2114
+ function pushContextPlan(input) {
2115
+ return __async(this, null, function* () {
2116
+ var _a2;
2117
+ const results = [];
2118
+ const send = (entry, content, expectedContentHash) => input.client.contexts.upsertSyncedFile(
2119
+ input.projectId,
2120
+ __spreadValues({
2121
+ sourcePath: entry.sourcePath,
2122
+ content
2123
+ }, expectedContentHash !== void 0 ? { expectedContentHash } : {}),
2124
+ { org: input.org }
2125
+ );
2126
+ for (const entry of input.plan.push) {
2127
+ try {
2128
+ const content = yield readPlannedContent(input.root, entry);
2129
+ if (content === null) {
2130
+ results.push({
2131
+ sourcePath: entry.sourcePath,
2132
+ status: "changed-during-run"
2133
+ });
2134
+ continue;
2135
+ }
2136
+ try {
2137
+ results.push(
2138
+ fromResult(
2139
+ entry.sourcePath,
2140
+ yield send(entry, content, entry.expectedContentHash)
2141
+ )
2142
+ );
2143
+ } catch (error) {
2144
+ const conflict = conflictOf(error);
2145
+ if (conflict === null) {
2146
+ throw error;
2147
+ }
2148
+ const { current } = conflict;
2149
+ if (!input.force || current !== null && current.contentHash === null) {
2150
+ results.push({
2151
+ sourcePath: entry.sourcePath,
2152
+ status: "conflict",
2153
+ contentHash: conflict.contentHash,
2154
+ wasLocked: entry.expectedContentHash !== void 0,
2155
+ afterForce: false,
2156
+ current
2157
+ });
2158
+ continue;
2159
+ }
2160
+ const forcedContent = yield readPlannedContent(
2161
+ input.root,
2162
+ entry
2163
+ );
2164
+ if (forcedContent === null) {
2165
+ results.push({
2166
+ sourcePath: entry.sourcePath,
2167
+ status: "changed-during-run"
2168
+ });
2169
+ continue;
2170
+ }
2171
+ try {
2172
+ const forced = fromResult(
2173
+ entry.sourcePath,
2174
+ yield send(
2175
+ entry,
2176
+ forcedContent,
2177
+ current === null ? void 0 : (_a2 = current.contentHash) != null ? _a2 : void 0
2178
+ )
2179
+ );
2180
+ results.push(
2181
+ forced.status === "updated" && current !== null ? __spreadProps(__spreadValues({}, forced), { overwrote: current }) : forced
2182
+ );
2183
+ } catch (retryError) {
2184
+ const again = conflictOf(retryError);
2185
+ if (again === null) {
2186
+ throw retryError;
2187
+ }
2188
+ results.push({
2189
+ sourcePath: entry.sourcePath,
2190
+ status: "conflict",
2191
+ contentHash: again.contentHash,
2192
+ wasLocked: entry.expectedContentHash !== void 0,
2193
+ afterForce: true,
2194
+ current: again.current
2195
+ });
2196
+ }
2197
+ }
2198
+ } catch (error) {
2199
+ const failure = asCliFailure(error);
2200
+ if (stopsTheRun(error)) {
2201
+ return { results, stoppedBy: failure };
2202
+ }
2203
+ results.push({
2204
+ sourcePath: entry.sourcePath,
2205
+ status: "failed",
2206
+ error: describeError(error),
2207
+ exitCode: failure.exitCode
2208
+ });
2209
+ }
2210
+ }
2211
+ return { results, stoppedBy: null };
2212
+ });
2213
+ }
2214
+ function nextContextLock(input) {
2215
+ var _a2, _b, _c;
2216
+ const files = __spreadValues({}, (_b = (_a2 = input.previous) == null ? void 0 : _a2.files) != null ? _b : {});
2217
+ let changed = false;
2218
+ for (const sourcePath of (_c = input.forgotten) != null ? _c : []) {
2219
+ if (sourcePath in files) {
2220
+ delete files[sourcePath];
2221
+ changed = true;
2222
+ }
1409
2223
  }
1410
- for (const segment of segments) {
1411
- if (/[. ]$/.test(segment)) {
1412
- return {
1413
- ok: false,
1414
- reason: "trailing_dot_or_space",
1415
- detail: input
2224
+ for (const result of input.results) {
2225
+ if (result.status === "duplicate") {
2226
+ const before2 = files[result.sourcePath];
2227
+ if ((before2 == null ? void 0 : before2.state) === "duplicate" && before2.sha256 === result.contentHash) {
2228
+ continue;
2229
+ }
2230
+ files[result.sourcePath] = {
2231
+ sha256: result.contentHash,
2232
+ state: "duplicate"
1416
2233
  };
2234
+ changed = true;
2235
+ continue;
1417
2236
  }
1418
- if (RESERVED_DEVICE_NAME.test(segment)) {
1419
- return {
1420
- ok: false,
1421
- reason: "reserved_device_name",
1422
- detail: input
1423
- };
2237
+ if (result.status !== "created" && result.status !== "updated" && result.status !== "unchanged") {
2238
+ continue;
1424
2239
  }
1425
- if (FORBIDDEN_NAME_CHARACTER.test(segment)) {
1426
- return {
1427
- ok: false,
1428
- reason: "forbidden_character",
1429
- detail: input
1430
- };
2240
+ const before = files[result.sourcePath];
2241
+ if ((before == null ? void 0 : before.state) === void 0 && (before == null ? void 0 : before.sha256) === result.contentHash && before.contextId === result.contextId) {
2242
+ continue;
1431
2243
  }
2244
+ files[result.sourcePath] = {
2245
+ sha256: result.contentHash,
2246
+ contextId: result.contextId
2247
+ };
2248
+ changed = true;
1432
2249
  }
1433
- return { ok: true, path: input };
2250
+ if (!changed) {
2251
+ return null;
2252
+ }
2253
+ return {
2254
+ version: CONTEXT_LOCK_VERSION,
2255
+ projectId: input.projectId,
2256
+ pushedAt: input.now.toISOString(),
2257
+ files
2258
+ };
1434
2259
  }
1435
- function isReservedPath(input) {
2260
+
2261
+ // src/lib/context-sync/report.ts
2262
+ function countOutcomes(plan, results) {
2263
+ const count = (status) => results.filter((r) => r.status === status).length;
2264
+ return {
2265
+ created: count("created"),
2266
+ updated: count("updated"),
2267
+ unchanged: count("unchanged") + plan.unchangedLocal.length,
2268
+ duplicate: count("duplicate"),
2269
+ conflict: count("conflict"),
2270
+ changedDuringRun: count("changed-during-run"),
2271
+ failed: count("failed"),
2272
+ removed: plan.removed.length,
2273
+ skipped: plan.skipped.length
2274
+ };
2275
+ }
2276
+ function who(current) {
2277
+ var _a2, _b;
2278
+ return (_b = (_a2 = current.contentUpdatedBy) == null ? void 0 : _a2.name) != null ? _b : "someone";
2279
+ }
2280
+ function when(current) {
1436
2281
  var _a2;
1437
- const first = (_a2 = input.split("/")[0]) == null ? void 0 : _a2.toLowerCase();
1438
- if (RESERVED_ROOTS.some((root) => first === root)) {
1439
- return true;
1440
- }
1441
- const key = collisionKey(input);
1442
- return RESERVED_EXACT_PATHS.some(
1443
- (reserved) => collisionKey(reserved) === key
1444
- );
2282
+ return (_a2 = current.contentUpdatedAt) != null ? _a2 : "an unknown time";
1445
2283
  }
1446
- function collisionKey(input) {
1447
- return input.normalize("NFC").toLowerCase();
2284
+ function conflictLine(result) {
2285
+ const { current } = result;
2286
+ if (current === null) {
2287
+ return `${result.sourcePath}: deleted on the server since your last push`;
2288
+ }
2289
+ let text;
2290
+ if (result.afterForce) {
2291
+ text = `changed on the server again by ${who(current)} at ${when(current)} while --force was replacing it`;
2292
+ } else if (result.wasLocked) {
2293
+ text = `changed on the server by ${who(current)} at ${when(current)} since your last push`;
2294
+ } else {
2295
+ text = `already on the server with different content, last changed by ${who(current)} at ${when(current)}`;
2296
+ }
2297
+ if (current.contentHash === null) {
2298
+ text += "; that version has no content hash, so --force cannot replace it";
2299
+ }
2300
+ return `${result.sourcePath}: ${text}`;
1448
2301
  }
1449
- function findCollision(paths) {
1450
- const seen = /* @__PURE__ */ new Map();
1451
- for (const candidate of paths) {
1452
- const key = collisionKey(candidate);
1453
- const previous = seen.get(key);
1454
- if (previous !== void 0) {
1455
- return { first: previous, second: candidate };
1456
- }
1457
- seen.set(key, candidate);
2302
+ function storedLine(result) {
2303
+ if (result.overwrote) {
2304
+ return `${result.sourcePath}: replaced the version ${who(result.overwrote)} changed at ${when(result.overwrote)} (--force)`;
1458
2305
  }
1459
- return null;
2306
+ return result.sourcePath;
1460
2307
  }
1461
- function describeRejection(rejection) {
1462
- switch (rejection.reason) {
1463
- case "empty":
1464
- return `empty or malformed path (${JSON.stringify(rejection.detail)})`;
1465
- case "absolute":
1466
- return `absolute path refused: ${rejection.detail}`;
1467
- case "traversal":
1468
- return `path traversal refused: ${rejection.detail}`;
1469
- case "backslash":
1470
- return `backslash in path refused: ${rejection.detail}`;
1471
- case "control_char":
1472
- return `control character in path refused: ${JSON.stringify(rejection.detail)}`;
1473
- case "trailing_separator":
1474
- return `path names a directory, not a file: ${rejection.detail}`;
1475
- case "trailing_dot_or_space":
1476
- return `path segment ending in a dot or space refused: ${rejection.detail}`;
1477
- case "reserved_device_name":
1478
- return `reserved device name refused: ${rejection.detail}`;
1479
- case "forbidden_character":
1480
- return `path contains a character Windows will not put in a filename (< > : " | ? *): ${rejection.detail}`;
1481
- case "reserved_path":
1482
- return `this tool never writes or deletes ${RESERVED_ROOTS.join(", ")} or ${RESERVED_EXACT_PATHS.join(", ")}: ${rejection.detail}`;
1483
- case "not_a_regular_file":
1484
- return `path is not a regular file: ${rejection.detail}`;
1485
- case "escapes_destination":
1486
- return `path resolves outside the destination: ${rejection.detail}`;
1487
- case "symlink":
1488
- return `refusing to follow a symlink: ${rejection.detail}`;
1489
- case "not_a_directory":
1490
- return `a path component is not a directory: ${rejection.detail}`;
1491
- case "collision":
1492
- return `two paths name the same file on a case- or Unicode-insensitive filesystem: ${rejection.detail}`;
2308
+ function skippedLine(skip) {
2309
+ if (skip.reason === "ignored") {
2310
+ return `${skip.path}: now excluded by the ignore rules; server entry kept`;
1493
2311
  }
2312
+ return skip.detail ? `${skip.path}: ${skip.reason} (${skip.detail})` : `${skip.path}: ${skip.reason}`;
1494
2313
  }
1495
-
1496
- // src/lib/instructions/safe-write.ts
1497
- import { createHash, randomBytes } from "crypto";
1498
- import { constants } from "fs";
1499
- import {
1500
- lstat,
1501
- mkdir,
1502
- open,
1503
- readFile,
1504
- realpath,
1505
- rename,
1506
- unlink
1507
- } from "fs/promises";
1508
- import path from "path";
1509
- var ALLOWED_MODES = /* @__PURE__ */ new Set([420, 493]);
1510
- function isAllowedMode(mode) {
1511
- if (mode === null || mode === void 0) {
1512
- return true;
2314
+ function group(lines, label, items) {
2315
+ if (items.length === 0) {
2316
+ return;
1513
2317
  }
1514
- if (!Number.isInteger(mode) || mode < 0) {
1515
- return false;
2318
+ lines.push(`${label} (${items.length})`);
2319
+ for (const item of items) {
2320
+ lines.push(` ${item}`);
1516
2321
  }
1517
- const type = mode & 61440;
1518
- if (type !== 0 && type !== 32768) {
1519
- return false;
2322
+ }
2323
+ function formatPushReport(input) {
2324
+ const { plan, results } = input;
2325
+ const of = (status) => results.filter((r) => r.status === status);
2326
+ const lines = [
2327
+ `Context push from ${input.directory} to project ${input.projectId}:`
2328
+ ];
2329
+ group(lines, "created", of("created").map(storedLine));
2330
+ group(lines, "updated", of("updated").map(storedLine));
2331
+ group(
2332
+ lines,
2333
+ "duplicate",
2334
+ of("duplicate").map(
2335
+ (r) => {
2336
+ var _a2;
2337
+ return `${r.sourcePath}: duplicate of ${(_a2 = r.duplicateOfSourcePath) != null ? _a2 : "another source in this project"}`;
2338
+ }
2339
+ )
2340
+ );
2341
+ group(lines, "conflict", of("conflict").map(conflictLine));
2342
+ group(
2343
+ lines,
2344
+ "changed during the run",
2345
+ of("changed-during-run").map(
2346
+ (r) => `${r.sourcePath}: changed-during-run; not sent, the next push sends the new content`
2347
+ )
2348
+ );
2349
+ group(
2350
+ lines,
2351
+ "failed",
2352
+ of("failed").map((r) => `${r.sourcePath}: ${r.error}`)
2353
+ );
2354
+ group(
2355
+ lines,
2356
+ "removed",
2357
+ plan.removed.map((p) => `${p}: removed locally; server entry kept`)
2358
+ );
2359
+ group(lines, "skipped", plan.skipped.map(skippedLine));
2360
+ const unchanged = countOutcomes(plan, results).unchanged;
2361
+ if (unchanged > 0) {
2362
+ lines.push(`unchanged (${unchanged})`);
1520
2363
  }
1521
- return ALLOWED_MODES.has(mode & 4095);
2364
+ return lines;
2365
+ }
2366
+ function formatDryRunReport(input) {
2367
+ const { plan } = input;
2368
+ const lines = [
2369
+ `Dry run of a context push from ${input.directory} to project ${input.projectId}: nothing was sent and the lock was not written.`
2370
+ ];
2371
+ group(
2372
+ lines,
2373
+ "new",
2374
+ plan.push.filter((entry) => entry.expectedContentHash === void 0).map((entry) => entry.sourcePath)
2375
+ );
2376
+ group(
2377
+ lines,
2378
+ "changed",
2379
+ plan.push.filter((entry) => entry.expectedContentHash !== void 0).map((entry) => entry.sourcePath)
2380
+ );
2381
+ group(
2382
+ lines,
2383
+ "removed",
2384
+ plan.removed.map(
2385
+ (p) => `${p}: removed locally; server entry would be kept`
2386
+ )
2387
+ );
2388
+ group(lines, "skipped", plan.skipped.map(skippedLine));
2389
+ if (plan.unchangedLocal.length > 0) {
2390
+ lines.push(`unchanged (${plan.unchangedLocal.length})`);
2391
+ }
2392
+ return lines;
2393
+ }
2394
+ function contextPushJson(input) {
2395
+ return {
2396
+ projectId: input.projectId,
2397
+ directory: input.directory,
2398
+ dryRun: input.dryRun,
2399
+ force: input.force,
2400
+ lock: input.lockPath,
2401
+ lockWritten: input.lockWritten,
2402
+ plan: {
2403
+ push: input.plan.push.map((entry) => {
2404
+ var _a2;
2405
+ return {
2406
+ sourcePath: entry.sourcePath,
2407
+ sha256: entry.sha256,
2408
+ bytes: entry.bytes,
2409
+ expectedContentHash: (_a2 = entry.expectedContentHash) != null ? _a2 : null
2410
+ };
2411
+ }),
2412
+ unchangedLocal: input.plan.unchangedLocal,
2413
+ removed: input.plan.removed,
2414
+ forgotten: input.plan.forgotten,
2415
+ skipped: input.plan.skipped
2416
+ },
2417
+ results: input.results,
2418
+ counts: countOutcomes(input.plan, input.results)
2419
+ };
1522
2420
  }
1523
- function permissionBits(mode) {
1524
- if (mode === null || mode === void 0) {
1525
- return null;
1526
- }
1527
- return mode & 4095;
2421
+
2422
+ // src/commands/context/index.ts
2423
+ var HOOK_DEADLINE_MS = 1e4;
2424
+ var PUSH_TIMEOUT_MS = 6e4;
2425
+ function collectPattern(value, previous) {
2426
+ return [...previous, value];
1528
2427
  }
1529
- function resolveDestinationRoot(destination) {
1530
- return __async(this, null, function* () {
1531
- yield mkdir(destination, { recursive: true });
1532
- return realpath(destination);
2428
+ function buildContextCommand() {
2429
+ const context = new Command8("context").description(
2430
+ "Sync local knowledge files into a project's Context"
2431
+ );
2432
+ context.command("push").description(
2433
+ "Push every text file under <dir> into the project's Context by its relative path"
2434
+ ).argument(
2435
+ "<dir>",
2436
+ "Folder to push (required; never the working directory by default)"
2437
+ ).requiredOption("--project <id>", "Project ID").option("--org <slug>", "Organization context").option(
2438
+ "--force",
2439
+ "On a conflict, replace the server's version once with this folder's"
2440
+ ).option("--dry-run", "Print the plan; send nothing and write no lock").option(
2441
+ "--exclude <pattern>",
2442
+ "Also leave out paths matching this gitignore pattern (repeatable)",
2443
+ collectPattern,
2444
+ []
2445
+ ).option(
2446
+ "--hook",
2447
+ "Hook mode: never fails, never blocks, one line on stderr on failure"
2448
+ ).option("--format <format>", "Output format: text|json").action(function(dir, opts) {
2449
+ return __async(this, null, function* () {
2450
+ yield run(opts, () => runPush(dir, opts, outputFormatFor(this)));
2451
+ });
1533
2452
  });
2453
+ return context;
1534
2454
  }
1535
- function assertSafeTarget(root, relativePath) {
2455
+ function run(opts, body) {
1536
2456
  return __async(this, null, function* () {
1537
- const target = path.resolve(root, relativePath);
1538
- if (target === root || !target.startsWith(root + path.sep)) {
1539
- return {
1540
- ok: false,
1541
- reason: "escapes_destination",
1542
- detail: relativePath
1543
- };
1544
- }
1545
- const segments = relativePath.split("/");
1546
- let current = root;
1547
- let deepestExisting = root;
1548
- for (const [index, segment] of segments.entries()) {
1549
- current = path.join(current, segment);
1550
- const stats = yield lstatOrNull(current);
1551
- if (stats === null) {
1552
- break;
1553
- }
1554
- if (stats.isSymbolicLink()) {
1555
- return { ok: false, reason: "symlink", detail: current };
2457
+ try {
2458
+ if (opts.hook) {
2459
+ yield withDeadline(HOOK_DEADLINE_MS, () => body());
2460
+ } else {
2461
+ yield body();
1556
2462
  }
1557
- if (index < segments.length - 1 && !stats.isDirectory()) {
1558
- return { ok: false, reason: "not_a_directory", detail: current };
2463
+ } catch (error) {
2464
+ const message = describeError(error);
2465
+ if (opts.hook) {
2466
+ process.stderr.write(`fabric: context push failed: ${message}
2467
+ `);
2468
+ process.exit(0);
1559
2469
  }
1560
- deepestExisting = current;
2470
+ process.stderr.write(`\u2717 ${message}
2471
+ `);
2472
+ process.exit(error instanceof CliFailure ? error.exitCode : 1);
1561
2473
  }
1562
- if (deepestExisting !== root) {
1563
- const canonical = yield realpath(deepestExisting).catch(() => null);
1564
- if (canonical !== null && canonical !== root && !canonical.startsWith(root + path.sep)) {
1565
- return {
1566
- ok: false,
1567
- reason: "escapes_destination",
1568
- detail: relativePath
1569
- };
1570
- }
2474
+ });
2475
+ }
2476
+ function contextClient(opts) {
2477
+ if (!getApiKey()) {
2478
+ throw new CliFailure(
2479
+ "Not authenticated. Run: fabric auth login --key <api-key>",
2480
+ 3
2481
+ );
2482
+ }
2483
+ return getClient(
2484
+ opts.hook ? { timeoutMs: HOOK_DEADLINE_MS, retry: { maxRetries: 0 } } : { timeoutMs: PUSH_TIMEOUT_MS }
2485
+ ).withoutContext();
2486
+ }
2487
+ function resolveFolder(dir) {
2488
+ return __async(this, null, function* () {
2489
+ const directory = path5.resolve(dir);
2490
+ const root = yield resolveExistingRoot(directory);
2491
+ const stats = yield lstat3(root).catch(() => null);
2492
+ if (stats === null || !stats.isDirectory()) {
2493
+ throw new CliFailure(`${directory} is not a directory.`, 2);
1571
2494
  }
1572
- return { ok: true, path: target };
2495
+ return root;
1573
2496
  });
1574
2497
  }
1575
- function assertWritableTarget(root, relativePath) {
2498
+ function readLockForProject(root, projectId) {
1576
2499
  return __async(this, null, function* () {
1577
- const check = yield assertSafeTarget(root, relativePath);
1578
- if (!check.ok) {
1579
- return check;
2500
+ let lock;
2501
+ try {
2502
+ lock = yield readContextLock(root);
2503
+ } catch (error) {
2504
+ throw new CliFailure(describeError(error), 7);
1580
2505
  }
1581
- const stats = yield lstatOrNull(check.path);
1582
- if (stats === null) {
1583
- return check;
2506
+ if (lock !== null && lock.projectId !== projectId) {
2507
+ throw new CliFailure(
2508
+ `${contextLockPath(root)} belongs to project ${lock.projectId}, not ${projectId}. Push this folder to ${lock.projectId}, or remove that lock if this folder should now feed ${projectId}.`,
2509
+ 7
2510
+ );
1584
2511
  }
1585
- if (stats.isSymbolicLink()) {
1586
- return { ok: false, reason: "symlink", detail: check.path };
2512
+ return lock;
2513
+ });
2514
+ }
2515
+ function print(lines) {
2516
+ process.stdout.write(`${lines.join("\n")}
2517
+ `);
2518
+ }
2519
+ function runPush(dir, opts, format) {
2520
+ return __async(this, null, function* () {
2521
+ const root = yield resolveFolder(dir);
2522
+ const client = opts.dryRun ? null : contextClient(opts);
2523
+ const lock = yield readLockForProject(root, opts.project);
2524
+ const plan = yield computeContextPlan({
2525
+ root,
2526
+ lock,
2527
+ excludes: opts.exclude
2528
+ });
2529
+ if (client === null) {
2530
+ if (format === "json") {
2531
+ printOutput(
2532
+ jsonOutcome({
2533
+ root,
2534
+ opts,
2535
+ plan,
2536
+ results: [],
2537
+ lockWritten: false
2538
+ }),
2539
+ { format: "json" }
2540
+ );
2541
+ return;
2542
+ }
2543
+ print(
2544
+ formatDryRunReport({
2545
+ projectId: opts.project,
2546
+ directory: root,
2547
+ plan
2548
+ })
2549
+ );
2550
+ return;
1587
2551
  }
1588
- if (!stats.isFile()) {
1589
- return { ok: false, reason: "not_a_regular_file", detail: check.path };
2552
+ const pushed = yield pushContextPlan({
2553
+ client,
2554
+ projectId: opts.project,
2555
+ org: opts.org,
2556
+ root,
2557
+ plan,
2558
+ force: Boolean(opts.force)
2559
+ });
2560
+ const next = nextContextLock({
2561
+ previous: lock,
2562
+ projectId: opts.project,
2563
+ results: pushed.results,
2564
+ forgotten: plan.forgotten,
2565
+ now: /* @__PURE__ */ new Date()
2566
+ });
2567
+ if (next !== null) {
2568
+ yield writeContextLock(root, next);
1590
2569
  }
1591
- return check;
2570
+ if (format === "json") {
2571
+ printOutput(
2572
+ jsonOutcome({
2573
+ root,
2574
+ opts,
2575
+ plan,
2576
+ results: pushed.results,
2577
+ lockWritten: next !== null
2578
+ }),
2579
+ { format: "json" }
2580
+ );
2581
+ } else {
2582
+ print(
2583
+ formatPushReport({
2584
+ projectId: opts.project,
2585
+ directory: root,
2586
+ plan,
2587
+ results: pushed.results
2588
+ })
2589
+ );
2590
+ }
2591
+ if (pushed.stoppedBy !== null) {
2592
+ throw pushed.stoppedBy;
2593
+ }
2594
+ throwIfIncomplete(pushed.results);
1592
2595
  });
1593
2596
  }
1594
- function readFileSafely(_0, _1) {
1595
- return __async(this, arguments, function* (root, relativePath, options = {}) {
1596
- const check = yield assertWritableTarget(root, relativePath);
1597
- if (!check.ok) {
1598
- throw new Error(`Refusing to sync: ${describeRejection(check)}.`);
2597
+ function jsonOutcome(input) {
2598
+ return contextPushJson({
2599
+ projectId: input.opts.project,
2600
+ directory: input.root,
2601
+ lockPath: contextLockPath(input.root),
2602
+ lockWritten: input.lockWritten,
2603
+ dryRun: Boolean(input.opts.dryRun),
2604
+ force: Boolean(input.opts.force),
2605
+ plan: input.plan,
2606
+ results: input.results
2607
+ });
2608
+ }
2609
+ function throwIfIncomplete(results) {
2610
+ const failed = results.filter((r) => r.status === "failed");
2611
+ const conflicts = results.filter((r) => r.status === "conflict");
2612
+ if (failed.length === 0 && conflicts.length === 0) {
2613
+ return;
2614
+ }
2615
+ const parts = [];
2616
+ if (conflicts.length > 0) {
2617
+ parts.push(
2618
+ `${conflicts.length} conflict${conflicts.length === 1 ? "" : "s"}`
2619
+ );
2620
+ }
2621
+ const first = failed[0];
2622
+ if (first && first.status === "failed") {
2623
+ parts.push(
2624
+ `${failed.length} failed, first ${first.sourcePath}: ${first.error}`
2625
+ );
2626
+ }
2627
+ const total = conflicts.length + failed.length;
2628
+ let message = `${total} of ${results.length} file${results.length === 1 ? "" : "s"} sent ${total === 1 ? "was" : "were"} not stored (${parts.join(", ")}).`;
2629
+ if (conflicts.length > 0) {
2630
+ message += " A conflict means the server holds a version this folder did not name, or deleted the one it named: compare the two in the project's Context tab, and use --force to replace the version the conflict reported with this folder's (or to send a deleted file again with no version, which recreates it, or answers duplicate if that content is already stored under another path).";
2631
+ }
2632
+ throw new CliFailure(
2633
+ message,
2634
+ first && first.status === "failed" ? first.exitCode : 1
2635
+ );
2636
+ }
2637
+
2638
+ // src/commands/ctx/index.ts
2639
+ import { Command as Command9 } from "commander";
2640
+ function buildCtxCommand() {
2641
+ const ctx = new Command9("ctx").description(
2642
+ "Manage the organization commands run in"
2643
+ );
2644
+ ctx.command("current").description("Show the active default context").action(() => {
2645
+ const stored = getDefaultContext();
2646
+ if (!stored) {
2647
+ printWarning(
2648
+ "No default context set. Use --org <slug> on each command, or run:\n fabric ctx use org <slug>"
2649
+ );
2650
+ return;
1599
2651
  }
1600
- let handle;
1601
- try {
1602
- handle = yield open(
1603
- check.path,
1604
- constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK
2652
+ if (stored.type === "personal") {
2653
+ printWarning(
2654
+ "Context: personal \u2014 retired, and no command will accept it.\nReplace it with:\n fabric ctx use org <slug>"
1605
2655
  );
1606
- } catch (error) {
1607
- if (error.code === "ENOENT") {
1608
- return null;
1609
- }
1610
- throw error;
2656
+ return;
1611
2657
  }
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;
2658
+ process.stdout.write(`Context: org:${stored.slug}
2659
+ `);
2660
+ });
2661
+ const use = new Command9("use").description(
2662
+ "Set the default organization for commands"
2663
+ );
2664
+ use.command("personal").description("Retired \u2014 context is organization-only").action(() => {
2665
+ printError(
2666
+ "Personal context no longer exists \u2014 every command runs inside an organization.\nSet one with:\n fabric ctx use org <slug>",
2667
+ 2
2668
+ );
2669
+ });
2670
+ use.command("org <slug>").description("Use an org context by default").action((slug) => {
2671
+ saveDefaultContext({ type: "org", slug });
2672
+ printSuccess(`Default context set to: org:${slug}`);
2673
+ });
2674
+ ctx.addCommand(use);
2675
+ return ctx;
2676
+ }
2677
+
2678
+ // src/commands/features/index.ts
2679
+ import { Command as Command10 } from "commander";
2680
+ function buildFeaturesCommand() {
2681
+ const features = new Command10("features").description(
2682
+ "Manage project features"
2683
+ );
2684
+ features.command("list").description("List features in a project").requiredOption("-p, --project <id>", "Project ID").option("--org <slug>", "Organization context").option("--personal", "Retired \u2014 context is organization-only").option(
2685
+ "--priority <priority>",
2686
+ "Filter: P0_CRITICAL|P1_HIGH|P2_MEDIUM|P3_LOW"
2687
+ ).option("--search <query>", "Search by title").option("--limit <n>", "Max results (default 50)", "50").option(
2688
+ "--format <format>",
2689
+ "Output format: table|json|yaml|csv",
2690
+ "table"
2691
+ ).option("-q, --quiet", "Print IDs only").action(
2692
+ (opts) => __async(null, null, function* () {
2693
+ const ctx = resolveContext(opts.personal, opts.org, false);
2694
+ const client = getClient();
2695
+ let list;
2696
+ try {
2697
+ list = yield client.projects.listFeatures(opts.project, {
2698
+ org: (ctx == null ? void 0 : ctx.type) === "org" ? ctx.slug : void 0,
2699
+ priority: opts.priority,
2700
+ search: opts.search,
2701
+ limit: Number(opts.limit)
2702
+ });
2703
+ } catch (err) {
2704
+ printError(err.message, 1);
1657
2705
  }
1658
- if (total > options.maxBytes) {
1659
- throw new Error(
1660
- `${relativePath} is too large to read safely (maximum ${options.maxBytes} bytes).`
1661
- );
2706
+ if (opts.quiet) {
2707
+ for (const f of list) {
2708
+ process.stdout.write(`${f.id}
2709
+ `);
2710
+ }
2711
+ return;
1662
2712
  }
1663
- return { bytes: buffer.subarray(0, total), mode: opened.mode };
1664
- } finally {
1665
- yield handle.close();
1666
- }
1667
- });
1668
- }
1669
- function resolveExistingRoot(destination) {
1670
- return __async(this, null, function* () {
1671
- return realpath(destination).catch(() => path.resolve(destination));
1672
- });
1673
- }
1674
- function makeParents(root, relativePath) {
1675
- return __async(this, null, function* () {
1676
- const segments = relativePath.split("/").slice(0, -1);
1677
- let current = root;
1678
- for (const segment of segments) {
1679
- current = path.join(current, segment);
1680
- const stats = yield lstatOrNull(current);
1681
- if (stats === null) {
1682
- yield mkdir(current).catch((error) => __async(null, null, function* () {
1683
- if (error.code !== "EEXIST") {
1684
- throw error;
1685
- }
1686
- const raced = yield lstatOrNull(current);
1687
- if (raced == null ? void 0 : raced.isSymbolicLink()) {
1688
- throw new Error(
1689
- `Refusing to sync: ${describeRejection({ ok: false, reason: "symlink", detail: current })}.`
1690
- );
2713
+ printOutput(list, {
2714
+ format: opts.format,
2715
+ columns: ["identifier", "title", "priority", "createdAt"]
2716
+ });
2717
+ })
2718
+ );
2719
+ features.command("show <id>").description("Show feature details").requiredOption("-p, --project <projectId>", "Project ID").option("--org <slug>", "Organization context").option("--personal", "Retired \u2014 context is organization-only").option("--format <format>", "Output format: table|json|yaml", "table").action(
2720
+ (id, opts) => __async(null, null, function* () {
2721
+ var _a2, _b, _c;
2722
+ const ctx = resolveContext(opts.personal, opts.org, false);
2723
+ const client = getClient();
2724
+ let feature;
2725
+ try {
2726
+ feature = yield client.projects.getFeature(
2727
+ opts.project,
2728
+ id,
2729
+ {
2730
+ org: (ctx == null ? void 0 : ctx.type) === "org" ? ctx.slug : void 0
1691
2731
  }
1692
- }));
1693
- continue;
1694
- }
1695
- if (stats.isSymbolicLink()) {
1696
- throw new Error(
1697
- `Refusing to sync: ${describeRejection({ ok: false, reason: "symlink", detail: current })}.`
1698
2732
  );
2733
+ } catch (err) {
2734
+ printError(err.message, 1);
1699
2735
  }
1700
- if (!stats.isDirectory()) {
1701
- throw new Error(
1702
- `Refusing to sync: ${describeRejection({ ok: false, reason: "not_a_directory", detail: current })}.`
2736
+ const fmt = opts.format;
2737
+ if (fmt === "json" || fmt === "yaml") {
2738
+ printOutput(feature, { format: fmt });
2739
+ return;
2740
+ }
2741
+ printRecord({
2742
+ ID: feature.id,
2743
+ Identifier: feature.identifier,
2744
+ Title: feature.title,
2745
+ Priority: feature.priority,
2746
+ Description: (_a2 = feature.description) != null ? _a2 : "(none)",
2747
+ "Acceptance Criteria": (_b = feature.acceptanceCriteria) != null ? _b : "(none)",
2748
+ Project: feature.projectId,
2749
+ Assignee: (_c = feature.assigneeId) != null ? _c : "(none)",
2750
+ Created: feature.createdAt.slice(0, 10)
2751
+ });
2752
+ })
2753
+ );
2754
+ features.command("create <title>").description("Create a feature in a project").requiredOption("-p, --project <projectId>", "Project ID").option("--org <slug>", "Organization context").option("--personal", "Retired \u2014 context is organization-only").option("-d, --description <text>", "Feature description").option(
2755
+ "--priority <priority>",
2756
+ "Priority: P0_CRITICAL|P1_HIGH|P2_MEDIUM|P3_LOW (default: P2_MEDIUM)"
2757
+ ).option("--format <format>", "Output format: table|json|yaml", "table").action(
2758
+ (title, opts) => __async(null, null, function* () {
2759
+ const ctx = resolveContext(opts.personal, opts.org, false);
2760
+ const client = getClient();
2761
+ let feature;
2762
+ try {
2763
+ feature = yield client.projects.createFeature(
2764
+ opts.project,
2765
+ {
2766
+ title,
2767
+ description: opts.description,
2768
+ priority: opts.priority,
2769
+ org: (ctx == null ? void 0 : ctx.type) === "org" ? ctx.slug : void 0
2770
+ }
1703
2771
  );
2772
+ } catch (err) {
2773
+ printError(err.message, 1);
1704
2774
  }
1705
- }
1706
- });
1707
- }
1708
- function writeFileSafely(input) {
1709
- return __async(this, null, function* () {
1710
- var _a2;
1711
- const check = yield assertSafeTarget(input.root, input.relativePath);
1712
- if (!check.ok) {
1713
- throw new Error(`Refusing to sync: ${describeRejection(check)}.`);
1714
- }
1715
- if (!isAllowedMode(input.mode)) {
1716
- throw new Error(
1717
- `Refusing to sync: ${input.relativePath} asks for file mode ${((_a2 = input.mode) != null ? _a2 : 0).toString(8)}, and only 644 and 755 are accepted.`
1718
- );
1719
- }
1720
- yield makeParents(input.root, input.relativePath);
1721
- const target = check.path;
1722
- const directory = path.dirname(target);
1723
- const directoryBefore = yield lstatOrNull(directory);
1724
- const temporary = path.join(
1725
- directory,
1726
- `.${path.basename(target)}.${randomBytes(8).toString("hex")}.tmp`
1727
- );
1728
- let handle = null;
1729
- try {
1730
- handle = yield open(temporary, "wx", 384);
1731
- yield handle.writeFile(input.bytes);
1732
- const bits = permissionBits(input.mode);
1733
- if (bits !== null && process.platform !== "win32") {
1734
- yield handle.chmod(bits);
2775
+ const fmt = opts.format;
2776
+ if (fmt === "json" || fmt === "yaml") {
2777
+ printOutput(feature, { format: fmt });
2778
+ return;
1735
2779
  }
1736
- yield handle.close();
1737
- handle = null;
1738
- const parentStats = yield lstatOrNull(directory);
1739
- if (parentStats === null || parentStats.isSymbolicLink()) {
1740
- throw new Error(
1741
- `Refusing to sync: ${describeRejection({ ok: false, reason: "symlink", detail: directory })}.`
2780
+ printSuccess(`Feature ${feature.identifier} created`);
2781
+ printRecord({
2782
+ ID: feature.id,
2783
+ Identifier: feature.identifier,
2784
+ Title: feature.title,
2785
+ Priority: feature.priority,
2786
+ Project: feature.projectId,
2787
+ Created: feature.createdAt.slice(0, 10)
2788
+ });
2789
+ })
2790
+ );
2791
+ features.command("tasks").description("List tasks (sub-items) for a feature").requiredOption("-p, --project <projectId>", "Project ID").requiredOption("-f, --feature <featureId>", "Feature ID").option("--org <slug>", "Organization context").option("--personal", "Retired \u2014 context is organization-only").option(
2792
+ "--format <format>",
2793
+ "Output format: table|json|yaml|csv",
2794
+ "table"
2795
+ ).option("-q, --quiet", "Print IDs only").action(
2796
+ (opts) => __async(null, null, function* () {
2797
+ const ctx = resolveContext(opts.personal, opts.org, false);
2798
+ const client = getClient();
2799
+ let tasks;
2800
+ try {
2801
+ tasks = yield client.projects.listTasks(
2802
+ opts.project,
2803
+ opts.feature,
2804
+ {
2805
+ org: (ctx == null ? void 0 : ctx.type) === "org" ? ctx.slug : void 0
2806
+ }
1742
2807
  );
2808
+ } catch (err) {
2809
+ printError(err.message, 1);
1743
2810
  }
1744
- const targetStats = yield lstatOrNull(target);
1745
- if (targetStats == null ? void 0 : targetStats.isSymbolicLink()) {
1746
- throw new Error(
1747
- `Refusing to sync: ${describeRejection({ ok: false, reason: "symlink", detail: target })}.`
1748
- );
2811
+ if (opts.quiet) {
2812
+ for (const t of tasks) {
2813
+ process.stdout.write(`${t.id}
2814
+ `);
2815
+ }
2816
+ return;
1749
2817
  }
1750
- yield rename(temporary, target);
1751
- } catch (error) {
1752
- yield handle == null ? void 0 : handle.close().catch(() => {
2818
+ printOutput(tasks, {
2819
+ format: opts.format,
2820
+ columns: [
2821
+ "identifier",
2822
+ "title",
2823
+ "status",
2824
+ "estimatedHours"
2825
+ ]
1753
2826
  });
1754
- if (yield isSameDirectory(directory, directoryBefore)) {
1755
- yield unlink(temporary).catch(() => {
1756
- });
1757
- }
1758
- throw error;
1759
- }
1760
- });
1761
- }
1762
- function isSameDirectory(directory, before) {
1763
- return __async(this, null, function* () {
1764
- if (before === null) {
1765
- return false;
1766
- }
1767
- const now = yield lstatOrNull(directory);
1768
- return now !== null && now.dev === before.dev && now.ino === before.ino;
1769
- });
1770
- }
1771
- function deleteFileSafely(root, relativePath, expectedSha256) {
1772
- return __async(this, null, function* () {
1773
- const check = yield assertSafeTarget(root, relativePath);
1774
- if (!check.ok) {
1775
- throw new Error(`Refusing to sync: ${describeRejection(check)}.`);
1776
- }
1777
- const stats = yield lstatOrNull(check.path);
1778
- if (stats === null) {
1779
- return "missing";
1780
- }
1781
- if (stats.isSymbolicLink()) {
1782
- throw new Error(
1783
- `Refusing to sync: ${describeRejection({ ok: false, reason: "symlink", detail: check.path })}.`
1784
- );
1785
- }
1786
- if (!stats.isFile()) {
1787
- throw new Error(
1788
- `Refusing to sync: ${describeRejection({ ok: false, reason: "not_a_regular_file", detail: check.path })}.`
1789
- );
1790
- }
1791
- let actual;
1792
- try {
1793
- actual = createHash("sha256").update(yield readFile(check.path)).digest("hex");
1794
- } catch (error) {
1795
- if (error.code === "ENOENT") {
1796
- return "missing";
2827
+ })
2828
+ );
2829
+ features.command("update <id>").description("Update a feature").requiredOption("-p, --project <projectId>", "Project ID").option("--org <slug>", "Organization context").option("--personal", "Retired \u2014 context is organization-only").option("--title <title>", "New title").option("-d, --description <text>", "New description").option(
2830
+ "--priority <priority>",
2831
+ "Priority: P0_CRITICAL|P1_HIGH|P2_MEDIUM|P3_LOW"
2832
+ ).option("--format <format>", "Output format: table|json|yaml", "table").action(
2833
+ (id, opts) => __async(null, null, function* () {
2834
+ const ctx = resolveContext(opts.personal, opts.org, false);
2835
+ const client = getClient();
2836
+ let feature;
2837
+ try {
2838
+ feature = yield client.projects.updateFeature(
2839
+ opts.project,
2840
+ id,
2841
+ {
2842
+ title: opts.title,
2843
+ description: opts.description,
2844
+ priority: opts.priority,
2845
+ org: (ctx == null ? void 0 : ctx.type) === "org" ? ctx.slug : void 0
2846
+ }
2847
+ );
2848
+ } catch (err) {
2849
+ printError(err.message, 1);
1797
2850
  }
1798
- throw error;
1799
- }
1800
- if (actual !== expectedSha256) {
1801
- return "modified";
1802
- }
1803
- try {
1804
- yield unlink(check.path);
1805
- return "deleted";
1806
- } catch (error) {
1807
- if (error.code === "ENOENT") {
1808
- return "missing";
2851
+ const fmt = opts.format;
2852
+ if (fmt === "json" || fmt === "yaml") {
2853
+ printOutput(feature, { format: fmt });
2854
+ return;
1809
2855
  }
1810
- throw error;
1811
- }
1812
- });
2856
+ printRecord({
2857
+ ID: feature.id,
2858
+ Identifier: feature.identifier,
2859
+ Title: feature.title,
2860
+ Priority: feature.priority,
2861
+ Updated: feature.updatedAt.slice(0, 10)
2862
+ });
2863
+ })
2864
+ );
2865
+ return features;
1813
2866
  }
1814
- function lstatOrNull(target) {
1815
- return __async(this, null, function* () {
1816
- try {
1817
- return yield lstat(target);
1818
- } catch (error) {
1819
- if (error.code === "ENOENT") {
1820
- return null;
2867
+
2868
+ // src/commands/frames/index.ts
2869
+ import { Command as Command11 } from "commander";
2870
+ function buildFramesCommand() {
2871
+ const frames = new Command11("frames").description(
2872
+ "Manage AI-generated frames and slideshows"
2873
+ );
2874
+ frames.command("list").description("List frames and slideshows").option("--org <slug>", "Organization context").option("--personal", "Retired \u2014 context is organization-only").option("--limit <n>", "Max results (default 50)", "50").option(
2875
+ "--format <format>",
2876
+ "Output format: table|json|yaml|csv",
2877
+ "table"
2878
+ ).option("-q, --quiet", "Print IDs only").action(
2879
+ (opts) => __async(null, null, function* () {
2880
+ const ctx = resolveContext(opts.personal, opts.org, false);
2881
+ const client = getClient();
2882
+ let list;
2883
+ try {
2884
+ list = yield client.frames.list({
2885
+ org: (ctx == null ? void 0 : ctx.type) === "org" ? ctx.slug : void 0,
2886
+ limit: Number(opts.limit)
2887
+ });
2888
+ } catch (err) {
2889
+ printError(err.message, 1);
1821
2890
  }
1822
- throw error;
1823
- }
1824
- });
1825
- }
1826
- function resolvesInside(root, candidate) {
1827
- return __async(this, null, function* () {
1828
- const canonicalRoot = yield realpath(root).catch(() => path.resolve(root));
1829
- const canonicalCandidate = yield realpathDeepest(candidate);
1830
- return canonicalCandidate === canonicalRoot || canonicalCandidate.startsWith(canonicalRoot + path.sep);
1831
- });
1832
- }
1833
- function realpathDeepest(candidate) {
1834
- return __async(this, null, function* () {
1835
- const absolute = path.resolve(candidate);
1836
- const tail = [];
1837
- let current = absolute;
1838
- for (; ; ) {
1839
- const resolved = yield realpath(current).catch(() => null);
1840
- if (resolved !== null) {
1841
- return tail.length === 0 ? resolved : path.join(resolved, ...tail);
2891
+ if (opts.quiet) {
2892
+ for (const f of list) {
2893
+ process.stdout.write(`${f.id}
2894
+ `);
2895
+ }
2896
+ return;
1842
2897
  }
1843
- const parent = path.dirname(current);
1844
- if (parent === current) {
1845
- return absolute;
2898
+ printOutput(list, {
2899
+ format: opts.format,
2900
+ columns: [
2901
+ "id",
2902
+ "title",
2903
+ "fileType",
2904
+ "status",
2905
+ "isPublic",
2906
+ "updatedAt"
2907
+ ]
2908
+ });
2909
+ })
2910
+ );
2911
+ frames.command("show <id>").description("Show frame details").option("--org <slug>", "Organization context").option("--personal", "Retired \u2014 context is organization-only").option("--format <format>", "Output format: table|json|yaml", "table").action(
2912
+ (id, opts) => __async(null, null, function* () {
2913
+ var _a2, _b, _c;
2914
+ const ctx = resolveContext(opts.personal, opts.org, false);
2915
+ const client = getClient();
2916
+ let frame;
2917
+ try {
2918
+ frame = yield client.frames.get(id, {
2919
+ org: (ctx == null ? void 0 : ctx.type) === "org" ? ctx.slug : void 0
2920
+ });
2921
+ } catch (err) {
2922
+ printError(err.message, 1);
1846
2923
  }
1847
- tail.unshift(path.basename(current));
1848
- current = parent;
1849
- }
1850
- });
2924
+ const fmt = opts.format;
2925
+ if (fmt === "json" || fmt === "yaml") {
2926
+ printOutput(frame, { format: fmt });
2927
+ return;
2928
+ }
2929
+ printRecord({
2930
+ ID: frame.id,
2931
+ Title: frame.title,
2932
+ Kind: (_a2 = frame.kind) != null ? _a2 : "(none)",
2933
+ "File Type": frame.fileType,
2934
+ Status: frame.status,
2935
+ Public: frame.isPublic ? "yes" : "no",
2936
+ "Share Token": (_b = frame.shareToken) != null ? _b : "(none)",
2937
+ Description: (_c = frame.description) != null ? _c : "(none)",
2938
+ Updated: frame.updatedAt.slice(0, 10)
2939
+ });
2940
+ })
2941
+ );
2942
+ return frames;
1851
2943
  }
1852
2944
 
2945
+ // src/commands/instructions/index.ts
2946
+ import path8 from "path";
2947
+ import { Command as Command12 } from "commander";
2948
+
1853
2949
  // src/lib/instructions/apply.ts
2950
+ import { createHash as createHash3 } from "crypto";
2951
+ import { chmod } from "fs/promises";
1854
2952
  function applyPlan(input) {
1855
2953
  return __async(this, null, function* () {
1856
2954
  var _a2, _b;
@@ -1880,7 +2978,7 @@ function applyPlan(input) {
1880
2978
  `The downloaded bundle is missing ${entry.path}. Nothing was written.`
1881
2979
  );
1882
2980
  }
1883
- const actual = createHash2("sha256").update(bytes).digest("hex");
2981
+ const actual = createHash3("sha256").update(bytes).digest("hex");
1884
2982
  if (actual !== entry.sha256) {
1885
2983
  throw new Error(
1886
2984
  `Checksum mismatch for ${entry.path}: the published snapshot expects ${entry.sha256} and the downloaded bundle contains ${actual}. Nothing was written.`
@@ -2081,12 +3179,12 @@ function extractBundle(archive, wanted) {
2081
3179
  }
2082
3180
 
2083
3181
  // src/lib/instructions/hook.ts
2084
- import path2 from "path";
2085
- var CLAUDE_SETTINGS_RELATIVE_PATH = path2.join(
3182
+ import path6 from "path";
3183
+ var CLAUDE_SETTINGS_RELATIVE_PATH = path6.join(
2086
3184
  ".claude",
2087
3185
  "settings.local.json"
2088
3186
  );
2089
- var CODEX_HOOKS_RELATIVE_PATH = path2.join(".codex", "hooks.json");
3187
+ var CODEX_HOOKS_RELATIVE_PATH = path6.join(".codex", "hooks.json");
2090
3188
  var CLAUDE_SETTINGS_POSIX_PATH = ".claude/settings.local.json";
2091
3189
  var CODEX_HOOKS_POSIX_PATH = ".codex/hooks.json";
2092
3190
  var HOOK_TIMEOUT_SECONDS = 15;
@@ -2110,7 +3208,7 @@ function mergeSessionStartHook(input) {
2110
3208
  return __async(this, null, function* () {
2111
3209
  var _a2;
2112
3210
  const hookTarget = hookTargetFor((_a2 = input.tool) != null ? _a2 : "claude-code");
2113
- const settingsPath = path2.join(input.root, hookTarget.relativePath);
3211
+ const settingsPath = path6.join(input.root, hookTarget.relativePath);
2114
3212
  let existingRaw;
2115
3213
  try {
2116
3214
  const existing = yield readFileSafely(
@@ -2150,14 +3248,14 @@ function mergeSessionStartHook(input) {
2150
3248
  throw refuseShape(settingsPath, '"hooks.SessionStart" is not an array');
2151
3249
  }
2152
3250
  const rawGroups = Array.isArray(hooks.SessionStart) ? hooks.SessionStart : [];
2153
- for (const [index, group] of rawGroups.entries()) {
2154
- if (!isRecord(group)) {
3251
+ for (const [index, group2] of rawGroups.entries()) {
3252
+ if (!isRecord(group2)) {
2155
3253
  throw refuseShape(
2156
3254
  settingsPath,
2157
3255
  `"hooks.SessionStart[${index}]" is not an object`
2158
3256
  );
2159
3257
  }
2160
- if (group.hooks !== void 0 && !Array.isArray(group.hooks)) {
3258
+ if (group2.hooks !== void 0 && !Array.isArray(group2.hooks)) {
2161
3259
  throw refuseShape(
2162
3260
  settingsPath,
2163
3261
  `"hooks.SessionStart[${index}].hooks" is not an array`
@@ -2165,8 +3263,8 @@ function mergeSessionStartHook(input) {
2165
3263
  }
2166
3264
  }
2167
3265
  const sessionStart = rawGroups.map(
2168
- (group) => __spreadProps(__spreadValues({}, group), {
2169
- hooks: Array.isArray(group == null ? void 0 : group.hooks) ? [...group.hooks] : []
3266
+ (group2) => __spreadProps(__spreadValues({}, group2), {
3267
+ hooks: Array.isArray(group2 == null ? void 0 : group2.hooks) ? [...group2.hooks] : []
2170
3268
  })
2171
3269
  );
2172
3270
  const replacement = {
@@ -2176,16 +3274,16 @@ function mergeSessionStartHook(input) {
2176
3274
  };
2177
3275
  let replacedCount = 0;
2178
3276
  const pruned = [];
2179
- for (const group of sessionStart) {
2180
- const kept = group.hooks.filter(
3277
+ for (const group2 of sessionStart) {
3278
+ const kept = group2.hooks.filter(
2181
3279
  (hook) => !isFabricHookFor(hook, input.projectId)
2182
3280
  );
2183
- const removed = group.hooks.length - kept.length;
3281
+ const removed = group2.hooks.length - kept.length;
2184
3282
  replacedCount += removed;
2185
3283
  if (kept.length === 0 && removed > 0) {
2186
3284
  continue;
2187
3285
  }
2188
- pruned.push(__spreadProps(__spreadValues({}, group), { hooks: kept }));
3286
+ pruned.push(__spreadProps(__spreadValues({}, group2), { hooks: kept }));
2189
3287
  }
2190
3288
  pruned.push({ hooks: [replacement] });
2191
3289
  const next = __spreadProps(__spreadValues({}, settings), {
@@ -2258,14 +3356,14 @@ function isRecord(value) {
2258
3356
 
2259
3357
  // src/lib/instructions/lock.ts
2260
3358
  import { readFile as readFile2 } from "fs/promises";
2261
- import path3 from "path";
3359
+ import path7 from "path";
2262
3360
  var LOCK_DIRECTORY = ".fabric";
2263
3361
  var LOCK_FILENAME = "instructions.lock";
2264
3362
  var LOCK_VERSION = 1;
2265
3363
  var LOCK_RELATIVE_PATH = `${LOCK_DIRECTORY}/${LOCK_FILENAME}`;
2266
- var SHA256_HEX = /^[0-9a-f]{64}$/;
3364
+ var SHA256_HEX2 = /^[0-9a-f]{64}$/;
2267
3365
  function lockPath(destination) {
2268
- return path3.join(destination, LOCK_DIRECTORY, LOCK_FILENAME);
3366
+ return path7.join(destination, LOCK_DIRECTORY, LOCK_FILENAME);
2269
3367
  }
2270
3368
  function readLock(destination) {
2271
3369
  return __async(this, null, function* () {
@@ -2287,7 +3385,7 @@ function readLock(destination) {
2287
3385
  `${file} is unreadable: it is not valid JSON. Delete it to start from a clean sync.`
2288
3386
  );
2289
3387
  }
2290
- const problem = lockProblem(parsed);
3388
+ const problem = lockProblem2(parsed);
2291
3389
  if (problem !== null) {
2292
3390
  throw new Error(
2293
3391
  `${file} is unreadable: ${problem}. Delete it to start from a clean sync.`
@@ -2296,7 +3394,7 @@ function readLock(destination) {
2296
3394
  return parsed;
2297
3395
  });
2298
3396
  }
2299
- function serializeLock(lock) {
3397
+ function serializeLock2(lock) {
2300
3398
  return `${JSON.stringify(lock, null, 2).replace(/\r\n/g, "\n")}
2301
3399
  `;
2302
3400
  }
@@ -2305,11 +3403,11 @@ function writeLock(root, lock) {
2305
3403
  yield writeFileSafely({
2306
3404
  root,
2307
3405
  relativePath: LOCK_RELATIVE_PATH,
2308
- bytes: new TextEncoder().encode(serializeLock(lock))
3406
+ bytes: new TextEncoder().encode(serializeLock2(lock))
2309
3407
  });
2310
3408
  });
2311
3409
  }
2312
- function lockProblem(value) {
3410
+ function lockProblem2(value) {
2313
3411
  if (typeof value !== "object" || value === null || Array.isArray(value)) {
2314
3412
  return "it is not a JSON object";
2315
3413
  }
@@ -2341,7 +3439,7 @@ function lockProblem(value) {
2341
3439
  return `its entry for ${JSON.stringify(filePath)} is not an object`;
2342
3440
  }
2343
3441
  const record = entry;
2344
- if (typeof record.sha256 !== "string" || !SHA256_HEX.test(record.sha256)) {
3442
+ if (typeof record.sha256 !== "string" || !SHA256_HEX2.test(record.sha256)) {
2345
3443
  return `its entry for ${JSON.stringify(filePath)} has no 64-character hex sha256`;
2346
3444
  }
2347
3445
  if (record.mode !== null && (typeof record.mode !== "number" || !Number.isInteger(record.mode) || record.mode < 0)) {
@@ -2352,8 +3450,8 @@ function lockProblem(value) {
2352
3450
  }
2353
3451
 
2354
3452
  // src/lib/instructions/manifest.ts
2355
- import { createHash as createHash3 } from "crypto";
2356
- var SHA256_HEX2 = /^[0-9a-f]{64}$/;
3453
+ import { createHash as createHash4 } from "crypto";
3454
+ var SHA256_HEX3 = /^[0-9a-f]{64}$/;
2357
3455
  var MANIFEST_LIMITS = {
2358
3456
  maxFiles: 5e3,
2359
3457
  maxTotalBytes: 52428800,
@@ -2372,7 +3470,7 @@ function maxArchiveBytes(entries) {
2372
3470
  function computeSnapshotDigest(entries) {
2373
3471
  const lines = [...entries].sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0).map((entry) => `${entry.path}\0${entry.sha256}
2374
3472
  `).join("");
2375
- return createHash3("sha256").update(Buffer.from(lines, "utf8")).digest("hex");
3473
+ return createHash4("sha256").update(Buffer.from(lines, "utf8")).digest("hex");
2376
3474
  }
2377
3475
  function assertValidManifest(input) {
2378
3476
  const { manifest, snapshot } = input;
@@ -2458,7 +3556,7 @@ function validateEntry(raw) {
2458
3556
  })}.`
2459
3557
  );
2460
3558
  }
2461
- if (typeof entry.sha256 !== "string" || !SHA256_HEX2.test(entry.sha256)) {
3559
+ if (typeof entry.sha256 !== "string" || !SHA256_HEX3.test(entry.sha256)) {
2462
3560
  throw new Error(
2463
3561
  `The manifest entry for ${entry.path} has no 64-character hex sha256. Nothing was changed.`
2464
3562
  );
@@ -2493,11 +3591,11 @@ function validateEntry(raw) {
2493
3591
  }
2494
3592
 
2495
3593
  // src/lib/instructions/plan.ts
2496
- import { createHash as createHash4 } from "crypto";
3594
+ import { createHash as createHash5 } from "crypto";
2497
3595
  function hashLocalFile(root, relativePath) {
2498
3596
  return __async(this, null, function* () {
2499
3597
  const read = yield readFileSafely(root, relativePath);
2500
- return read === null ? null : createHash4("sha256").update(read.bytes).digest("hex");
3598
+ return read === null ? null : createHash5("sha256").update(read.bytes).digest("hex");
2501
3599
  });
2502
3600
  }
2503
3601
  function lockPaths(lock) {
@@ -2550,7 +3648,7 @@ function verifyLedger(input) {
2550
3648
  drifted.push(`${lockedPath} (missing)`);
2551
3649
  continue;
2552
3650
  }
2553
- const actual = createHash4("sha256").update(read.bytes).digest("hex");
3651
+ const actual = createHash5("sha256").update(read.bytes).digest("hex");
2554
3652
  if (actual !== locked.sha256) {
2555
3653
  drifted.push(`${lockedPath} (edited)`);
2556
3654
  continue;
@@ -2658,7 +3756,7 @@ function nextLock(input) {
2658
3756
  }
2659
3757
 
2660
3758
  // src/lib/instructions/push.ts
2661
- import { createHash as createHash5 } from "crypto";
3759
+ import { createHash as createHash6 } from "crypto";
2662
3760
  var MAX_PUSH_CHANGES = 50;
2663
3761
  function assertPushablePath(candidate, source) {
2664
3762
  const check = checkRelativePath(candidate);
@@ -2697,16 +3795,16 @@ function assertLockMatchesManifest(lock, manifest) {
2697
3795
  const published = new Map(
2698
3796
  manifest.map((entry) => [entry.path, entry.sha256])
2699
3797
  );
2700
- for (const [path5, entry] of Object.entries(lock.files)) {
2701
- const expected = published.get(path5);
3798
+ for (const [path9, entry] of Object.entries(lock.files)) {
3799
+ const expected = published.get(path9);
2702
3800
  if (expected === void 0) {
2703
3801
  refuseStaleLedger(
2704
- `it lists ${path5}, which version ${lock.snapshotVersion} does not contain`
3802
+ `it lists ${path9}, which version ${lock.snapshotVersion} does not contain`
2705
3803
  );
2706
3804
  }
2707
3805
  if (expected !== entry.sha256) {
2708
3806
  refuseStaleLedger(
2709
- `its record of ${path5} is not the published file`
3807
+ `its record of ${path9} is not the published file`
2710
3808
  );
2711
3809
  }
2712
3810
  }
@@ -2772,7 +3870,7 @@ function computePushPlan(input) {
2772
3870
  changes.push({ op: "delete", path: lockedPath });
2773
3871
  continue;
2774
3872
  }
2775
- const actual = createHash5("sha256").update(read.bytes).digest("hex");
3873
+ const actual = createHash6("sha256").update(read.bytes).digest("hex");
2776
3874
  if (actual === ((_b = lock.files[lockedPath]) == null ? void 0 : _b.sha256)) {
2777
3875
  unchanged.push(lockedPath);
2778
3876
  continue;
@@ -2801,23 +3899,16 @@ function computePushPlan(input) {
2801
3899
  }
2802
3900
 
2803
3901
  // src/commands/instructions/index.ts
2804
- var HOOK_DEADLINE_MS = 1e4;
3902
+ var HOOK_DEADLINE_MS2 = 1e4;
2805
3903
  var CHECK_TIMEOUT_MS = 5e3;
2806
3904
  var SYNC_TIMEOUT_MS = 15e3;
2807
3905
  var BUNDLE_TIMEOUT_MS = 6e4;
2808
- var PUSH_TIMEOUT_MS = 6e4;
3906
+ var PUSH_TIMEOUT_MS2 = 6e4;
2809
3907
  function collectAdded(value, previous) {
2810
3908
  return [...previous, value];
2811
3909
  }
2812
- var CliFailure = class extends Error {
2813
- constructor(message, exitCode) {
2814
- super(message);
2815
- this.exitCode = exitCode;
2816
- this.name = "CliFailure";
2817
- }
2818
- };
2819
3910
  function buildInstructionsCommand() {
2820
- const instructions = new Command11("instructions").description(
3911
+ const instructions = new Command12("instructions").description(
2821
3912
  "Keep a checkout current with a project's published coding instructions"
2822
3913
  );
2823
3914
  instructions.command("check").description(
@@ -2830,7 +3921,11 @@ function buildInstructionsCommand() {
2830
3921
  "Session-hook mode: plain text, never fails, never blocks a session"
2831
3922
  ).option("--format <format>", "Output format: text|json").action(function(opts) {
2832
3923
  return __async(this, null, function* () {
2833
- yield run(opts, "check", () => runCheck(opts, formatFor(this)));
3924
+ yield run2(
3925
+ opts,
3926
+ "check",
3927
+ () => runCheck(opts, outputFormatFor(this))
3928
+ );
2834
3929
  });
2835
3930
  });
2836
3931
  instructions.command("sync").description(
@@ -2840,22 +3935,25 @@ function buildInstructionsCommand() {
2840
3935
  "Session-hook mode: plain text, never fails, never blocks a session"
2841
3936
  ).option("--format <format>", "Output format: text|json").action(function(opts) {
2842
3937
  return __async(this, null, function* () {
2843
- yield run(opts, "sync", () => runSync(opts, formatFor(this)));
3938
+ yield run2(opts, "sync", () => runSync(opts, outputFormatFor(this)));
2844
3939
  });
2845
3940
  });
2846
3941
  instructions.command("push").description(
2847
- "Suggest this checkout's edits to the project's coding instructions"
3942
+ "Propose this checkout's edits to the project's coding instructions, or publish them with --publish"
2848
3943
  ).requiredOption("--project <id>", "Project ID").option("--dest <dir>", "Destination directory (default: cwd)").option("--org <slug>", "Organization context").option(
2849
3944
  "--add <path>",
2850
3945
  "Also send a file the last sync did not write (repeatable)",
2851
3946
  collectAdded,
2852
3947
  []
3948
+ ).option(
3949
+ "--publish",
3950
+ "Publish the change as a new version instead of proposing it (needs a key with instructions:publish)"
2853
3951
  ).option("--dry-run", "Print the change set and send nothing").option("--format <format>", "Output format: text|json").action(function(opts) {
2854
3952
  return __async(this, null, function* () {
2855
- yield run(
3953
+ yield run2(
2856
3954
  __spreadProps(__spreadValues({}, opts), { hook: false }),
2857
3955
  "push",
2858
- () => runPush(opts, formatFor(this))
3956
+ () => runPush2(opts, outputFormatFor(this))
2859
3957
  );
2860
3958
  });
2861
3959
  });
@@ -2869,24 +3967,25 @@ function buildInstructionsCommand() {
2869
3967
  "Let the hook apply changes instead of only reporting them"
2870
3968
  ).option("--format <format>", "Output format: text|json").action(function(opts) {
2871
3969
  return __async(this, null, function* () {
2872
- yield run(
3970
+ yield run2(
2873
3971
  __spreadProps(__spreadValues({}, opts), { hook: false }),
2874
3972
  "init",
2875
- () => runInit(opts, formatFor(this))
3973
+ () => runInit(opts, outputFormatFor(this))
2876
3974
  );
2877
3975
  });
2878
3976
  });
2879
3977
  return instructions;
2880
3978
  }
2881
- function formatFor(command) {
2882
- const resolved = command.optsWithGlobals();
2883
- return resolved.format === "json" ? "json" : "text";
2884
- }
2885
- function run(opts, verb, body) {
3979
+ function run2(opts, verb, body) {
2886
3980
  return __async(this, null, function* () {
2887
3981
  try {
2888
3982
  if (opts.hook) {
2889
- yield withDeadline(HOOK_DEADLINE_MS, body);
3983
+ yield withDeadline(HOOK_DEADLINE_MS2, (signal) => {
3984
+ activeDeadline = signal;
3985
+ return body();
3986
+ }).finally(() => {
3987
+ activeDeadline = void 0;
3988
+ });
2890
3989
  } else {
2891
3990
  yield body();
2892
3991
  }
@@ -2905,63 +4004,10 @@ function run(opts, verb, body) {
2905
4004
  }
2906
4005
  });
2907
4006
  }
2908
- function withDeadline(totalMs, body) {
2909
- return __async(this, null, function* () {
2910
- const controller = new AbortController();
2911
- let timer;
2912
- const deadline = new Promise((_resolve, reject) => {
2913
- timer = setTimeout(() => {
2914
- controller.abort();
2915
- reject(
2916
- new Error(
2917
- `gave up after ${totalMs}ms so the session is not held up`
2918
- )
2919
- );
2920
- }, totalMs);
2921
- });
2922
- activeDeadline = controller.signal;
2923
- try {
2924
- yield Promise.race([body(), deadline]);
2925
- } finally {
2926
- activeDeadline = void 0;
2927
- if (timer) {
2928
- clearTimeout(timer);
2929
- }
2930
- }
2931
- });
2932
- }
2933
4007
  var activeDeadline;
2934
- function describeError(error) {
2935
- if (error instanceof Error) {
2936
- return error.message.replace(/\s*\n\s*/g, " ");
2937
- }
2938
- return String(error);
2939
- }
2940
- function asCliFailure(error) {
2941
- if (error instanceof CliFailure) {
2942
- return error;
2943
- }
2944
- const status = error.status;
2945
- const message = error instanceof Error ? error.message : String(error);
2946
- switch (status) {
2947
- case 401:
2948
- return new CliFailure(message, 3);
2949
- case 403:
2950
- return new CliFailure(message, 5);
2951
- case 404:
2952
- return new CliFailure(message, 4);
2953
- case 429:
2954
- return new CliFailure(message, 6);
2955
- case 400:
2956
- case 422:
2957
- return new CliFailure(message, 7);
2958
- default:
2959
- return new CliFailure(message, 1);
2960
- }
2961
- }
2962
4008
  function destinationOf(opts) {
2963
4009
  var _a2;
2964
- return path4.resolve((_a2 = opts.dest) != null ? _a2 : process.cwd());
4010
+ return path8.resolve((_a2 = opts.dest) != null ? _a2 : process.cwd());
2965
4011
  }
2966
4012
  function instructionsClient(opts, timeoutMs, { neverRetry = false } = {}) {
2967
4013
  if (!getApiKey()) {
@@ -2976,7 +4022,7 @@ function instructionsClient(opts, timeoutMs, { neverRetry = false } = {}) {
2976
4022
  // call it makes, so it cannot spend it on retries: a
2977
4023
  // session that will not start is worse than instructions
2978
4024
  // one version stale.
2979
- timeoutMs: Math.min(timeoutMs, HOOK_DEADLINE_MS),
4025
+ timeoutMs: Math.min(timeoutMs, HOOK_DEADLINE_MS2),
2980
4026
  retry: { maxRetries: 0 }
2981
4027
  } : neverRetry ? { timeoutMs, retry: { maxRetries: 0 } } : { timeoutMs }
2982
4028
  ).withoutContext();
@@ -2984,7 +4030,7 @@ function instructionsClient(opts, timeoutMs, { neverRetry = false } = {}) {
2984
4030
  function downloadUrlClient(opts) {
2985
4031
  return instructionsClient(
2986
4032
  opts,
2987
- opts.hook ? HOOK_DEADLINE_MS : BUNDLE_TIMEOUT_MS,
4033
+ opts.hook ? HOOK_DEADLINE_MS2 : BUNDLE_TIMEOUT_MS,
2988
4034
  { neverRetry: true }
2989
4035
  );
2990
4036
  }
@@ -3015,7 +4061,7 @@ function assertHookMayAct(opts, published) {
3015
4061
  );
3016
4062
  }
3017
4063
  }
3018
- function readLockForProject(destination, projectId) {
4064
+ function readLockForProject2(destination, projectId) {
3019
4065
  return __async(this, null, function* () {
3020
4066
  const lock = yield readLock(destination);
3021
4067
  if (lock !== null && lock.projectId !== projectId) {
@@ -3045,7 +4091,7 @@ function runCheck(opts, format) {
3045
4091
  var _a2;
3046
4092
  const destination = destinationOf(opts);
3047
4093
  const root = yield resolveExistingRoot(destination);
3048
- const lock = yield readLockForProject(destination, opts.project);
4094
+ const lock = yield readLockForProject2(destination, opts.project);
3049
4095
  const client = instructionsClient(opts, CHECK_TIMEOUT_MS);
3050
4096
  const published = yield fetchPublished(client, opts, lock == null ? void 0 : lock.digest);
3051
4097
  const drifted = opts.verify && lock !== null ? yield verifyLedger({ root, lock }) : [];
@@ -3154,7 +4200,7 @@ function syncOnce(opts) {
3154
4200
  return __async(this, null, function* () {
3155
4201
  const root = yield resolveDestinationRoot(destinationOf(opts));
3156
4202
  const outcome = emptyOutcome(opts, root);
3157
- const lock = yield readLockForProject(root, opts.project);
4203
+ const lock = yield readLockForProject2(root, opts.project);
3158
4204
  const client = instructionsClient(opts, SYNC_TIMEOUT_MS);
3159
4205
  let published = yield fetchPublished(client, opts, lock == null ? void 0 : lock.digest);
3160
4206
  assertInitSyncMayAct(opts, published);
@@ -3208,7 +4254,7 @@ function syncOnce(opts) {
3208
4254
  manifest.map((entry) => [entry.path, entry.size])
3209
4255
  );
3210
4256
  const archive = yield fetchBundle(download.url, {
3211
- timeoutMs: opts.hook ? HOOK_DEADLINE_MS : BUNDLE_TIMEOUT_MS,
4257
+ timeoutMs: opts.hook ? HOOK_DEADLINE_MS2 : BUNDLE_TIMEOUT_MS,
3212
4258
  // Bounded by what this manifest says it holds, not by what the
3213
4259
  // response claims. The manifest has already been checked against
3214
4260
  // the published snapshot limits, so this is a number the client
@@ -3296,12 +4342,12 @@ function reportSync(outcome, opts) {
3296
4342
  line(`Everything in ${outcome.destination} already matched.`);
3297
4343
  }
3298
4344
  }
3299
- function runPush(opts, format) {
4345
+ function runPush2(opts, format) {
3300
4346
  return __async(this, null, function* () {
3301
4347
  var _a2;
3302
4348
  const destination = destinationOf(opts);
3303
4349
  const root = yield resolveExistingRoot(destination);
3304
- const lock = yield readLockForProject(root, opts.project);
4350
+ const lock = yield readLockForProject2(root, opts.project);
3305
4351
  if (lock === null) {
3306
4352
  throw new CliFailure(
3307
4353
  `No coding-instructions lock in ${destination}. Run \`fabric instructions sync --project ${opts.project}\` first: a push is a diff against the version that was last applied here, and without that ledger there is nothing to diff against.`,
@@ -3310,7 +4356,7 @@ function runPush(opts, format) {
3310
4356
  }
3311
4357
  const client = instructionsClient(
3312
4358
  __spreadProps(__spreadValues({}, opts), { hook: false }),
3313
- PUSH_TIMEOUT_MS
4359
+ PUSH_TIMEOUT_MS2
3314
4360
  );
3315
4361
  const published = yield fetchPublished(client, opts);
3316
4362
  if (!published.published || !published.snapshot) {
@@ -3347,6 +4393,7 @@ function runPush(opts, format) {
3347
4393
  projectId: opts.project,
3348
4394
  destination: root,
3349
4395
  dryRun: Boolean(opts.dryRun),
4396
+ publish: Boolean(opts.publish),
3350
4397
  baseSnapshotId: lock.snapshotId,
3351
4398
  baseVersion: lock.snapshotVersion,
3352
4399
  put: plan.entries.filter((entry) => entry.action === "put").map((entry) => entry.path),
@@ -3355,11 +4402,17 @@ function runPush(opts, format) {
3355
4402
  snapshotId: null,
3356
4403
  version: null,
3357
4404
  proposalStatus: null,
3358
- status: null
4405
+ status: null,
4406
+ published: null
3359
4407
  };
3360
4408
  if (!opts.dryRun) {
3361
4409
  try {
3362
- const submitted = yield client.instructions.submitChange(
4410
+ const submitted = opts.publish ? yield client.instructions.publishChange(
4411
+ opts.project,
4412
+ lock.snapshotId,
4413
+ plan.changes,
4414
+ { org: orgSlugFor(opts) }
4415
+ ) : yield client.instructions.submitChange(
3363
4416
  opts.project,
3364
4417
  lock.snapshotId,
3365
4418
  plan.changes,
@@ -3369,6 +4422,7 @@ function runPush(opts, format) {
3369
4422
  outcome.version = submitted.version;
3370
4423
  outcome.proposalStatus = submitted.proposalStatus;
3371
4424
  outcome.status = submitted.status;
4425
+ outcome.published = submitted.published;
3372
4426
  } catch (error) {
3373
4427
  throw asPushFailure(error);
3374
4428
  }
@@ -3408,6 +4462,16 @@ function asPushFailure(error) {
3408
4462
  }
3409
4463
  function pushVerdict(outcome) {
3410
4464
  var _a2;
4465
+ if (outcome.publish) {
4466
+ if (outcome.published) {
4467
+ return `Published version ${outcome.version}. Run \`fabric instructions sync\` to bring this checkout onto it.`;
4468
+ }
4469
+ if (outcome.status === "FAILED" || outcome.status === "REJECTED") {
4470
+ 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.`;
4471
+ }
4472
+ 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.`;
4473
+ return outcome.status === "READY" ? `${sent} It has passed its checks; its publication state is shown in the project's Coding Instructions tab.` : sent;
4474
+ }
3411
4475
  if (outcome.proposalStatus === "PENDING" && outcome.status === "RECEIVING") {
3412
4476
  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.`;
3413
4477
  }
@@ -3434,13 +4498,13 @@ function reportPush(outcome) {
3434
4498
  listPaths("deleted", outcome.deleted);
3435
4499
  if (outcome.dryRun) {
3436
4500
  line(
3437
- "Nothing was sent. Without --dry-run this would open a proposal for review."
4501
+ 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."
3438
4502
  );
3439
4503
  return;
3440
4504
  }
3441
4505
  line(pushVerdict(outcome));
3442
4506
  line(
3443
- "Your lock was not changed: it still names the published version, which is what `fabric instructions sync` compares against."
4507
+ 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."
3444
4508
  );
3445
4509
  }
3446
4510
  function runInit(opts, format) {
@@ -3453,7 +4517,7 @@ function runInit(opts, format) {
3453
4517
  }
3454
4518
  const tool = opts.tool;
3455
4519
  const root = yield resolveDestinationRoot(destinationOf(opts));
3456
- yield readLockForProject(root, opts.project);
4520
+ yield readLockForProject2(root, opts.project);
3457
4521
  try {
3458
4522
  yield assertKeyStaysOutside(root, getConfigPath());
3459
4523
  } catch (error) {
@@ -3534,9 +4598,9 @@ function hookPathFor(tool) {
3534
4598
  }
3535
4599
 
3536
4600
  // src/commands/mcp/index.ts
3537
- import { Command as Command12 } from "commander";
4601
+ import { Command as Command13 } from "commander";
3538
4602
  function buildMcpCommand() {
3539
- const mcp = new Command12("mcp").description(
4603
+ const mcp = new Command13("mcp").description(
3540
4604
  "Manage MCP servers and configurations"
3541
4605
  );
3542
4606
  mcp.command("servers").description("List accessible MCP servers (system + custom)").option("--org <slug>", "Organization context").option("--personal", "Retired \u2014 context is organization-only").option(
@@ -3574,7 +4638,7 @@ function buildMcpCommand() {
3574
4638
  });
3575
4639
  })
3576
4640
  );
3577
- const configs = new Command12("configs").description(
4641
+ const configs = new Command13("configs").description(
3578
4642
  "Manage MCP configurations"
3579
4643
  );
3580
4644
  configs.command("list", { isDefault: true }).description("List your MCP configurations").option("--org <slug>", "Organization context").option("--personal", "Retired \u2014 context is organization-only").option(
@@ -3679,9 +4743,9 @@ function buildMcpCommand() {
3679
4743
  }
3680
4744
 
3681
4745
  // src/commands/orgs/index.ts
3682
- import { Command as Command13 } from "commander";
4746
+ import { Command as Command14 } from "commander";
3683
4747
  function buildOrgsCommand() {
3684
- const orgs = new Command13("orgs").description("Manage organizations");
4748
+ const orgs = new Command14("orgs").description("Manage organizations");
3685
4749
  orgs.command("list").description("List organizations you belong to").option(
3686
4750
  "--format <format>",
3687
4751
  "Output format: table|json|yaml|csv",
@@ -3738,9 +4802,9 @@ function buildOrgsCommand() {
3738
4802
  }
3739
4803
 
3740
4804
  // src/commands/projects/index.ts
3741
- import { Command as Command14 } from "commander";
4805
+ import { Command as Command15 } from "commander";
3742
4806
  function buildProjectsCommand() {
3743
- const projects = new Command14("projects").description("Manage projects");
4807
+ const projects = new Command15("projects").description("Manage projects");
3744
4808
  projects.command("list").description("List projects").option("--org <slug>", "Organization context").option("--personal", "Retired \u2014 context is organization-only").option(
3745
4809
  "--status <status>",
3746
4810
  "Filter by status: ACTIVE|DRAFT|COMPLETED|ARCHIVED"
@@ -3875,9 +4939,9 @@ function buildProjectsCommand() {
3875
4939
  }
3876
4940
 
3877
4941
  // src/commands/prompts/index.ts
3878
- import { Command as Command15 } from "commander";
4942
+ import { Command as Command16 } from "commander";
3879
4943
  function buildPromptsCommand() {
3880
- const prompts = new Command15("prompts").description(
4944
+ const prompts = new Command16("prompts").description(
3881
4945
  "Manage prompt templates"
3882
4946
  );
3883
4947
  prompts.command("list").description("List prompt templates").option("--org <slug>", "Organization context").option("--personal", "Retired \u2014 context is organization-only").option("--search <query>", "Search by name").option("--category <cat>", "Filter by category").option("--limit <n>", "Max results", "50").option(
@@ -4030,12 +5094,12 @@ ${content}
4030
5094
  }
4031
5095
 
4032
5096
  // src/commands/reports/index.ts
4033
- import { Command as Command16 } from "commander";
5097
+ import { Command as Command17 } from "commander";
4034
5098
  function buildReportsCommand() {
4035
- const reports = new Command16("reports").description(
5099
+ const reports = new Command17("reports").description(
4036
5100
  "Manage report templates and executions"
4037
5101
  );
4038
- const templates = new Command16("templates").description(
5102
+ const templates = new Command17("templates").description(
4039
5103
  "Manage report templates"
4040
5104
  );
4041
5105
  templates.command("list", { isDefault: true }).description("List report templates").option("--org <slug>", "Organization context").option("--personal", "Retired \u2014 context is organization-only").option("--search <query>", "Search by name").option("--category <cat>", "Filter by category").option("--limit <n>", "Max results (default 20)", "20").option(
@@ -4109,7 +5173,7 @@ function buildReportsCommand() {
4109
5173
  })
4110
5174
  );
4111
5175
  reports.addCommand(templates);
4112
- const executions = new Command16("executions").description(
5176
+ const executions = new Command17("executions").description(
4113
5177
  "View report executions"
4114
5178
  );
4115
5179
  executions.command("list", { isDefault: true }).description("List report executions").option("--org <slug>", "Organization context").option("--personal", "Retired \u2014 context is organization-only").option("--limit <n>", "Max results (default 20)", "20").option(
@@ -4153,9 +5217,9 @@ function buildReportsCommand() {
4153
5217
  }
4154
5218
 
4155
5219
  // src/commands/skills/index.ts
4156
- import { Command as Command17 } from "commander";
5220
+ import { Command as Command18 } from "commander";
4157
5221
  function buildSkillsCommand() {
4158
- const skills = new Command17("skills").description(
5222
+ const skills = new Command18("skills").description(
4159
5223
  "Browse skill definitions"
4160
5224
  );
4161
5225
  skills.command("list").description("List skills").option("--org <slug>", "Organization context").option("--personal", "Retired \u2014 context is organization-only").option("--search <query>", "Search by name").option("--category <cat>", "Filter by category").option("--limit <n>", "Max results", "50").option(
@@ -4222,9 +5286,9 @@ function buildSkillsCommand() {
4222
5286
  }
4223
5287
 
4224
5288
  // src/commands/workflows/index.ts
4225
- import { Command as Command18 } from "commander";
5289
+ import { Command as Command19 } from "commander";
4226
5290
  function buildWorkflowsCommand() {
4227
- const workflows = new Command18("workflows").description("Manage workflows");
5291
+ const workflows = new Command19("workflows").description("Manage workflows");
4228
5292
  workflows.command("list").description("List workflows").option("--org <slug>", "Organization context").option("--personal", "Retired \u2014 context is organization-only").option("--status <status>", "Filter: DRAFT|ACTIVE|PAUSED|ARCHIVED").option("--search <query>", "Search by name").option("--limit <n>", "Max results", "20").option(
4229
5293
  "--format <format>",
4230
5294
  "Output format: table|json|yaml|csv",
@@ -4352,9 +5416,9 @@ function buildWorkflowsCommand() {
4352
5416
  }
4353
5417
 
4354
5418
  // src/commands/workspaces/index.ts
4355
- import { Command as Command19 } from "commander";
5419
+ import { Command as Command20 } from "commander";
4356
5420
  function buildWorkspacesCommand() {
4357
- const workspaces = new Command19("workspaces").description(
5421
+ const workspaces = new Command20("workspaces").description(
4358
5422
  "Manage workspaces"
4359
5423
  );
4360
5424
  workspaces.command("list").description("List workspaces").option("--org <slug>", "Organization context").option("--personal", "Retired \u2014 context is organization-only").option("--search <query>", "Search by name").option("--status <status>", "Filter: ACTIVE|ARCHIVED").option("--limit <n>", "Max results (default 20)", "20").option(
@@ -4430,14 +5494,14 @@ function buildWorkspacesCommand() {
4430
5494
  }
4431
5495
 
4432
5496
  // src/bin/fabric.ts
4433
- var program = new Command20();
5497
+ var program = new Command21();
4434
5498
  var _a;
4435
5499
  program.name("fabric").description("Fabric AI platform CLI").version(package_default.version).option(
4436
5500
  "--format <format>",
4437
5501
  "Output format: table|json|yaml|csv",
4438
5502
  (_a = process.env.FABRIC_FORMAT) != null ? _a : "table"
4439
5503
  );
4440
- var auth = new Command20("auth").description(
5504
+ var auth = new Command21("auth").description(
4441
5505
  "Authenticate and manage API keys"
4442
5506
  );
4443
5507
  auth.addCommand(buildLoginCommand());
@@ -4454,13 +5518,14 @@ program.addCommand(buildPromptsCommand());
4454
5518
  program.addCommand(buildAgentsCommand());
4455
5519
  program.addCommand(buildSkillsCommand());
4456
5520
  program.addCommand(buildInstructionsCommand());
5521
+ program.addCommand(buildContextCommand());
4457
5522
  program.addCommand(buildMcpCommand());
4458
5523
  program.addCommand(buildFramesCommand());
4459
5524
  program.addCommand(buildWorkspacesCommand());
4460
5525
  program.addCommand(buildChatsCommand());
4461
5526
  program.addCommand(buildReportsCommand());
4462
5527
  program.addCommand(buildCompletionCommand());
4463
- var config = new Command20("config").description("Show CLI configuration");
5528
+ var config = new Command21("config").description("Show CLI configuration");
4464
5529
  config.command("path").description("Print the path to the config file").action(() => {
4465
5530
  process.stdout.write(`${getConfigPath()}
4466
5531
  `);