@zebralabs/context-cli 0.1.0 → 0.1.2

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 +178 -107
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.2",
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,16 +16,25 @@ 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>/practices-and-standards/
29
38
  `.trim() + "\n");
30
39
  }
31
40
 
@@ -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`);
@@ -296,32 +287,41 @@ function ensureDir(p) {
296
287
  }
297
288
 
298
289
  function writeYamlFile(filePath, obj) {
299
- fs.writeFileSync(filePath, YAML.stringify(obj), "utf8");
290
+ const text = YAML.stringify(obj, { indent: 2 });
291
+ fs.writeFileSync(filePath, text.endsWith("\n") ? text : (text + "\n"), "utf8");
300
292
  }
301
293
 
302
- function readYamlIfExists(filePath) {
303
- if (!fs.existsSync(filePath)) return null;
304
- return readYamlFile(filePath);
294
+ function readYamlFileSafe(filePath) {
295
+ const raw = fs.readFileSync(filePath, "utf8");
296
+ try {
297
+ return YAML.parse(raw);
298
+ } catch (e) {
299
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
300
+ const backup = `${filePath}.backup-${stamp}.yaml`;
301
+ fs.copyFileSync(filePath, backup);
302
+ return null;
303
+ }
305
304
  }
306
305
 
307
- function isWindows() {
308
- return process.platform === "win32";
306
+ function readYamlIfExistsSafe(filePath) {
307
+ if(!fs.existsSync(filePath)) return null;
308
+ return readYamlFileSafe(filePath);
309
309
  }
310
310
 
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";
311
+ function isWindows() {
312
+ return process.platform === "win32";
315
313
  }
316
314
 
317
315
  function runPwsh(command, { cwd } = {}) {
318
- let exe = findPwsh();
316
+ // Prefer pwsh, fallback to powershell (Windows PowerShell)
317
+ let exe = "pwsh";
319
318
  let r = spawnSync(exe, ["-NoProfile", "-Command", command], { stdio: "inherit", cwd });
320
- if (r.error && isWindows()) {
321
- // fallback to Windows PowerShell
319
+
320
+ if ((r.error || r.status !== 0) && isWindows()) {
322
321
  exe = "powershell";
323
322
  r = spawnSync(exe, ["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", command], { stdio: "inherit", cwd });
324
323
  }
324
+
325
325
  if (r.status !== 0) {
326
326
  die(`PowerShell command failed (exit ${r.status}): ${command}`);
327
327
  }
@@ -355,7 +355,7 @@ function ensureContextInitialized(repoRoot, registryUrlMaybe) {
355
355
  ensureDir(psRoot);
356
356
 
357
357
  const ctxPath = path.join(psRoot, "context.yaml");
358
- let ctx = readYamlIfExists(ctxPath);
358
+ let ctx = readYamlIfExistsSafe(ctxPath);
359
359
 
360
360
  if (!ctx) {
361
361
  ctx = {
@@ -366,7 +366,6 @@ function ensureContextInitialized(repoRoot, registryUrlMaybe) {
366
366
  };
367
367
  writeYamlFile(ctxPath, ctx);
368
368
  } else {
369
- // Ensure schema exists
370
369
  if (!ctx.schema) ctx.schema = "context-install/v1";
371
370
  if (registryUrlMaybe && !ctx.registry) ctx.registry = registryUrlMaybe;
372
371
  if (!Array.isArray(ctx.installed_packs)) ctx.installed_packs = [];
@@ -377,8 +376,24 @@ function ensureContextInitialized(repoRoot, registryUrlMaybe) {
377
376
  return { ctxPath, ctx, psRoot };
378
377
  }
379
378
 
379
+ function normalizeContext(ctx) {
380
+ const out = (ctx && typeof ctx === "object") ? ctx : {};
381
+ out.schema = out.schema || "context-install/v1";
382
+
383
+ if (!Array.isArray(out.installed_packs)) out.installed_packs = [];
384
+ if (!Array.isArray(out.precedence)) out.precedence = [];
385
+
386
+ // Defensive: remove accidental duplicates / wrong shapes
387
+ out.installed_packs = out.installed_packs.filter(p => p && typeof p === "object");
388
+ out.precedence = out.precedence.filter(x => typeof x === "string");
389
+
390
+ return out;
391
+ }
392
+
393
+
380
394
  function upsertInstalledPack(ctxObj, packId, version) {
381
395
  if (!Array.isArray(ctxObj.installed_packs)) ctxObj.installed_packs = [];
396
+ if (!Array.isArray(ctxObj.precedence)) ctxObj.precedence = [];
382
397
 
383
398
  const manifest = `practices-and-standards/packs/${packId}/pack.yaml`;
384
399
  const existing = ctxObj.installed_packs.find(p => p.id === packId);
@@ -390,68 +405,129 @@ function upsertInstalledPack(ctxObj, packId, version) {
390
405
  ctxObj.installed_packs.push({ id: packId, version, manifest });
391
406
  }
392
407
 
393
- // Keep precedence additive unless user already configured it
394
408
  if (!Array.isArray(ctxObj.precedence)) ctxObj.precedence = [];
395
409
  if (!ctxObj.precedence.includes(packId)) ctxObj.precedence.push(packId);
396
410
  }
397
411
 
398
412
  async function cmdPackInstall(repoRoot, packId, opts) {
399
- const token = opts.token;
400
- if (!token) die("Missing required --token");
413
+ const mode = opts.mode || "SkipExisting";
414
+ if (!["SkipExisting", "Overwrite"].includes(mode)) {
415
+ die("Invalid --mode. Use SkipExisting or Overwrite.");
416
+ }
401
417
 
402
- // init or load context
418
+ // init or load context.yaml (always)
403
419
  const { ctxPath, ctx } = ensureContextInitialized(repoRoot, opts.registry);
404
420
 
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
421
+ // Create temp workspace
419
422
  const tmpBase = fs.mkdtempSync(path.join(os.tmpdir(), "context-pack-"));
420
- const zipPath = path.join(tmpBase, `${packId}-${version}.zip`);
421
423
  const extractDir = path.join(tmpBase, "extract");
422
424
  ensureDir(extractDir);
423
425
 
424
- const dlUrl = `${registry.replace(/\/$/, "")}/packs/${encodeURIComponent(packId)}/${encodeURIComponent(version)}/download`;
425
- console.log(`\nDownloading ${packId}@${version}...`);
426
- await downloadToFile(dlUrl, token, zipPath);
426
+ try {
427
+ let zipPath = null;
428
+ let version = opts.version;
427
429
 
428
- // Expand zip using PowerShell (no Node unzip deps)
429
- console.log("Extracting...");
430
- runPwsh(`Expand-Archive -Path "${zipPath}" -DestinationPath "${extractDir}" -Force`);
430
+ if (opts.zip) {
431
+ zipPath = path.isAbsolute(opts.zip) ? opts.zip : path.join(process.cwd(), opts.zip);
432
+ if (!fs.existsSync(zipPath)) die(`Zip not found: ${zipPath}`);
433
+ // version will be discovered from pack.yaml after extraction if not provided
434
+ } else {
435
+ const token = opts.token;
436
+ if (!token) die("Missing required --token for registry install.");
431
437
 
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
- }
438
+ const registry = opts.registry || ctx.registry;
439
+ if (!registry) die("No registry configured. Provide --registry <url> or set registry: in practices-and-standards/context.yaml");
439
440
 
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 });
441
+ if (!version) {
442
+ const latestUrl = `${registry.replace(/\/$/, "")}/packs/${encodeURIComponent(packId)}/latest`;
443
+ const latest = await fetchJson(latestUrl, token);
444
+ version = latest?.version;
445
+ if (!version) die(`Registry did not return a version from ${latestUrl}`);
446
+ }
444
447
 
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);
448
+ const dlUrl = `${registry.replace(/\/$/, "")}/packs/${encodeURIComponent(packId)}/${encodeURIComponent(version)}/download`;
449
+ zipPath = path.join(tmpBase, `${packId}-${version}.zip`);
450
+ console.log(`\nDownloading ${packId}@${version}...`);
451
+ await downloadToFile(dlUrl, token, zipPath);
452
+ }
450
453
 
451
- console.log("\n✅ Pack installed.");
452
- console.log(`- Pack: ${packId} @ ${version}`);
453
- console.log(`- Updated: ${path.relative(repoRoot, ctxPath).replaceAll("\\", "/")}`);
454
- console.log("");
454
+ // Extract zip
455
+ console.log("Extracting...");
456
+ runPwsh(`Expand-Archive -Path "${zipPath}" -DestinationPath "${extractDir}" -Force`);
457
+
458
+ // Find installer
459
+ const installer = path.join(extractDir, "practices-and-standards", "install.ps1");
460
+ if (!fs.existsSync(installer)) {
461
+ die(`Pack zip does not contain practices-and-standards/install.ps1 (looked at: ${installer})`);
462
+ }
463
+
464
+ if (!version) {
465
+ const packYamlPath = path.join(
466
+ extractDir,
467
+ "practices-and-standards",
468
+ "packs",
469
+ packId,
470
+ "pack.yaml"
471
+ );
472
+
473
+ if (fs.existsSync(packYamlPath)) {
474
+ const packMeta = readYamlFile(packYamlPath);
475
+ if (packMeta?.version) version = String(packMeta.version);
476
+ }
477
+
478
+ if (!version) version = "0.0.0";
479
+ }
480
+
481
+ // Run installer
482
+ console.log(`Installing (mode=${mode})...`);
483
+ const installCmd = `& "${installer}" -TargetRoot "${repoRoot}" -PackId "${packId}" -Mode "${mode}"`;
484
+ runPwsh(installCmd, { cwd: extractDir });
485
+
486
+ // Update context.yaml (safe read + repair)
487
+ let ctx2 = readYamlFileSafe(ctxPath);
488
+ if (!ctx2) {
489
+ // If YAML was corrupted by an external process, repair by resetting to a clean baseline.
490
+ ctx2 = {
491
+ schema: "context-install/v1",
492
+ ...(opts.registry ? { registry: opts.registry } : {}),
493
+ installed_packs: [],
494
+ precedence: []
495
+ };
496
+ }
497
+
498
+ ctx2 = normalizeContext(ctx2);
499
+
500
+ upsertInstalledPack(ctx2, packId, version);
501
+
502
+ if (!Array.isArray(ctx2.precedence)) ctx2.precedence = [];
503
+ if (!ctx2.precedence.includes(packId)) ctx2.precedence.push(packId);
504
+ if (opts.registry && !ctx2.registry) ctx2.registry = opts.registry;
505
+
506
+ writeYamlFile(ctxPath, ctx2);
507
+
508
+
509
+ console.log("\n✅ Pack installed.");
510
+ console.log(`- Pack: ${packId} @ ${version}`);
511
+ console.log(`- Updated: ${path.relative(repoRoot, ctxPath).replaceAll("\\", "/")}`);
512
+ console.log("");
513
+ } finally {
514
+ try { fs.rmSync(tmpBase, { recursive: true, force: true }); } catch {}
515
+ }
516
+ }
517
+
518
+ function parseOpts(args) {
519
+ const opts = {};
520
+ for (let i = 0; i < args.length; i++) {
521
+ const a = args[i];
522
+ if (a === "--token") opts.token = args[++i];
523
+ else if (a === "--version") opts.version = args[++i];
524
+ else if (a === "--registry") opts.registry = args[++i];
525
+ else if (a === "--zip") opts.zip = args[++i];
526
+ else if (a === "--mode") opts.mode = args[++i];
527
+ else if (a === "--repo-root") opts.repoRoot = args[++i];
528
+ else die(`Unknown option: ${a}`);
529
+ }
530
+ return opts;
455
531
  }
456
532
 
457
533
 
@@ -460,7 +536,12 @@ async function main() {
460
536
 
461
537
  if (cmd === "help") { showHelp(); return; }
462
538
 
463
- // pack subcommands
539
+ if (cmd === "--version" || cmd === "-v" || cmd === "version") {
540
+ // Make sure package.json has version (or hardcode a constant)
541
+ console.log("0.1.2");
542
+ return;
543
+ }
544
+
464
545
  if (cmd === "pack") {
465
546
  const sub = (process.argv[3] ?? "").toLowerCase();
466
547
  if (sub !== "install") {
@@ -470,22 +551,13 @@ async function main() {
470
551
  }
471
552
 
472
553
  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
- }
554
+ if (!packId) die("Usage: ctx pack install <packId> [--zip <path>] | [--registry <url> --token <token> ...]");
555
+
556
+ const opts = parseOpts(process.argv.slice(5));
485
557
 
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();
558
+ const repoRoot = opts.repoRoot
559
+ ? (path.isAbsolute(opts.repoRoot) ? opts.repoRoot : path.join(process.cwd(), opts.repoRoot))
560
+ : (findRepoContextRoot(process.cwd()) ?? process.cwd());
489
561
 
490
562
  await cmdPackInstall(repoRoot, packId, opts);
491
563
  return;
@@ -514,4 +586,3 @@ async function main() {
514
586
  }
515
587
 
516
588
  main().catch(e => die(e?.stack || String(e)));
517
-