@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/LICENSE +21 -0
- package/dist/{api-WUIL5TMR.js → api-2BK6MGZB.js} +2 -2
- package/dist/{chunk-QXOLSB3Q.js → chunk-HYEL7L5Z.js} +1 -1
- package/dist/chunk-OZULVVQC.js +411 -0
- package/dist/{chunk-VLKLEV4U.js → chunk-T2H7EXOV.js} +6 -1
- package/dist/{config-GRE3MIQL.js → config-SHMIVRAP.js} +3 -1
- package/dist/index.js +484 -413
- package/dist/sync-6PKI35ZY.js +16 -0
- package/package.json +3 -1
package/dist/index.js
CHANGED
|
@@ -2,14 +2,20 @@
|
|
|
2
2
|
import {
|
|
3
3
|
apiRequest,
|
|
4
4
|
rawRequest
|
|
5
|
-
} from "./chunk-
|
|
5
|
+
} from "./chunk-HYEL7L5Z.js";
|
|
6
6
|
import {
|
|
7
|
-
|
|
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-
|
|
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.
|
|
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("
|
|
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(
|
|
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
|
-
|
|
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("
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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-
|
|
457
|
-
const { apiRequest: apiRequest2 } = await import("./api-
|
|
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
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
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
|
-
|
|
650
|
-
console.log(
|
|
651
|
-
|
|
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
|
-
|
|
660
|
+
} catch {
|
|
682
661
|
}
|
|
683
662
|
});
|
|
684
|
-
|
|
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
|
|
687
|
-
console.error("Not
|
|
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
|
|
698
|
-
|
|
699
|
-
|
|
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/
|
|
687
|
+
// src/commands/connections/index.ts
|
|
712
688
|
import "commander";
|
|
713
|
-
function
|
|
714
|
-
program2.command("
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
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
|
|
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();
|