@headways/cli 0.2.1 → 0.4.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,14 +2,20 @@
2
2
  import {
3
3
  apiRequest,
4
4
  rawRequest
5
- } from "./chunk-QXOLSB3Q.js";
5
+ } from "./chunk-HYEL7L5Z.js";
6
6
  import {
7
- HEADWAYS_DIR,
7
+ registerSetupCommand,
8
+ registerSyncCommands,
9
+ registerUninstallCommand
10
+ } from "./chunk-OZULVVQC.js";
11
+ import {
12
+ INSTALLED_DIR,
8
13
  getApiUrl,
14
+ getAppUrl,
9
15
  readConfig,
10
16
  requireAuth,
11
17
  writeConfig
12
- } from "./chunk-VLKLEV4U.js";
18
+ } from "./chunk-T2H7EXOV.js";
13
19
 
14
20
  // src/index.ts
15
21
  import "dotenv/config";
@@ -17,9 +23,12 @@ import { program } from "commander";
17
23
 
18
24
  // src/commands/auth.ts
19
25
  import "commander";
26
+ import * as http from "http";
20
27
  import * as readline from "readline/promises";
28
+ import { execSync } from "child_process";
29
+ var NO_KEY_MSG = "No API key configured. Run `headways login` or `headways config --api-key <key>`.";
21
30
  async function promptApiKey(opts) {
22
- let token = opts.token;
31
+ let token = opts.apiKey;
23
32
  if (!token) {
24
33
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
25
34
  token = (await rl.question("API key (sk_\u2026): ")).trim();
@@ -32,11 +41,20 @@ async function promptApiKey(opts) {
32
41
  return token;
33
42
  }
34
43
  function registerAuthCommands(program2) {
35
- const configure = program2.command("configure").description("Set your Headways API key and optional API URL").option("--token <token>", "API key (skip interactive prompt)").option("--api-url <url>", "API base URL (default: https://api.headways.ai)").option("--app-url <url>", "Web app URL (default: https://app.headways.ai)").action(async (opts) => {
36
- const token = await promptApiKey(opts);
44
+ const configure = program2.command("config").description("Set API key, URLs, or view/clear saved credentials").option("--api-key <key>", "API key (skip interactive prompt)").option("--api-url <url>", "API base URL (default: https://api.headways.ai)").option("--app-url <url>", "Web app URL (default: https://app.headways.ai)").action(async (opts) => {
37
45
  const cfg = readConfig();
38
46
  if (opts.apiUrl) cfg.apiUrl = opts.apiUrl;
39
47
  if (opts.appUrl) cfg.appUrl = opts.appUrl;
48
+ if (!opts.apiKey && (opts.apiUrl || opts.appUrl)) {
49
+ writeConfig(cfg);
50
+ const parts = [
51
+ opts.apiUrl && `API URL: ${opts.apiUrl}`,
52
+ opts.appUrl && `App URL: ${opts.appUrl}`
53
+ ].filter(Boolean);
54
+ console.log(parts.join(" | "));
55
+ return;
56
+ }
57
+ const token = await promptApiKey(opts);
40
58
  try {
41
59
  const me = await rawRequest(
42
60
  "/v1/me",
@@ -45,6 +63,7 @@ function registerAuthCommands(program2) {
45
63
  cfg.apiUrl
46
64
  );
47
65
  cfg.token = token;
66
+ cfg.setupComplete = true;
48
67
  if (me.type === "api_key" && me.organization) {
49
68
  cfg.orgSlug = me.organization.slug;
50
69
  cfg.orgId = me.organization.id;
@@ -58,34 +77,88 @@ function registerAuthCommands(program2) {
58
77
  process.exit(1);
59
78
  }
60
79
  });
80
+ configure.command("clear").description("Clear credentials and reset setup state (keeps API and app URLs)").action(() => {
81
+ const cfg = readConfig();
82
+ writeConfig({ apiUrl: cfg.apiUrl, appUrl: cfg.appUrl });
83
+ console.log("Credentials cleared. The desktop app will show onboarding on next launch.");
84
+ });
61
85
  configure.command("status").description("Show current API key and org").action(() => {
62
86
  const cfg = readConfig();
63
87
  if (!cfg.token) {
64
- console.log("No API key configured. Run `headways configure`.");
88
+ console.log(NO_KEY_MSG);
65
89
  } else {
66
90
  console.log(
67
91
  `API key: ${cfg.token.slice(0, 12)}\u2026 | Org: ${cfg.orgSlug ?? "(none set)"} | API: ${cfg.apiUrl ?? "https://api.headways.ai"}`
68
92
  );
69
93
  }
70
94
  });
71
- configure.command("clear").description("Remove stored API key and org").action(() => {
95
+ program2.command("logout").description("Remove stored credentials (keeps API and app URLs)").action(() => {
72
96
  const cfg = readConfig();
73
97
  delete cfg.token;
74
98
  delete cfg.orgSlug;
75
99
  delete cfg.orgId;
100
+ delete cfg.setupComplete;
76
101
  writeConfig(cfg);
77
- console.log("Configuration cleared.");
102
+ console.log("Logged out.");
103
+ });
104
+ program2.command("login").description("Sign in via browser (SSO) \u2014 opens your browser to authenticate").option("--timeout <seconds>", "seconds to wait for browser callback", "120").action(async (opts) => {
105
+ const cfg = readConfig();
106
+ const state = crypto.randomUUID();
107
+ const port = await findFreePort();
108
+ const callbackUrl = `http://localhost:${port}/callback`;
109
+ const appUrl = getAppUrl();
110
+ const apiUrl = getApiUrl();
111
+ const loginUrl = `${appUrl}/auth/device?state=${encodeURIComponent(state)}&callback=${encodeURIComponent(callbackUrl)}`;
112
+ console.log("Opening browser to sign in\u2026");
113
+ openBrowser(loginUrl);
114
+ console.log(`
115
+ If the browser did not open, visit:
116
+ ${loginUrl}
117
+ `);
118
+ const timeoutMs = Math.max(10, parseInt(opts.timeout, 10)) * 1e3;
119
+ let grant;
120
+ try {
121
+ grant = await waitForCallback(port, state, timeoutMs);
122
+ } catch (err) {
123
+ console.error(`
124
+ Sign-in timed out or failed: ${String(err)}`);
125
+ process.exit(1);
126
+ }
127
+ let result;
128
+ try {
129
+ const res = await fetch(`${apiUrl}/v1/auth/device/exchange`, {
130
+ method: "POST",
131
+ headers: { "content-type": "application/json" },
132
+ body: JSON.stringify({ grant })
133
+ });
134
+ if (!res.ok) {
135
+ const body = await res.text().catch(() => "");
136
+ throw new Error(`Exchange failed (${res.status}): ${body}`);
137
+ }
138
+ result = await res.json();
139
+ } catch (err) {
140
+ console.error(`Sign-in failed: ${String(err)}`);
141
+ process.exit(1);
142
+ }
143
+ writeConfig({
144
+ ...cfg,
145
+ token: result.token,
146
+ orgId: result.orgId,
147
+ orgSlug: result.orgSlug,
148
+ setupComplete: true
149
+ });
150
+ console.log(`Signed in. Org: ${result.orgSlug}`);
78
151
  });
79
152
  program2.command("org").description("Manage org context").command("use <slug>").description("Set active org by slug").action(async (slug) => {
80
153
  const cfg = readConfig();
81
154
  if (!cfg.token) {
82
- console.error("No API key configured. Run `headways configure` first.");
155
+ console.error(NO_KEY_MSG);
83
156
  process.exit(1);
84
157
  }
85
158
  try {
86
159
  await rawRequest("/v1/me", cfg.token);
87
160
  } catch {
88
- console.error("Invalid API key. Run `headways configure` again.");
161
+ console.error("Invalid API key. Run `headways config` again.");
89
162
  process.exit(1);
90
163
  }
91
164
  cfg.orgSlug = slug;
@@ -93,6 +166,58 @@ function registerAuthCommands(program2) {
93
166
  console.log(`Active org set to: ${slug}`);
94
167
  });
95
168
  }
169
+ function openBrowser(url) {
170
+ try {
171
+ const platform = process.platform;
172
+ if (platform === "darwin") execSync(`open ${JSON.stringify(url)}`);
173
+ else if (platform === "win32") execSync(`start "" ${JSON.stringify(url)}`);
174
+ else execSync(`xdg-open ${JSON.stringify(url)}`);
175
+ } catch {
176
+ }
177
+ }
178
+ function findFreePort() {
179
+ return new Promise((resolve, reject) => {
180
+ const srv = http.createServer();
181
+ srv.listen(0, "127.0.0.1", () => {
182
+ const addr = srv.address();
183
+ srv.close(() => {
184
+ if (addr && typeof addr === "object") resolve(addr.port);
185
+ else reject(new Error("Could not bind to port"));
186
+ });
187
+ });
188
+ });
189
+ }
190
+ function waitForCallback(port, expectedState, timeoutMs) {
191
+ return new Promise((resolve, reject) => {
192
+ const server = http.createServer((req, res) => {
193
+ const url = new URL(req.url ?? "/", `http://localhost:${port}`);
194
+ const grant = url.searchParams.get("grant");
195
+ const state = url.searchParams.get("state");
196
+ if (state !== expectedState || !grant) {
197
+ res.writeHead(400);
198
+ res.end("Invalid callback");
199
+ return;
200
+ }
201
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
202
+ res.end(
203
+ '<html><head><meta charset="utf-8"></head><body style="font-family:system-ui;text-align:center;padding:4rem"><h2>Signed in to Headways \u2713</h2><p>You can close this tab and return to the terminal.</p></body></html>'
204
+ );
205
+ server.close();
206
+ resolve(grant);
207
+ });
208
+ const timer = setTimeout(() => {
209
+ server.close();
210
+ reject(new Error("Timed out waiting for browser callback"));
211
+ }, timeoutMs);
212
+ server.listen(port, "127.0.0.1", () => {
213
+ });
214
+ server.on("error", (err) => {
215
+ clearTimeout(timer);
216
+ reject(err);
217
+ });
218
+ server.on("close", () => clearTimeout(timer));
219
+ });
220
+ }
96
221
 
97
222
  // src/commands/skills/index.ts
98
223
  import "commander";
@@ -154,6 +279,13 @@ runtimes: [claude-code]
154
279
  kind: outcome
155
280
  description: 'Captures the main output artifact'
156
281
  schema: {}
282
+ `
283
+ );
284
+ await fs.writeFile(
285
+ path.join(dir, "connections.yaml"),
286
+ `# connections:
287
+ # - connector: slack
288
+ # purpose: "Post skill output to a Slack channel"
157
289
  `
158
290
  );
159
291
  await fs.mkdir(path.join(dir, "fixtures"), { recursive: true });
@@ -269,16 +401,47 @@ async function readSkillDir(dir) {
269
401
  capabilities = { raw: capYaml };
270
402
  } catch {
271
403
  }
272
- return { body, headline, capabilities };
404
+ let connections;
405
+ const connYamlPath = path3.join(dir, "connections.yaml");
406
+ try {
407
+ const connYaml = await fs3.readFile(connYamlPath, "utf-8");
408
+ const items = parseConnectionsYaml(connYaml);
409
+ if (items.length > 0) connections = items;
410
+ } catch {
411
+ }
412
+ return { body, headline, capabilities, connections };
413
+ }
414
+ function parseConnectionsYaml(yaml) {
415
+ const items = [];
416
+ const connectorRe = /^\s*-\s+connector:\s*(.+)$/;
417
+ const purposeRe = /^\s+purpose:\s*["']?(.+?)["']?\s*$/;
418
+ const lines = yaml.split("\n");
419
+ let current = null;
420
+ for (const line of lines) {
421
+ if (line.trimStart().startsWith("#")) continue;
422
+ const connMatch = connectorRe.exec(line);
423
+ if (connMatch) {
424
+ if (current?.connector && current.purpose) items.push(current);
425
+ current = { connector: (connMatch[1] ?? "").trim() };
426
+ continue;
427
+ }
428
+ if (current) {
429
+ const purposeMatch = purposeRe.exec(line);
430
+ if (purposeMatch) current.purpose = (purposeMatch[1] ?? "").trim();
431
+ }
432
+ }
433
+ if (current?.connector && current.purpose) items.push(current);
434
+ return items;
273
435
  }
274
436
  async function pushSkill(slug, dir) {
275
- const { body, headline, capabilities } = await readSkillDir(dir);
437
+ const { body, headline, capabilities, connections } = await readSkillDir(dir);
276
438
  await apiRequest(`/v1/skills/${slug}/draft`, {
277
439
  method: "PUT",
278
440
  body: JSON.stringify({
279
441
  body,
280
442
  ...headline ? { headline } : {},
281
- ...capabilities ? { capabilities } : {}
443
+ ...capabilities ? { capabilities } : {},
444
+ ...connections ? { connections } : {}
282
445
  })
283
446
  });
284
447
  console.log(`Pushed '${slug}' draft`);
@@ -308,153 +471,164 @@ function registerPushCommand(program2) {
308
471
  });
309
472
  }
310
473
 
311
- // src/commands/skills/capture.ts
312
- import "commander";
313
- import * as fs4 from "fs/promises";
314
- import * as path4 from "path";
315
- import { homedir } from "os";
316
- var SETTINGS_PATH = path4.join(homedir(), ".claude", "settings.json");
317
- async function readSettingsJson() {
318
- try {
319
- const raw = await fs4.readFile(SETTINGS_PATH, "utf8");
320
- return JSON.parse(raw);
321
- } catch {
322
- return {};
323
- }
324
- }
325
- async function writeSettingsJson(settings) {
326
- await fs4.mkdir(path4.dirname(SETTINGS_PATH), { recursive: true });
327
- await fs4.writeFile(SETTINGS_PATH, JSON.stringify(settings, null, 2) + "\n");
328
- }
329
- async function injectCaptureHooks(slug, apiUrl, token) {
330
- const settings = await readSettingsJson();
331
- const hooks = settings["hooks"] ?? {};
332
- const postToolUse = hooks["PostToolUse"] ?? [];
333
- const preToolUse = hooks["PreToolUse"] ?? [];
334
- const postHookId = `headways-capture-post-${slug}`;
335
- const preHookId = `headways-capture-pre-${slug}`;
336
- if (!postToolUse.some((h) => h["id"] === postHookId)) {
337
- postToolUse.push({
338
- id: postHookId,
339
- matcher: "*",
340
- hooks: [
341
- {
342
- type: "command",
343
- command: `headways capture-record --slug ${slug} --event tool.{{tool_name}} --status {{tool_result_is_error}} 2>/dev/null || true`
344
- }
345
- ]
346
- });
347
- }
348
- if (!preToolUse.some((h) => h["id"] === preHookId)) {
349
- preToolUse.push({
350
- id: preHookId,
351
- matcher: "*",
352
- hooks: [
353
- {
354
- type: "command",
355
- command: `headways capture-record --slug ${slug} --event tool.pre.{{tool_name}} 2>/dev/null || true`
356
- }
357
- ]
358
- });
359
- }
360
- hooks["PostToolUse"] = postToolUse;
361
- hooks["PreToolUse"] = preToolUse;
362
- settings["hooks"] = hooks;
363
- await writeSettingsJson(settings);
364
- console.log(`Injected capture hooks for ${slug} into ${SETTINGS_PATH}`);
365
- }
366
- async function removeCaptureHooks(slug) {
367
- const settings = await readSettingsJson();
368
- const hooks = settings["hooks"] ?? {};
369
- const postHookId = `headways-capture-post-${slug}`;
370
- const preHookId = `headways-capture-pre-${slug}`;
371
- if (Array.isArray(hooks["PostToolUse"])) {
372
- hooks["PostToolUse"] = hooks["PostToolUse"].filter(
373
- (h) => h["id"] !== postHookId
374
- );
375
- }
376
- if (Array.isArray(hooks["PreToolUse"])) {
377
- hooks["PreToolUse"] = hooks["PreToolUse"].filter(
378
- (h) => h["id"] !== preHookId
379
- );
380
- }
381
- settings["hooks"] = hooks;
382
- await writeSettingsJson(settings);
383
- console.log(`Removed capture hooks for ${slug}`);
384
- }
385
- function registerCaptureCommand(program2) {
386
- program2.command("capture <slug>").description("Capture a live skill run session and upload as sample run").option("--version <version>", "Skill version to capture against", "draft").option("--stop", "Stop capturing and upload the session").action(async (slug, opts) => {
387
- const { token, orgId } = requireAuth();
388
- const apiUrl = getApiUrl();
389
- if (opts.stop) {
390
- await removeCaptureHooks(slug);
391
- const sessionFile2 = path4.join(homedir(), ".headways", `capture-${slug}.json`);
392
- let session = null;
393
- try {
394
- const raw = await fs4.readFile(sessionFile2, "utf8");
395
- session = JSON.parse(raw);
396
- } catch {
397
- console.error("No active capture session found. Run `headways capture <slug>` first.");
398
- process.exit(1);
399
- }
400
- const res = await fetch(
401
- `${apiUrl}/v1/skills/${slug}/versions/${opts.version ?? "draft"}/sample-run/capture-upload`,
402
- {
403
- method: "POST",
404
- headers: {
405
- "Content-Type": "application/json",
406
- Authorization: `Bearer ${token}`,
407
- "x-headways-org-id": orgId
408
- },
409
- body: JSON.stringify(session)
410
- }
411
- );
412
- if (!res.ok) {
413
- console.error(`Upload failed: ${res.status} ${await res.text()}`);
414
- process.exit(1);
415
- }
416
- const result = await res.json();
417
- await fs4.unlink(sessionFile2).catch(() => {
418
- });
419
- console.log(`Capture uploaded. Run ID: ${result.skillRunId}`);
420
- return;
421
- }
422
- await injectCaptureHooks(slug, apiUrl, token);
423
- const sessionFile = path4.join(homedir(), ".headways", `capture-${slug}.json`);
424
- await fs4.mkdir(path4.dirname(sessionFile), { recursive: true });
425
- await fs4.writeFile(
426
- sessionFile,
427
- JSON.stringify(
428
- {
429
- skill_slug: slug,
430
- version: opts.version ?? "draft",
431
- invocation_prompt: "",
432
- tool_calls: [],
433
- hook_emissions: [],
434
- fixture_reads: [],
435
- artifact_writes: [],
436
- duration_ms: 0,
437
- captured_at: (/* @__PURE__ */ new Date()).toISOString()
438
- },
439
- null,
440
- 2
441
- )
442
- );
443
- console.log(`Capturing ${slug}. Run the skill in Claude Code, then stop with:`);
444
- console.log(` headways capture ${slug} --stop`);
445
- });
446
- }
447
-
448
474
  // src/commands/skills/index.ts
475
+ var SKILLS_GUIDE = `
476
+ # Headways Skill Authoring Guide
477
+
478
+ ## Workflow
479
+
480
+ \`\`\`bash
481
+ headways skills new --slug <slug> --headline "<headline>" # scaffold + register in org
482
+ # Edit the skill body
483
+ vim <slug>/SKILL.md
484
+ headways skills push <slug> # push local edits as a draft
485
+ # Publish via web UI at app.headways.ai/skills/<slug>
486
+ \`\`\`
487
+
488
+ ## Field Constraints
489
+
490
+ | Field | Rule |
491
+ |----------------|----------------------------------------------------------------|
492
+ | \`slug\` | \`^[a-z0-9-]+$\`, 1\u201364 chars, immutable after creation |
493
+ | \`headline\` | 1\u2013200 chars at creation; **\u2264 90 chars to submit** (hard gate) |
494
+ | \`name\` | 1\u2013120 chars (display name, defaults to headline) |
495
+ | \`channel\` | \`prompt\` (default) | \`auto\` | \`manual\` |
496
+ | \`data_classes\` | \`none\` (default) | \`pii\` | \`phi\` | \`pci\` |
497
+
498
+ > Critical: headline must be \u2264 90 characters or the web UI will block submission.
499
+
500
+ ## File Bundle (\`<slug>/\`)
501
+
502
+ \`\`\`
503
+ <slug>/
504
+ SKILL.md # skill body \u2014 instructions for the AI agent
505
+ headways.yaml # metadata: slug, name, headline, channel, runtimes
506
+ capabilities.yaml # what the skill is allowed to do
507
+ connections.yaml # MCP connectors this skill requires (optional)
508
+ hooks.yaml # structured hooks the skill exposes (optional)
509
+ \`\`\`
510
+
511
+ ### SKILL.md
512
+
513
+ \`\`\`markdown
514
+ ---
515
+ description: One-sentence summary used in skill listings.
516
+ allowed_tools:
517
+ - Bash
518
+ - Read
519
+ - Edit
520
+ ---
521
+
522
+ # Skill Title
523
+
524
+ Full instructions for the AI agent. Be explicit: cover inputs, outputs,
525
+ edge cases, and concrete examples. Vague goals produce poor results.
526
+ \`\`\`
527
+
528
+ ### headways.yaml
529
+
530
+ \`\`\`yaml
531
+ slug: my-skill
532
+ name: My Skill
533
+ headline: Verb-first summary of the outcome (\u226490 chars)
534
+ channel: prompt # prompt | auto | manual
535
+ runtimes:
536
+ - claude-code
537
+ \`\`\`
538
+
539
+ ### capabilities.yaml
540
+
541
+ \`\`\`yaml
542
+ reads: [] # file glob patterns the skill reads
543
+ writes: [] # file glob patterns the skill writes
544
+ external: [] # external domains the skill contacts
545
+ data_classes: none # none | pii | phi | pci
546
+ auto_send: false # true = skill may act without user confirmation
547
+ \`\`\`
548
+
549
+ ### connections.yaml (required for any skill that uses MCP connector tools)
550
+
551
+ Declare every MCP connector the skill depends on. Users see this list on \`headways skills accept\`
552
+ and the Headways app gates installation on the connectors being configured.
553
+
554
+ \`\`\`yaml
555
+ - connector: slack # connector identifier (e.g. slack, github, jira, linear, notion, google-drive)
556
+ purpose: Read channel messages and threads via Slack MCP tools
557
+ - connector: github
558
+ purpose: Read pull requests and issues
559
+ \`\`\`
560
+
561
+ Omit the file entirely if the skill has no connector dependencies.
562
+
563
+ ### hooks.yaml (omit if unused)
564
+
565
+ \`\`\`yaml
566
+ - name: my-hook
567
+ kind: pre_tool_use # pre_tool_use | post_tool_use | notification | stop
568
+ description: What this hook does.
569
+ schema:
570
+ type: object
571
+ properties: {}
572
+ \`\`\`
573
+
574
+ ## Importing an Existing Skill
575
+
576
+ Use \`import\` when you have an existing prompt file, SKILL.md bundle, or YAML you want to
577
+ register in Headways rather than authoring from scratch.
578
+
579
+ \`\`\`bash
580
+ headways skills import <path> # file (.md, .yaml) or directory containing SKILL.md
581
+ headways skills import <path> --slug <slug> # override the derived slug
582
+ \`\`\`
583
+
584
+ **Format detection (automatic):**
585
+
586
+ | Input | Detected format |
587
+ |------------------------------|-------------------|
588
+ | Directory with \`SKILL.md\` | \`skill-md\` |
589
+ | \`.yaml\` / \`.yml\` file | \`headways-yaml\` |
590
+ | Any other \`.md\` / text file | \`markdown\` |
591
+
592
+ The slug is derived from the filename by default (lowercased, non-alphanumeric \u2192 \`-\`).
593
+ Use \`--slug\` to override. Same slug constraints apply: \`^[a-z0-9-]+$\`, 1\u201364 chars.
594
+
595
+ After import, a draft is created in your org. Review and edit via the web UI or push
596
+ local edits with \`headways skills push <slug>\`.
597
+
598
+ ## What Makes a High-Quality Skill
599
+
600
+ - **Headline and description**: always verb-first and action-oriented \u2014 describe what the
601
+ skill *does*, not what it *is*.
602
+ - Good: "Scaffold a typed REST endpoint from an OpenAPI spec"
603
+ - Good: "Review a PR diff for security vulnerabilities"
604
+ - Bad: "This skill helps with REST endpoints" (passive, no action)
605
+ - Bad: "Security review skill" (noun phrase, no verb)
606
+ - **SKILL.md body**: step-by-step instructions, not goals. Cover what to do,
607
+ what files to touch, what to avoid, and example invocations.
608
+ - **\`allowed_tools\`**: list only tools the skill actually needs.
609
+ - **\`capabilities.yaml\`**: declare all external domains and file patterns.
610
+ Undeclared capabilities are blocked at runtime.
611
+ - **\`data_classes\`**: set to \`pii\`/\`phi\`/\`pci\` if the skill touches sensitive data.
612
+
613
+ ## Common Failure Modes
614
+
615
+ - Headline > 90 chars \u2192 submit blocked with 422. Shorten before pushing.
616
+ - Uppercase or special chars in slug \u2192 rejected at creation. Use \`a-z\`, \`0-9\`, \`-\` only.
617
+ - Missing \`capabilities.yaml\` entries \u2192 skill silently blocked at runtime.
618
+ - 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.
619
+ - Passive or noun-phrase headline \u2192 poor discoverability; rewrite as a verb phrase.
620
+ `.trim();
449
621
  function registerSkillsCommands(program2) {
450
622
  const skills = program2.command("skills").description("Manage skills");
451
623
  registerNewCommand(skills);
452
624
  registerImportCommand(skills);
453
625
  registerPushCommand(skills);
454
- registerCaptureCommand(program2);
626
+ skills.command("guide").description("Print skill authoring reference (constraints, file bundle, examples)").action(() => {
627
+ console.log(SKILLS_GUIDE);
628
+ });
455
629
  skills.command("list").description("List skills in the active org").action(async () => {
456
- const { requireAuth: requireAuth2 } = await import("./config-GRE3MIQL.js");
457
- const { apiRequest: apiRequest2 } = await import("./api-WUIL5TMR.js");
630
+ const { requireAuth: requireAuth2 } = await import("./config-SHMIVRAP.js");
631
+ const { apiRequest: apiRequest2 } = await import("./api-2BK6MGZB.js");
458
632
  requireAuth2();
459
633
  const result = await apiRequest2("/v1/skills");
460
634
  if (result.data.length === 0) {
@@ -465,276 +639,71 @@ function registerSkillsCommands(program2) {
465
639
  }
466
640
  }
467
641
  });
468
- }
469
-
470
- // src/commands/sync/index.ts
471
- import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, rmSync } from "fs";
472
- import { homedir as homedir2 } from "os";
473
- import { join as join5 } from "path";
474
- import { createGunzip } from "zlib";
475
- import { Readable } from "stream";
476
- import "stream/promises";
477
- import "commander";
478
- var PENDING_FILE = join5(HEADWAYS_DIR, "pending.json");
479
- var SYNC_STATE_FILE = join5(HEADWAYS_DIR, "sync-state.json");
480
- var INSTALLED_DIR = join5(HEADWAYS_DIR, "installed");
481
- function readSyncState() {
482
- if (!existsSync(SYNC_STATE_FILE)) return {};
483
- try {
484
- return JSON.parse(readFileSync(SYNC_STATE_FILE, "utf8"));
485
- } catch {
486
- return {};
487
- }
488
- }
489
- function writeSyncState(state) {
490
- if (!existsSync(HEADWAYS_DIR)) mkdirSync(HEADWAYS_DIR, { recursive: true });
491
- writeFileSync(SYNC_STATE_FILE, JSON.stringify(state, null, 2) + "\n");
492
- }
493
- function readPending() {
494
- if (!existsSync(PENDING_FILE)) return [];
495
- try {
496
- return JSON.parse(readFileSync(PENDING_FILE, "utf8"));
497
- } catch {
498
- return [];
499
- }
500
- }
501
- function writePending(updates) {
502
- if (!existsSync(HEADWAYS_DIR)) mkdirSync(HEADWAYS_DIR, { recursive: true });
503
- writeFileSync(PENDING_FILE, JSON.stringify(updates, null, 2) + "\n");
504
- }
505
- function deviceHeaders(state) {
506
- return {
507
- Authorization: `Bearer ${state.device_token ?? ""}`,
508
- "x-headways-device-id": state.device_id ?? "",
509
- "x-headways-timestamp": String(Math.floor(Date.now() / 1e3))
510
- };
511
- }
512
- async function registerDevice(token, orgId, apiUrl) {
513
- const res = await fetch(`${apiUrl}/v1/sync/devices/register`, {
514
- method: "POST",
515
- headers: {
516
- "Content-Type": "application/json",
517
- Authorization: `Bearer ${token}`,
518
- "x-headways-org-id": orgId
519
- },
520
- body: JSON.stringify({
521
- publicKey: Buffer.from(`stub-pubkey-${Date.now()}`).toString("base64url"),
522
- platform: process.platform === "darwin" ? "macos" : process.platform === "win32" ? "windows" : "linux",
523
- hostname: (await import("os")).hostname()
524
- })
525
- });
526
- if (!res.ok) throw new Error(`Device registration failed: ${res.status}`);
527
- const data = await res.json();
528
- return { device_id: data.deviceId, device_token: data.deviceToken };
529
- }
530
- async function pollCatalog(state, apiUrl) {
531
- const url = state.etag ? `${apiUrl}/v1/sync/catalog?since=${encodeURIComponent(state.etag)}` : `${apiUrl}/v1/sync/catalog`;
532
- const res = await fetch(url, { headers: deviceHeaders(state) });
533
- if (res.status === 304) return null;
534
- if (!res.ok) throw new Error(`Catalog poll failed: ${res.status}`);
535
- return res.json();
536
- }
537
- async function downloadAndMaterialize(slug, version, state, apiUrl) {
538
- const res = await fetch(`${apiUrl}/v1/sync/bundles/${slug}/${version}`, {
539
- redirect: "follow",
540
- headers: deviceHeaders(state)
541
- });
542
- if (!res.ok) throw new Error(`Bundle fetch failed: ${res.status}`);
543
- const buf = Buffer.from(await res.arrayBuffer());
544
- const skillsDir = join5(homedir2(), ".claude", "skills");
545
- const dest = join5(skillsDir, slug);
546
- const staging = join5(skillsDir, `.${slug}-staging`);
547
- mkdirSync(staging, { recursive: true });
548
- await extractTarGz(buf, staging);
549
- if (existsSync(dest)) rmSync(dest, { recursive: true });
550
- renameSync(staging, dest);
551
- mkdirSync(INSTALLED_DIR, { recursive: true });
552
- writeFileSync(
553
- join5(INSTALLED_DIR, `${slug}.json`),
554
- JSON.stringify(
555
- { slug, version, runtime: "claude-code", installed_at: (/* @__PURE__ */ new Date()).toISOString() },
556
- null,
557
- 2
558
- )
559
- );
560
- console.log(`Materialized ${slug}@${version} \u2192 ${dest}`);
561
- }
562
- async function extractTarGz(buf, destDir) {
563
- const decompressed = await new Promise((resolve, reject) => {
564
- const chunks = [];
565
- const gunzip = createGunzip();
566
- const src = Readable.from(buf);
567
- src.pipe(gunzip);
568
- gunzip.on("data", (chunk) => chunks.push(chunk));
569
- gunzip.on("end", () => resolve(Buffer.concat(chunks)));
570
- gunzip.on("error", reject);
571
- });
572
- let offset = 0;
573
- const { writeFileSync: wf, mkdirSync: md } = await import("fs");
574
- const { dirname: dirname2 } = await import("path");
575
- while (offset + 512 <= decompressed.length) {
576
- const header = decompressed.slice(offset, offset + 512);
577
- const name = header.slice(0, 100).toString("utf8").replace(/\0/g, "").trim();
578
- if (!name) break;
579
- const sizeOctal = header.slice(124, 136).toString("utf8").replace(/\0/g, "").trim();
580
- const size = parseInt(sizeOctal, 8) || 0;
581
- const typeFlag = header[156];
582
- offset += 512;
583
- if (typeFlag === 53 || name.endsWith("/")) {
584
- md(join5(destDir, name), { recursive: true });
585
- } else if (typeFlag === 0 || typeFlag === 48 || typeFlag === void 0) {
586
- const filePath = join5(destDir, name);
587
- md(dirname2(filePath), { recursive: true });
588
- wf(filePath, decompressed.slice(offset, offset + size));
589
- }
590
- offset += Math.ceil(size / 512) * 512;
591
- }
592
- }
593
- function registerSyncCommands(program2) {
594
- const sync = program2.command("sync").description("Sync skills from Headways to local Claude Code");
595
- 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) => {
596
- const cfg = readConfig();
597
- if (!cfg.token || !cfg.orgId) {
598
- console.error("Not logged in. Run: headways login");
599
- process.exit(1);
600
- }
601
- const apiUrl = getApiUrl();
602
- let state = readSyncState();
603
- if (!state.device_id || !state.device_token) {
604
- console.log("Registering device with Headways\u2026");
605
- try {
606
- const deviceState = await registerDevice(cfg.token, cfg.orgId, apiUrl);
607
- state = { ...state, ...deviceState };
608
- writeSyncState(state);
609
- console.log(`Device registered: ${state.device_id}`);
610
- } catch (err) {
611
- console.error(`Registration failed: ${err instanceof Error ? err.message : String(err)}`);
612
- process.exit(1);
613
- }
614
- }
615
- const doPoll = async () => {
616
- try {
617
- const delta = await pollCatalog(state, apiUrl);
618
- if (!delta) {
619
- console.log("Catalog up to date.");
620
- return;
621
- }
622
- state.etag = delta.etag;
623
- state.last_poll = (/* @__PURE__ */ new Date()).toISOString();
624
- writeSyncState(state);
625
- const pendingMap = new Map(readPending().map((p) => [p.slug, p]));
626
- for (const ev of delta.events) {
627
- if (ev.kind === "version_published") {
628
- if (ev.channel === "auto") {
629
- console.log(`Auto-installing: ${ev.skill_slug}@${ev.version}`);
630
- await downloadAndMaterialize(ev.skill_slug, ev.version, state, apiUrl);
631
- pendingMap.delete(ev.skill_slug);
632
- } else {
633
- pendingMap.set(ev.skill_slug, {
634
- slug: ev.skill_slug,
635
- version: ev.version,
636
- user_visible_change: ev.user_visible_change,
637
- channel: ev.channel,
638
- capabilities_delta_empty: ev.capabilities_delta_empty
639
- });
640
- console.log(
641
- `Queued: ${ev.skill_slug}@${ev.version}${ev.user_visible_change ? ` \u2014 ${ev.user_visible_change}` : ""}`
642
- );
643
- }
644
- } else if (ev.kind === "skill_archived" || ev.kind === "entitlement_revoked") {
645
- pendingMap.delete(ev.skill_slug);
646
- console.log(`Removed: ${ev.skill_slug}`);
647
- }
642
+ skills.command("accept <slug>").description("Accept a pending skill update and install it locally").action(async (slug) => {
643
+ const { acceptSkill } = await import("./sync-6PKI35ZY.js");
644
+ await acceptSkill(slug);
645
+ try {
646
+ const { apiRequest: apiRequest2 } = await import("./api-2BK6MGZB.js");
647
+ const metadata = await apiRequest2(`/v1/skills/${slug}/bundle/metadata`);
648
+ const reqs = metadata.connectionRequirements ?? [];
649
+ if (reqs.length > 0) {
650
+ console.log("");
651
+ console.log("This skill requires the following connectors:");
652
+ console.log("");
653
+ for (const req of reqs) {
654
+ console.log(` - ${req.connector.padEnd(20)} ${req.purpose}`);
648
655
  }
649
- writePending([...pendingMap.values()]);
650
- console.log(`Synced. ETag: ${delta.etag}`);
651
- } catch (err) {
652
- console.error(`Poll error: ${err instanceof Error ? err.message : String(err)}`);
653
- }
654
- };
655
- await doPoll();
656
- if (opts.daemon) {
657
- console.log("Running sync daemon (60s interval). Press Ctrl-C to stop.");
658
- setInterval(doPoll, 6e4);
659
- }
660
- });
661
- sync.command("status").description("Show current sync status and pending updates").action(() => {
662
- const state = readSyncState();
663
- const pending = readPending();
664
- if (!state.device_id) {
665
- console.log("Device not registered. Run: headways sync start");
666
- return;
667
- }
668
- console.log(`Device ID : ${state.device_id}`);
669
- console.log(`Last poll : ${state.last_poll ?? "never"}`);
670
- console.log(`Catalog ETag: ${state.etag ?? "none"}`);
671
- if (pending.length === 0) {
672
- console.log("\nAll skills up to date. No pending updates.");
673
- } else {
674
- console.log(`
675
- Pending updates (${pending.length}):`);
676
- for (const p of pending) {
677
- const change = p.user_visible_change ? ` \u2014 ${p.user_visible_change}` : "";
678
- const caps = p.capabilities_delta_empty ? "" : " [CAPS CHANGED]";
679
- console.log(` ${p.slug}@${p.version}${change}${caps}`);
656
+ console.log("");
657
+ console.log("To authorize these connectors, use the Headways desktop app");
658
+ console.log("or run: headways connections add <provider>");
680
659
  }
681
- console.log("\nRun `headways accept <skill>` to install.");
660
+ } catch {
682
661
  }
683
662
  });
684
- program2.command("accept <skill>").description("Accept a pending skill update and materialize it locally").action(async (skillSlug) => {
663
+ skills.command("feedback <slug>").description("Submit feedback about a skill").option(
664
+ "--reaction <type>",
665
+ "thumbs_up, thumbs_down, wrong_output, missing_step",
666
+ "thumbs_down"
667
+ ).option("--note <text>", "Free-text note").option("--run-id <runId>", "Skill run ID (or set HEADWAYS_RUN_ID env var)").action(async (slug, opts) => {
685
668
  const cfg = readConfig();
686
- if (!cfg.token || !cfg.orgId) {
687
- console.error("Not logged in. Run: headways login");
688
- process.exit(1);
689
- }
690
- const pending = readPending();
691
- const update = pending.find((p) => p.slug === skillSlug);
692
- if (!update) {
693
- console.error(`No pending update for skill: ${skillSlug}`);
694
- console.log("Run `headways sync status` to see pending updates.");
669
+ if (!cfg.token) {
670
+ console.error("Not authenticated. Run `headways login` first.");
695
671
  process.exit(1);
696
672
  }
697
- const state = readSyncState();
698
- if (!state.device_id || !state.device_token) {
699
- console.error("Device not registered. Run: headways sync start");
673
+ const runId = opts.runId ?? process.env["HEADWAYS_RUN_ID"];
674
+ try {
675
+ await rawRequest(`/v1/skills/${slug}/feedback?source=runtime_cli`, cfg.token, {
676
+ method: "POST",
677
+ body: JSON.stringify({ reaction: opts.reaction, note: opts.note, runId })
678
+ });
679
+ console.log(`Feedback submitted for ${slug}.`);
680
+ } catch (err) {
681
+ console.error(`Error: ${String(err)}`);
700
682
  process.exit(1);
701
683
  }
702
- console.log(`Accepting ${skillSlug}@${update.version}\u2026`);
703
- await downloadAndMaterialize(skillSlug, update.version, state, getApiUrl());
704
- writePending(pending.filter((p) => p.slug !== skillSlug));
705
- console.log(
706
- `${skillSlug} is ready \u2014 invoke it in Claude Code with the skill's invocation phrase.`
707
- );
708
684
  });
709
685
  }
710
686
 
711
- // src/commands/feedback.ts
687
+ // src/commands/connections/index.ts
712
688
  import "commander";
713
- function registerFeedbackCommand(program2) {
714
- program2.command("feedback <skillSlug>").description("Submit feedback about a skill (posts to Headways API)").option(
715
- "--reaction <type>",
716
- "Reaction type: thumbs_up, thumbs_down, wrong_output, missing_step",
717
- "thumbs_down"
718
- ).option("--note <text>", "Free-text note about the issue").option("--run-id <runId>", "Skill run ID (or set HEADWAYS_RUN_ID env var)").action(
719
- async (skillSlug, opts) => {
720
- const cfg = readConfig();
721
- if (!cfg.token) {
722
- console.error("Not authenticated. Run `headways auth login` first.");
723
- process.exit(1);
724
- }
725
- const runId = opts.runId ?? process.env["HEADWAYS_RUN_ID"];
726
- try {
727
- await rawRequest(`/v1/skills/${skillSlug}/feedback?source=runtime_cli`, cfg.token, {
728
- method: "POST",
729
- body: JSON.stringify({ reaction: opts.reaction, note: opts.note, runId })
730
- });
731
- console.log(`Feedback submitted for ${skillSlug}.`);
732
- } catch (err) {
733
- console.error(`Error: ${String(err)}`);
734
- process.exit(1);
735
- }
736
- }
737
- );
689
+ function registerConnectionsCommands(program2) {
690
+ const connections = program2.command("connections").description("Manage connector authorizations");
691
+ connections.command("add [provider]").description("Authorize a connector (opens the desktop app)").action((provider) => {
692
+ const target = provider ? `the ${provider} connector` : "connectors";
693
+ console.log(`To authorize ${target}, open the Headways desktop app.`);
694
+ console.log("");
695
+ console.log(" 1. Open Headways");
696
+ console.log(" 2. Go to Settings \u2192 Connections");
697
+ console.log(' 3. Click "Connect" next to the connector you want to authorize');
698
+ console.log("");
699
+ console.log(
700
+ "Alternatively, install a skill that requires the connector and it will be set up automatically."
701
+ );
702
+ });
703
+ connections.command("list").description("List authorized connections (opens the desktop app)").action(() => {
704
+ console.log("To view your connections, open the Headways desktop app.");
705
+ console.log("Go to Settings \u2192 Connections to see and manage your authorized connectors.");
706
+ });
738
707
  }
739
708
 
740
709
  // src/sdk/emit.ts
@@ -777,11 +746,113 @@ function registerEmitCommand(program2) {
777
746
  );
778
747
  }
779
748
 
749
+ // src/commands/prime.ts
750
+ import { existsSync, readdirSync, readFileSync } from "fs";
751
+ import "commander";
752
+ function registerPrimeCommand(program2) {
753
+ program2.command("prime").description("Output Headways workflow context for AI coding assistants").action(() => {
754
+ const cfg = readConfig();
755
+ const skills = getInstalledSkills();
756
+ const lines = [
757
+ "# Headways \u2014 AI Skill Manager",
758
+ "",
759
+ "Headways distributes versioned skills (system prompts + context files) to AI coding agents.",
760
+ "Skills live in `~/.claude/skills/<slug>/` and are loaded automatically by Claude Code hooks.",
761
+ "",
762
+ "> **For AI agents:** Before acting on any Headways task, run the relevant command with",
763
+ "> `--help` to discover full options, or run `headways skills guide` before authoring a skill.",
764
+ "> Do not guess at flags or constraints \u2014 discover them at runtime.",
765
+ "",
766
+ "## Auth & Config",
767
+ "",
768
+ `Status: ${cfg.token ? `Signed in (org: ${cfg.orgSlug ?? cfg.orgId ?? "unknown"})` : "Not signed in \u2014 run `headways login`"}`,
769
+ `Config: ~/.headways/config.json`,
770
+ "",
771
+ "## Key Commands",
772
+ "",
773
+ "```bash",
774
+ "headways login # Browser SSO sign-in",
775
+ "headways logout # Remove stored credentials",
776
+ "headways config status # Show saved key, org, URLs",
777
+ "headways config clear # Clear credentials + reset setup state",
778
+ "",
779
+ "headways sync start # Pull catalog updates once",
780
+ "headways sync start --daemon # Poll every 60s in background",
781
+ "headways sync status # Show pending skill updates",
782
+ "",
783
+ "headways skills list # List skills in your org",
784
+ "headways skills new # Scaffold a new skill",
785
+ "headways skills import <path> # Create a new skill from a local file or directory",
786
+ "headways skills push <slug> # Push edits to an existing skill (import or new first)",
787
+ "headways skills accept <slug> # Install a pending skill update",
788
+ "headways skills feedback <slug> # Submit feedback on a skill",
789
+ "headways skills guide # Authoring reference (run before creating a skill)",
790
+ "",
791
+ "headways setup claude # Install Claude Code hooks (SessionStart + PreCompact)",
792
+ "headways prime # Print this context (used by hooks)",
793
+ "```",
794
+ "",
795
+ "## Workflow",
796
+ "",
797
+ "1. `headways sync start` \u2014 pull the latest catalog from your org",
798
+ "2. `headways accept <skill>` \u2014 install a skill locally",
799
+ "3. Skills are automatically available to Claude Code via `~/.claude/skills/<slug>/`",
800
+ "4. Run `headways sync start --daemon` to keep skills up to date in the background",
801
+ "",
802
+ "## Installed Skills",
803
+ ""
804
+ ];
805
+ if (skills.length === 0) {
806
+ lines.push(
807
+ "No skills installed. Run `headways sync start` then `headways accept <skill>`."
808
+ );
809
+ } else {
810
+ for (const skill of skills) {
811
+ const runLine = skill.lastRunAt ? `last run ${new Date(skill.lastRunAt).toLocaleDateString()}` : "never run";
812
+ lines.push(`- **${skill.slug}** v${skill.version} (${skill.runtime}, ${runLine})`);
813
+ if (skill.connectionRequirements.length > 0) {
814
+ const connectors = skill.connectionRequirements.map((r) => r.connector).join(", ");
815
+ lines.push(` - Requires connectors: ${connectors}`);
816
+ }
817
+ }
818
+ }
819
+ lines.push("", "## Skill Files", "");
820
+ lines.push("Installed skill bundles land in `~/.claude/skills/<slug>/`.");
821
+ lines.push("Claude Code automatically discovers them via the skills directory.");
822
+ console.log(lines.join("\n"));
823
+ });
824
+ }
825
+ function getInstalledSkills() {
826
+ if (!existsSync(INSTALLED_DIR)) return [];
827
+ try {
828
+ return readdirSync(INSTALLED_DIR).filter((f) => f.endsWith(".json")).map((f) => {
829
+ const slug = f.replace(/\.json$/, "");
830
+ try {
831
+ const raw = JSON.parse(readFileSync(`${INSTALLED_DIR}/${f}`, "utf8"));
832
+ return {
833
+ slug,
834
+ version: String(raw.version ?? ""),
835
+ runtime: String(raw.runtime ?? "claude-code"),
836
+ lastRunAt: raw.last_run_at ?? null,
837
+ connectionRequirements: Array.isArray(raw.connection_requirements) ? raw.connection_requirements : []
838
+ };
839
+ } catch {
840
+ return null;
841
+ }
842
+ }).filter((s) => s !== null);
843
+ } catch {
844
+ return [];
845
+ }
846
+ }
847
+
780
848
  // src/index.ts
781
- program.name("headways").description("Headways CLI \u2014 skill authoring, sync, and runtime SDK").version("0.1.0");
849
+ program.name("headways").description("Headways CLI \u2014 skill authoring, sync, and runtime SDK").version("0.2.1");
782
850
  registerAuthCommands(program);
783
851
  registerSkillsCommands(program);
852
+ registerConnectionsCommands(program);
784
853
  registerSyncCommands(program);
785
- registerFeedbackCommand(program);
786
854
  registerEmitCommand(program);
855
+ registerPrimeCommand(program);
856
+ registerSetupCommand(program);
857
+ registerUninstallCommand(program);
787
858
  program.parse();