@headways/cli 0.4.2 → 1.1.0

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/index.js CHANGED
@@ -2,72 +2,22 @@
2
2
  import {
3
3
  apiRequest,
4
4
  rawRequest
5
- } from "./chunk-2INXZHRG.js";
6
- import {
7
- registerSetupCommand,
8
- registerSyncCommands,
9
- registerUninstallCommand
10
- } from "./chunk-XTEQBKIN.js";
5
+ } from "./chunk-GN2N6M4B.js";
11
6
  import {
12
7
  CLAUDE_SKILLS_DIR,
13
8
  INSTALLED_DIR,
9
+ RUNS_DIR,
14
10
  getApiUrl,
15
11
  getAppUrl,
16
12
  readConfig,
17
13
  requireAuth,
18
14
  writeConfig
19
- } from "./chunk-UUFIIGTZ.js";
15
+ } from "./chunk-COGZMSYS.js";
20
16
 
21
17
  // src/index.ts
22
18
  import "dotenv/config";
23
19
  import { program } from "commander";
24
20
 
25
- // package.json
26
- var package_default = {
27
- name: "@headways/cli",
28
- version: "0.4.2",
29
- type: "module",
30
- description: "Headways CLI \u2014 authoring, sync, and runtime SDK",
31
- license: "MIT",
32
- files: [
33
- "dist",
34
- "LICENSE",
35
- "README.md"
36
- ],
37
- bin: {
38
- headways: "./dist/index.js"
39
- },
40
- publishConfig: {
41
- access: "public"
42
- },
43
- scripts: {
44
- build: "tsup",
45
- dev: "tsx src/index.ts",
46
- test: "vitest run",
47
- "test:unit": "vitest run",
48
- "type-check": "tsc -p tsconfig.json --noEmit",
49
- prepublishOnly: "pnpm build"
50
- },
51
- dependencies: {
52
- "@headways/db": "workspace:*",
53
- "@modelcontextprotocol/sdk": "^1.15.0",
54
- chalk: "^5.3.0",
55
- commander: "^12.1.0",
56
- dotenv: "^16.4.7",
57
- "node-fetch": "^3.3.2",
58
- yaml: "^2.5.1",
59
- zod: "^3.25.28"
60
- },
61
- devDependencies: {
62
- "@headways/config": "workspace:*",
63
- "@types/node": "^22.16.5",
64
- tsup: "^8.5.1",
65
- tsx: "^4.21.0",
66
- typescript: "^5.8.3",
67
- vitest: "^3.2.4"
68
- }
69
- };
70
-
71
21
  // src/commands/auth.ts
72
22
  import "commander";
73
23
  import * as http from "http";
@@ -285,6 +235,12 @@ function registerNewCommand(program2) {
285
235
  console.error("slug and headline are required.");
286
236
  process.exit(1);
287
237
  }
238
+ if (headline.length > 90) {
239
+ console.error(
240
+ `Headline is ${headline.length} chars \u2014 must be \u2264 90. Shorten to one sentence.`
241
+ );
242
+ process.exit(1);
243
+ }
288
244
  const dir = path.join(process.cwd(), slug);
289
245
  await fs.mkdir(dir, { recursive: true });
290
246
  await fs.writeFile(
@@ -356,96 +312,64 @@ runtimes: [claude-code]
356
312
 
357
313
  // src/commands/skills/import.ts
358
314
  import "commander";
315
+ import * as fs3 from "fs/promises";
316
+ import * as path3 from "path";
317
+
318
+ // src/commands/skills/push.ts
319
+ import "commander";
359
320
  import * as fs2 from "fs/promises";
360
321
  import * as path2 from "path";
361
- var ORG_PROFILES = {
362
- hippocratic: {
363
- connectorHints: ["ehr.read", "ehr.write", "phi.access"],
364
- fixtureTemplate: "patient-encounter",
365
- channelPolicy: "stable"
366
- },
367
- revive: {
368
- connectorHints: ["email.read", "calendar.read", "ads.read"],
369
- fixtureTemplate: "campaign-brief",
370
- channelPolicy: "beta"
371
- }
372
- };
373
- function registerImportCommand(program2) {
374
- program2.command("import <path>").description("Import a skill from a file, directory, or URL").option("--slug <slug>", "Override derived slug").option(
375
- "--org <orgProfile>",
376
- "Apply org-specific connector hints + fixture templates (hippocratic, revive)"
377
- ).action(async (inputPath, opts) => {
378
- requireAuth();
379
- let source;
380
- let format = "auto";
322
+ import { watch, existsSync } from "fs";
323
+ import * as YAML from "yaml";
324
+ var RESERVED_TOP_LEVEL = /* @__PURE__ */ new Set([
325
+ "SKILL.md",
326
+ "headways.yaml",
327
+ "capabilities.yaml",
328
+ "connections.yaml",
329
+ "hooks.yaml"
330
+ ]);
331
+ var IGNORE_NAMES = /* @__PURE__ */ new Set([".git", "node_modules", ".DS_Store", ".gitkeep"]);
332
+ async function collectExtraFiles(dir) {
333
+ const result = {};
334
+ async function walk(current, prefix) {
335
+ let entries;
381
336
  try {
382
- const stat2 = await fs2.stat(inputPath);
383
- if (stat2.isDirectory()) {
384
- const skillMdPath = path2.join(inputPath, "SKILL.md");
385
- source = await fs2.readFile(skillMdPath, "utf-8");
386
- format = "skill-md";
387
- } else {
388
- source = await fs2.readFile(inputPath, "utf-8");
389
- if (inputPath.endsWith(".yaml") || inputPath.endsWith(".yml")) {
390
- format = "headways-yaml";
391
- } else {
392
- format = "markdown";
393
- }
394
- }
337
+ entries = await fs2.readdir(current, { withFileTypes: true });
395
338
  } catch {
396
- console.error(`Cannot read '${inputPath}': file or directory not found.`);
397
- process.exit(1);
398
- }
399
- const profile = opts.org ? ORG_PROFILES[opts.org] : void 0;
400
- if (opts.org && !profile) {
401
- console.warn(
402
- `Unknown org profile '${opts.org}'. Known profiles: ${Object.keys(ORG_PROFILES).join(", ")}`
403
- );
339
+ return;
404
340
  }
405
- const derivedSlug = opts.slug ?? path2.basename(inputPath, path2.extname(inputPath)).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
406
- const result = await apiRequest("/v1/skills/import", {
407
- method: "POST",
408
- body: JSON.stringify({
409
- source,
410
- format,
411
- suggestedSlug: derivedSlug,
412
- connectorHints: profile?.connectorHints,
413
- channelPolicy: profile?.channelPolicy
414
- })
415
- });
416
- console.log(`Imported as '${result.slug}' (format: ${result.detectedFormat})`);
417
- console.log(` Headline: ${result.headline}`);
418
- console.log(` Skill ID: ${result.skillId}`);
419
- if (profile) {
420
- console.log(` Org profile: ${opts.org}`);
421
- console.log(` Connector hints: ${profile.connectorHints.join(", ")}`);
422
- console.log(` Fixture template: ${profile.fixtureTemplate}`);
341
+ for (const entry of entries) {
342
+ if (IGNORE_NAMES.has(entry.name) || entry.name.startsWith(".")) continue;
343
+ const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
344
+ const abs = path2.join(current, entry.name);
345
+ if (entry.isDirectory()) {
346
+ await walk(abs, rel);
347
+ } else if (!prefix && RESERVED_TOP_LEVEL.has(entry.name)) {
348
+ } else {
349
+ result[rel] = await fs2.readFile(abs, "utf-8");
350
+ }
423
351
  }
424
- console.log(` Run 'headways skills push ${result.slug}' to sync edits.`);
425
- });
352
+ }
353
+ await walk(dir, "");
354
+ return result;
426
355
  }
427
-
428
- // src/commands/skills/push.ts
429
- import "commander";
430
- import * as fs3 from "fs/promises";
431
- import * as path3 from "path";
432
- import { watch, existsSync } from "fs";
433
356
  var catchMissing = (e) => {
434
357
  if (e.code === "ENOENT") return null;
435
358
  throw e;
436
359
  };
437
360
  async function readSkillDir(dir) {
438
- const skillMdPath = path3.join(dir, "SKILL.md");
361
+ const skillMdPath = path2.join(dir, "SKILL.md");
439
362
  let body;
440
363
  try {
441
- body = await fs3.readFile(skillMdPath, "utf-8");
364
+ body = await fs2.readFile(skillMdPath, "utf-8");
442
365
  } catch {
443
366
  throw new Error(`SKILL.md not found in ${dir}`);
444
367
  }
445
- const [headwaysYaml, capabilitiesYaml, connectionsYaml] = await Promise.all([
446
- fs3.readFile(path3.join(dir, "headways.yaml"), "utf-8").catch(catchMissing),
447
- fs3.readFile(path3.join(dir, "capabilities.yaml"), "utf-8").catch(catchMissing),
448
- fs3.readFile(path3.join(dir, "connections.yaml"), "utf-8").catch(catchMissing)
368
+ const [headwaysYaml, capabilitiesYaml, connectionsYaml, hooksYaml] = await Promise.all([
369
+ fs2.readFile(path2.join(dir, "headways.yaml"), "utf-8").catch(catchMissing),
370
+ fs2.readFile(path2.join(dir, "capabilities.yaml"), "utf-8").catch(catchMissing),
371
+ fs2.readFile(path2.join(dir, "connections.yaml"), "utf-8").catch(catchMissing),
372
+ fs2.readFile(path2.join(dir, "hooks.yaml"), "utf-8").catch(catchMissing)
449
373
  ]);
450
374
  let headline;
451
375
  if (headwaysYaml) {
@@ -457,7 +381,35 @@ async function readSkillDir(dir) {
457
381
  const items = parseConnectionsYaml(connectionsYaml);
458
382
  if (items.length > 0) connections = items;
459
383
  }
460
- return { body, headline, capabilities: capabilitiesYaml ?? void 0, connections };
384
+ let capabilities;
385
+ if (capabilitiesYaml) {
386
+ try {
387
+ capabilities = YAML.parse(capabilitiesYaml);
388
+ } catch (err) {
389
+ throw new Error(
390
+ `Failed to parse capabilities.yaml: ${err instanceof Error ? err.message : String(err)}`
391
+ );
392
+ }
393
+ }
394
+ let hooks;
395
+ if (hooksYaml) {
396
+ try {
397
+ hooks = YAML.parse(hooksYaml);
398
+ } catch (err) {
399
+ throw new Error(
400
+ `Failed to parse hooks.yaml: ${err instanceof Error ? err.message : String(err)}`
401
+ );
402
+ }
403
+ }
404
+ const extraFiles = await collectExtraFiles(dir);
405
+ return {
406
+ body,
407
+ headline,
408
+ capabilities,
409
+ hooks,
410
+ connections,
411
+ files: Object.keys(extraFiles).length > 0 ? extraFiles : void 0
412
+ };
461
413
  }
462
414
  function parseConnectionsYaml(yaml) {
463
415
  const items = [];
@@ -482,28 +434,36 @@ function parseConnectionsYaml(yaml) {
482
434
  return items;
483
435
  }
484
436
  async function pushSkill(slug, dir) {
485
- const { body, headline, capabilities, connections } = await readSkillDir(dir);
437
+ const { body, headline, capabilities, hooks, connections, files } = await readSkillDir(dir);
438
+ if (headline && headline.length > 90) {
439
+ throw new Error(
440
+ `Headline is ${headline.length} chars \u2014 must be \u2264 90. Shorten to one sentence in headways.yaml.`
441
+ );
442
+ }
486
443
  await apiRequest(`/v1/skills/${slug}/draft`, {
487
444
  method: "PUT",
488
445
  body: JSON.stringify({
489
446
  body,
490
447
  ...headline ? { headline } : {},
491
448
  ...capabilities ? { capabilities } : {},
492
- ...connections ? { connections } : {}
449
+ ...hooks ? { hooks } : {},
450
+ ...connections ? { connections } : {},
451
+ ...files ? { files } : {}
493
452
  })
494
453
  });
495
- console.log(`Pushed '${slug}' draft`);
454
+ const fileCount = files ? Object.keys(files).length : 0;
455
+ console.log(`Pushed '${slug}' draft${fileCount > 0 ? ` (${fileCount} extra file${fileCount === 1 ? "" : "s"})` : ""}`);
496
456
  }
497
457
  function resolveSkillDir(slug) {
498
458
  if (!slug) return process.cwd();
499
- const installedPath = path3.join(CLAUDE_SKILLS_DIR, slug);
459
+ const installedPath = path2.join(CLAUDE_SKILLS_DIR, slug);
500
460
  if (existsSync(installedPath)) return installedPath;
501
- return path3.join(process.cwd(), slug);
461
+ return path2.join(process.cwd(), slug);
502
462
  }
503
463
  function registerPushCommand(program2) {
504
464
  program2.command("push [slug]").description("Push local skill files as a draft to Headways").option("--watch", "Watch for file changes and auto-push").option("--dir <dir>", "Skill directory (default: installed location, then ./<slug>)").action(async (slug, opts) => {
505
465
  requireAuth();
506
- const resolvedSlug = slug ?? path3.basename(process.cwd());
466
+ const resolvedSlug = slug ?? path2.basename(process.cwd());
507
467
  const dir = opts.dir ?? resolveSkillDir(slug);
508
468
  await pushSkill(resolvedSlug, dir);
509
469
  if (opts.watch) {
@@ -525,6 +485,86 @@ function registerPushCommand(program2) {
525
485
  });
526
486
  }
527
487
 
488
+ // src/commands/skills/import.ts
489
+ var ORG_PROFILES = {
490
+ hippocratic: {
491
+ connectorHints: ["ehr.read", "ehr.write", "phi.access"],
492
+ fixtureTemplate: "patient-encounter",
493
+ channelPolicy: "stable"
494
+ },
495
+ revive: {
496
+ connectorHints: ["email.read", "calendar.read", "ads.read"],
497
+ fixtureTemplate: "campaign-brief",
498
+ channelPolicy: "beta"
499
+ }
500
+ };
501
+ function registerImportCommand(program2) {
502
+ program2.command("import <path>").description("Import a skill from a file, directory, or URL").option("--slug <slug>", "Override derived slug").option(
503
+ "--org <orgProfile>",
504
+ "Apply org-specific connector hints + fixture templates (hippocratic, revive)"
505
+ ).action(async (inputPath, opts) => {
506
+ requireAuth();
507
+ let source;
508
+ let format = "auto";
509
+ let extraFiles = {};
510
+ try {
511
+ const stat2 = await fs3.stat(inputPath);
512
+ if (stat2.isDirectory()) {
513
+ const skillMdPath = path3.join(inputPath, "SKILL.md");
514
+ source = await fs3.readFile(skillMdPath, "utf-8");
515
+ format = "skill-md";
516
+ extraFiles = await collectExtraFiles(inputPath);
517
+ } else {
518
+ source = await fs3.readFile(inputPath, "utf-8");
519
+ if (inputPath.endsWith(".yaml") || inputPath.endsWith(".yml")) {
520
+ format = "headways-yaml";
521
+ } else {
522
+ format = "markdown";
523
+ }
524
+ }
525
+ } catch {
526
+ console.error(`Cannot read '${inputPath}': file or directory not found.`);
527
+ process.exit(1);
528
+ }
529
+ const profile = opts.org ? ORG_PROFILES[opts.org] : void 0;
530
+ if (opts.org && !profile) {
531
+ console.warn(
532
+ `Unknown org profile '${opts.org}'. Known profiles: ${Object.keys(ORG_PROFILES).join(", ")}`
533
+ );
534
+ }
535
+ const derivedSlug = opts.slug ?? path3.basename(inputPath, path3.extname(inputPath)).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
536
+ const result = await apiRequest("/v1/skills/import", {
537
+ method: "POST",
538
+ body: JSON.stringify({
539
+ source,
540
+ format,
541
+ suggestedSlug: derivedSlug,
542
+ connectorHints: profile?.connectorHints,
543
+ channelPolicy: profile?.channelPolicy,
544
+ ...Object.keys(extraFiles).length > 0 ? { files: extraFiles } : {}
545
+ })
546
+ });
547
+ if (result.headline.length > 90) {
548
+ console.warn(
549
+ ` Warning: headline is ${result.headline.length} chars (\u2264 90 required to publish). Edit headways.yaml and run 'headways skills push ${result.slug}'.`
550
+ );
551
+ }
552
+ const fileCount = Object.keys(extraFiles).length;
553
+ console.log(`Imported as '${result.slug}' (format: ${result.detectedFormat})`);
554
+ console.log(` Headline: ${result.headline}`);
555
+ console.log(` Skill ID: ${result.skillId}`);
556
+ if (fileCount > 0) {
557
+ console.log(` Files: ${fileCount} extra file${fileCount === 1 ? "" : "s"} uploaded`);
558
+ }
559
+ if (profile) {
560
+ console.log(` Org profile: ${opts.org}`);
561
+ console.log(` Connector hints: ${profile.connectorHints.join(", ")}`);
562
+ console.log(` Fixture template: ${profile.fixtureTemplate}`);
563
+ }
564
+ console.log(` Run 'headways skills push ${result.slug}' to sync edits.`);
565
+ });
566
+ }
567
+
528
568
  // src/commands/skills/index.ts
529
569
  var SKILLS_GUIDE = `
530
570
  # Headways Skill Authoring Guide
@@ -544,12 +584,12 @@ headways skills push <slug> # push local edits as a draft
544
584
  | Field | Rule |
545
585
  |----------------|----------------------------------------------------------------|
546
586
  | \`slug\` | \`^[a-z0-9-]+$\`, 1\u201364 chars, immutable after creation |
547
- | \`headline\` | 1\u2013200 chars at creation; **\u2264 90 chars to submit** (hard gate) |
587
+ | \`headline\` | **One short sentence, \u2264 90 chars** \u2014 hard gate at submit |
548
588
  | \`name\` | 1\u2013120 chars (display name, defaults to headline) |
549
589
  | \`channel\` | \`prompt\` (default) | \`auto\` | \`manual\` |
550
590
  | \`data_classes\` | \`none\` (default) | \`pii\` | \`phi\` | \`pci\` |
551
591
 
552
- > Critical: headline must be \u2264 90 characters or the web UI will block submission.
592
+ > Critical: headline must be one short sentence, \u2264 90 characters. The web UI blocks submission if exceeded.
553
593
 
554
594
  ## File Bundle (\`<slug>/\`)
555
595
 
@@ -584,7 +624,7 @@ edge cases, and concrete examples. Vague goals produce poor results.
584
624
  \`\`\`yaml
585
625
  slug: my-skill
586
626
  name: My Skill
587
- headline: Verb-first summary of the outcome (\u226490 chars)
627
+ headline: One short sentence, verb-first (\u2264 90 chars)
588
628
  channel: prompt # prompt | auto | manual
589
629
  runtimes:
590
630
  - claude-code
@@ -602,8 +642,8 @@ auto_send: false # true = skill may act without user confirmation
602
642
 
603
643
  ### connections.yaml (required for any skill that uses MCP connector tools)
604
644
 
605
- Declare every MCP connector the skill depends on. Users see this list on \`headways skills accept\`
606
- and the Headways app gates installation on the connectors being configured.
645
+ Declare every MCP connector the skill depends on. Users see this list when they install the skill
646
+ via the Headways desktop app, which gates installation on the connectors being configured.
607
647
 
608
648
  \`\`\`yaml
609
649
  - connector: slack # connector identifier (e.g. slack, github, atlassian, linear, notion, google-drive, stripe, asana, hubspot, datadog)
@@ -614,6 +654,38 @@ and the Headways app gates installation on the connectors being configured.
614
654
 
615
655
  Omit the file entirely if the skill has no connector dependencies.
616
656
 
657
+ ## Runtime Events
658
+
659
+ Skills can emit events during execution that show up in the Headways dashboard.
660
+ Two layers fire events:
661
+
662
+ 1. **Automatic** \u2014 Claude Code hooks emit \`tool.*\` (per tool call) and \`turn.end\`
663
+ (per response turn) for every skill invocation. A \`skill.start\`-style record is created
664
+ by the desktop app's \`UserPromptSubmit\` hook when the user invokes \`/<slug>\`. You don't write these.
665
+
666
+ 2. **Manual** \u2014 Inside your skill body, call \`headways emit\` to record concrete
667
+ outcomes the agent produced.
668
+
669
+ \`\`\`bash
670
+ headways emit --hook outcome.pr_created -f url=https://github.com/...
671
+ headways emit --hook outcome.email_sent -f to=user@example.com
672
+ headways emit --hook outcome.doc_written -f path=wiki/pages/x.md
673
+ \`\`\`
674
+
675
+ ### Hook name format
676
+
677
+ All hook names must match \`<namespace>.<name>\` where namespace is one of:
678
+
679
+ | Namespace | Emitted by | Example |
680
+ |-------------|---------------|--------------------------------------|
681
+ | \`skill.*\` | Hook (auto) | \`skill.start\` |
682
+ | \`tool.*\` | Hook (auto) | \`tool.Read\`, \`tool.Bash\` |
683
+ | \`turn.*\` | Hook (auto) | \`turn.end\` |
684
+ | \`outcome.*\` | Skill (manual)| \`outcome.pr_created\`, \`outcome.email_sent\` |
685
+
686
+ Names outside these four namespaces are rejected with HTTP 422. Pick a short,
687
+ verb-past-tense suffix for outcomes: \`pr_created\`, not \`creating_pr\`.
688
+
617
689
  ### hooks.yaml (omit if unused)
618
690
 
619
691
  \`\`\`yaml
@@ -666,7 +738,6 @@ local edits with \`headways skills push <slug>\`.
666
738
 
667
739
  ## Common Failure Modes
668
740
 
669
- - Headline > 90 chars \u2192 submit blocked with 422. Shorten before pushing.
670
741
  - Uppercase or special chars in slug \u2192 rejected at creation. Use \`a-z\`, \`0-9\`, \`-\` only.
671
742
  - Missing \`capabilities.yaml\` entries \u2192 skill silently blocked at runtime.
672
743
  - Missing \`connections.yaml\` for MCP-dependent skills \u2192 users install the skill but hit tool-not-found errors at runtime with no explanation. Always create this file when the skill calls MCP tools.
@@ -681,8 +752,8 @@ function registerSkillsCommands(program2) {
681
752
  console.log(SKILLS_GUIDE);
682
753
  });
683
754
  skills.command("list").description("List skills in the active org").action(async () => {
684
- const { requireAuth: requireAuth2 } = await import("./config-XQHAXREA.js");
685
- const { apiRequest: apiRequest2 } = await import("./api-5EKGGFQ6.js");
755
+ const { requireAuth: requireAuth2 } = await import("./config-APIR4RCR.js");
756
+ const { apiRequest: apiRequest2 } = await import("./api-H34ZX4FL.js");
686
757
  requireAuth2();
687
758
  const result = await apiRequest2("/v1/skills");
688
759
  if (result.data.length === 0) {
@@ -693,27 +764,6 @@ function registerSkillsCommands(program2) {
693
764
  }
694
765
  }
695
766
  });
696
- skills.command("accept <slug>").description("Accept a pending skill update and install it locally").action(async (slug) => {
697
- const { acceptSkill } = await import("./sync-Q3OQUWOD.js");
698
- await acceptSkill(slug);
699
- try {
700
- const { apiRequest: apiRequest2 } = await import("./api-5EKGGFQ6.js");
701
- const metadata = await apiRequest2(`/v1/skills/${slug}/bundle/metadata`);
702
- const reqs = metadata.connectionRequirements ?? [];
703
- if (reqs.length > 0) {
704
- console.log("");
705
- console.log("This skill requires the following connectors:");
706
- console.log("");
707
- for (const req of reqs) {
708
- console.log(` - ${req.connector.padEnd(20)} ${req.purpose}`);
709
- }
710
- console.log("");
711
- console.log("To authorize these connectors, use the Headways desktop app");
712
- console.log("or run: headways connections add <provider>");
713
- }
714
- } catch {
715
- }
716
- });
717
767
  skills.command("feedback <slug>").description("Submit feedback about a skill").option(
718
768
  "--reaction <type>",
719
769
  "thumbs_up, thumbs_down, wrong_output, missing_step",
@@ -762,14 +812,95 @@ function registerConnectionsCommands(program2) {
762
812
 
763
813
  // src/sdk/emit.ts
764
814
  import "commander";
815
+
816
+ // src/lib/run-session.ts
817
+ import {
818
+ existsSync as existsSync2,
819
+ mkdirSync,
820
+ readdirSync,
821
+ readFileSync,
822
+ statSync,
823
+ unlinkSync,
824
+ writeFileSync
825
+ } from "fs";
826
+ import { join as join4 } from "path";
827
+ var THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1e3;
828
+ async function readHookStdin() {
829
+ if (process.stdin.isTTY) return null;
830
+ const chunks = [];
831
+ for await (const chunk of process.stdin) {
832
+ chunks.push(chunk);
833
+ }
834
+ const raw = Buffer.concat(chunks).toString("utf8").trim();
835
+ if (!raw) return null;
836
+ try {
837
+ return JSON.parse(raw);
838
+ } catch {
839
+ return null;
840
+ }
841
+ }
842
+ function sessionFilePath(sessionId) {
843
+ return join4(RUNS_DIR, sessionId);
844
+ }
845
+ function writeSessionRun(sessionId, run) {
846
+ if (!existsSync2(RUNS_DIR)) mkdirSync(RUNS_DIR, { recursive: true });
847
+ writeFileSync(sessionFilePath(sessionId), JSON.stringify(run));
848
+ }
849
+ function readSessionRun(sessionId) {
850
+ const path4 = sessionFilePath(sessionId);
851
+ if (!existsSync2(path4)) return null;
852
+ try {
853
+ return JSON.parse(readFileSync(path4, "utf8"));
854
+ } catch {
855
+ return null;
856
+ }
857
+ }
858
+ function cleanupStaleRuns(now = Date.now()) {
859
+ if (!existsSync2(RUNS_DIR)) return;
860
+ let entries;
861
+ try {
862
+ entries = readdirSync(RUNS_DIR);
863
+ } catch {
864
+ return;
865
+ }
866
+ for (const name of entries) {
867
+ const path4 = join4(RUNS_DIR, name);
868
+ try {
869
+ const stat2 = statSync(path4);
870
+ if (now - stat2.mtimeMs > THIRTY_DAYS_MS) unlinkSync(path4);
871
+ } catch {
872
+ }
873
+ }
874
+ }
875
+
876
+ // src/sdk/emit.ts
877
+ var HOOK_NAME_RE = /^(skill|tool|turn|outcome)\.[A-Za-z0-9_]+$/;
878
+ var HOOK_NAME_HELP = "Hook name must be <namespace>.<name> where namespace is one of: skill, tool, turn, outcome.\n skill.* lifecycle (e.g. skill.start)\n tool.* tool call events (e.g. tool.Read, tool.Bash)\n turn.* conversation turns (e.g. turn.end)\n outcome.* concrete artifacts (e.g. outcome.pr_created, outcome.email_sent)";
765
879
  function registerEmitCommand(program2) {
766
- program2.command("emit").description("Emit a skill run event (used by Claude Code hooks)").option("--run-id <runId>", "Skill run ID (or set HEADWAYS_RUN_ID env var)").option("--hook <hookName>", "Hook name to emit").option("--event <event>", "Legacy alias for --hook").option("--status <status>", "Tool result status (true=error, false=ok)").option("-f, --field <entries...>", "Field values in key=value format").allowUnknownOption(true).action(
880
+ program2.command("emit").description(
881
+ "Emit a skill run event (used by Claude Code hooks and from inside skills).\n" + HOOK_NAME_HELP
882
+ ).option("--run-id <runId>", "Skill run ID (or set HEADWAYS_RUN_ID env var)").option("--hook <hookName>", "Hook name to emit, e.g. outcome.pr_created").option("--event <event>", "Legacy alias for --hook").option("--status <status>", "Tool result status (true=error, false=ok)").option("-f, --field <entries...>", "Field values in key=value format").allowUnknownOption(true).action(
767
883
  async (opts) => {
884
+ const hookName = opts.hook ?? opts.event ?? "tool.unknown";
885
+ if (!HOOK_NAME_RE.test(hookName)) {
886
+ console.error(`headways emit: invalid --hook "${hookName}"
887
+
888
+ ${HOOK_NAME_HELP}`);
889
+ process.exitCode = 1;
890
+ return;
891
+ }
768
892
  const cfg = readConfig();
769
893
  if (!cfg.token) return;
770
- const runId = opts.runId ?? process.env["HEADWAYS_RUN_ID"];
894
+ let runId = opts.runId ?? process.env["HEADWAYS_RUN_ID"];
895
+ if (!runId) {
896
+ const input = await readHookStdin();
897
+ const sessionId = input?.session_id;
898
+ if (typeof sessionId === "string") {
899
+ const session = readSessionRun(sessionId);
900
+ if (session) runId = session.runId;
901
+ }
902
+ }
771
903
  if (!runId) return;
772
- const hookName = opts.hook ?? opts.event ?? "tool.unknown";
773
904
  const payload = {};
774
905
  if (opts.status !== void 0) {
775
906
  payload["tool_result_is_error"] = opts.status === "true" || opts.status === "1";
@@ -800,8 +931,67 @@ function registerEmitCommand(program2) {
800
931
  );
801
932
  }
802
933
 
934
+ // src/commands/skill-run.ts
935
+ import { existsSync as existsSync3 } from "fs";
936
+ import { join as join5 } from "path";
937
+ import "commander";
938
+ var SLASH_COMMAND_RE = /^\/([a-z0-9-]+(?::[a-z0-9-]+)?)\b/;
939
+ function resolveSkillSlug(input, isInstalled) {
940
+ const direct = input.tool_input?.skill;
941
+ if (typeof direct === "string" && direct.length > 0) return direct;
942
+ if (typeof input.prompt === "string") {
943
+ const m = SLASH_COMMAND_RE.exec(input.prompt);
944
+ if (m && m[1]) {
945
+ const slug = m[1];
946
+ if (isInstalled(slug)) return slug;
947
+ }
948
+ }
949
+ return null;
950
+ }
951
+ function defaultIsInstalled(slug) {
952
+ return existsSync3(join5(INSTALLED_DIR, `${slug}.json`));
953
+ }
954
+ function registerSkillRunCommands(program2) {
955
+ const skillRun = program2.command("skill-run").description("Skill-run lifecycle commands (invoked by Claude Code hooks).");
956
+ skillRun.command("start").description(
957
+ "Create a skill run from a Claude Code hook. Accepts UserPromptSubmit (parses /<slug> from prompt) or PreToolUse(Skill) (tool_input.skill). POSTs /v1/skill-runs and writes ~/.headways/runs/<session_id>."
958
+ ).action(async () => {
959
+ cleanupStaleRuns();
960
+ const cfg = readConfig();
961
+ if (!cfg.token) return;
962
+ const input = await readHookStdin();
963
+ if (!input) return;
964
+ const sessionId = input.session_id;
965
+ if (typeof sessionId !== "string") return;
966
+ const skillSlug = resolveSkillSlug(input, defaultIsInstalled);
967
+ if (!skillSlug) return;
968
+ let runId;
969
+ try {
970
+ const res = await fetch(`${getApiUrl()}/v1/skill-runs`, {
971
+ method: "POST",
972
+ headers: {
973
+ "Content-Type": "application/json",
974
+ Authorization: `Bearer ${cfg.token}`
975
+ },
976
+ body: JSON.stringify({ skillSlug, runtime: "claude-code" })
977
+ });
978
+ if (!res.ok) return;
979
+ const body = await res.json();
980
+ if (!body.runId) return;
981
+ runId = body.runId;
982
+ } catch {
983
+ return;
984
+ }
985
+ writeSessionRun(sessionId, {
986
+ runId,
987
+ skill: skillSlug,
988
+ startedAt: (/* @__PURE__ */ new Date()).toISOString()
989
+ });
990
+ });
991
+ }
992
+
803
993
  // src/commands/prime.ts
804
- import { existsSync as existsSync2, readdirSync, readFileSync } from "fs";
994
+ import { existsSync as existsSync4, readdirSync as readdirSync2, readFileSync as readFileSync2 } from "fs";
805
995
  import "commander";
806
996
  function registerPrimeCommand(program2) {
807
997
  program2.command("prime").description("Output Headways workflow context for AI coding assistants").action(() => {
@@ -817,6 +1007,11 @@ function registerPrimeCommand(program2) {
817
1007
  "> `--help` to discover full options, or run `headways skills guide` before authoring a skill.",
818
1008
  "> Do not guess at flags or constraints \u2014 discover them at runtime.",
819
1009
  "",
1010
+ "## Responsibility split",
1011
+ "",
1012
+ "- **Desktop app (macOS):** installs skills, syncs the catalog, manages Claude Code hooks. Open it from `/Applications/Headways.app`.",
1013
+ "- **CLI (this tool):** skill *authoring* (push, import, new, list) plus runtime helpers Claude Code shells out to (`prime`, `emit`, `skill-run start`).",
1014
+ "",
820
1015
  "## Auth & Config",
821
1016
  "",
822
1017
  `Status: ${cfg.token ? `Signed in (org: ${cfg.orgSlug ?? cfg.orgId ?? "unknown"})` : "Not signed in \u2014 run `headways login`"}`,
@@ -828,37 +1023,33 @@ function registerPrimeCommand(program2) {
828
1023
  "headways login # Browser SSO sign-in",
829
1024
  "headways logout # Remove stored credentials",
830
1025
  "headways config status # Show saved key, org, URLs",
831
- "headways config clear # Clear credentials + reset setup state",
832
- "",
833
- "headways sync start # Pull catalog updates once",
834
- "headways sync start --daemon # Poll every 60s in background",
835
- "headways sync status # Show pending skill updates",
1026
+ "headways config clear # Clear credentials",
836
1027
  "",
837
1028
  "headways skills list # List skills in your org",
838
1029
  "headways skills new # Scaffold a new skill",
839
1030
  "headways skills import <path> # Create a new skill from a local file or directory",
840
1031
  "headways skills push <slug> # Push edits to an existing skill (import or new first)",
841
- "headways skills accept <slug> # Install a pending skill update",
842
1032
  "headways skills feedback <slug> # Submit feedback on a skill",
843
1033
  "headways skills guide # Authoring reference (run before creating a skill)",
844
1034
  "",
845
- "headways setup claude # Install Claude Code hooks (SessionStart + PreCompact)",
846
- "headways prime # Print this context (used by hooks)",
1035
+ "# Runtime helpers (invoked automatically by Claude Code hooks; rarely run by hand):",
1036
+ "headways prime # Print this context",
1037
+ "headways emit # Emit a skill-run event",
1038
+ "headways skill-run start # Start a skill run from a UserPromptSubmit hook",
847
1039
  "```",
848
1040
  "",
849
1041
  "## Workflow",
850
1042
  "",
851
- "1. `headways sync start` \u2014 pull the latest catalog from your org",
852
- "2. `headways accept <skill>` \u2014 install a skill locally",
853
- "3. Skills are automatically available to Claude Code via `~/.claude/skills/<slug>/`",
854
- "4. Run `headways sync start --daemon` to keep skills up to date in the background",
1043
+ "1. Open the **Headways desktop app** \u2014 it syncs the catalog, shows pending updates, and clicks-to-install bundles.",
1044
+ "2. New skill versions appear in the Library view; click **Install** to bring them onto this machine.",
1045
+ "3. To author skills: `headways skills new` or `headways skills import <path>`, edit, then `headways skills push <slug>`. Publish via the web UI.",
855
1046
  "",
856
1047
  "## Installed Skills",
857
1048
  ""
858
1049
  ];
859
1050
  if (skills.length === 0) {
860
1051
  lines.push(
861
- "No skills installed. Run `headways sync start` then `headways accept <skill>`."
1052
+ "No skills installed. Open the Headways desktop app to sync your org's catalog."
862
1053
  );
863
1054
  } else {
864
1055
  for (const skill of skills) {
@@ -877,12 +1068,12 @@ function registerPrimeCommand(program2) {
877
1068
  });
878
1069
  }
879
1070
  function getInstalledSkills() {
880
- if (!existsSync2(INSTALLED_DIR)) return [];
1071
+ if (!existsSync4(INSTALLED_DIR)) return [];
881
1072
  try {
882
- return readdirSync(INSTALLED_DIR).filter((f) => f.endsWith(".json")).map((f) => {
1073
+ return readdirSync2(INSTALLED_DIR).filter((f) => f.endsWith(".json")).map((f) => {
883
1074
  const slug = f.replace(/\.json$/, "");
884
1075
  try {
885
- const raw = JSON.parse(readFileSync(`${INSTALLED_DIR}/${f}`, "utf8"));
1076
+ const raw = JSON.parse(readFileSync2(`${INSTALLED_DIR}/${f}`, "utf8"));
886
1077
  return {
887
1078
  slug,
888
1079
  version: String(raw.version ?? ""),
@@ -899,14 +1090,125 @@ function getInstalledSkills() {
899
1090
  }
900
1091
  }
901
1092
 
1093
+ // src/commands/upgrade.ts
1094
+ import { chmodSync, renameSync, unlinkSync as unlinkSync2, writeFileSync as writeFileSync2 } from "fs";
1095
+ import { delimiter, basename as basename3, dirname, join as join6 } from "path";
1096
+ import { existsSync as existsSync5, statSync as statSync2 } from "fs";
1097
+ import "commander";
1098
+ var GCS_BASE = "https://storage.googleapis.com/headways-releases/cli";
1099
+ var NPM_PACKAGE = "@headways/cli";
1100
+ function registerUpgradeCommand(program2) {
1101
+ program2.command("upgrade").description("Upgrade the Headways CLI to the latest version").option("--force", "Reinstall even if already on the latest version").action(async (opts) => {
1102
+ warnIfShadowed();
1103
+ const mode = detectInstallMode();
1104
+ if (mode.kind === "npm") {
1105
+ console.log(
1106
+ `This binary was installed via npm. Upgrade with:
1107
+
1108
+ npm i -g ${NPM_PACKAGE}@latest
1109
+ `
1110
+ );
1111
+ return;
1112
+ }
1113
+ if (mode.kind === "unknown") {
1114
+ console.error(
1115
+ `Could not determine how this CLI was installed (execPath: ${process.execPath}).
1116
+ If installed via npm: npm i -g ${NPM_PACKAGE}@latest
1117
+ If installed via desktop: re-run the Install step from the Headways app.`
1118
+ );
1119
+ process.exitCode = 1;
1120
+ return;
1121
+ }
1122
+ await selfUpdate(mode.binPath, opts.force ?? false);
1123
+ });
1124
+ }
1125
+ function detectInstallMode() {
1126
+ const exec = process.execPath;
1127
+ const execName = basename3(exec).toLowerCase();
1128
+ if (execName === "node" || execName === "bun" || execName.startsWith("node")) {
1129
+ return { kind: "npm" };
1130
+ }
1131
+ if (execName === "headways" || execName.startsWith("headways")) {
1132
+ return { kind: "standalone", binPath: exec };
1133
+ }
1134
+ return { kind: "unknown" };
1135
+ }
1136
+ function detectTriple() {
1137
+ if (process.platform !== "darwin") return null;
1138
+ if (process.arch === "arm64") return "aarch64-apple-darwin";
1139
+ if (process.arch === "x64") return "x86_64-apple-darwin";
1140
+ return null;
1141
+ }
1142
+ async function selfUpdate(binPath, force) {
1143
+ const triple = detectTriple();
1144
+ if (!triple) {
1145
+ console.error(
1146
+ `No prebuilt binary for ${process.platform}/${process.arch}. Standalone binaries are macOS-only; on other platforms use: npm i -g ${NPM_PACKAGE}@latest`
1147
+ );
1148
+ process.exitCode = 1;
1149
+ return;
1150
+ }
1151
+ console.log(`Checking for updates\u2026 (current: ${"1.1.0"})`);
1152
+ const latestRes = await fetch(`${GCS_BASE}/latest.txt`);
1153
+ if (!latestRes.ok) {
1154
+ console.error(`Failed to fetch latest version: ${latestRes.status} ${latestRes.statusText}`);
1155
+ process.exitCode = 1;
1156
+ return;
1157
+ }
1158
+ const latest = (await latestRes.text()).trim();
1159
+ if (latest === "1.1.0" && !force) {
1160
+ console.log(`Already on the latest version (${latest}).`);
1161
+ return;
1162
+ }
1163
+ console.log(`Downloading ${latest} for ${triple}\u2026`);
1164
+ const binRes = await fetch(`${GCS_BASE}/${latest}/headways-${triple}`);
1165
+ if (!binRes.ok) {
1166
+ console.error(`Failed to download binary: ${binRes.status} ${binRes.statusText}`);
1167
+ process.exitCode = 1;
1168
+ return;
1169
+ }
1170
+ const bin = Buffer.from(await binRes.arrayBuffer());
1171
+ const tmpPath = join6(dirname(binPath), `.headways.upgrade.${process.pid}`);
1172
+ writeFileSync2(tmpPath, bin);
1173
+ try {
1174
+ chmodSync(tmpPath, 493);
1175
+ renameSync(tmpPath, binPath);
1176
+ } catch (e) {
1177
+ try {
1178
+ unlinkSync2(tmpPath);
1179
+ } catch {
1180
+ }
1181
+ throw e;
1182
+ }
1183
+ console.log(`Upgraded to ${latest} \u2192 ${binPath}`);
1184
+ }
1185
+ function warnIfShadowed() {
1186
+ const exec = process.execPath;
1187
+ const pathDirs = (process.env.PATH ?? "").split(delimiter).filter(Boolean);
1188
+ const found = [];
1189
+ for (const dir of pathDirs) {
1190
+ const candidate = join6(dir, "headways");
1191
+ try {
1192
+ if (existsSync5(candidate) && statSync2(candidate).isFile()) {
1193
+ found.push(candidate);
1194
+ }
1195
+ } catch {
1196
+ }
1197
+ }
1198
+ if (found.length > 0 && found[0] !== exec) {
1199
+ console.error(
1200
+ `note: another 'headways' is earlier on PATH at ${found[0]} \u2014 running upgrade here won't change which binary your shell picks.`
1201
+ );
1202
+ }
1203
+ }
1204
+
902
1205
  // src/index.ts
903
- program.name("headways").description("Headways CLI \u2014 skill authoring, sync, and runtime SDK").version(package_default.version);
1206
+ program.name("headways").description("Headways CLI \u2014 skill authoring + Claude Code runtime helpers").version("1.1.0");
904
1207
  registerAuthCommands(program);
905
1208
  registerSkillsCommands(program);
906
1209
  registerConnectionsCommands(program);
907
- registerSyncCommands(program);
908
1210
  registerEmitCommand(program);
1211
+ registerSkillRunCommands(program);
909
1212
  registerPrimeCommand(program);
910
- registerSetupCommand(program);
911
- registerUninstallCommand(program);
1213
+ registerUpgradeCommand(program);
912
1214
  program.parse();