@headways/cli 0.2.0 → 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Headways
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -2,8 +2,8 @@
2
2
  import {
3
3
  apiRequest,
4
4
  rawRequest
5
- } from "./chunk-QXOLSB3Q.js";
6
- import "./chunk-VLKLEV4U.js";
5
+ } from "./chunk-HYEL7L5Z.js";
6
+ import "./chunk-T2H7EXOV.js";
7
7
  export {
8
8
  apiRequest,
9
9
  rawRequest
@@ -2,7 +2,7 @@
2
2
  import {
3
3
  getApiUrl,
4
4
  requireAuth
5
- } from "./chunk-VLKLEV4U.js";
5
+ } from "./chunk-T2H7EXOV.js";
6
6
 
7
7
  // src/lib/api.ts
8
8
  async function rawRequest(path, token, options = {}, apiUrl) {
@@ -0,0 +1,411 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ HEADWAYS_DIR,
4
+ getApiUrl,
5
+ readConfig
6
+ } from "./chunk-T2H7EXOV.js";
7
+
8
+ // src/commands/sync/index.ts
9
+ import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2, renameSync, rmSync as rmSync2 } from "fs";
10
+ import { homedir as homedir2 } from "os";
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
+ const skillsDir = join(homedir(), ".claude", "skills");
57
+ if (existsSync(skillsDir)) {
58
+ rmSync(skillsDir, { recursive: true, force: true });
59
+ console.log("\u2713 Removed ~/.claude/skills/");
60
+ }
61
+ const headwaysDir = join(homedir(), ".headways");
62
+ if (existsSync(headwaysDir)) {
63
+ rmSync(headwaysDir, { recursive: true, force: true });
64
+ console.log("\u2713 Removed ~/.headways/");
65
+ }
66
+ console.log(
67
+ "\nHeadways data removed. You can now delete the Headways.app from /Applications."
68
+ );
69
+ });
70
+ }
71
+ function registerSetupCommand(program) {
72
+ const setup = program.command("setup").description("Install Headways integration for AI coding assistants");
73
+ 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) => {
74
+ const settingsPath = opts.project ? CLAUDE_SETTINGS_LOCAL : CLAUDE_SETTINGS_GLOBAL;
75
+ if (opts.check) {
76
+ const installed = isInstalled(settingsPath);
77
+ console.log(
78
+ installed ? `\u2713 Installed: ${settingsPath}` : `\u2717 Not installed: ${settingsPath}`
79
+ );
80
+ process.exit(installed ? 0 : 1);
81
+ }
82
+ if (opts.remove) {
83
+ removeHooks(settingsPath);
84
+ console.log(`Removed Headways hooks from ${settingsPath}`);
85
+ return;
86
+ }
87
+ installHooks(settingsPath, opts.project ?? false);
88
+ updateClaudeMd(opts.project ?? false);
89
+ });
90
+ }
91
+ function ensureClaudeHooks() {
92
+ if (!isInstalled(CLAUDE_SETTINGS_GLOBAL)) {
93
+ installHooks(CLAUDE_SETTINGS_GLOBAL, false, true);
94
+ }
95
+ }
96
+ function readSettings(path) {
97
+ if (!existsSync(path)) return {};
98
+ try {
99
+ return JSON.parse(readFileSync(path, "utf8"));
100
+ } catch {
101
+ return {};
102
+ }
103
+ }
104
+ function writeSettings(path, settings) {
105
+ mkdirSync(dirname(path), { recursive: true });
106
+ writeFileSync(path, JSON.stringify(settings, null, 2) + "\n");
107
+ }
108
+ function isInstalled(settingsPath) {
109
+ const s = readSettings(settingsPath);
110
+ const hooks = s["hooks"];
111
+ if (!hooks) return false;
112
+ return ["SessionStart", "PreCompact"].every(
113
+ (event) => hooks[event]?.some(
114
+ (entry) => entry.hooks?.some((h) => h.command === PRIME_HOOK.command)
115
+ )
116
+ );
117
+ }
118
+ function installHooks(settingsPath, isProject, silent = false) {
119
+ const settings = readSettings(settingsPath);
120
+ const hooks = settings["hooks"] ?? {};
121
+ for (const event of ["SessionStart", "PreCompact"]) {
122
+ const entries = hooks[event] ?? [];
123
+ for (const entry of entries) {
124
+ entry.hooks = entry.hooks.filter((h) => h.command !== PRIME_HOOK.command);
125
+ }
126
+ const mainEntry = entries.find((e) => e.matcher === "" || e.matcher === void 0);
127
+ if (mainEntry) {
128
+ mainEntry.hooks.push(PRIME_HOOK);
129
+ } else {
130
+ entries.unshift({ matcher: "", hooks: [PRIME_HOOK] });
131
+ }
132
+ hooks[event] = entries;
133
+ }
134
+ settings["hooks"] = hooks;
135
+ writeSettings(settingsPath, settings);
136
+ if (!silent) {
137
+ console.log(`\u2713 Hooks installed: ${settingsPath}`);
138
+ if (!isProject) {
139
+ console.log(" SessionStart + PreCompact \u2192 headways prime");
140
+ }
141
+ }
142
+ }
143
+ function removeHooks(settingsPath) {
144
+ if (!existsSync(settingsPath)) return;
145
+ const settings = readSettings(settingsPath);
146
+ const hooks = settings["hooks"] ?? {};
147
+ for (const event of ["SessionStart", "PreCompact"]) {
148
+ if (hooks[event]) {
149
+ for (const entry of hooks[event]) {
150
+ entry.hooks = entry.hooks.filter((h) => h.command !== PRIME_HOOK.command);
151
+ }
152
+ }
153
+ }
154
+ settings["hooks"] = hooks;
155
+ writeSettings(settingsPath, settings);
156
+ }
157
+ function updateClaudeMd(isProject) {
158
+ const claudeMdPath = isProject ? "CLAUDE.md" : join(homedir(), "CLAUDE.md");
159
+ if (!existsSync(claudeMdPath)) return;
160
+ const content = readFileSync(claudeMdPath, "utf8");
161
+ if (content.includes("<!-- BEGIN HEADWAYS -->")) return;
162
+ writeFileSync(claudeMdPath, content.trimEnd() + "\n\n" + CLAUDE_MD_SECTION);
163
+ console.log(`\u2713 Updated ${claudeMdPath}`);
164
+ }
165
+
166
+ // src/commands/sync/index.ts
167
+ var PENDING_FILE = join2(HEADWAYS_DIR, "pending.json");
168
+ var SYNC_STATE_FILE = join2(HEADWAYS_DIR, "sync-state.json");
169
+ var INSTALLED_DIR = join2(HEADWAYS_DIR, "installed");
170
+ function readSyncState() {
171
+ if (!existsSync2(SYNC_STATE_FILE)) return {};
172
+ try {
173
+ return JSON.parse(readFileSync2(SYNC_STATE_FILE, "utf8"));
174
+ } catch {
175
+ return {};
176
+ }
177
+ }
178
+ function writeSyncState(state) {
179
+ if (!existsSync2(HEADWAYS_DIR)) mkdirSync2(HEADWAYS_DIR, { recursive: true });
180
+ writeFileSync2(SYNC_STATE_FILE, JSON.stringify(state, null, 2) + "\n");
181
+ }
182
+ function readPending() {
183
+ if (!existsSync2(PENDING_FILE)) return [];
184
+ try {
185
+ return JSON.parse(readFileSync2(PENDING_FILE, "utf8"));
186
+ } catch {
187
+ return [];
188
+ }
189
+ }
190
+ function writePending(updates) {
191
+ if (!existsSync2(HEADWAYS_DIR)) mkdirSync2(HEADWAYS_DIR, { recursive: true });
192
+ writeFileSync2(PENDING_FILE, JSON.stringify(updates, null, 2) + "\n");
193
+ }
194
+ function deviceHeaders(state) {
195
+ return {
196
+ Authorization: `Bearer ${state.device_token ?? ""}`,
197
+ "x-headways-device-id": state.device_id ?? "",
198
+ "x-headways-timestamp": String(Math.floor(Date.now() / 1e3))
199
+ };
200
+ }
201
+ async function registerDevice(token, orgId, apiUrl) {
202
+ const existingId = readSyncState().device_id;
203
+ const pubKey = existingId ? Buffer.from(`desktop-${existingId}`).toString("base64url") : Buffer.from(`desktop-${Date.now()}-${Math.random()}`).toString("base64url");
204
+ const res = await fetch(`${apiUrl}/v1/sync/devices/register`, {
205
+ method: "POST",
206
+ headers: {
207
+ "Content-Type": "application/json",
208
+ Authorization: `Bearer ${token}`,
209
+ "x-headways-org-id": orgId
210
+ },
211
+ body: JSON.stringify({
212
+ publicKey: pubKey,
213
+ platform: process.platform === "darwin" ? "macos" : process.platform === "win32" ? "windows" : "linux",
214
+ hostname: (await import("os")).hostname()
215
+ })
216
+ });
217
+ if (!res.ok) throw new Error(`Device registration failed: ${res.status}`);
218
+ const data = await res.json();
219
+ return { device_id: data.deviceId, device_token: data.deviceToken };
220
+ }
221
+ async function pollCatalog(state, apiUrl) {
222
+ const url = state.etag ? `${apiUrl}/v1/sync/catalog?since=${encodeURIComponent(state.etag)}` : `${apiUrl}/v1/sync/catalog`;
223
+ const res = await fetch(url, { headers: deviceHeaders(state) });
224
+ if (res.status === 304) return null;
225
+ if (!res.ok) throw new Error(`Catalog poll failed: ${res.status}`);
226
+ return res.json();
227
+ }
228
+ async function downloadAndMaterialize(slug, version, state, apiUrl) {
229
+ const res = await fetch(`${apiUrl}/v1/sync/bundles/${slug}/${version}`, {
230
+ redirect: "follow",
231
+ headers: deviceHeaders(state)
232
+ });
233
+ if (!res.ok) throw new Error(`Bundle fetch failed: ${res.status}`);
234
+ const buf = Buffer.from(await res.arrayBuffer());
235
+ const skillsDir = join2(homedir2(), ".claude", "skills");
236
+ const dest = join2(skillsDir, slug);
237
+ const staging = join2(skillsDir, `.${slug}-staging`);
238
+ mkdirSync2(staging, { recursive: true });
239
+ await extractTarGz(buf, staging);
240
+ if (existsSync2(dest)) rmSync2(dest, { recursive: true });
241
+ renameSync(staging, dest);
242
+ mkdirSync2(INSTALLED_DIR, { recursive: true });
243
+ writeFileSync2(
244
+ join2(INSTALLED_DIR, `${slug}.json`),
245
+ JSON.stringify(
246
+ { slug, version, runtime: "claude-code", installed_at: (/* @__PURE__ */ new Date()).toISOString() },
247
+ null,
248
+ 2
249
+ )
250
+ );
251
+ console.log(`Materialized ${slug}@${version} \u2192 ${dest}`);
252
+ }
253
+ async function extractTarGz(buf, destDir) {
254
+ const decompressed = await new Promise((resolve, reject) => {
255
+ const chunks = [];
256
+ const gunzip = createGunzip();
257
+ const src = Readable.from(buf);
258
+ src.pipe(gunzip);
259
+ gunzip.on("data", (chunk) => chunks.push(chunk));
260
+ gunzip.on("end", () => resolve(Buffer.concat(chunks)));
261
+ gunzip.on("error", reject);
262
+ });
263
+ let offset = 0;
264
+ const { writeFileSync: wf, mkdirSync: md } = await import("fs");
265
+ const { dirname: dirname2 } = await import("path");
266
+ while (offset + 512 <= decompressed.length) {
267
+ const header = decompressed.slice(offset, offset + 512);
268
+ const name = header.slice(0, 100).toString("utf8").replace(/\0/g, "").trim();
269
+ if (!name) break;
270
+ const sizeOctal = header.slice(124, 136).toString("utf8").replace(/\0/g, "").trim();
271
+ const size = parseInt(sizeOctal, 8) || 0;
272
+ const typeFlag = header[156];
273
+ offset += 512;
274
+ if (typeFlag === 53 || name.endsWith("/")) {
275
+ md(join2(destDir, name), { recursive: true });
276
+ } else if (typeFlag === 0 || typeFlag === 48 || typeFlag === void 0) {
277
+ const filePath = join2(destDir, name);
278
+ md(dirname2(filePath), { recursive: true });
279
+ wf(filePath, decompressed.slice(offset, offset + size));
280
+ }
281
+ offset += Math.ceil(size / 512) * 512;
282
+ }
283
+ }
284
+ function registerSyncCommands(program) {
285
+ const sync = program.command("sync").description("Sync skills from Headways to local Claude Code");
286
+ 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) => {
287
+ const cfg = readConfig();
288
+ if (!cfg.token || !cfg.orgId) {
289
+ console.error("Not logged in. Run: headways login");
290
+ process.exit(1);
291
+ }
292
+ const apiUrl = getApiUrl();
293
+ let state = readSyncState();
294
+ if (!state.device_id || !state.device_token) {
295
+ console.log("Registering device with Headways\u2026");
296
+ try {
297
+ const deviceState = await registerDevice(cfg.token, cfg.orgId, apiUrl);
298
+ state = { ...state, ...deviceState };
299
+ writeSyncState(state);
300
+ console.log(`Device registered: ${state.device_id}`);
301
+ } catch (err) {
302
+ console.error(`Registration failed: ${err instanceof Error ? err.message : String(err)}`);
303
+ process.exit(1);
304
+ }
305
+ }
306
+ const doPoll = async () => {
307
+ try {
308
+ ensureClaudeHooks();
309
+ const delta = await pollCatalog(state, apiUrl);
310
+ if (!delta) {
311
+ console.log("Catalog up to date.");
312
+ return;
313
+ }
314
+ state.etag = delta.etag;
315
+ state.last_poll = (/* @__PURE__ */ new Date()).toISOString();
316
+ writeSyncState(state);
317
+ const pendingMap = new Map(readPending().map((p) => [p.slug, p]));
318
+ for (const ev of delta.events) {
319
+ if (ev.kind === "version_published") {
320
+ if (ev.channel === "auto") {
321
+ console.log(`Auto-installing: ${ev.skill_slug}@${ev.version}`);
322
+ await downloadAndMaterialize(ev.skill_slug, ev.version, state, apiUrl);
323
+ pendingMap.delete(ev.skill_slug);
324
+ } else {
325
+ pendingMap.set(ev.skill_slug, {
326
+ slug: ev.skill_slug,
327
+ version: ev.version,
328
+ user_visible_change: ev.user_visible_change,
329
+ channel: ev.channel,
330
+ capabilities_delta_empty: ev.capabilities_delta_empty
331
+ });
332
+ console.log(
333
+ `Queued: ${ev.skill_slug}@${ev.version}${ev.user_visible_change ? ` \u2014 ${ev.user_visible_change}` : ""}`
334
+ );
335
+ }
336
+ } else if (ev.kind === "skill_archived" || ev.kind === "entitlement_revoked") {
337
+ pendingMap.delete(ev.skill_slug);
338
+ console.log(`Removed: ${ev.skill_slug}`);
339
+ }
340
+ }
341
+ writePending([...pendingMap.values()]);
342
+ console.log(`Synced. ETag: ${delta.etag}`);
343
+ } catch (err) {
344
+ console.error(`Poll error: ${err instanceof Error ? err.message : String(err)}`);
345
+ }
346
+ };
347
+ await doPoll();
348
+ if (opts.daemon) {
349
+ console.log("Running sync daemon (60s interval). Press Ctrl-C to stop.");
350
+ setInterval(doPoll, 6e4);
351
+ }
352
+ });
353
+ sync.command("status").description("Show current sync status and pending updates").action(() => {
354
+ const state = readSyncState();
355
+ const pending = readPending();
356
+ if (!state.device_id) {
357
+ console.log("Device not registered. Run: headways sync start");
358
+ return;
359
+ }
360
+ console.log(`Device ID : ${state.device_id}`);
361
+ console.log(`Last poll : ${state.last_poll ?? "never"}`);
362
+ console.log(`Catalog ETag: ${state.etag ?? "none"}`);
363
+ if (pending.length === 0) {
364
+ console.log("\nAll skills up to date. No pending updates.");
365
+ } else {
366
+ console.log(`
367
+ Pending updates (${pending.length}):`);
368
+ for (const p of pending) {
369
+ const change = p.user_visible_change ? ` \u2014 ${p.user_visible_change}` : "";
370
+ const caps = p.capabilities_delta_empty ? "" : " [CAPS CHANGED]";
371
+ console.log(` ${p.slug}@${p.version}${change}${caps}`);
372
+ }
373
+ console.log("\nRun `headways skills accept <skill>` to install.");
374
+ }
375
+ });
376
+ }
377
+ async function acceptSkill(skillSlug) {
378
+ const cfg = readConfig();
379
+ if (!cfg.token || !cfg.orgId) {
380
+ console.error("Not logged in. Run: headways login");
381
+ process.exit(1);
382
+ }
383
+ const pending = readPending();
384
+ const update = pending.find((p) => p.slug === skillSlug);
385
+ if (!update) {
386
+ console.error(`No pending update for skill: ${skillSlug}`);
387
+ console.log("Run `headways sync status` to see pending updates.");
388
+ process.exit(1);
389
+ }
390
+ const state = readSyncState();
391
+ if (!state.device_id || !state.device_token) {
392
+ console.error("Device not registered. Run: headways sync start");
393
+ process.exit(1);
394
+ }
395
+ console.log(`Accepting ${skillSlug}@${update.version}\u2026`);
396
+ await downloadAndMaterialize(skillSlug, update.version, state, getApiUrl());
397
+ writePending(pending.filter((p) => p.slug !== skillSlug));
398
+ console.log(
399
+ `${skillSlug} is ready \u2014 invoke it in Claude Code with the skill's invocation phrase.`
400
+ );
401
+ }
402
+
403
+ export {
404
+ registerUninstallCommand,
405
+ registerSetupCommand,
406
+ readSyncState,
407
+ writeSyncState,
408
+ registerDevice,
409
+ registerSyncCommands,
410
+ acceptSkill
411
+ };
@@ -24,10 +24,14 @@ function getApiUrl() {
24
24
  const cfg = readConfig();
25
25
  return cfg.apiUrl ?? process.env["HEADWAYS_API_URL"] ?? "https://api.headways.ai";
26
26
  }
27
+ function getAppUrl() {
28
+ const cfg = readConfig();
29
+ return cfg.appUrl ?? process.env["HEADWAYS_APP_URL"] ?? "https://app.headways.ai";
30
+ }
27
31
  function requireAuth() {
28
32
  const cfg = readConfig();
29
33
  if (!cfg.token || !cfg.orgId) {
30
- console.error("No API key configured. Run: headways configure");
34
+ console.error("No API key configured. Run: headways config");
31
35
  process.exit(1);
32
36
  }
33
37
  return { token: cfg.token, orgId: cfg.orgId };
@@ -41,5 +45,6 @@ export {
41
45
  readConfig,
42
46
  writeConfig,
43
47
  getApiUrl,
48
+ getAppUrl,
44
49
  requireAuth
45
50
  };
@@ -5,16 +5,18 @@ import {
5
5
  HEADWAYS_DIR,
6
6
  INSTALLED_DIR,
7
7
  getApiUrl,
8
+ getAppUrl,
8
9
  readConfig,
9
10
  requireAuth,
10
11
  writeConfig
11
- } from "./chunk-VLKLEV4U.js";
12
+ } from "./chunk-T2H7EXOV.js";
12
13
  export {
13
14
  CATALOG_FILE,
14
15
  CONFIG_FILE,
15
16
  HEADWAYS_DIR,
16
17
  INSTALLED_DIR,
17
18
  getApiUrl,
19
+ getAppUrl,
18
20
  readConfig,
19
21
  requireAuth,
20
22
  writeConfig