@zebralabs/context-cli 0.1.0 → 0.1.3

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.
Files changed (2) hide show
  1. package/package.json +11 -2
  2. package/src/context.js +185 -113
package/package.json CHANGED
@@ -1,13 +1,22 @@
1
1
  {
2
2
  "name": "@zebralabs/context-cli",
3
- "version": "0.1.0",
3
+ "version": "0.1.3",
4
4
  "description": "Context-as-Code CLI (help/list/compile/validate)",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "bin": {
8
8
  "ctx": "src/context.js"
9
9
  },
10
+ "files": [
11
+ "src",
12
+ "README.md",
13
+ "LICENSE"
14
+ ],
10
15
  "dependencies": {
11
- "yaml": "^2.5.0"
16
+ "yaml": "^2.5.0",
17
+ "extract-zip": "^2.0.1"
18
+ },
19
+ "scripts": {
20
+ "smoke": "node src/context.js help"
12
21
  }
13
22
  }
package/src/context.js CHANGED
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env node
1
+ #!/usr/bin/env node
2
2
  import os from "node:os";
3
3
  import { spawnSync } from "node:child_process";
4
4
  import fs from "node:fs";
@@ -16,23 +16,32 @@ function showHelp() {
16
16
  Context-as-Code CLI
17
17
 
18
18
  Usage:
19
- context help
20
- context list
21
- context compile
22
- context validate
23
-
24
- Commands:
25
- list Show installed packs and the documentation roots they contribute.
26
- compile Build a single AI-ready context bundle at practices-and-standards/.compiled/.
27
- Output: system-prompt.md (paste/upload to AI), sources.md (what was included), report.md.
28
- validate Validate that context.yaml and pack manifests are consistent and files exist.
19
+ ctx help
20
+ ctx list
21
+ ctx compile
22
+ ctx validate
23
+
24
+ Pack install:
25
+ ctx pack install <packId> [--repo-root <path>] [--mode SkipExisting|Overwrite]
26
+ ([--zip <path>] | [--registry <url> [--version <x.y.z>] --token <token>])
27
+
28
+ Examples (local zip test):
29
+ ctx pack install pack-01-documentation-management --zip D:\\packs\\pack-01-documentation-management-v0.1.0.zip
30
+
31
+ Examples (registry download):
32
+ ctx pack install pack-01-documentation-management --registry https://example.com --token YOURTOKEN
33
+ ctx pack install pack-01-documentation-management --registry https://example.com --token YOURTOKEN --version 0.1.0
34
+
35
+ Notes:
36
+ - Zip must contain: practices-and-standards/install.ps1
37
+ - Installer merges into <repo-root>/docs/practices-and-standards/
29
38
  `.trim() + "\n");
30
39
  }
31
40
 
32
41
  function findRepoContextRoot(startDir) {
33
42
  let dir = startDir;
34
43
  while (true) {
35
- const candidate = path.join(dir, "practices-and-standards", "context.yaml");
44
+ const candidate = path.join(dir, "docs", "practices-and-standards", "context.yaml");
36
45
  if (fs.existsSync(candidate)) return dir;
37
46
  const parent = path.dirname(dir);
38
47
  if (parent === dir) return null;
@@ -62,7 +71,6 @@ function listMarkdownFiles(dir) {
62
71
  }
63
72
 
64
73
  function stripFrontmatter(text) {
65
- // Remove YAML frontmatter if the file starts with ---
66
74
  if (!text.startsWith("---\n") && !text.startsWith("---\r\n")) return text;
67
75
  const lines = text.split(/\r?\n/);
68
76
  let fenceCount = 0;
@@ -76,7 +84,7 @@ function stripFrontmatter(text) {
76
84
  }
77
85
  }
78
86
  }
79
- if (endIndex === -1) return text; // malformed; keep
87
+ if (endIndex === -1) return text;
80
88
  return lines.slice(endIndex + 1).join("\n");
81
89
  }
82
90
 
@@ -92,7 +100,6 @@ function orderInstalledPacks(installed, precedence) {
92
100
  byId.delete(id);
93
101
  }
94
102
  }
95
- // stable remainder in original order
96
103
  for (const p of installed) {
97
104
  if (byId.has(p.id)) {
98
105
  ordered.push(p);
@@ -103,7 +110,6 @@ function orderInstalledPacks(installed, precedence) {
103
110
  }
104
111
 
105
112
  function resolvePackManifestPath(repoRoot, manifestRel) {
106
- // manifestRel in your schema is "practices-and-standards/packs/..."
107
113
  return path.join(repoRoot, manifestRel);
108
114
  }
109
115
 
@@ -137,7 +143,6 @@ function cmdValidate(repoRoot, ctx) {
137
143
  warnings.push("No installed packs found in context.yaml");
138
144
  }
139
145
 
140
- // duplicate installed ids
141
146
  const seen = new Set();
142
147
  for (const p of installed) {
143
148
  if (!p?.id) errors.push("Installed pack missing id");
@@ -148,13 +153,11 @@ function cmdValidate(repoRoot, ctx) {
148
153
  if (!p?.manifest) errors.push(`Pack '${p?.id ?? "unknown"}' missing manifest path`);
149
154
  }
150
155
 
151
- // precedence unknown ids
152
156
  const installedIds = new Set(installed.map(p => p.id).filter(Boolean));
153
157
  for (const pid of precedence) {
154
158
  if (!installedIds.has(pid)) warnings.push(`precedence references unknown pack id: ${pid}`);
155
159
  }
156
160
 
157
- // manifest checks
158
161
  for (const p of installed) {
159
162
  if (!p?.id || !p?.manifest) continue;
160
163
 
@@ -165,18 +168,6 @@ function cmdValidate(repoRoot, ctx) {
165
168
  }
166
169
 
167
170
  const pack = readYamlFile(manifestPath);
168
- const manifestId = pack?.id;
169
- if (manifestId && manifestId !== p.id) {
170
- warnings.push(`Pack '${p.id}' manifest id mismatch: manifest says '${manifestId}'`);
171
- }
172
-
173
- const parts = p.manifest.split(/[\\/]/);
174
- const packsIdx = parts.indexOf("packs");
175
- const folderId = (packsIdx >= 0 && parts[packsIdx + 1]) ? parts[packsIdx + 1] : null;
176
- if (folderId && folderId !== p.id) {
177
- warnings.push(`Pack '${p.id}' manifest folder mismatch: path folder is '${folderId}'`);
178
- }
179
-
180
171
  const roots = pack?.contributes?.roots ?? [];
181
172
  if (!Array.isArray(roots) || roots.length === 0) {
182
173
  warnings.push(`Pack '${p.id}' has no contributes.roots`);
@@ -208,7 +199,8 @@ function cmdValidate(repoRoot, ctx) {
208
199
 
209
200
  function cmdCompile(repoRoot, ctx) {
210
201
  const installed = orderInstalledPacks(ctx.installed_packs ?? [], ctx.precedence ?? []);
211
- const outDir = path.join(repoRoot, "practices-and-standards", ".compiled");
202
+ const psRoot = path.join(repoRoot, "docs", "practices-and-standards");
203
+ const outDir = path.join(psRoot, ".compiled");
212
204
  fs.mkdirSync(outDir, { recursive: true });
213
205
 
214
206
  const promptPath = path.join(outDir, "system-prompt.md");
@@ -296,32 +288,41 @@ function ensureDir(p) {
296
288
  }
297
289
 
298
290
  function writeYamlFile(filePath, obj) {
299
- fs.writeFileSync(filePath, YAML.stringify(obj), "utf8");
291
+ const text = YAML.stringify(obj, { indent: 2 });
292
+ fs.writeFileSync(filePath, text.endsWith("\n") ? text : (text + "\n"), "utf8");
300
293
  }
301
294
 
302
- function readYamlIfExists(filePath) {
303
- if (!fs.existsSync(filePath)) return null;
304
- return readYamlFile(filePath);
295
+ function readYamlFileSafe(filePath) {
296
+ const raw = fs.readFileSync(filePath, "utf8");
297
+ try {
298
+ return YAML.parse(raw);
299
+ } catch (e) {
300
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
301
+ const backup = `${filePath}.backup-${stamp}.yaml`;
302
+ fs.copyFileSync(filePath, backup);
303
+ return null;
304
+ }
305
305
  }
306
306
 
307
- function isWindows() {
308
- return process.platform === "win32";
307
+ function readYamlIfExistsSafe(filePath) {
308
+ if(!fs.existsSync(filePath)) return null;
309
+ return readYamlFileSafe(filePath);
309
310
  }
310
311
 
311
- function findPwsh() {
312
- // Prefer pwsh (PowerShell 7). Fallback to Windows PowerShell on Windows.
313
- // We'll just try spawning; if it fails, we fallback.
314
- return "pwsh";
312
+ function isWindows() {
313
+ return process.platform === "win32";
315
314
  }
316
315
 
317
316
  function runPwsh(command, { cwd } = {}) {
318
- let exe = findPwsh();
317
+ // Prefer pwsh, fallback to powershell (Windows PowerShell)
318
+ let exe = "pwsh";
319
319
  let r = spawnSync(exe, ["-NoProfile", "-Command", command], { stdio: "inherit", cwd });
320
- if (r.error && isWindows()) {
321
- // fallback to Windows PowerShell
320
+
321
+ if ((r.error || r.status !== 0) && isWindows()) {
322
322
  exe = "powershell";
323
323
  r = spawnSync(exe, ["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", command], { stdio: "inherit", cwd });
324
324
  }
325
+
325
326
  if (r.status !== 0) {
326
327
  die(`PowerShell command failed (exit ${r.status}): ${command}`);
327
328
  }
@@ -351,11 +352,11 @@ async function fetchJson(url, token) {
351
352
  }
352
353
 
353
354
  function ensureContextInitialized(repoRoot, registryUrlMaybe) {
354
- const psRoot = path.join(repoRoot, "practices-and-standards");
355
+ const psRoot = path.join(repoRoot, "docs", "practices-and-standards");
355
356
  ensureDir(psRoot);
356
357
 
357
358
  const ctxPath = path.join(psRoot, "context.yaml");
358
- let ctx = readYamlIfExists(ctxPath);
359
+ let ctx = readYamlIfExistsSafe(ctxPath);
359
360
 
360
361
  if (!ctx) {
361
362
  ctx = {
@@ -366,7 +367,6 @@ function ensureContextInitialized(repoRoot, registryUrlMaybe) {
366
367
  };
367
368
  writeYamlFile(ctxPath, ctx);
368
369
  } else {
369
- // Ensure schema exists
370
370
  if (!ctx.schema) ctx.schema = "context-install/v1";
371
371
  if (registryUrlMaybe && !ctx.registry) ctx.registry = registryUrlMaybe;
372
372
  if (!Array.isArray(ctx.installed_packs)) ctx.installed_packs = [];
@@ -377,10 +377,26 @@ function ensureContextInitialized(repoRoot, registryUrlMaybe) {
377
377
  return { ctxPath, ctx, psRoot };
378
378
  }
379
379
 
380
+ function normalizeContext(ctx) {
381
+ const out = (ctx && typeof ctx === "object") ? ctx : {};
382
+ out.schema = out.schema || "context-install/v1";
383
+
384
+ if (!Array.isArray(out.installed_packs)) out.installed_packs = [];
385
+ if (!Array.isArray(out.precedence)) out.precedence = [];
386
+
387
+ // Defensive: remove accidental duplicates / wrong shapes
388
+ out.installed_packs = out.installed_packs.filter(p => p && typeof p === "object");
389
+ out.precedence = out.precedence.filter(x => typeof x === "string");
390
+
391
+ return out;
392
+ }
393
+
394
+
380
395
  function upsertInstalledPack(ctxObj, packId, version) {
381
396
  if (!Array.isArray(ctxObj.installed_packs)) ctxObj.installed_packs = [];
397
+ if (!Array.isArray(ctxObj.precedence)) ctxObj.precedence = [];
382
398
 
383
- const manifest = `practices-and-standards/packs/${packId}/pack.yaml`;
399
+ const manifest = `docs/practices-and-standards/packs/${packId}/pack.yaml`;
384
400
  const existing = ctxObj.installed_packs.find(p => p.id === packId);
385
401
 
386
402
  if (existing) {
@@ -390,68 +406,129 @@ function upsertInstalledPack(ctxObj, packId, version) {
390
406
  ctxObj.installed_packs.push({ id: packId, version, manifest });
391
407
  }
392
408
 
393
- // Keep precedence additive unless user already configured it
394
409
  if (!Array.isArray(ctxObj.precedence)) ctxObj.precedence = [];
395
410
  if (!ctxObj.precedence.includes(packId)) ctxObj.precedence.push(packId);
396
411
  }
397
412
 
398
413
  async function cmdPackInstall(repoRoot, packId, opts) {
399
- const token = opts.token;
400
- if (!token) die("Missing required --token");
414
+ const mode = opts.mode || "SkipExisting";
415
+ if (!["SkipExisting", "Overwrite"].includes(mode)) {
416
+ die("Invalid --mode. Use SkipExisting or Overwrite.");
417
+ }
401
418
 
402
- // init or load context
419
+ // init or load context.yaml (always)
403
420
  const { ctxPath, ctx } = ensureContextInitialized(repoRoot, opts.registry);
404
421
 
405
- // Determine registry
406
- const registry = opts.registry || ctx.registry;
407
- if (!registry) die("No registry configured. Provide --registry <url> or set registry: in practices-and-standards/context.yaml");
408
-
409
- // Determine version
410
- let version = opts.version;
411
- if (!version) {
412
- const latestUrl = `${registry.replace(/\/$/, "")}/packs/${encodeURIComponent(packId)}/latest`;
413
- const latest = await fetchJson(latestUrl, token);
414
- version = latest?.version;
415
- if (!version) die(`Registry did not return a version from ${latestUrl}`);
416
- }
417
-
418
- // Download zip
422
+ // Create temp workspace
419
423
  const tmpBase = fs.mkdtempSync(path.join(os.tmpdir(), "context-pack-"));
420
- const zipPath = path.join(tmpBase, `${packId}-${version}.zip`);
421
424
  const extractDir = path.join(tmpBase, "extract");
422
425
  ensureDir(extractDir);
423
426
 
424
- const dlUrl = `${registry.replace(/\/$/, "")}/packs/${encodeURIComponent(packId)}/${encodeURIComponent(version)}/download`;
425
- console.log(`\nDownloading ${packId}@${version}...`);
426
- await downloadToFile(dlUrl, token, zipPath);
427
+ try {
428
+ let zipPath = null;
429
+ let version = opts.version;
427
430
 
428
- // Expand zip using PowerShell (no Node unzip deps)
429
- console.log("Extracting...");
430
- runPwsh(`Expand-Archive -Path "${zipPath}" -DestinationPath "${extractDir}" -Force`);
431
+ if (opts.zip) {
432
+ zipPath = path.isAbsolute(opts.zip) ? opts.zip : path.join(process.cwd(), opts.zip);
433
+ if (!fs.existsSync(zipPath)) die(`Zip not found: ${zipPath}`);
434
+ // version will be discovered from pack.yaml after extraction if not provided
435
+ } else {
436
+ const token = opts.token;
437
+ if (!token) die("Missing required --token for registry install.");
431
438
 
432
- // Find installer inside extracted zip
433
- // Your zips contain: practices-and-standards/install.ps1 at the root of the zip payload
434
- const extractedPsRoot = path.join(extractDir, "practices-and-standards");
435
- const installer = path.join(extractedPsRoot, "install.ps1");
436
- if (!fs.existsSync(installer)) {
437
- die(`Downloaded pack does not contain practices-and-standards/install.ps1 (looked at: ${installer})`);
438
- }
439
+ const registry = opts.registry || ctx.registry;
440
+ if (!registry) die("No registry configured. Provide --registry <url> or set registry: in docs/practices-and-standards/context.yaml");
439
441
 
440
- // Run installer: it will merge into repoRoot/practices-and-standards
441
- console.log("Installing (merge)...");
442
- const cmd = `& "${installer}" -TargetRoot "${repoRoot}" -PackId "${packId}" -Mode "SkipExisting"`;
443
- runPwsh(cmd, { cwd: extractDir });
442
+ if (!version) {
443
+ const latestUrl = `${registry.replace(/\/$/, "")}/packs/${encodeURIComponent(packId)}/latest`;
444
+ const latest = await fetchJson(latestUrl, token);
445
+ version = latest?.version;
446
+ if (!version) die(`Registry did not return a version from ${latestUrl}`);
447
+ }
444
448
 
445
- // Update context.yaml
446
- const ctx2 = readYamlFile(ctxPath);
447
- upsertInstalledPack(ctx2, packId, version);
448
- if (opts.registry && !ctx2.registry) ctx2.registry = opts.registry;
449
- writeYamlFile(ctxPath, ctx2);
449
+ const dlUrl = `${registry.replace(/\/$/, "")}/packs/${encodeURIComponent(packId)}/${encodeURIComponent(version)}/download`;
450
+ zipPath = path.join(tmpBase, `${packId}-${version}.zip`);
451
+ console.log(`\nDownloading ${packId}@${version}...`);
452
+ await downloadToFile(dlUrl, token, zipPath);
453
+ }
450
454
 
451
- console.log("\n✅ Pack installed.");
452
- console.log(`- Pack: ${packId} @ ${version}`);
453
- console.log(`- Updated: ${path.relative(repoRoot, ctxPath).replaceAll("\\", "/")}`);
454
- console.log("");
455
+ // Extract zip
456
+ console.log("Extracting...");
457
+ runPwsh(`Expand-Archive -Path "${zipPath}" -DestinationPath "${extractDir}" -Force`);
458
+
459
+ // Find installer
460
+ const installer = path.join(extractDir, "practices-and-standards", "install.ps1");
461
+ if (!fs.existsSync(installer)) {
462
+ die(`Pack zip does not contain practices-and-standards/install.ps1 (looked at: ${installer})`);
463
+ }
464
+
465
+ if (!version) {
466
+ const packYamlPath = path.join(
467
+ extractDir,
468
+ "practices-and-standards",
469
+ "packs",
470
+ packId,
471
+ "pack.yaml"
472
+ );
473
+
474
+ if (fs.existsSync(packYamlPath)) {
475
+ const packMeta = readYamlFile(packYamlPath);
476
+ if (packMeta?.version) version = String(packMeta.version);
477
+ }
478
+
479
+ if (!version) version = "0.0.0";
480
+ }
481
+
482
+ // Run installer
483
+ console.log(`Installing (mode=${mode})...`);
484
+ const installCmd = `& "${installer}" -TargetRoot "${repoRoot}" -PackId "${packId}" -Mode "${mode}"`;
485
+ runPwsh(installCmd, { cwd: extractDir });
486
+
487
+ // Update context.yaml (safe read + repair)
488
+ let ctx2 = readYamlFileSafe(ctxPath);
489
+ if (!ctx2) {
490
+ // If YAML was corrupted by an external process, repair by resetting to a clean baseline.
491
+ ctx2 = {
492
+ schema: "context-install/v1",
493
+ ...(opts.registry ? { registry: opts.registry } : {}),
494
+ installed_packs: [],
495
+ precedence: []
496
+ };
497
+ }
498
+
499
+ ctx2 = normalizeContext(ctx2);
500
+
501
+ upsertInstalledPack(ctx2, packId, version);
502
+
503
+ if (!Array.isArray(ctx2.precedence)) ctx2.precedence = [];
504
+ if (!ctx2.precedence.includes(packId)) ctx2.precedence.push(packId);
505
+ if (opts.registry && !ctx2.registry) ctx2.registry = opts.registry;
506
+
507
+ writeYamlFile(ctxPath, ctx2);
508
+
509
+
510
+ console.log("\n✅ Pack installed.");
511
+ console.log(`- Pack: ${packId} @ ${version}`);
512
+ console.log(`- Updated: ${path.relative(repoRoot, ctxPath).replaceAll("\\", "/")}`);
513
+ console.log("");
514
+ } finally {
515
+ try { fs.rmSync(tmpBase, { recursive: true, force: true }); } catch {}
516
+ }
517
+ }
518
+
519
+ function parseOpts(args) {
520
+ const opts = {};
521
+ for (let i = 0; i < args.length; i++) {
522
+ const a = args[i];
523
+ if (a === "--token") opts.token = args[++i];
524
+ else if (a === "--version") opts.version = args[++i];
525
+ else if (a === "--registry") opts.registry = args[++i];
526
+ else if (a === "--zip") opts.zip = args[++i];
527
+ else if (a === "--mode") opts.mode = args[++i];
528
+ else if (a === "--repo-root") opts.repoRoot = args[++i];
529
+ else die(`Unknown option: ${a}`);
530
+ }
531
+ return opts;
455
532
  }
456
533
 
457
534
 
@@ -460,7 +537,12 @@ async function main() {
460
537
 
461
538
  if (cmd === "help") { showHelp(); return; }
462
539
 
463
- // pack subcommands
540
+ if (cmd === "--version" || cmd === "-v" || cmd === "version") {
541
+ // Make sure package.json has version (or hardcode a constant)
542
+ console.log("0.1.3");
543
+ return;
544
+ }
545
+
464
546
  if (cmd === "pack") {
465
547
  const sub = (process.argv[3] ?? "").toLowerCase();
466
548
  if (sub !== "install") {
@@ -470,22 +552,13 @@ async function main() {
470
552
  }
471
553
 
472
554
  const packId = process.argv[4];
473
- if (!packId) die("Usage: context pack install <packId> --token <token> [--version <v>] [--registry <url>]");
474
-
475
- // parse flags
476
- const args = process.argv.slice(5);
477
- const opts = {};
478
- for (let i = 0; i < args.length; i++) {
479
- const a = args[i];
480
- if (a === "--token") opts.token = args[++i];
481
- else if (a === "--version") opts.version = args[++i];
482
- else if (a === "--registry") opts.registry = args[++i];
483
- else die(`Unknown option: ${a}`);
484
- }
555
+ if (!packId) die("Usage: ctx pack install <packId> [--zip <path>] | [--registry <url> --token <token> ...]");
556
+
557
+ const opts = parseOpts(process.argv.slice(5));
485
558
 
486
- // Repo root: prefer existing context.yaml search, else assume cwd is repo root
487
- const found = findRepoContextRoot(process.cwd());
488
- const repoRoot = found ?? process.cwd();
559
+ const repoRoot = opts.repoRoot
560
+ ? (path.isAbsolute(opts.repoRoot) ? opts.repoRoot : path.join(process.cwd(), opts.repoRoot))
561
+ : (findRepoContextRoot(process.cwd()) ?? process.cwd());
489
562
 
490
563
  await cmdPackInstall(repoRoot, packId, opts);
491
564
  return;
@@ -493,9 +566,9 @@ async function main() {
493
566
 
494
567
  // Existing behavior: these require an existing context.yaml
495
568
  const repoRoot = findRepoContextRoot(process.cwd());
496
- if (!repoRoot) die("Could not find practices-and-standards/context.yaml in this directory or any parent.");
569
+ if (!repoRoot) die("Could not find docs/practices-and-standards/context.yaml in this directory or any parent.");
497
570
 
498
- const contextPath = path.join(repoRoot, "practices-and-standards", "context.yaml");
571
+ const contextPath = path.join(repoRoot, "docs", "practices-and-standards", "context.yaml");
499
572
  const ctx = readYamlFile(contextPath);
500
573
 
501
574
  if (!ctx?.schema || ctx.schema !== "context-install/v1") {
@@ -514,4 +587,3 @@ async function main() {
514
587
  }
515
588
 
516
589
  main().catch(e => die(e?.stack || String(e)));
517
-