@headways/cli 0.2.1 → 0.3.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,91 @@ 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
+ const cleared = {};
83
+ if (cfg.apiUrl) cleared.apiUrl = cfg.apiUrl;
84
+ if (cfg.appUrl) cleared.appUrl = cfg.appUrl;
85
+ writeConfig(cleared);
86
+ console.log("Credentials cleared. The desktop app will show onboarding on next launch.");
87
+ });
61
88
  configure.command("status").description("Show current API key and org").action(() => {
62
89
  const cfg = readConfig();
63
90
  if (!cfg.token) {
64
- console.log("No API key configured. Run `headways configure`.");
91
+ console.log(NO_KEY_MSG);
65
92
  } else {
66
93
  console.log(
67
94
  `API key: ${cfg.token.slice(0, 12)}\u2026 | Org: ${cfg.orgSlug ?? "(none set)"} | API: ${cfg.apiUrl ?? "https://api.headways.ai"}`
68
95
  );
69
96
  }
70
97
  });
71
- configure.command("clear").description("Remove stored API key and org").action(() => {
98
+ program2.command("logout").description("Remove stored credentials (keeps API and app URLs)").action(() => {
72
99
  const cfg = readConfig();
73
100
  delete cfg.token;
74
101
  delete cfg.orgSlug;
75
102
  delete cfg.orgId;
103
+ delete cfg.setupComplete;
76
104
  writeConfig(cfg);
77
- console.log("Configuration cleared.");
105
+ console.log("Logged out.");
106
+ });
107
+ 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) => {
108
+ const cfg = readConfig();
109
+ const state = crypto.randomUUID();
110
+ const port = await findFreePort();
111
+ const callbackUrl = `http://localhost:${port}/callback`;
112
+ const appUrl = getAppUrl();
113
+ const apiUrl = getApiUrl();
114
+ const loginUrl = `${appUrl}/auth/device?state=${encodeURIComponent(state)}&callback=${encodeURIComponent(callbackUrl)}`;
115
+ console.log("Opening browser to sign in\u2026");
116
+ openBrowser(loginUrl);
117
+ console.log(`
118
+ If the browser did not open, visit:
119
+ ${loginUrl}
120
+ `);
121
+ const timeoutMs = Math.max(10, parseInt(opts.timeout, 10)) * 1e3;
122
+ let grant;
123
+ try {
124
+ grant = await waitForCallback(port, state, timeoutMs);
125
+ } catch (err) {
126
+ console.error(`
127
+ Sign-in timed out or failed: ${String(err)}`);
128
+ process.exit(1);
129
+ }
130
+ let result;
131
+ try {
132
+ const res = await fetch(`${apiUrl}/v1/auth/device/exchange`, {
133
+ method: "POST",
134
+ headers: { "content-type": "application/json" },
135
+ body: JSON.stringify({ grant })
136
+ });
137
+ if (!res.ok) {
138
+ const body = await res.text().catch(() => "");
139
+ throw new Error(`Exchange failed (${res.status}): ${body}`);
140
+ }
141
+ result = await res.json();
142
+ } catch (err) {
143
+ console.error(`Sign-in failed: ${String(err)}`);
144
+ process.exit(1);
145
+ }
146
+ writeConfig({
147
+ ...cfg,
148
+ token: result.token,
149
+ orgId: result.orgId,
150
+ orgSlug: result.orgSlug,
151
+ setupComplete: true
152
+ });
153
+ console.log(`Signed in. Org: ${result.orgSlug}`);
78
154
  });
79
155
  program2.command("org").description("Manage org context").command("use <slug>").description("Set active org by slug").action(async (slug) => {
80
156
  const cfg = readConfig();
81
157
  if (!cfg.token) {
82
- console.error("No API key configured. Run `headways configure` first.");
158
+ console.error(NO_KEY_MSG);
83
159
  process.exit(1);
84
160
  }
85
161
  try {
86
162
  await rawRequest("/v1/me", cfg.token);
87
163
  } catch {
88
- console.error("Invalid API key. Run `headways configure` again.");
164
+ console.error("Invalid API key. Run `headways config` again.");
89
165
  process.exit(1);
90
166
  }
91
167
  cfg.orgSlug = slug;
@@ -93,6 +169,58 @@ function registerAuthCommands(program2) {
93
169
  console.log(`Active org set to: ${slug}`);
94
170
  });
95
171
  }
172
+ function openBrowser(url) {
173
+ try {
174
+ const platform = process.platform;
175
+ if (platform === "darwin") execSync(`open ${JSON.stringify(url)}`);
176
+ else if (platform === "win32") execSync(`start "" ${JSON.stringify(url)}`);
177
+ else execSync(`xdg-open ${JSON.stringify(url)}`);
178
+ } catch {
179
+ }
180
+ }
181
+ function findFreePort() {
182
+ return new Promise((resolve, reject) => {
183
+ const srv = http.createServer();
184
+ srv.listen(0, "127.0.0.1", () => {
185
+ const addr = srv.address();
186
+ srv.close(() => {
187
+ if (addr && typeof addr === "object") resolve(addr.port);
188
+ else reject(new Error("Could not bind to port"));
189
+ });
190
+ });
191
+ });
192
+ }
193
+ function waitForCallback(port, expectedState, timeoutMs) {
194
+ return new Promise((resolve, reject) => {
195
+ const server = http.createServer((req, res) => {
196
+ const url = new URL(req.url ?? "/", `http://localhost:${port}`);
197
+ const grant = url.searchParams.get("grant");
198
+ const state = url.searchParams.get("state");
199
+ if (state !== expectedState || !grant) {
200
+ res.writeHead(400);
201
+ res.end("Invalid callback");
202
+ return;
203
+ }
204
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
205
+ res.end(
206
+ '<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>'
207
+ );
208
+ server.close();
209
+ resolve(grant);
210
+ });
211
+ const timer = setTimeout(() => {
212
+ server.close();
213
+ reject(new Error("Timed out waiting for browser callback"));
214
+ }, timeoutMs);
215
+ server.listen(port, "127.0.0.1", () => {
216
+ });
217
+ server.on("error", (err) => {
218
+ clearTimeout(timer);
219
+ reject(err);
220
+ });
221
+ server.on("close", () => clearTimeout(timer));
222
+ });
223
+ }
96
224
 
97
225
  // src/commands/skills/index.ts
98
226
  import "commander";
@@ -308,153 +436,148 @@ function registerPushCommand(program2) {
308
436
  });
309
437
  }
310
438
 
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
439
  // src/commands/skills/index.ts
440
+ var SKILLS_GUIDE = `
441
+ # Headways Skill Authoring Guide
442
+
443
+ ## Workflow
444
+
445
+ \`\`\`bash
446
+ headways skills new --slug <slug> --headline "<headline>" # scaffold + register in org
447
+ # Edit the skill body
448
+ vim <slug>/SKILL.md
449
+ headways skills push <slug> # push local edits as a draft
450
+ # Publish via web UI at app.headways.ai/skills/<slug>
451
+ \`\`\`
452
+
453
+ ## Field Constraints
454
+
455
+ | Field | Rule |
456
+ |----------------|----------------------------------------------------------------|
457
+ | \`slug\` | \`^[a-z0-9-]+$\`, 1\u201364 chars, immutable after creation |
458
+ | \`headline\` | 1\u2013200 chars at creation; **\u2264 90 chars to submit** (hard gate) |
459
+ | \`name\` | 1\u2013120 chars (display name, defaults to headline) |
460
+ | \`channel\` | \`prompt\` (default) | \`auto\` | \`manual\` |
461
+ | \`data_classes\` | \`none\` (default) | \`pii\` | \`phi\` | \`pci\` |
462
+
463
+ > Critical: headline must be \u2264 90 characters or the web UI will block submission.
464
+
465
+ ## File Bundle (\`<slug>/\`)
466
+
467
+ \`\`\`
468
+ <slug>/
469
+ SKILL.md # skill body \u2014 instructions for the AI agent
470
+ headways.yaml # metadata: slug, name, headline, channel, runtimes
471
+ capabilities.yaml # what the skill is allowed to do
472
+ hooks.yaml # structured hooks the skill exposes (optional)
473
+ \`\`\`
474
+
475
+ ### SKILL.md
476
+
477
+ \`\`\`markdown
478
+ ---
479
+ description: One-sentence summary used in skill listings.
480
+ allowed_tools:
481
+ - Bash
482
+ - Read
483
+ - Edit
484
+ ---
485
+
486
+ # Skill Title
487
+
488
+ Full instructions for the AI agent. Be explicit: cover inputs, outputs,
489
+ edge cases, and concrete examples. Vague goals produce poor results.
490
+ \`\`\`
491
+
492
+ ### headways.yaml
493
+
494
+ \`\`\`yaml
495
+ slug: my-skill
496
+ name: My Skill
497
+ headline: Verb-first summary of the outcome (\u226490 chars)
498
+ channel: prompt # prompt | auto | manual
499
+ runtimes:
500
+ - claude-code
501
+ \`\`\`
502
+
503
+ ### capabilities.yaml
504
+
505
+ \`\`\`yaml
506
+ reads: [] # file glob patterns the skill reads
507
+ writes: [] # file glob patterns the skill writes
508
+ external: [] # external domains the skill contacts
509
+ data_classes: none # none | pii | phi | pci
510
+ auto_send: false # true = skill may act without user confirmation
511
+ \`\`\`
512
+
513
+ ### hooks.yaml (omit if unused)
514
+
515
+ \`\`\`yaml
516
+ - name: my-hook
517
+ kind: pre_tool_use # pre_tool_use | post_tool_use | notification | stop
518
+ description: What this hook does.
519
+ schema:
520
+ type: object
521
+ properties: {}
522
+ \`\`\`
523
+
524
+ ## Importing an Existing Skill
525
+
526
+ Use \`import\` when you have an existing prompt file, SKILL.md bundle, or YAML you want to
527
+ register in Headways rather than authoring from scratch.
528
+
529
+ \`\`\`bash
530
+ headways skills import <path> # file (.md, .yaml) or directory containing SKILL.md
531
+ headways skills import <path> --slug <slug> # override the derived slug
532
+ \`\`\`
533
+
534
+ **Format detection (automatic):**
535
+
536
+ | Input | Detected format |
537
+ |------------------------------|-------------------|
538
+ | Directory with \`SKILL.md\` | \`skill-md\` |
539
+ | \`.yaml\` / \`.yml\` file | \`headways-yaml\` |
540
+ | Any other \`.md\` / text file | \`markdown\` |
541
+
542
+ The slug is derived from the filename by default (lowercased, non-alphanumeric \u2192 \`-\`).
543
+ Use \`--slug\` to override. Same slug constraints apply: \`^[a-z0-9-]+$\`, 1\u201364 chars.
544
+
545
+ After import, a draft is created in your org. Review and edit via the web UI or push
546
+ local edits with \`headways skills push <slug>\`.
547
+
548
+ ## What Makes a High-Quality Skill
549
+
550
+ - **Headline and description**: always verb-first and action-oriented \u2014 describe what the
551
+ skill *does*, not what it *is*.
552
+ - Good: "Scaffold a typed REST endpoint from an OpenAPI spec"
553
+ - Good: "Review a PR diff for security vulnerabilities"
554
+ - Bad: "This skill helps with REST endpoints" (passive, no action)
555
+ - Bad: "Security review skill" (noun phrase, no verb)
556
+ - **SKILL.md body**: step-by-step instructions, not goals. Cover what to do,
557
+ what files to touch, what to avoid, and example invocations.
558
+ - **\`allowed_tools\`**: list only tools the skill actually needs.
559
+ - **\`capabilities.yaml\`**: declare all external domains and file patterns.
560
+ Undeclared capabilities are blocked at runtime.
561
+ - **\`data_classes\`**: set to \`pii\`/\`phi\`/\`pci\` if the skill touches sensitive data.
562
+
563
+ ## Common Failure Modes
564
+
565
+ - Headline > 90 chars \u2192 submit blocked with 422. Shorten before pushing.
566
+ - Uppercase or special chars in slug \u2192 rejected at creation. Use \`a-z\`, \`0-9\`, \`-\` only.
567
+ - Missing \`capabilities.yaml\` entries \u2192 skill silently blocked at runtime.
568
+ - Passive or noun-phrase headline \u2192 poor discoverability; rewrite as a verb phrase.
569
+ `.trim();
449
570
  function registerSkillsCommands(program2) {
450
571
  const skills = program2.command("skills").description("Manage skills");
451
572
  registerNewCommand(skills);
452
573
  registerImportCommand(skills);
453
574
  registerPushCommand(skills);
454
- registerCaptureCommand(program2);
575
+ skills.command("guide").description("Print skill authoring reference (constraints, file bundle, examples)").action(() => {
576
+ console.log(SKILLS_GUIDE);
577
+ });
455
578
  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");
579
+ const { requireAuth: requireAuth2 } = await import("./config-SHMIVRAP.js");
580
+ const { apiRequest: apiRequest2 } = await import("./api-2BK6MGZB.js");
458
581
  requireAuth2();
459
582
  const result = await apiRequest2("/v1/skills");
460
583
  if (result.data.length === 0) {
@@ -465,278 +588,34 @@ function registerSkillsCommands(program2) {
465
588
  }
466
589
  }
467
590
  });
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);
591
+ skills.command("accept <slug>").description("Accept a pending skill update and install it locally").action(async (slug) => {
592
+ const { acceptSkill } = await import("./sync-6PKI35ZY.js");
593
+ await acceptSkill(slug);
571
594
  });
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
- }
648
- }
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}`);
680
- }
681
- console.log("\nRun `headways accept <skill>` to install.");
682
- }
683
- });
684
- program2.command("accept <skill>").description("Accept a pending skill update and materialize it locally").action(async (skillSlug) => {
595
+ skills.command("feedback <slug>").description("Submit feedback about a skill").option(
596
+ "--reaction <type>",
597
+ "thumbs_up, thumbs_down, wrong_output, missing_step",
598
+ "thumbs_down"
599
+ ).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
600
  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.");
601
+ if (!cfg.token) {
602
+ console.error("Not authenticated. Run `headways login` first.");
695
603
  process.exit(1);
696
604
  }
697
- const state = readSyncState();
698
- if (!state.device_id || !state.device_token) {
699
- console.error("Device not registered. Run: headways sync start");
605
+ const runId = opts.runId ?? process.env["HEADWAYS_RUN_ID"];
606
+ try {
607
+ await rawRequest(`/v1/skills/${slug}/feedback?source=runtime_cli`, cfg.token, {
608
+ method: "POST",
609
+ body: JSON.stringify({ reaction: opts.reaction, note: opts.note, runId })
610
+ });
611
+ console.log(`Feedback submitted for ${slug}.`);
612
+ } catch (err) {
613
+ console.error(`Error: ${String(err)}`);
700
614
  process.exit(1);
701
615
  }
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
616
  });
709
617
  }
710
618
 
711
- // src/commands/feedback.ts
712
- 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
- );
738
- }
739
-
740
619
  // src/sdk/emit.ts
741
620
  import "commander";
742
621
  function registerEmitCommand(program2) {
@@ -777,11 +656,107 @@ function registerEmitCommand(program2) {
777
656
  );
778
657
  }
779
658
 
659
+ // src/commands/prime.ts
660
+ import { existsSync, readdirSync, readFileSync } from "fs";
661
+ import "commander";
662
+ function registerPrimeCommand(program2) {
663
+ program2.command("prime").description("Output Headways workflow context for AI coding assistants").action(() => {
664
+ const cfg = readConfig();
665
+ const skills = getInstalledSkills();
666
+ const lines = [
667
+ "# Headways \u2014 AI Skill Manager",
668
+ "",
669
+ "Headways distributes versioned skills (system prompts + context files) to AI coding agents.",
670
+ "Skills live in `~/.claude/skills/<slug>/` and are loaded automatically by Claude Code hooks.",
671
+ "",
672
+ "> **For AI agents:** Before acting on any Headways task, run the relevant command with",
673
+ "> `--help` to discover full options, or run `headways skills guide` before authoring a skill.",
674
+ "> Do not guess at flags or constraints \u2014 discover them at runtime.",
675
+ "",
676
+ "## Auth & Config",
677
+ "",
678
+ `Status: ${cfg.token ? `Signed in (org: ${cfg.orgSlug ?? cfg.orgId ?? "unknown"})` : "Not signed in \u2014 run `headways login`"}`,
679
+ `Config: ~/.headways/config.json`,
680
+ "",
681
+ "## Key Commands",
682
+ "",
683
+ "```bash",
684
+ "headways login # Browser SSO sign-in",
685
+ "headways logout # Remove stored credentials",
686
+ "headways config status # Show saved key, org, URLs",
687
+ "headways config clear # Clear credentials + reset setup state",
688
+ "",
689
+ "headways sync start # Pull catalog updates once",
690
+ "headways sync start --daemon # Poll every 60s in background",
691
+ "headways sync status # Show pending skill updates",
692
+ "",
693
+ "headways skills list # List skills in your org",
694
+ "headways skills new # Scaffold a new skill",
695
+ "headways skills import <path> # Create a new skill from a local file or directory",
696
+ "headways skills push <slug> # Push edits to an existing skill (import or new first)",
697
+ "headways skills accept <slug> # Install a pending skill update",
698
+ "headways skills feedback <slug> # Submit feedback on a skill",
699
+ "headways skills guide # Authoring reference (run before creating a skill)",
700
+ "",
701
+ "headways setup claude # Install Claude Code hooks (SessionStart + PreCompact)",
702
+ "headways prime # Print this context (used by hooks)",
703
+ "```",
704
+ "",
705
+ "## Workflow",
706
+ "",
707
+ "1. `headways sync start` \u2014 pull the latest catalog from your org",
708
+ "2. `headways accept <skill>` \u2014 install a skill locally",
709
+ "3. Skills are automatically available to Claude Code via `~/.claude/skills/<slug>/`",
710
+ "4. Run `headways sync start --daemon` to keep skills up to date in the background",
711
+ "",
712
+ "## Installed Skills",
713
+ ""
714
+ ];
715
+ if (skills.length === 0) {
716
+ lines.push(
717
+ "No skills installed. Run `headways sync start` then `headways accept <skill>`."
718
+ );
719
+ } else {
720
+ for (const skill of skills) {
721
+ const runLine = skill.lastRunAt ? `last run ${new Date(skill.lastRunAt).toLocaleDateString()}` : "never run";
722
+ lines.push(`- **${skill.slug}** v${skill.version} (${skill.runtime}, ${runLine})`);
723
+ }
724
+ }
725
+ lines.push("", "## Skill Files", "");
726
+ lines.push("Installed skill bundles land in `~/.claude/skills/<slug>/`.");
727
+ lines.push("Claude Code automatically discovers them via the skills directory.");
728
+ console.log(lines.join("\n"));
729
+ });
730
+ }
731
+ function getInstalledSkills() {
732
+ if (!existsSync(INSTALLED_DIR)) return [];
733
+ try {
734
+ return readdirSync(INSTALLED_DIR).filter((f) => f.endsWith(".json")).map((f) => {
735
+ const slug = f.replace(/\.json$/, "");
736
+ try {
737
+ const raw = JSON.parse(readFileSync(`${INSTALLED_DIR}/${f}`, "utf8"));
738
+ return {
739
+ slug,
740
+ version: String(raw.version ?? ""),
741
+ runtime: String(raw.runtime ?? "claude-code"),
742
+ lastRunAt: raw.last_run_at ?? null
743
+ };
744
+ } catch {
745
+ return null;
746
+ }
747
+ }).filter((s) => s !== null);
748
+ } catch {
749
+ return [];
750
+ }
751
+ }
752
+
780
753
  // src/index.ts
781
- program.name("headways").description("Headways CLI \u2014 skill authoring, sync, and runtime SDK").version("0.1.0");
754
+ program.name("headways").description("Headways CLI \u2014 skill authoring, sync, and runtime SDK").version("0.2.1");
782
755
  registerAuthCommands(program);
783
756
  registerSkillsCommands(program);
784
757
  registerSyncCommands(program);
785
- registerFeedbackCommand(program);
786
758
  registerEmitCommand(program);
759
+ registerPrimeCommand(program);
760
+ registerSetupCommand(program);
761
+ registerUninstallCommand(program);
787
762
  program.parse();