@headways/cli 0.4.2 → 1.0.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/README.md CHANGED
@@ -1,9 +1,16 @@
1
1
  # @headways/cli
2
2
 
3
- The official CLI for [Headways](https://headways.ai) author skills, sync them to your team, and capture workflows directly from Claude Code.
3
+ The official CLI for [Headways](https://headways.ai). Two responsibilities:
4
+
5
+ 1. **Skill authoring** — talk to the backend API: scaffold, push drafts, list, submit feedback.
6
+ 2. **Claude Code runtime helpers** — small binaries that Claude Code hooks shell out to (`prime`, `emit`, `skill-run start`).
7
+
8
+ Anything that mutates this machine's state — installing skill bundles, syncing the catalog, editing `~/.claude/settings.json` — lives in the [Headways desktop app](https://headways.ai/download) (macOS for now). The desktop auto-installs this CLI under `~/.local/bin/headways` on first launch.
4
9
 
5
10
  ## Installation
6
11
 
12
+ The desktop app installs the CLI for you. For dev / local work:
13
+
7
14
  ```bash
8
15
  npm install -g @headways/cli
9
16
  ```
@@ -11,63 +18,66 @@ npm install -g @headways/cli
11
18
  ## Setup
12
19
 
13
20
  ```bash
14
- headways configure
15
- # Prompts for your API key (sk_…). Validates and saves org automatically.
21
+ headways login
22
+ # Browser SSO. Saves credentials + org to ~/.headways/config.json.
16
23
  ```
17
24
 
18
- Options:
19
-
20
25
  ```bash
21
- headways configure --token <key> # non-interactive
22
- headways configure --api-url http://localhost:3001 # local dev
23
- headways configure status # show saved key + org
24
- headways configure clear # remove credentials
26
+ headways config status # show saved token + org + URLs
27
+ headways config clear # remove credentials
25
28
  ```
26
29
 
27
30
  ## Commands
28
31
 
29
- ### Skills
32
+ ### Authoring
30
33
 
31
34
  ```bash
32
- headways skills new # scaffold a new skill interactively
35
+ headways skills new # scaffold a new skill (creates ./<slug>/)
33
36
  headways skills new --slug <slug> --headline "<headline>" # non-interactive
34
- headways skills import <path> # ingest an existing file or directory
35
- headways skills push <slug> # sync local edits to the skill draft
36
- headways skills list # list skills in the active org
37
- headways skills capture # capture a workflow from Claude Code
37
+ headways skills import <path> # ingest an existing file or directory
38
+ headways skills push <slug> # push local edits as a draft
39
+ headways skills list # list skills in the active org
40
+ headways skills feedback <slug> # submit feedback on a skill
41
+ headways skills guide # authoring reference (run before creating a skill)
38
42
  ```
39
43
 
40
- ### Sync
44
+ ### Runtime helpers (invoked by Claude Code hooks; rarely run by hand)
41
45
 
42
46
  ```bash
43
- headways sync start # register device + pull updates once
44
- headways sync start --daemon # poll every 60s for background updates
45
- headways sync status # show pending skill updates
46
- headways accept <skill> # install a pending update
47
+ headways prime # print workflow context for AI agents
48
+ headways emit --hook <namespace>.<name> # emit a skill-run event
49
+ headways skill-run start # create a run from a UserPromptSubmit hook
47
50
  ```
48
51
 
49
- ### Other
52
+ ### Connections
50
53
 
51
54
  ```bash
52
- headways feedback <skillSlug> # submit feedback on a skill run
53
- headways org use <slug> # switch active org
55
+ headways connections list # list MCP connector authorizations
54
56
  ```
55
57
 
56
58
  ## Creating a skill from scratch
57
59
 
58
60
  ```bash
59
61
  cd ~/my-skills
60
- headways skills new # creates ./<slug>/ with SKILL.md,
61
- # headways.yaml, capabilities.yaml,
62
- # hooks.yaml
63
- # Edit the skill
64
- vim <slug>/SKILL.md
65
- # Push as a draft
66
- headways skills push <slug>
67
- # Publish via the web UI when ready
62
+ headways skills new # creates ./<slug>/ with SKILL.md,
63
+ # headways.yaml, capabilities.yaml,
64
+ # hooks.yaml
65
+ vim <slug>/SKILL.md # edit
66
+ headways skills push <slug> # push as draft
67
+ # Then publish via the web UI at app.headways.ai/skills/<slug>
68
68
  ```
69
69
 
70
- Installed skill bundles land in `~/.claude/skills/<slug>/`. Config is stored in `~/.headways/config.json`.
70
+ Once published, the skill shows up in the desktop app's Library view with an **Install** button.
71
+
72
+ ## Where things live
73
+
74
+ | Path | Purpose |
75
+ |---|---|
76
+ | `~/.headways/config.json` | API token, org, URLs |
77
+ | `~/.headways/installed/<slug>.json` | Local install manifests (written by the desktop) |
78
+ | `~/.headways/runs/<session_id>` | Active run id per Claude Code session (written by `skill-run start`) |
79
+ | `~/.claude/skills/<slug>/` | Materialized skill bundles (written by the desktop) |
80
+ | `~/.claude/settings.json` | Claude Code hook wiring (written by the desktop) |
71
81
 
72
82
  ## Local development
73
83
 
@@ -2,8 +2,8 @@
2
2
  import {
3
3
  apiRequest,
4
4
  rawRequest
5
- } from "./chunk-2INXZHRG.js";
6
- import "./chunk-UUFIIGTZ.js";
5
+ } from "./chunk-GN2N6M4B.js";
6
+ import "./chunk-COGZMSYS.js";
7
7
  export {
8
8
  apiRequest,
9
9
  rawRequest
@@ -8,6 +8,7 @@ var HEADWAYS_DIR = join(homedir(), ".headways");
8
8
  var CONFIG_FILE = join(HEADWAYS_DIR, "config.json");
9
9
  var CATALOG_FILE = join(HEADWAYS_DIR, "catalog.json");
10
10
  var INSTALLED_DIR = join(HEADWAYS_DIR, "installed");
11
+ var RUNS_DIR = join(HEADWAYS_DIR, "runs");
11
12
  var CLAUDE_SKILLS_DIR = join(homedir(), ".claude", "skills");
12
13
  function readConfig() {
13
14
  if (!existsSync(CONFIG_FILE)) return {};
@@ -43,6 +44,7 @@ export {
43
44
  CONFIG_FILE,
44
45
  CATALOG_FILE,
45
46
  INSTALLED_DIR,
47
+ RUNS_DIR,
46
48
  CLAUDE_SKILLS_DIR,
47
49
  readConfig,
48
50
  writeConfig,
@@ -2,7 +2,7 @@
2
2
  import {
3
3
  getApiUrl,
4
4
  requireAuth
5
- } from "./chunk-UUFIIGTZ.js";
5
+ } from "./chunk-COGZMSYS.js";
6
6
 
7
7
  // src/lib/api.ts
8
8
  async function rawRequest(path, token, options = {}, apiUrl) {
@@ -5,18 +5,20 @@ import {
5
5
  CONFIG_FILE,
6
6
  HEADWAYS_DIR,
7
7
  INSTALLED_DIR,
8
+ RUNS_DIR,
8
9
  getApiUrl,
9
10
  getAppUrl,
10
11
  readConfig,
11
12
  requireAuth,
12
13
  writeConfig
13
- } from "./chunk-UUFIIGTZ.js";
14
+ } from "./chunk-COGZMSYS.js";
14
15
  export {
15
16
  CATALOG_FILE,
16
17
  CLAUDE_SKILLS_DIR,
17
18
  CONFIG_FILE,
18
19
  HEADWAYS_DIR,
19
20
  INSTALLED_DIR,
21
+ RUNS_DIR,
20
22
  getApiUrl,
21
23
  getAppUrl,
22
24
  readConfig,
package/dist/index.js CHANGED
@@ -2,21 +2,17 @@
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";
@@ -25,7 +21,7 @@ import { program } from "commander";
25
21
  // package.json
26
22
  var package_default = {
27
23
  name: "@headways/cli",
28
- version: "0.4.2",
24
+ version: "1.0.0",
29
25
  type: "module",
30
26
  description: "Headways CLI \u2014 authoring, sync, and runtime SDK",
31
27
  license: "MIT",
@@ -285,6 +281,12 @@ function registerNewCommand(program2) {
285
281
  console.error("slug and headline are required.");
286
282
  process.exit(1);
287
283
  }
284
+ if (headline.length > 90) {
285
+ console.error(
286
+ `Headline is ${headline.length} chars \u2014 must be \u2264 90. Shorten to one sentence.`
287
+ );
288
+ process.exit(1);
289
+ }
288
290
  const dir = path.join(process.cwd(), slug);
289
291
  await fs.mkdir(dir, { recursive: true });
290
292
  await fs.writeFile(
@@ -356,96 +358,63 @@ runtimes: [claude-code]
356
358
 
357
359
  // src/commands/skills/import.ts
358
360
  import "commander";
361
+ import * as fs3 from "fs/promises";
362
+ import * as path3 from "path";
363
+
364
+ // src/commands/skills/push.ts
365
+ import "commander";
359
366
  import * as fs2 from "fs/promises";
360
367
  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";
368
+ import { watch, existsSync } from "fs";
369
+ var RESERVED_TOP_LEVEL = /* @__PURE__ */ new Set([
370
+ "SKILL.md",
371
+ "headways.yaml",
372
+ "capabilities.yaml",
373
+ "connections.yaml",
374
+ "hooks.yaml"
375
+ ]);
376
+ var IGNORE_NAMES = /* @__PURE__ */ new Set([".git", "node_modules", ".DS_Store", ".gitkeep"]);
377
+ async function collectExtraFiles(dir) {
378
+ const result = {};
379
+ async function walk(current, prefix) {
380
+ let entries;
381
381
  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
- }
382
+ entries = await fs2.readdir(current, { withFileTypes: true });
395
383
  } 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
- );
384
+ return;
404
385
  }
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}`);
386
+ for (const entry of entries) {
387
+ if (IGNORE_NAMES.has(entry.name) || entry.name.startsWith(".")) continue;
388
+ const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
389
+ const abs = path2.join(current, entry.name);
390
+ if (entry.isDirectory()) {
391
+ await walk(abs, rel);
392
+ } else if (!prefix && RESERVED_TOP_LEVEL.has(entry.name)) {
393
+ } else {
394
+ result[rel] = await fs2.readFile(abs, "utf-8");
395
+ }
423
396
  }
424
- console.log(` Run 'headways skills push ${result.slug}' to sync edits.`);
425
- });
397
+ }
398
+ await walk(dir, "");
399
+ return result;
426
400
  }
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
401
  var catchMissing = (e) => {
434
402
  if (e.code === "ENOENT") return null;
435
403
  throw e;
436
404
  };
437
405
  async function readSkillDir(dir) {
438
- const skillMdPath = path3.join(dir, "SKILL.md");
406
+ const skillMdPath = path2.join(dir, "SKILL.md");
439
407
  let body;
440
408
  try {
441
- body = await fs3.readFile(skillMdPath, "utf-8");
409
+ body = await fs2.readFile(skillMdPath, "utf-8");
442
410
  } catch {
443
411
  throw new Error(`SKILL.md not found in ${dir}`);
444
412
  }
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)
413
+ const [headwaysYaml, capabilitiesYaml, connectionsYaml, hooksYaml] = await Promise.all([
414
+ fs2.readFile(path2.join(dir, "headways.yaml"), "utf-8").catch(catchMissing),
415
+ fs2.readFile(path2.join(dir, "capabilities.yaml"), "utf-8").catch(catchMissing),
416
+ fs2.readFile(path2.join(dir, "connections.yaml"), "utf-8").catch(catchMissing),
417
+ fs2.readFile(path2.join(dir, "hooks.yaml"), "utf-8").catch(catchMissing)
449
418
  ]);
450
419
  let headline;
451
420
  if (headwaysYaml) {
@@ -457,7 +426,15 @@ async function readSkillDir(dir) {
457
426
  const items = parseConnectionsYaml(connectionsYaml);
458
427
  if (items.length > 0) connections = items;
459
428
  }
460
- return { body, headline, capabilities: capabilitiesYaml ?? void 0, connections };
429
+ const extraFiles = await collectExtraFiles(dir);
430
+ return {
431
+ body,
432
+ headline,
433
+ capabilities: capabilitiesYaml ?? void 0,
434
+ hooks: hooksYaml ?? void 0,
435
+ connections,
436
+ files: Object.keys(extraFiles).length > 0 ? extraFiles : void 0
437
+ };
461
438
  }
462
439
  function parseConnectionsYaml(yaml) {
463
440
  const items = [];
@@ -482,28 +459,36 @@ function parseConnectionsYaml(yaml) {
482
459
  return items;
483
460
  }
484
461
  async function pushSkill(slug, dir) {
485
- const { body, headline, capabilities, connections } = await readSkillDir(dir);
462
+ const { body, headline, capabilities, hooks, connections, files } = await readSkillDir(dir);
463
+ if (headline && headline.length > 90) {
464
+ throw new Error(
465
+ `Headline is ${headline.length} chars \u2014 must be \u2264 90. Shorten to one sentence in headways.yaml.`
466
+ );
467
+ }
486
468
  await apiRequest(`/v1/skills/${slug}/draft`, {
487
469
  method: "PUT",
488
470
  body: JSON.stringify({
489
471
  body,
490
472
  ...headline ? { headline } : {},
491
473
  ...capabilities ? { capabilities } : {},
492
- ...connections ? { connections } : {}
474
+ ...hooks ? { hooks } : {},
475
+ ...connections ? { connections } : {},
476
+ ...files ? { files } : {}
493
477
  })
494
478
  });
495
- console.log(`Pushed '${slug}' draft`);
479
+ const fileCount = files ? Object.keys(files).length : 0;
480
+ console.log(`Pushed '${slug}' draft${fileCount > 0 ? ` (${fileCount} extra file${fileCount === 1 ? "" : "s"})` : ""}`);
496
481
  }
497
482
  function resolveSkillDir(slug) {
498
483
  if (!slug) return process.cwd();
499
- const installedPath = path3.join(CLAUDE_SKILLS_DIR, slug);
484
+ const installedPath = path2.join(CLAUDE_SKILLS_DIR, slug);
500
485
  if (existsSync(installedPath)) return installedPath;
501
- return path3.join(process.cwd(), slug);
486
+ return path2.join(process.cwd(), slug);
502
487
  }
503
488
  function registerPushCommand(program2) {
504
489
  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
490
  requireAuth();
506
- const resolvedSlug = slug ?? path3.basename(process.cwd());
491
+ const resolvedSlug = slug ?? path2.basename(process.cwd());
507
492
  const dir = opts.dir ?? resolveSkillDir(slug);
508
493
  await pushSkill(resolvedSlug, dir);
509
494
  if (opts.watch) {
@@ -525,6 +510,86 @@ function registerPushCommand(program2) {
525
510
  });
526
511
  }
527
512
 
513
+ // src/commands/skills/import.ts
514
+ var ORG_PROFILES = {
515
+ hippocratic: {
516
+ connectorHints: ["ehr.read", "ehr.write", "phi.access"],
517
+ fixtureTemplate: "patient-encounter",
518
+ channelPolicy: "stable"
519
+ },
520
+ revive: {
521
+ connectorHints: ["email.read", "calendar.read", "ads.read"],
522
+ fixtureTemplate: "campaign-brief",
523
+ channelPolicy: "beta"
524
+ }
525
+ };
526
+ function registerImportCommand(program2) {
527
+ program2.command("import <path>").description("Import a skill from a file, directory, or URL").option("--slug <slug>", "Override derived slug").option(
528
+ "--org <orgProfile>",
529
+ "Apply org-specific connector hints + fixture templates (hippocratic, revive)"
530
+ ).action(async (inputPath, opts) => {
531
+ requireAuth();
532
+ let source;
533
+ let format = "auto";
534
+ let extraFiles = {};
535
+ try {
536
+ const stat2 = await fs3.stat(inputPath);
537
+ if (stat2.isDirectory()) {
538
+ const skillMdPath = path3.join(inputPath, "SKILL.md");
539
+ source = await fs3.readFile(skillMdPath, "utf-8");
540
+ format = "skill-md";
541
+ extraFiles = await collectExtraFiles(inputPath);
542
+ } else {
543
+ source = await fs3.readFile(inputPath, "utf-8");
544
+ if (inputPath.endsWith(".yaml") || inputPath.endsWith(".yml")) {
545
+ format = "headways-yaml";
546
+ } else {
547
+ format = "markdown";
548
+ }
549
+ }
550
+ } catch {
551
+ console.error(`Cannot read '${inputPath}': file or directory not found.`);
552
+ process.exit(1);
553
+ }
554
+ const profile = opts.org ? ORG_PROFILES[opts.org] : void 0;
555
+ if (opts.org && !profile) {
556
+ console.warn(
557
+ `Unknown org profile '${opts.org}'. Known profiles: ${Object.keys(ORG_PROFILES).join(", ")}`
558
+ );
559
+ }
560
+ const derivedSlug = opts.slug ?? path3.basename(inputPath, path3.extname(inputPath)).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
561
+ const result = await apiRequest("/v1/skills/import", {
562
+ method: "POST",
563
+ body: JSON.stringify({
564
+ source,
565
+ format,
566
+ suggestedSlug: derivedSlug,
567
+ connectorHints: profile?.connectorHints,
568
+ channelPolicy: profile?.channelPolicy,
569
+ ...Object.keys(extraFiles).length > 0 ? { files: extraFiles } : {}
570
+ })
571
+ });
572
+ if (result.headline.length > 90) {
573
+ console.warn(
574
+ ` Warning: headline is ${result.headline.length} chars (\u2264 90 required to publish). Edit headways.yaml and run 'headways skills push ${result.slug}'.`
575
+ );
576
+ }
577
+ const fileCount = Object.keys(extraFiles).length;
578
+ console.log(`Imported as '${result.slug}' (format: ${result.detectedFormat})`);
579
+ console.log(` Headline: ${result.headline}`);
580
+ console.log(` Skill ID: ${result.skillId}`);
581
+ if (fileCount > 0) {
582
+ console.log(` Files: ${fileCount} extra file${fileCount === 1 ? "" : "s"} uploaded`);
583
+ }
584
+ if (profile) {
585
+ console.log(` Org profile: ${opts.org}`);
586
+ console.log(` Connector hints: ${profile.connectorHints.join(", ")}`);
587
+ console.log(` Fixture template: ${profile.fixtureTemplate}`);
588
+ }
589
+ console.log(` Run 'headways skills push ${result.slug}' to sync edits.`);
590
+ });
591
+ }
592
+
528
593
  // src/commands/skills/index.ts
529
594
  var SKILLS_GUIDE = `
530
595
  # Headways Skill Authoring Guide
@@ -544,12 +609,12 @@ headways skills push <slug> # push local edits as a draft
544
609
  | Field | Rule |
545
610
  |----------------|----------------------------------------------------------------|
546
611
  | \`slug\` | \`^[a-z0-9-]+$\`, 1\u201364 chars, immutable after creation |
547
- | \`headline\` | 1\u2013200 chars at creation; **\u2264 90 chars to submit** (hard gate) |
612
+ | \`headline\` | **One short sentence, \u2264 90 chars** \u2014 hard gate at submit |
548
613
  | \`name\` | 1\u2013120 chars (display name, defaults to headline) |
549
614
  | \`channel\` | \`prompt\` (default) | \`auto\` | \`manual\` |
550
615
  | \`data_classes\` | \`none\` (default) | \`pii\` | \`phi\` | \`pci\` |
551
616
 
552
- > Critical: headline must be \u2264 90 characters or the web UI will block submission.
617
+ > Critical: headline must be one short sentence, \u2264 90 characters. The web UI blocks submission if exceeded.
553
618
 
554
619
  ## File Bundle (\`<slug>/\`)
555
620
 
@@ -584,7 +649,7 @@ edge cases, and concrete examples. Vague goals produce poor results.
584
649
  \`\`\`yaml
585
650
  slug: my-skill
586
651
  name: My Skill
587
- headline: Verb-first summary of the outcome (\u226490 chars)
652
+ headline: One short sentence, verb-first (\u2264 90 chars)
588
653
  channel: prompt # prompt | auto | manual
589
654
  runtimes:
590
655
  - claude-code
@@ -602,8 +667,8 @@ auto_send: false # true = skill may act without user confirmation
602
667
 
603
668
  ### connections.yaml (required for any skill that uses MCP connector tools)
604
669
 
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.
670
+ Declare every MCP connector the skill depends on. Users see this list when they install the skill
671
+ via the Headways desktop app, which gates installation on the connectors being configured.
607
672
 
608
673
  \`\`\`yaml
609
674
  - connector: slack # connector identifier (e.g. slack, github, atlassian, linear, notion, google-drive, stripe, asana, hubspot, datadog)
@@ -614,6 +679,38 @@ and the Headways app gates installation on the connectors being configured.
614
679
 
615
680
  Omit the file entirely if the skill has no connector dependencies.
616
681
 
682
+ ## Runtime Events
683
+
684
+ Skills can emit events during execution that show up in the Headways dashboard.
685
+ Two layers fire events:
686
+
687
+ 1. **Automatic** \u2014 Claude Code hooks emit \`tool.*\` (per tool call) and \`turn.end\`
688
+ (per response turn) for every skill invocation. A \`skill.start\`-style record is created
689
+ by the desktop app's \`UserPromptSubmit\` hook when the user invokes \`/<slug>\`. You don't write these.
690
+
691
+ 2. **Manual** \u2014 Inside your skill body, call \`headways emit\` to record concrete
692
+ outcomes the agent produced.
693
+
694
+ \`\`\`bash
695
+ headways emit --hook outcome.pr_created -f url=https://github.com/...
696
+ headways emit --hook outcome.email_sent -f to=user@example.com
697
+ headways emit --hook outcome.doc_written -f path=wiki/pages/x.md
698
+ \`\`\`
699
+
700
+ ### Hook name format
701
+
702
+ All hook names must match \`<namespace>.<name>\` where namespace is one of:
703
+
704
+ | Namespace | Emitted by | Example |
705
+ |-------------|---------------|--------------------------------------|
706
+ | \`skill.*\` | Hook (auto) | \`skill.start\` |
707
+ | \`tool.*\` | Hook (auto) | \`tool.Read\`, \`tool.Bash\` |
708
+ | \`turn.*\` | Hook (auto) | \`turn.end\` |
709
+ | \`outcome.*\` | Skill (manual)| \`outcome.pr_created\`, \`outcome.email_sent\` |
710
+
711
+ Names outside these four namespaces are rejected with HTTP 422. Pick a short,
712
+ verb-past-tense suffix for outcomes: \`pr_created\`, not \`creating_pr\`.
713
+
617
714
  ### hooks.yaml (omit if unused)
618
715
 
619
716
  \`\`\`yaml
@@ -666,7 +763,6 @@ local edits with \`headways skills push <slug>\`.
666
763
 
667
764
  ## Common Failure Modes
668
765
 
669
- - Headline > 90 chars \u2192 submit blocked with 422. Shorten before pushing.
670
766
  - Uppercase or special chars in slug \u2192 rejected at creation. Use \`a-z\`, \`0-9\`, \`-\` only.
671
767
  - Missing \`capabilities.yaml\` entries \u2192 skill silently blocked at runtime.
672
768
  - 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 +777,8 @@ function registerSkillsCommands(program2) {
681
777
  console.log(SKILLS_GUIDE);
682
778
  });
683
779
  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");
780
+ const { requireAuth: requireAuth2 } = await import("./config-APIR4RCR.js");
781
+ const { apiRequest: apiRequest2 } = await import("./api-H34ZX4FL.js");
686
782
  requireAuth2();
687
783
  const result = await apiRequest2("/v1/skills");
688
784
  if (result.data.length === 0) {
@@ -693,27 +789,6 @@ function registerSkillsCommands(program2) {
693
789
  }
694
790
  }
695
791
  });
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
792
  skills.command("feedback <slug>").description("Submit feedback about a skill").option(
718
793
  "--reaction <type>",
719
794
  "thumbs_up, thumbs_down, wrong_output, missing_step",
@@ -762,14 +837,95 @@ function registerConnectionsCommands(program2) {
762
837
 
763
838
  // src/sdk/emit.ts
764
839
  import "commander";
840
+
841
+ // src/lib/run-session.ts
842
+ import {
843
+ existsSync as existsSync2,
844
+ mkdirSync,
845
+ readdirSync,
846
+ readFileSync,
847
+ statSync,
848
+ unlinkSync,
849
+ writeFileSync
850
+ } from "fs";
851
+ import { join as join4 } from "path";
852
+ var THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1e3;
853
+ async function readHookStdin() {
854
+ if (process.stdin.isTTY) return null;
855
+ const chunks = [];
856
+ for await (const chunk of process.stdin) {
857
+ chunks.push(chunk);
858
+ }
859
+ const raw = Buffer.concat(chunks).toString("utf8").trim();
860
+ if (!raw) return null;
861
+ try {
862
+ return JSON.parse(raw);
863
+ } catch {
864
+ return null;
865
+ }
866
+ }
867
+ function sessionFilePath(sessionId) {
868
+ return join4(RUNS_DIR, sessionId);
869
+ }
870
+ function writeSessionRun(sessionId, run) {
871
+ if (!existsSync2(RUNS_DIR)) mkdirSync(RUNS_DIR, { recursive: true });
872
+ writeFileSync(sessionFilePath(sessionId), JSON.stringify(run));
873
+ }
874
+ function readSessionRun(sessionId) {
875
+ const path4 = sessionFilePath(sessionId);
876
+ if (!existsSync2(path4)) return null;
877
+ try {
878
+ return JSON.parse(readFileSync(path4, "utf8"));
879
+ } catch {
880
+ return null;
881
+ }
882
+ }
883
+ function cleanupStaleRuns(now = Date.now()) {
884
+ if (!existsSync2(RUNS_DIR)) return;
885
+ let entries;
886
+ try {
887
+ entries = readdirSync(RUNS_DIR);
888
+ } catch {
889
+ return;
890
+ }
891
+ for (const name of entries) {
892
+ const path4 = join4(RUNS_DIR, name);
893
+ try {
894
+ const stat2 = statSync(path4);
895
+ if (now - stat2.mtimeMs > THIRTY_DAYS_MS) unlinkSync(path4);
896
+ } catch {
897
+ }
898
+ }
899
+ }
900
+
901
+ // src/sdk/emit.ts
902
+ var HOOK_NAME_RE = /^(skill|tool|turn|outcome)\.[A-Za-z0-9_]+$/;
903
+ 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
904
  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(
905
+ program2.command("emit").description(
906
+ "Emit a skill run event (used by Claude Code hooks and from inside skills).\n" + HOOK_NAME_HELP
907
+ ).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
908
  async (opts) => {
909
+ const hookName = opts.hook ?? opts.event ?? "tool.unknown";
910
+ if (!HOOK_NAME_RE.test(hookName)) {
911
+ console.error(`headways emit: invalid --hook "${hookName}"
912
+
913
+ ${HOOK_NAME_HELP}`);
914
+ process.exitCode = 1;
915
+ return;
916
+ }
768
917
  const cfg = readConfig();
769
918
  if (!cfg.token) return;
770
- const runId = opts.runId ?? process.env["HEADWAYS_RUN_ID"];
919
+ let runId = opts.runId ?? process.env["HEADWAYS_RUN_ID"];
920
+ if (!runId) {
921
+ const input = await readHookStdin();
922
+ const sessionId = input?.session_id;
923
+ if (typeof sessionId === "string") {
924
+ const session = readSessionRun(sessionId);
925
+ if (session) runId = session.runId;
926
+ }
927
+ }
771
928
  if (!runId) return;
772
- const hookName = opts.hook ?? opts.event ?? "tool.unknown";
773
929
  const payload = {};
774
930
  if (opts.status !== void 0) {
775
931
  payload["tool_result_is_error"] = opts.status === "true" || opts.status === "1";
@@ -800,8 +956,67 @@ function registerEmitCommand(program2) {
800
956
  );
801
957
  }
802
958
 
959
+ // src/commands/skill-run.ts
960
+ import { existsSync as existsSync3 } from "fs";
961
+ import { join as join5 } from "path";
962
+ import "commander";
963
+ var SLASH_COMMAND_RE = /^\/([a-z0-9-]+(?::[a-z0-9-]+)?)\b/;
964
+ function resolveSkillSlug(input, isInstalled) {
965
+ const direct = input.tool_input?.skill;
966
+ if (typeof direct === "string" && direct.length > 0) return direct;
967
+ if (typeof input.prompt === "string") {
968
+ const m = SLASH_COMMAND_RE.exec(input.prompt);
969
+ if (m && m[1]) {
970
+ const slug = m[1];
971
+ if (isInstalled(slug)) return slug;
972
+ }
973
+ }
974
+ return null;
975
+ }
976
+ function defaultIsInstalled(slug) {
977
+ return existsSync3(join5(INSTALLED_DIR, `${slug}.json`));
978
+ }
979
+ function registerSkillRunCommands(program2) {
980
+ const skillRun = program2.command("skill-run").description("Skill-run lifecycle commands (invoked by Claude Code hooks).");
981
+ skillRun.command("start").description(
982
+ "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>."
983
+ ).action(async () => {
984
+ cleanupStaleRuns();
985
+ const cfg = readConfig();
986
+ if (!cfg.token) return;
987
+ const input = await readHookStdin();
988
+ if (!input) return;
989
+ const sessionId = input.session_id;
990
+ if (typeof sessionId !== "string") return;
991
+ const skillSlug = resolveSkillSlug(input, defaultIsInstalled);
992
+ if (!skillSlug) return;
993
+ let runId;
994
+ try {
995
+ const res = await fetch(`${getApiUrl()}/v1/skill-runs`, {
996
+ method: "POST",
997
+ headers: {
998
+ "Content-Type": "application/json",
999
+ Authorization: `Bearer ${cfg.token}`
1000
+ },
1001
+ body: JSON.stringify({ skillSlug, runtime: "claude-code" })
1002
+ });
1003
+ if (!res.ok) return;
1004
+ const body = await res.json();
1005
+ if (!body.runId) return;
1006
+ runId = body.runId;
1007
+ } catch {
1008
+ return;
1009
+ }
1010
+ writeSessionRun(sessionId, {
1011
+ runId,
1012
+ skill: skillSlug,
1013
+ startedAt: (/* @__PURE__ */ new Date()).toISOString()
1014
+ });
1015
+ });
1016
+ }
1017
+
803
1018
  // src/commands/prime.ts
804
- import { existsSync as existsSync2, readdirSync, readFileSync } from "fs";
1019
+ import { existsSync as existsSync4, readdirSync as readdirSync2, readFileSync as readFileSync2 } from "fs";
805
1020
  import "commander";
806
1021
  function registerPrimeCommand(program2) {
807
1022
  program2.command("prime").description("Output Headways workflow context for AI coding assistants").action(() => {
@@ -817,6 +1032,11 @@ function registerPrimeCommand(program2) {
817
1032
  "> `--help` to discover full options, or run `headways skills guide` before authoring a skill.",
818
1033
  "> Do not guess at flags or constraints \u2014 discover them at runtime.",
819
1034
  "",
1035
+ "## Responsibility split",
1036
+ "",
1037
+ "- **Desktop app (macOS):** installs skills, syncs the catalog, manages Claude Code hooks. Open it from `/Applications/Headways.app`.",
1038
+ "- **CLI (this tool):** skill *authoring* (push, import, new, list) plus runtime helpers Claude Code shells out to (`prime`, `emit`, `skill-run start`).",
1039
+ "",
820
1040
  "## Auth & Config",
821
1041
  "",
822
1042
  `Status: ${cfg.token ? `Signed in (org: ${cfg.orgSlug ?? cfg.orgId ?? "unknown"})` : "Not signed in \u2014 run `headways login`"}`,
@@ -828,37 +1048,33 @@ function registerPrimeCommand(program2) {
828
1048
  "headways login # Browser SSO sign-in",
829
1049
  "headways logout # Remove stored credentials",
830
1050
  "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",
1051
+ "headways config clear # Clear credentials",
836
1052
  "",
837
1053
  "headways skills list # List skills in your org",
838
1054
  "headways skills new # Scaffold a new skill",
839
1055
  "headways skills import <path> # Create a new skill from a local file or directory",
840
1056
  "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
1057
  "headways skills feedback <slug> # Submit feedback on a skill",
843
1058
  "headways skills guide # Authoring reference (run before creating a skill)",
844
1059
  "",
845
- "headways setup claude # Install Claude Code hooks (SessionStart + PreCompact)",
846
- "headways prime # Print this context (used by hooks)",
1060
+ "# Runtime helpers (invoked automatically by Claude Code hooks; rarely run by hand):",
1061
+ "headways prime # Print this context",
1062
+ "headways emit # Emit a skill-run event",
1063
+ "headways skill-run start # Start a skill run from a UserPromptSubmit hook",
847
1064
  "```",
848
1065
  "",
849
1066
  "## Workflow",
850
1067
  "",
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",
1068
+ "1. Open the **Headways desktop app** \u2014 it syncs the catalog, shows pending updates, and clicks-to-install bundles.",
1069
+ "2. New skill versions appear in the Library view; click **Install** to bring them onto this machine.",
1070
+ "3. To author skills: `headways skills new` or `headways skills import <path>`, edit, then `headways skills push <slug>`. Publish via the web UI.",
855
1071
  "",
856
1072
  "## Installed Skills",
857
1073
  ""
858
1074
  ];
859
1075
  if (skills.length === 0) {
860
1076
  lines.push(
861
- "No skills installed. Run `headways sync start` then `headways accept <skill>`."
1077
+ "No skills installed. Open the Headways desktop app to sync your org's catalog."
862
1078
  );
863
1079
  } else {
864
1080
  for (const skill of skills) {
@@ -877,12 +1093,12 @@ function registerPrimeCommand(program2) {
877
1093
  });
878
1094
  }
879
1095
  function getInstalledSkills() {
880
- if (!existsSync2(INSTALLED_DIR)) return [];
1096
+ if (!existsSync4(INSTALLED_DIR)) return [];
881
1097
  try {
882
- return readdirSync(INSTALLED_DIR).filter((f) => f.endsWith(".json")).map((f) => {
1098
+ return readdirSync2(INSTALLED_DIR).filter((f) => f.endsWith(".json")).map((f) => {
883
1099
  const slug = f.replace(/\.json$/, "");
884
1100
  try {
885
- const raw = JSON.parse(readFileSync(`${INSTALLED_DIR}/${f}`, "utf8"));
1101
+ const raw = JSON.parse(readFileSync2(`${INSTALLED_DIR}/${f}`, "utf8"));
886
1102
  return {
887
1103
  slug,
888
1104
  version: String(raw.version ?? ""),
@@ -900,13 +1116,11 @@ function getInstalledSkills() {
900
1116
  }
901
1117
 
902
1118
  // src/index.ts
903
- program.name("headways").description("Headways CLI \u2014 skill authoring, sync, and runtime SDK").version(package_default.version);
1119
+ program.name("headways").description("Headways CLI \u2014 skill authoring + Claude Code runtime helpers").version(package_default.version);
904
1120
  registerAuthCommands(program);
905
1121
  registerSkillsCommands(program);
906
1122
  registerConnectionsCommands(program);
907
- registerSyncCommands(program);
908
1123
  registerEmitCommand(program);
1124
+ registerSkillRunCommands(program);
909
1125
  registerPrimeCommand(program);
910
- registerSetupCommand(program);
911
- registerUninstallCommand(program);
912
1126
  program.parse();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@headways/cli",
3
- "version": "0.4.2",
3
+ "version": "1.0.0",
4
4
  "type": "module",
5
5
  "description": "Headways CLI — authoring, sync, and runtime SDK",
6
6
  "license": "MIT",
@@ -1,409 +0,0 @@
1
- #!/usr/bin/env node
2
- import {
3
- CLAUDE_SKILLS_DIR,
4
- HEADWAYS_DIR,
5
- getApiUrl,
6
- readConfig
7
- } from "./chunk-UUFIIGTZ.js";
8
-
9
- // src/commands/sync/index.ts
10
- import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2, renameSync, rmSync as rmSync2 } from "fs";
11
- import { join as join2 } from "path";
12
- import { createGunzip } from "zlib";
13
- import { Readable } from "stream";
14
- import "stream/promises";
15
- import "commander";
16
-
17
- // src/commands/setup.ts
18
- import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
19
- import { homedir } from "os";
20
- import { dirname, join } from "path";
21
- import "commander";
22
- var CLAUDE_SETTINGS_GLOBAL = join(homedir(), ".claude", "settings.json");
23
- var CLAUDE_SETTINGS_LOCAL = join(".claude", "settings.json");
24
- var PRIME_HOOK = { type: "command", command: "headways prime" };
25
- var CLAUDE_MD_SECTION = `
26
- <!-- BEGIN HEADWAYS -->
27
- ## Headways Skills
28
-
29
- This project uses [Headways](https://headways.ai) for AI skill management.
30
-
31
- - \`headways prime\` \u2014 load workflow context (called automatically by hooks)
32
- - \`headways sync start\` \u2014 pull latest skill updates from your org
33
- - \`headways accept <skill>\` \u2014 install a pending skill update
34
- - \`headways skills list\` \u2014 list skills in your org
35
-
36
- Skills are installed to \`~/.claude/skills/<slug>/\` and loaded automatically by Claude Code.
37
- <!-- END HEADWAYS -->
38
- `.trimStart();
39
- function registerUninstallCommand(program) {
40
- program.command("uninstall").description("Remove all Headways hooks, skill files, and local state").option("--yes", "Skip confirmation prompt").action(async (opts) => {
41
- const { createInterface } = await import("readline/promises");
42
- if (!opts.yes) {
43
- const rl = createInterface({ input: process.stdin, output: process.stdout });
44
- const ans = await rl.question(
45
- "This will remove all local Headways data (hooks, skill files, config). Continue? [y/N] "
46
- );
47
- rl.close();
48
- if (ans.trim().toLowerCase() !== "y") {
49
- console.log("Aborted.");
50
- return;
51
- }
52
- }
53
- removeHooks(CLAUDE_SETTINGS_GLOBAL);
54
- removeHooks(CLAUDE_SETTINGS_LOCAL);
55
- console.log("\u2713 Removed Claude Code hooks");
56
- if (existsSync(CLAUDE_SKILLS_DIR)) {
57
- rmSync(CLAUDE_SKILLS_DIR, { recursive: true, force: true });
58
- console.log("\u2713 Removed ~/.claude/skills/");
59
- }
60
- const headwaysDir = join(homedir(), ".headways");
61
- if (existsSync(headwaysDir)) {
62
- rmSync(headwaysDir, { recursive: true, force: true });
63
- console.log("\u2713 Removed ~/.headways/");
64
- }
65
- console.log(
66
- "\nHeadways data removed. You can now delete the Headways.app from /Applications."
67
- );
68
- });
69
- }
70
- function registerSetupCommand(program) {
71
- const setup = program.command("setup").description("Install Headways integration for AI coding assistants");
72
- setup.command("claude").description("Install Claude Code hooks (SessionStart + PreCompact)").option("--project", "Install for this project only (default: global)").option("--remove", "Remove the integration").option("--check", "Check if integration is installed").action((opts) => {
73
- const settingsPath = opts.project ? CLAUDE_SETTINGS_LOCAL : CLAUDE_SETTINGS_GLOBAL;
74
- if (opts.check) {
75
- const installed = isInstalled(settingsPath);
76
- console.log(
77
- installed ? `\u2713 Installed: ${settingsPath}` : `\u2717 Not installed: ${settingsPath}`
78
- );
79
- process.exit(installed ? 0 : 1);
80
- }
81
- if (opts.remove) {
82
- removeHooks(settingsPath);
83
- console.log(`Removed Headways hooks from ${settingsPath}`);
84
- return;
85
- }
86
- installHooks(settingsPath, opts.project ?? false);
87
- updateClaudeMd(opts.project ?? false);
88
- });
89
- }
90
- function ensureClaudeHooks() {
91
- if (!isInstalled(CLAUDE_SETTINGS_GLOBAL)) {
92
- installHooks(CLAUDE_SETTINGS_GLOBAL, false, true);
93
- }
94
- }
95
- function readSettings(path) {
96
- if (!existsSync(path)) return {};
97
- try {
98
- return JSON.parse(readFileSync(path, "utf8"));
99
- } catch {
100
- return {};
101
- }
102
- }
103
- function writeSettings(path, settings) {
104
- mkdirSync(dirname(path), { recursive: true });
105
- writeFileSync(path, JSON.stringify(settings, null, 2) + "\n");
106
- }
107
- function isInstalled(settingsPath) {
108
- const s = readSettings(settingsPath);
109
- const hooks = s["hooks"];
110
- if (!hooks) return false;
111
- return ["SessionStart", "PreCompact"].every(
112
- (event) => hooks[event]?.some(
113
- (entry) => entry.hooks?.some((h) => h.command === PRIME_HOOK.command)
114
- )
115
- );
116
- }
117
- function installHooks(settingsPath, isProject, silent = false) {
118
- const settings = readSettings(settingsPath);
119
- const hooks = settings["hooks"] ?? {};
120
- for (const event of ["SessionStart", "PreCompact"]) {
121
- const entries = hooks[event] ?? [];
122
- for (const entry of entries) {
123
- entry.hooks = entry.hooks.filter((h) => h.command !== PRIME_HOOK.command);
124
- }
125
- const mainEntry = entries.find((e) => e.matcher === "" || e.matcher === void 0);
126
- if (mainEntry) {
127
- mainEntry.hooks.push(PRIME_HOOK);
128
- } else {
129
- entries.unshift({ matcher: "", hooks: [PRIME_HOOK] });
130
- }
131
- hooks[event] = entries;
132
- }
133
- settings["hooks"] = hooks;
134
- writeSettings(settingsPath, settings);
135
- if (!silent) {
136
- console.log(`\u2713 Hooks installed: ${settingsPath}`);
137
- if (!isProject) {
138
- console.log(" SessionStart + PreCompact \u2192 headways prime");
139
- }
140
- }
141
- }
142
- function removeHooks(settingsPath) {
143
- if (!existsSync(settingsPath)) return;
144
- const settings = readSettings(settingsPath);
145
- const hooks = settings["hooks"] ?? {};
146
- for (const event of ["SessionStart", "PreCompact"]) {
147
- if (hooks[event]) {
148
- for (const entry of hooks[event]) {
149
- entry.hooks = entry.hooks.filter((h) => h.command !== PRIME_HOOK.command);
150
- }
151
- }
152
- }
153
- settings["hooks"] = hooks;
154
- writeSettings(settingsPath, settings);
155
- }
156
- function updateClaudeMd(isProject) {
157
- const claudeMdPath = isProject ? "CLAUDE.md" : join(homedir(), "CLAUDE.md");
158
- if (!existsSync(claudeMdPath)) return;
159
- const content = readFileSync(claudeMdPath, "utf8");
160
- if (content.includes("<!-- BEGIN HEADWAYS -->")) return;
161
- writeFileSync(claudeMdPath, content.trimEnd() + "\n\n" + CLAUDE_MD_SECTION);
162
- console.log(`\u2713 Updated ${claudeMdPath}`);
163
- }
164
-
165
- // src/commands/sync/index.ts
166
- var PENDING_FILE = join2(HEADWAYS_DIR, "pending.json");
167
- var SYNC_STATE_FILE = join2(HEADWAYS_DIR, "sync-state.json");
168
- var INSTALLED_DIR = join2(HEADWAYS_DIR, "installed");
169
- function readSyncState() {
170
- if (!existsSync2(SYNC_STATE_FILE)) return {};
171
- try {
172
- return JSON.parse(readFileSync2(SYNC_STATE_FILE, "utf8"));
173
- } catch {
174
- return {};
175
- }
176
- }
177
- function writeSyncState(state) {
178
- if (!existsSync2(HEADWAYS_DIR)) mkdirSync2(HEADWAYS_DIR, { recursive: true });
179
- writeFileSync2(SYNC_STATE_FILE, JSON.stringify(state, null, 2) + "\n");
180
- }
181
- function readPending() {
182
- if (!existsSync2(PENDING_FILE)) return [];
183
- try {
184
- return JSON.parse(readFileSync2(PENDING_FILE, "utf8"));
185
- } catch {
186
- return [];
187
- }
188
- }
189
- function writePending(updates) {
190
- if (!existsSync2(HEADWAYS_DIR)) mkdirSync2(HEADWAYS_DIR, { recursive: true });
191
- writeFileSync2(PENDING_FILE, JSON.stringify(updates, null, 2) + "\n");
192
- }
193
- function deviceHeaders(state) {
194
- return {
195
- Authorization: `Bearer ${state.device_token ?? ""}`,
196
- "x-headways-device-id": state.device_id ?? "",
197
- "x-headways-timestamp": String(Math.floor(Date.now() / 1e3))
198
- };
199
- }
200
- async function registerDevice(token, orgId, apiUrl) {
201
- const existingId = readSyncState().device_id;
202
- const pubKey = existingId ? Buffer.from(`desktop-${existingId}`).toString("base64url") : Buffer.from(`desktop-${Date.now()}-${Math.random()}`).toString("base64url");
203
- const res = await fetch(`${apiUrl}/v1/sync/devices/register`, {
204
- method: "POST",
205
- headers: {
206
- "Content-Type": "application/json",
207
- Authorization: `Bearer ${token}`,
208
- "x-headways-org-id": orgId
209
- },
210
- body: JSON.stringify({
211
- publicKey: pubKey,
212
- platform: process.platform === "darwin" ? "macos" : process.platform === "win32" ? "windows" : "linux",
213
- hostname: (await import("os")).hostname()
214
- })
215
- });
216
- if (!res.ok) throw new Error(`Device registration failed: ${res.status}`);
217
- const data = await res.json();
218
- return { device_id: data.deviceId, device_token: data.deviceToken };
219
- }
220
- async function pollCatalog(state, apiUrl) {
221
- const url = state.etag ? `${apiUrl}/v1/sync/catalog?since=${encodeURIComponent(state.etag)}` : `${apiUrl}/v1/sync/catalog`;
222
- const res = await fetch(url, { headers: deviceHeaders(state) });
223
- if (res.status === 304) return null;
224
- if (!res.ok) throw new Error(`Catalog poll failed: ${res.status}`);
225
- return res.json();
226
- }
227
- async function downloadAndMaterialize(slug, version, state, apiUrl) {
228
- const res = await fetch(`${apiUrl}/v1/sync/bundles/${slug}/${version}`, {
229
- redirect: "follow",
230
- headers: deviceHeaders(state)
231
- });
232
- if (!res.ok) throw new Error(`Bundle fetch failed: ${res.status}`);
233
- const buf = Buffer.from(await res.arrayBuffer());
234
- const dest = join2(CLAUDE_SKILLS_DIR, slug);
235
- const staging = join2(CLAUDE_SKILLS_DIR, `.${slug}-staging`);
236
- mkdirSync2(staging, { recursive: true });
237
- await extractTarGz(buf, staging);
238
- if (existsSync2(dest)) rmSync2(dest, { recursive: true });
239
- renameSync(staging, dest);
240
- mkdirSync2(INSTALLED_DIR, { recursive: true });
241
- writeFileSync2(
242
- join2(INSTALLED_DIR, `${slug}.json`),
243
- JSON.stringify(
244
- { slug, version, runtime: "claude-code", installed_at: (/* @__PURE__ */ new Date()).toISOString() },
245
- null,
246
- 2
247
- )
248
- );
249
- console.log(`Materialized ${slug}@${version} \u2192 ${dest}`);
250
- }
251
- async function extractTarGz(buf, destDir) {
252
- const decompressed = await new Promise((resolve, reject) => {
253
- const chunks = [];
254
- const gunzip = createGunzip();
255
- const src = Readable.from(buf);
256
- src.pipe(gunzip);
257
- gunzip.on("data", (chunk) => chunks.push(chunk));
258
- gunzip.on("end", () => resolve(Buffer.concat(chunks)));
259
- gunzip.on("error", reject);
260
- });
261
- let offset = 0;
262
- const { writeFileSync: wf, mkdirSync: md } = await import("fs");
263
- const { dirname: dirname2 } = await import("path");
264
- while (offset + 512 <= decompressed.length) {
265
- const header = decompressed.slice(offset, offset + 512);
266
- const name = header.slice(0, 100).toString("utf8").replace(/\0/g, "").trim();
267
- if (!name) break;
268
- const sizeOctal = header.slice(124, 136).toString("utf8").replace(/\0/g, "").trim();
269
- const size = parseInt(sizeOctal, 8) || 0;
270
- const typeFlag = header[156];
271
- offset += 512;
272
- if (typeFlag === 53 || name.endsWith("/")) {
273
- md(join2(destDir, name), { recursive: true });
274
- } else if (typeFlag === 0 || typeFlag === 48 || typeFlag === void 0) {
275
- const filePath = join2(destDir, name);
276
- md(dirname2(filePath), { recursive: true });
277
- wf(filePath, decompressed.slice(offset, offset + size));
278
- }
279
- offset += Math.ceil(size / 512) * 512;
280
- }
281
- }
282
- function registerSyncCommands(program) {
283
- const sync = program.command("sync").description("Sync skills from Headways to local Claude Code");
284
- sync.command("start").description("Register device and pull latest skill catalog from Headways").option("--daemon", "Run as background daemon (60s poll loop)").action(async (opts) => {
285
- const cfg = readConfig();
286
- if (!cfg.token || !cfg.orgId) {
287
- console.error("Not logged in. Run: headways login");
288
- process.exit(1);
289
- }
290
- const apiUrl = getApiUrl();
291
- let state = readSyncState();
292
- if (!state.device_id || !state.device_token) {
293
- console.log("Registering device with Headways\u2026");
294
- try {
295
- const deviceState = await registerDevice(cfg.token, cfg.orgId, apiUrl);
296
- state = { ...state, ...deviceState };
297
- writeSyncState(state);
298
- console.log(`Device registered: ${state.device_id}`);
299
- } catch (err) {
300
- console.error(`Registration failed: ${err instanceof Error ? err.message : String(err)}`);
301
- process.exit(1);
302
- }
303
- }
304
- const doPoll = async () => {
305
- try {
306
- ensureClaudeHooks();
307
- const delta = await pollCatalog(state, apiUrl);
308
- if (!delta) {
309
- console.log("Catalog up to date.");
310
- return;
311
- }
312
- state.etag = delta.etag;
313
- state.last_poll = (/* @__PURE__ */ new Date()).toISOString();
314
- writeSyncState(state);
315
- const pendingMap = new Map(readPending().map((p) => [p.slug, p]));
316
- for (const ev of delta.events) {
317
- if (ev.kind === "version_published") {
318
- if (ev.channel === "auto") {
319
- console.log(`Auto-installing: ${ev.skill_slug}@${ev.version}`);
320
- await downloadAndMaterialize(ev.skill_slug, ev.version, state, apiUrl);
321
- pendingMap.delete(ev.skill_slug);
322
- } else {
323
- pendingMap.set(ev.skill_slug, {
324
- slug: ev.skill_slug,
325
- version: ev.version,
326
- user_visible_change: ev.user_visible_change,
327
- channel: ev.channel,
328
- capabilities_delta_empty: ev.capabilities_delta_empty
329
- });
330
- console.log(
331
- `Queued: ${ev.skill_slug}@${ev.version}${ev.user_visible_change ? ` \u2014 ${ev.user_visible_change}` : ""}`
332
- );
333
- }
334
- } else if (ev.kind === "skill_archived" || ev.kind === "entitlement_revoked") {
335
- pendingMap.delete(ev.skill_slug);
336
- console.log(`Removed: ${ev.skill_slug}`);
337
- }
338
- }
339
- writePending([...pendingMap.values()]);
340
- console.log(`Synced. ETag: ${delta.etag}`);
341
- } catch (err) {
342
- console.error(`Poll error: ${err instanceof Error ? err.message : String(err)}`);
343
- }
344
- };
345
- await doPoll();
346
- if (opts.daemon) {
347
- console.log("Running sync daemon (60s interval). Press Ctrl-C to stop.");
348
- setInterval(doPoll, 6e4);
349
- }
350
- });
351
- sync.command("status").description("Show current sync status and pending updates").action(() => {
352
- const state = readSyncState();
353
- const pending = readPending();
354
- if (!state.device_id) {
355
- console.log("Device not registered. Run: headways sync start");
356
- return;
357
- }
358
- console.log(`Device ID : ${state.device_id}`);
359
- console.log(`Last poll : ${state.last_poll ?? "never"}`);
360
- console.log(`Catalog ETag: ${state.etag ?? "none"}`);
361
- if (pending.length === 0) {
362
- console.log("\nAll skills up to date. No pending updates.");
363
- } else {
364
- console.log(`
365
- Pending updates (${pending.length}):`);
366
- for (const p of pending) {
367
- const change = p.user_visible_change ? ` \u2014 ${p.user_visible_change}` : "";
368
- const caps = p.capabilities_delta_empty ? "" : " [CAPS CHANGED]";
369
- console.log(` ${p.slug}@${p.version}${change}${caps}`);
370
- }
371
- console.log("\nRun `headways skills accept <skill>` to install.");
372
- }
373
- });
374
- }
375
- async function acceptSkill(skillSlug) {
376
- const cfg = readConfig();
377
- if (!cfg.token || !cfg.orgId) {
378
- console.error("Not logged in. Run: headways login");
379
- process.exit(1);
380
- }
381
- const pending = readPending();
382
- const update = pending.find((p) => p.slug === skillSlug);
383
- if (!update) {
384
- console.error(`No pending update for skill: ${skillSlug}`);
385
- console.log("Run `headways sync status` to see pending updates.");
386
- process.exit(1);
387
- }
388
- const state = readSyncState();
389
- if (!state.device_id || !state.device_token) {
390
- console.error("Device not registered. Run: headways sync start");
391
- process.exit(1);
392
- }
393
- console.log(`Accepting ${skillSlug}@${update.version}\u2026`);
394
- await downloadAndMaterialize(skillSlug, update.version, state, getApiUrl());
395
- writePending(pending.filter((p) => p.slug !== skillSlug));
396
- console.log(
397
- `${skillSlug} is ready \u2014 invoke it in Claude Code with the skill's invocation phrase.`
398
- );
399
- }
400
-
401
- export {
402
- registerUninstallCommand,
403
- registerSetupCommand,
404
- readSyncState,
405
- writeSyncState,
406
- registerDevice,
407
- registerSyncCommands,
408
- acceptSkill
409
- };
@@ -1,16 +0,0 @@
1
- #!/usr/bin/env node
2
- import {
3
- acceptSkill,
4
- readSyncState,
5
- registerDevice,
6
- registerSyncCommands,
7
- writeSyncState
8
- } from "./chunk-XTEQBKIN.js";
9
- import "./chunk-UUFIIGTZ.js";
10
- export {
11
- acceptSkill,
12
- readSyncState,
13
- registerDevice,
14
- registerSyncCommands,
15
- writeSyncState
16
- };