@launchsecure/launch-kit 0.0.27 → 0.0.29

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.
Files changed (48) hide show
  1. package/dist/beacon/beacon.mjs +1003 -440
  2. package/dist/beacon/beacon.mjs.map +1 -1
  3. package/dist/beacon/beacon.umd.js +45 -24
  4. package/dist/beacon/beacon.umd.js.map +1 -1
  5. package/dist/beacon/types/capture/events.d.ts +20 -0
  6. package/dist/beacon/types/capture/events.d.ts.map +1 -0
  7. package/dist/beacon/types/element.d.ts +1 -0
  8. package/dist/beacon/types/element.d.ts.map +1 -1
  9. package/dist/beacon/types/index.d.ts +2 -1
  10. package/dist/beacon/types/index.d.ts.map +1 -1
  11. package/dist/beacon/types/monitor/dom.d.ts +13 -0
  12. package/dist/beacon/types/monitor/dom.d.ts.map +1 -0
  13. package/dist/beacon/types/monitor/index.d.ts +19 -0
  14. package/dist/beacon/types/monitor/index.d.ts.map +1 -0
  15. package/dist/beacon/types/monitor/network.d.ts +12 -0
  16. package/dist/beacon/types/monitor/network.d.ts.map +1 -0
  17. package/dist/beacon/types/monitor/transport.d.ts +27 -0
  18. package/dist/beacon/types/monitor/transport.d.ts.map +1 -0
  19. package/dist/beacon/types/monitor/types.d.ts +117 -0
  20. package/dist/beacon/types/monitor/types.d.ts.map +1 -0
  21. package/dist/beacon/types/types.d.ts +10 -0
  22. package/dist/beacon/types/types.d.ts.map +1 -1
  23. package/dist/beacon/types/ui/drawer.d.ts +3 -1
  24. package/dist/beacon/types/ui/drawer.d.ts.map +1 -1
  25. package/dist/beacon/types/ui/monitor-panel.d.ts +19 -0
  26. package/dist/beacon/types/ui/monitor-panel.d.ts.map +1 -0
  27. package/dist/server/beacon-monitor-entry.js +353 -0
  28. package/dist/server/chart-serve.js +3 -1
  29. package/dist/server/cli.js +276 -218
  30. package/dist/server/course-entry.js +246 -0
  31. package/dist/server/graph-mcp-entry.js +35 -72
  32. package/dist/server/init-entry.js +1051 -122
  33. package/dist/server/orbit-entry.js +187 -24
  34. package/package.json +5 -3
  35. package/scaffolds/ls-marketplace/.claude-plugin/marketplace.json +15 -0
  36. package/scaffolds/ls-marketplace/plugins/kit/.claude-plugin/plugin.json +19 -0
  37. package/scaffolds/ls-marketplace/plugins/kit/commands/activate-beacon.md +216 -0
  38. package/scaffolds/ls-marketplace/plugins/kit/commands/activate-statusline.md +46 -0
  39. package/scaffolds/ls-marketplace/plugins/kit/commands/beacon-array.md +92 -0
  40. package/scaffolds/ls-marketplace/plugins/kit/commands/beacon-clear.md +68 -0
  41. package/scaffolds/ls-marketplace/plugins/kit/commands/beacon-pulse.md +80 -0
  42. package/scaffolds/ls-marketplace/plugins/kit/commands/beacon-scan.md +62 -0
  43. package/scaffolds/ls-marketplace/plugins/kit/commands/deactivate-statusline.md +34 -0
  44. package/scaffolds/ls-marketplace/plugins/kit/commands/show-mcp-status.md +109 -0
  45. package/scaffolds/ls-marketplace/plugins/kit/commands/standup.md +191 -0
  46. package/scaffolds/recall-hook/scripts/ensure-recall.sh +69 -0
  47. package/scaffolds/statusline/statusline-mcp.sh +192 -0
  48. package/scaffolds/statusline/statusline-wrapper.sh +50 -0
@@ -6,6 +6,13 @@ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
6
6
  var __getOwnPropNames = Object.getOwnPropertyNames;
7
7
  var __getProtoOf = Object.getPrototypeOf;
8
8
  var __hasOwnProp = Object.prototype.hasOwnProperty;
9
+ var __esm = (fn, res) => function __init() {
10
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
11
+ };
12
+ var __export = (target, all) => {
13
+ for (var name in all)
14
+ __defProp(target, name, { get: all[name], enumerable: true });
15
+ };
9
16
  var __copyProps = (to, from, except, desc) => {
10
17
  if (from && typeof from === "object" || typeof from === "function") {
11
18
  for (let key of __getOwnPropNames(from))
@@ -23,20 +30,273 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
23
30
  mod
24
31
  ));
25
32
 
33
+ // src/server/statusline-install.ts
34
+ var statusline_install_exports = {};
35
+ __export(statusline_install_exports, {
36
+ activateStatusline: () => activateStatusline,
37
+ deactivateStatusline: () => deactivateStatusline
38
+ });
39
+ function readSettings() {
40
+ if (!fs3.existsSync(SETTINGS_PATH)) return null;
41
+ try {
42
+ return JSON.parse(fs3.readFileSync(SETTINGS_PATH, "utf-8"));
43
+ } catch {
44
+ return null;
45
+ }
46
+ }
47
+ function writeSettings(s) {
48
+ fs3.mkdirSync(path3.dirname(SETTINGS_PATH), { recursive: true });
49
+ fs3.writeFileSync(SETTINGS_PATH, JSON.stringify(s, null, 2) + "\n", "utf-8");
50
+ }
51
+ function readScaffold(name) {
52
+ const p = path3.resolve(__dirname, "..", "..", "scaffolds", "statusline", name);
53
+ return fs3.readFileSync(p, "utf-8");
54
+ }
55
+ function writeScripts() {
56
+ fs3.mkdirSync(LK_DIR, { recursive: true });
57
+ fs3.writeFileSync(WRAPPER_PATH, readScaffold("statusline-wrapper.sh"), { mode: 493 });
58
+ fs3.writeFileSync(CHIP_PATH, readScaffold("statusline-mcp.sh"), { mode: 493 });
59
+ }
60
+ function wrapperCommand(opts) {
61
+ const env = [];
62
+ if (opts.show) env.push(`LK_STATUSLINE_SHOW=${opts.show}`);
63
+ if (opts.compact) env.push(`LK_STATUSLINE_COMPACT=1`);
64
+ const prefix = env.length > 0 ? env.join(" ") + " " : "";
65
+ return `${prefix}bash ${WRAPPER_PATH}`;
66
+ }
67
+ function activateStatusline(opts = {}) {
68
+ const settings = readSettings();
69
+ if (!settings) {
70
+ return { ok: false, outcome: "no-settings", message: `no ~/.claude/settings.json \u2014 nothing to wrap` };
71
+ }
72
+ const currentCmd = settings.statusLine?.command;
73
+ const alreadyWrapped = typeof currentCmd === "string" && currentCmd.includes(WRAPPER_PATH);
74
+ if (alreadyWrapped) {
75
+ writeScripts();
76
+ if (opts.show !== void 0 || opts.compact !== void 0) {
77
+ const updated = {
78
+ ...settings,
79
+ statusLine: { type: "command", command: wrapperCommand(opts) }
80
+ };
81
+ writeSettings(updated);
82
+ const parts = [];
83
+ if (opts.show) parts.push(`--show=${opts.show}`);
84
+ if (opts.compact) parts.push("--compact");
85
+ const desc = parts.length > 0 ? parts.join(" ") : "default (all chips, verbose)";
86
+ return { ok: true, outcome: "refreshed", message: `refreshed chip scripts and updated mode: ${desc}` };
87
+ }
88
+ return { ok: true, outcome: "refreshed", message: "statusline already wrapped \u2014 refreshed chip scripts only" };
89
+ }
90
+ if (!currentCmd) {
91
+ return { ok: false, outcome: "no-statusline", message: "no statusLine.command in ~/.claude/settings.json \u2014 launch-kit only extends an existing statusline" };
92
+ }
93
+ writeScripts();
94
+ const wrapped = {
95
+ ...settings,
96
+ statusLine: { type: "command", command: wrapperCommand(opts) },
97
+ [ORIGINAL_KEY]: settings.statusLine
98
+ };
99
+ writeSettings(wrapped);
100
+ const modeParts = [];
101
+ if (opts.show) modeParts.push(`chips: ${opts.show}`);
102
+ if (opts.compact) modeParts.push("compact mode");
103
+ const modeDesc = modeParts.length > 0 ? ` (${modeParts.join(", ")})` : "";
104
+ return { ok: true, outcome: "activated", message: `wrapped statusLine.command${modeDesc}; original stashed under ${ORIGINAL_KEY}` };
105
+ }
106
+ function deactivateStatusline() {
107
+ const settings = readSettings();
108
+ if (!settings) return { ok: false, outcome: "no-settings", message: "no ~/.claude/settings.json" };
109
+ const original = settings[ORIGINAL_KEY];
110
+ if (!original) {
111
+ return { ok: false, outcome: "not-active", message: `no ${ORIGINAL_KEY} in settings.json \u2014 statusline isn't wrapped by launch-kit` };
112
+ }
113
+ const restored = { ...settings, statusLine: original };
114
+ delete restored[ORIGINAL_KEY];
115
+ writeSettings(restored);
116
+ for (const p of [WRAPPER_PATH, CHIP_PATH]) {
117
+ try {
118
+ fs3.unlinkSync(p);
119
+ } catch {
120
+ }
121
+ }
122
+ return { ok: true, outcome: "deactivated", message: "restored original statusLine.command" };
123
+ }
124
+ var fs3, path3, import_node_os, LK_DIR, WRAPPER_PATH, CHIP_PATH, SETTINGS_PATH, ORIGINAL_KEY;
125
+ var init_statusline_install = __esm({
126
+ "src/server/statusline-install.ts"() {
127
+ "use strict";
128
+ fs3 = __toESM(require("node:fs"));
129
+ path3 = __toESM(require("node:path"));
130
+ import_node_os = require("node:os");
131
+ LK_DIR = path3.join((0, import_node_os.homedir)(), ".launchsecure");
132
+ WRAPPER_PATH = path3.join(LK_DIR, "statusline-wrapper.sh");
133
+ CHIP_PATH = path3.join(LK_DIR, "statusline-mcp.sh");
134
+ SETTINGS_PATH = path3.join((0, import_node_os.homedir)(), ".claude", "settings.json");
135
+ ORIGINAL_KEY = "_launchKitStatuslineOriginal";
136
+ }
137
+ });
138
+
26
139
  // src/server/init-entry.ts
27
140
  var import_node_child_process = require("node:child_process");
28
- var fs = __toESM(require("node:fs"));
141
+ var crypto = __toESM(require("node:crypto"));
142
+ var fs4 = __toESM(require("node:fs"));
29
143
  var import_node_http = require("node:http");
30
144
  var import_node_https = require("node:https");
31
- var path = __toESM(require("node:path"));
145
+ var path4 = __toESM(require("node:path"));
32
146
  var readline = __toESM(require("node:readline"));
33
147
  var import_node_url = require("node:url");
34
- var DEFAULT_SERVER_URL = "https://launchsecure-v2.vercel.app";
148
+
149
+ // src/server/cred-shape.ts
150
+ var fs = __toESM(require("node:fs"));
151
+ var path = __toESM(require("node:path"));
35
152
  var CONFIG_FILENAME = ".launch-secure.cred.config";
153
+ function inferCourseName(serverUrl) {
154
+ try {
155
+ const host = new URL(serverUrl).hostname.toLowerCase();
156
+ if (host === "localhost" || host === "127.0.0.1" || host.endsWith(".local")) return "local";
157
+ if (host.includes("staging")) return "staging";
158
+ if (host.endsWith(".vercel.app")) return "prod";
159
+ return host.split(".")[0] || "default";
160
+ } catch {
161
+ return "default";
162
+ }
163
+ }
164
+ function toNested(cred) {
165
+ if (cred.profiles && cred.active && cred.profiles[cred.active]) {
166
+ return { active: cred.active, profiles: cred.profiles };
167
+ }
168
+ if (!cred.pat || !cred.orgSlug || !cred.projectSlug || !cred.serverUrl) {
169
+ return null;
170
+ }
171
+ const name = inferCourseName(cred.serverUrl);
172
+ return {
173
+ active: name,
174
+ profiles: {
175
+ [name]: {
176
+ pat: cred.pat,
177
+ orgSlug: cred.orgSlug,
178
+ projectSlug: cred.projectSlug,
179
+ serverUrl: cred.serverUrl
180
+ }
181
+ }
182
+ };
183
+ }
184
+ function upsertProfile(existing, name, profile) {
185
+ const base = existing ? toNested(existing) ?? { active: name, profiles: {} } : { active: name, profiles: {} };
186
+ return {
187
+ active: name,
188
+ profiles: { ...base.profiles, [name]: profile }
189
+ };
190
+ }
191
+ function readCredFile(repoRoot) {
192
+ const p = path.join(repoRoot, CONFIG_FILENAME);
193
+ if (!fs.existsSync(p)) return null;
194
+ try {
195
+ return JSON.parse(fs.readFileSync(p, "utf-8"));
196
+ } catch (err) {
197
+ throw new Error(`could not parse ${CONFIG_FILENAME}: ${err instanceof Error ? err.message : String(err)}`);
198
+ }
199
+ }
200
+ function writeJsonAtomic(absPath, value, mode) {
201
+ const tmp = `${absPath}.tmp`;
202
+ fs.writeFileSync(tmp, JSON.stringify(value, null, 2) + "\n", "utf-8");
203
+ if (mode !== void 0) {
204
+ try {
205
+ fs.chmodSync(tmp, mode);
206
+ } catch {
207
+ }
208
+ }
209
+ fs.renameSync(tmp, absPath);
210
+ }
211
+
212
+ // src/server/cred-recovery.ts
213
+ var fs2 = __toESM(require("node:fs"));
214
+ var path2 = __toESM(require("node:path"));
36
215
  var LEGACY_CONFIG_FILENAME = ".launch-secure.config";
216
+ function migrateLegacyCredFile(targetDir, opts) {
217
+ const legacy = path2.join(targetDir, LEGACY_CONFIG_FILENAME);
218
+ const dest = path2.join(targetDir, CONFIG_FILENAME);
219
+ if (!fs2.existsSync(legacy) || fs2.existsSync(dest)) return;
220
+ let parsed;
221
+ try {
222
+ parsed = JSON.parse(fs2.readFileSync(legacy, "utf-8"));
223
+ } catch {
224
+ return;
225
+ }
226
+ const pat = parsed?.pat;
227
+ if (typeof pat !== "string" || !pat.startsWith("ls_pat_")) return;
228
+ if (opts.dryRun) {
229
+ opts.log.dryNote(`would migrate legacy ${LEGACY_CONFIG_FILENAME} \u2192 ${CONFIG_FILENAME} and strip the legacy gitignore line`);
230
+ return;
231
+ }
232
+ fs2.renameSync(legacy, dest);
233
+ removeGitignoreLine(targetDir, LEGACY_CONFIG_FILENAME, opts);
234
+ opts.log.ok(`migrated legacy ${LEGACY_CONFIG_FILENAME} \u2192 ${CONFIG_FILENAME} (the old name is now reserved for file-backed-config)`);
235
+ }
236
+ function removeGitignoreLine(targetDir, line, opts) {
237
+ const p = path2.join(targetDir, ".gitignore");
238
+ if (!fs2.existsSync(p)) return;
239
+ const before = fs2.readFileSync(p, "utf-8");
240
+ const after = before.split(/\r?\n/).filter((l) => l.trim() !== line).join("\n");
241
+ if (after === before) return;
242
+ if (opts.dryRun) {
243
+ opts.log.dryNote(`would remove "${line}" from .gitignore`);
244
+ return;
245
+ }
246
+ fs2.writeFileSync(p, after, "utf-8");
247
+ opts.log.ok(`removed ${line} from .gitignore (now reserved for file-backed-config)`);
248
+ }
249
+ function recoverCredFromMcp(targetDir) {
250
+ const p = path2.join(targetDir, ".mcp.json");
251
+ if (!fs2.existsSync(p)) return null;
252
+ let mcp;
253
+ try {
254
+ mcp = JSON.parse(fs2.readFileSync(p, "utf-8"));
255
+ } catch {
256
+ return null;
257
+ }
258
+ const entry = mcp.mcpServers?.["launch-secure"];
259
+ if (!entry?.headers || !entry.url) return null;
260
+ const auth = entry.headers["Authorization"];
261
+ const pat = typeof auth === "string" && auth.startsWith("Bearer ") ? auth.slice("Bearer ".length).trim() : null;
262
+ const orgSlug = entry.headers["X-Org-Slug"];
263
+ const projectSlug = entry.headers["X-Project-Slug"];
264
+ if (!pat || !pat.startsWith("ls_pat_") || !orgSlug || !projectSlug) return null;
265
+ const serverUrl = entry.url.replace(/\/api\/mcp\/project\/?$/, "").replace(/\/+$/, "");
266
+ return { pat, orgSlug, projectSlug, serverUrl };
267
+ }
268
+ function recoverCred(targetDir, opts) {
269
+ migrateLegacyCredFile(targetDir, opts);
270
+ let cred = null;
271
+ try {
272
+ cred = readCredFile(targetDir);
273
+ } catch {
274
+ }
275
+ if (cred) return { cred, source: "cred-file" };
276
+ if (opts.dryRun) {
277
+ const legacyPath = path2.join(targetDir, LEGACY_CONFIG_FILENAME);
278
+ if (fs2.existsSync(legacyPath)) {
279
+ try {
280
+ const parsed = JSON.parse(fs2.readFileSync(legacyPath, "utf-8"));
281
+ if (typeof parsed?.pat === "string" && parsed.pat.startsWith("ls_pat_")) {
282
+ opts.log.info(`(dry-run) using legacy ${LEGACY_CONFIG_FILENAME} for preview \u2014 a real run would migrate it to ${CONFIG_FILENAME} first`);
283
+ return { cred: parsed, source: "legacy-dry-run" };
284
+ }
285
+ } catch {
286
+ }
287
+ }
288
+ }
289
+ const recovered = recoverCredFromMcp(targetDir);
290
+ if (recovered) return { cred: recovered, source: "mcp" };
291
+ return { cred: null, source: null };
292
+ }
293
+
294
+ // src/server/init-entry.ts
295
+ init_statusline_install();
296
+ var DEFAULT_SERVER_URL = "https://launchsecure-v2.vercel.app";
37
297
  var ONBOARD_SCRIPT_NAME = "onboard";
38
298
  var LAUNCH_KIT_PKG = "@launchsecure/launch-kit";
39
- var LAUNCH_KIT_TOOLS_GUIDE = `
299
+ var LAUNCH_KIT_TOOLS_GUIDE_STATIC_HEAD = `
40
300
  Wired in Claude Code (.mcp.json):
41
301
  launch-secure \u2014 LS API: work items, comms, secrets, members, board
42
302
  launch-chart \u2014 code search + project graph (use instead of grep/glob)
@@ -47,13 +307,60 @@ Wired in Claude Code (.mcp.json):
47
307
  Other tools (run on demand via npx):
48
308
  npx launch-pod radar \u2014 webhook listener (LS pings \u2192 terminal/UI)
49
309
  npx launch-pod \u2014 full pipeline UI (separate launch-pod login)
310
+ npx launch-beacon monitor \u2014 local HTTP receiver for the launch-kit-beacon
311
+ in-browser monitor. Paste the printed URL into
312
+ the beacon debug panel; events stream to
313
+ .launchsecure/beacon-<token>.ndjson for the
314
+ /kit:beacon-* commands below to read.
315
+ `;
316
+ var LAUNCH_KIT_TOOLS_GUIDE_STATIC_TAIL = `
317
+ Open this repo in Claude Code; on first open you'll be prompted to install
318
+ the "launch-secure" marketplace \u2014 accept to enable the commands above.
50
319
  `;
320
+ function renderLsCommandsSection() {
321
+ const dir = path4.resolve(__dirname, "..", "..", "scaffolds", "ls-marketplace", "plugins", "kit", "commands");
322
+ if (!fs4.existsSync(dir)) {
323
+ return "\nLS slash commands: (scaffold dir not bundled \u2014 this is a packaging bug)\n";
324
+ }
325
+ const files = fs4.readdirSync(dir).filter((f) => f.endsWith(".md")).sort();
326
+ if (files.length === 0) return "\nLS slash commands: (none defined)\n";
327
+ const lines = files.map((file) => {
328
+ const name = file.replace(/\.md$/, "");
329
+ const text = fs4.readFileSync(path4.join(dir, file), "utf-8");
330
+ const fmMatch = text.match(/^---\r?\n([\s\S]*?)\r?\n---/);
331
+ const desc = fmMatch?.[1].match(/^description:\s*(.+)$/m)?.[1]?.trim() ?? "";
332
+ const padded = `/${PLUGIN_ID}:${name}`.padEnd(26);
333
+ return ` ${padded} \u2014 ${desc}`;
334
+ });
335
+ return `
336
+ LS slash commands (run inside Claude Code in this project):
337
+ ${lines.join("\n")}
338
+ `;
339
+ }
340
+ function getLaunchKitToolsGuide() {
341
+ return `${LAUNCH_KIT_TOOLS_GUIDE_STATIC_HEAD}${renderLsCommandsSection()}${LAUNCH_KIT_TOOLS_GUIDE_STATIC_TAIL}`;
342
+ }
51
343
  var PACKAGE_MANAGERS = [
52
344
  { name: "pnpm", binary: "pnpm", lockfiles: ["pnpm-lock.yaml"], workspaceFiles: ["pnpm-workspace.yaml"], installArgs: ["install"] },
53
345
  { name: "yarn", binary: "yarn", lockfiles: ["yarn.lock"], installArgs: ["install"] },
54
346
  { name: "bun", binary: "bun", lockfiles: ["bun.lockb", "bun.lock"], installArgs: ["install"] },
55
347
  { name: "npm", binary: "npm", lockfiles: ["package-lock.json"], installArgs: ["install"] }
56
348
  ];
349
+ var KNOWN_BOOL_FLAGS = /* @__PURE__ */ new Set([
350
+ "--help",
351
+ "-h",
352
+ "--no-install",
353
+ "--no-onboard",
354
+ "--no-recall",
355
+ "--no-migrate-safety",
356
+ "--no-ls-marketplace",
357
+ "--no-recall-hook",
358
+ "--refresh-scaffolds",
359
+ "--quiet",
360
+ "--force",
361
+ "--dry-run"
362
+ ]);
363
+ var KNOWN_KV_KEYS = /* @__PURE__ */ new Set(["token", "org", "project", "url", "dir", "course"]);
57
364
  function parseArgs(argv) {
58
365
  const args = {
59
366
  token: process.env.LS_PAT ?? null,
@@ -61,12 +368,23 @@ function parseArgs(argv) {
61
368
  projectSlug: null,
62
369
  serverUrl: DEFAULT_SERVER_URL,
63
370
  targetDir: null,
371
+ course: null,
64
372
  noInstall: false,
373
+ noOnboard: false,
65
374
  noRecall: false,
66
375
  noMigrateSafety: false,
376
+ noLsMarketplace: false,
377
+ noRecallHook: false,
378
+ refreshScaffolds: false,
379
+ quiet: false,
380
+ force: false,
381
+ dryRun: false,
67
382
  help: false
68
383
  };
69
- for (const raw of argv) {
384
+ const unknown = [];
385
+ for (let i = 0; i < argv.length; i++) {
386
+ const raw = argv[i];
387
+ if (i === 0 && !raw.startsWith("--") && !raw.startsWith("-")) continue;
70
388
  if (raw === "--help" || raw === "-h") {
71
389
  args.help = true;
72
390
  continue;
@@ -75,6 +393,10 @@ function parseArgs(argv) {
75
393
  args.noInstall = true;
76
394
  continue;
77
395
  }
396
+ if (raw === "--no-onboard") {
397
+ args.noOnboard = true;
398
+ continue;
399
+ }
78
400
  if (raw === "--no-recall") {
79
401
  args.noRecall = true;
80
402
  continue;
@@ -83,23 +405,124 @@ function parseArgs(argv) {
83
405
  args.noMigrateSafety = true;
84
406
  continue;
85
407
  }
408
+ if (raw === "--no-ls-marketplace") {
409
+ args.noLsMarketplace = true;
410
+ continue;
411
+ }
412
+ if (raw === "--no-recall-hook") {
413
+ args.noRecallHook = true;
414
+ continue;
415
+ }
416
+ if (raw === "--refresh-scaffolds") {
417
+ args.refreshScaffolds = true;
418
+ continue;
419
+ }
420
+ if (raw === "--quiet") {
421
+ args.quiet = true;
422
+ continue;
423
+ }
424
+ if (raw === "--force") {
425
+ args.force = true;
426
+ continue;
427
+ }
428
+ if (raw === "--dry-run") {
429
+ args.dryRun = true;
430
+ continue;
431
+ }
86
432
  const eq = raw.indexOf("=");
87
- if (!raw.startsWith("--") || eq < 0) continue;
88
- const key = raw.slice(2, eq);
89
- const val = raw.slice(eq + 1);
90
- if (key === "token") args.token = val;
91
- else if (key === "org") args.orgSlug = val;
92
- else if (key === "project") args.projectSlug = val;
93
- else if (key === "url") args.serverUrl = val.replace(/\/+$/, "");
94
- else if (key === "dir") args.targetDir = val;
433
+ if (raw.startsWith("--") && eq > 0) {
434
+ const key = raw.slice(2, eq);
435
+ const val = raw.slice(eq + 1);
436
+ if (key === "token") {
437
+ args.token = val;
438
+ continue;
439
+ }
440
+ if (key === "org") {
441
+ args.orgSlug = val;
442
+ continue;
443
+ }
444
+ if (key === "project") {
445
+ args.projectSlug = val;
446
+ continue;
447
+ }
448
+ if (key === "url") {
449
+ args.serverUrl = val.replace(/\/+$/, "");
450
+ continue;
451
+ }
452
+ if (key === "dir") {
453
+ args.targetDir = val;
454
+ continue;
455
+ }
456
+ if (key === "course") {
457
+ args.course = val;
458
+ continue;
459
+ }
460
+ unknown.push(raw);
461
+ continue;
462
+ }
463
+ if (raw.startsWith("-")) {
464
+ unknown.push(raw);
465
+ continue;
466
+ }
467
+ unknown.push(raw);
468
+ }
469
+ if (unknown.length > 0) {
470
+ const knownBool = [...KNOWN_BOOL_FLAGS].join(", ");
471
+ const knownKv = [...KNOWN_KV_KEYS].map((k) => `--${k}=<value>`).join(", ");
472
+ fail(`Unknown argument(s): ${unknown.join(" ")}
473
+ Known boolean flags: ${knownBool}
474
+ Known key=value flags: ${knownKv}`);
95
475
  }
96
476
  return args;
97
477
  }
478
+ function printRefreshHelp() {
479
+ console.log(`launch-kit refresh \u2014 re-apply launch-kit scaffolds in an already-initialized project
480
+
481
+ Usage:
482
+ npx @launchsecure/launch-kit@latest refresh [--dir=<path>] [options]
483
+
484
+ What it does:
485
+ Re-merges .mcp.json with the latest launch-kit entries (preserves your other
486
+ entries), refreshes the launch-secure marketplace tree at .claude/marketplace/
487
+ (picks up any new /kit:* commands), refreshes scripts/ensure-recall.sh and
488
+ the SessionStart hook, and refreshes the migrate-safety scaffold.
489
+
490
+ Reads org/project/serverUrl/pat from the existing .launch-secure.cred.config.
491
+ Does NOT clone, re-install deps, re-prompt for PAT, or re-run the onboard
492
+ script. Use \`init\` for those.
493
+
494
+ Options:
495
+ --dir=<path> Target directory (default: cwd). Must contain a
496
+ valid .launch-secure.cred.config.
497
+ --no-migrate-safety Skip refreshing the migrate-safety scaffold.
498
+ --no-ls-marketplace Skip refreshing the launch-secure marketplace.
499
+ --no-recall-hook Skip refreshing the recall-hook scaffold.
500
+ --refresh-scaffolds Force-overwrite migrate-safety files (default is to
501
+ preserve user edits). Use this to pull updates
502
+ published to @launchsecure/launch-kit.
503
+ --quiet Suppress the post-run tools guide.
504
+ --dry-run Preview every file write without making changes.
505
+ --help Show this help.
506
+
507
+ Tip: prefix the command with @latest (\`launch-kit@latest refresh\`) to force
508
+ npx to pull the newest published version instead of using a cached older one.
509
+ `);
510
+ }
98
511
  function printHelp() {
99
- console.log(`launch-kit init \u2014 bootstrap a LaunchSecure project on this machine
512
+ console.log(`launch-kit \u2014 bootstrap and refresh a LaunchSecure project on this machine
513
+
514
+ Subcommands:
515
+ init Bootstrap a new project (clone, cred file, MCP, scaffolds, install)
516
+ refresh Re-apply scaffolds + MCP entries in an already-initialized project
517
+ (no clone, no install, no PAT prompt \u2014 see \`launch-kit refresh --help\`)
518
+ statusline activate Wrap ~/.claude/settings.json's statusLine.command so MCP daemon
519
+ chips (recall, chart, deck, council) get appended. Refuses to
520
+ create one if none exists \u2014 additive only.
521
+ statusline deactivate Restore the original statusLine.command and remove kit scripts.
100
522
 
101
523
  Usage:
102
- npx launch-kit init --token=<pat> --org=<orgSlug> --project=<projectSlug> [options]
524
+ npx @launchsecure/launch-kit init --token=<pat> --org=<orgSlug> --project=<projectSlug> [options]
525
+ npx @launchsecure/launch-kit@latest refresh [--dir=<path>] [options]
103
526
 
104
527
  Required:
105
528
  --token=<pat> LaunchSecure PAT (ls_pat_...). Or set LS_PAT env var.
@@ -109,10 +532,45 @@ Required:
109
532
  Options:
110
533
  --url=<serverUrl> LaunchSecure base URL (default: ${DEFAULT_SERVER_URL}).
111
534
  --dir=<path> Target directory (default: ./<projectSlug>).
112
- --no-install Skip dependency install step.
535
+ --course=<name> Name for the course (profile) being added to
536
+ .launch-secure.cred.config. When omitted, inferred
537
+ from --url: localhost\u2192"local", *staging*\u2192"staging",
538
+ *.vercel.app\u2192"prod", else hostname. The course
539
+ becomes active; re-run with a different --course
540
+ and --url to add another (e.g. local + staging).
541
+ Use \`launch-course set <name>\` to switch later.
542
+ --no-install Skip dependency install step (also skips the onboard
543
+ script \u2014 install is its prerequisite).
544
+ --no-onboard Skip the onboard script even when install runs.
113
545
  --no-recall Skip launch-recall (shadow git backup) scaffold.
114
546
  --no-migrate-safety Skip migrate-safety scaffold (pg_dump-before-migrate
115
547
  wrapper + GitHub Action + runbook).
548
+ --no-ls-marketplace Skip the Claude Code "launch-secure" marketplace
549
+ scaffold (.claude/marketplace/ + .claude/settings.json
550
+ wiring \u2014 exposes /kit:activate-beacon and future
551
+ ls-namespaced slash commands).
552
+ --no-recall-hook Skip the SessionStart hook scaffold (Claude Code
553
+ hook + scripts/ensure-recall.sh that auto-restarts
554
+ the launch-recall watcher if it died between
555
+ sessions). The hook is the surfacing layer for
556
+ watcher-died-silently scenarios.
557
+ --refresh-scaffolds Force-overwrite migrate-safety scaffold files even
558
+ when they already exist. Default behavior preserves
559
+ user edits; use this to pull updates published to
560
+ @launchsecure/launch-kit (e.g., a newer
561
+ migrate-with-backup.sh).
562
+ --quiet Suppress the post-run tools guide (the long block
563
+ listing /kit:* commands and wired MCPs). Useful for
564
+ idempotent re-runs in CI or scripts.
565
+ --force Skip the auto-delegate-to-refresh check. By default
566
+ init detects an existing bootstrap (cred file +
567
+ launch-secure MCP entry) and runs refresh instead.
568
+ Pass --force to re-init from scratch even when the
569
+ target dir is already set up.
570
+ --dry-run Preview every file write, merge, clone, and install
571
+ command without making any changes. Useful before
572
+ re-running init against a customized project. The
573
+ project_info HTTP call still runs (it's read-only).
116
574
  --help Show this help.
117
575
 
118
576
  What it does:
@@ -132,13 +590,22 @@ What it does:
132
590
  8. Scaffolds launch-recall (shadow git backup). Skip with --no-recall.
133
591
  9. Scaffolds migrate-safety (pg_dump wrapper + GHA backup workflow +
134
592
  runbook + .backups/ gitignore line). Skip with --no-migrate-safety.
593
+ 10. Scaffolds the Claude Code "launch-secure" marketplace at
594
+ .claude/marketplace/ and wires .claude/settings.json so Claude Code
595
+ auto-discovers it and enables the "kit" plugin (exposes
596
+ /kit:activate-beacon for wiring the launch-kit-beacon in-app feedback
597
+ widget). Skip with --no-ls-marketplace.
598
+ 11. Scaffolds scripts/ensure-recall.sh and appends a SessionStart hook to
599
+ .claude/settings.json that respawns the launch-recall watcher if it
600
+ died between sessions. Idempotent (dedupes by hook command-match).
601
+ Skip with --no-recall-hook.
135
602
  `);
136
603
  }
137
604
  async function prompt(question) {
138
605
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
139
- return new Promise((resolve2) => rl.question(question, (answer) => {
606
+ return new Promise((resolve3) => rl.question(question, (answer) => {
140
607
  rl.close();
141
- resolve2(answer.trim());
608
+ resolve3(answer.trim());
142
609
  }));
143
610
  }
144
611
  function fail(msg) {
@@ -151,6 +618,10 @@ function info(msg) {
151
618
  function ok(msg) {
152
619
  console.log(`[launch-kit] \u2713 ${msg}`);
153
620
  }
621
+ var DRY_RUN = false;
622
+ function dryNote(msg) {
623
+ console.log(`[launch-kit] (dry-run) ${msg}`);
624
+ }
154
625
  function which(bin) {
155
626
  const res = (0, import_node_child_process.spawnSync)(process.platform === "win32" ? "where" : "which", [bin], { encoding: "utf-8" });
156
627
  if (res.status !== 0) return null;
@@ -164,8 +635,17 @@ function preflight() {
164
635
  ok(`preflight ok \u2014 node ${process.versions.node}, git present${hasGh ? ", gh present" : ", gh not found (will use git for clone)"}`);
165
636
  return { hasGh };
166
637
  }
167
- function callProjectInfo(args) {
168
- return new Promise((resolve2, reject) => {
638
+ var PROJECT_INFO_TIMEOUT_MS = 3e4;
639
+ var PROJECT_INFO_MAX_ATTEMPTS = 3;
640
+ var ProjectInfoHttpError = class extends Error {
641
+ constructor(status, retryable, message) {
642
+ super(message);
643
+ this.status = status;
644
+ this.retryable = retryable;
645
+ }
646
+ };
647
+ function attemptProjectInfo(args) {
648
+ return new Promise((resolve3, reject) => {
169
649
  const mcpUrl = new import_node_url.URL("/api/mcp/project", args.serverUrl);
170
650
  const body = JSON.stringify({
171
651
  jsonrpc: "2.0",
@@ -197,12 +677,24 @@ function callProjectInfo(args) {
197
677
  res.on("data", (c) => chunks.push(c));
198
678
  res.on("end", () => {
199
679
  const text = Buffer.concat(chunks).toString("utf-8");
200
- if (res.statusCode === 401 || res.statusCode === 403) {
201
- reject(new Error(`PAT rejected (${res.statusCode}). Check token + that it has access to ${args.orgSlug}/${args.projectSlug}.`));
680
+ if (res.statusCode === 401) {
681
+ reject(new ProjectInfoHttpError(401, false, `PAT rejected (401). Token is invalid or expired. Generate a new PAT at ${args.serverUrl}/settings/tokens and retry.`));
682
+ return;
683
+ }
684
+ if (res.statusCode === 403) {
685
+ reject(new ProjectInfoHttpError(403, false, `Access denied (403). Token is valid but lacks access to ${args.orgSlug}/${args.projectSlug}. Check membership/permissions on the project.`));
686
+ return;
687
+ }
688
+ if (res.statusCode === 404) {
689
+ reject(new ProjectInfoHttpError(404, false, `Project not found (404). Verify ${args.orgSlug}/${args.projectSlug} exists at ${args.serverUrl}.`));
690
+ return;
691
+ }
692
+ if (res.statusCode && res.statusCode >= 500) {
693
+ reject(new ProjectInfoHttpError(res.statusCode, true, `LaunchSecure server error ${res.statusCode}: ${text.slice(0, 200)}`));
202
694
  return;
203
695
  }
204
696
  if (res.statusCode && res.statusCode >= 400) {
205
- reject(new Error(`LaunchSecure responded ${res.statusCode}: ${text.slice(0, 300)}`));
697
+ reject(new ProjectInfoHttpError(res.statusCode, false, `LaunchSecure responded ${res.statusCode}: ${text.slice(0, 300)}`));
206
698
  return;
207
699
  }
208
700
  let json = text;
@@ -231,7 +723,7 @@ function callProjectInfo(args) {
231
723
  return;
232
724
  }
233
725
  const payload = JSON.parse(inner);
234
- resolve2({
726
+ resolve3({
235
727
  orgSlug: payload.org.slug,
236
728
  projectSlug: payload.project.slug,
237
729
  projectName: payload.project.name,
@@ -243,11 +735,34 @@ function callProjectInfo(args) {
243
735
  });
244
736
  }
245
737
  );
246
- req.on("error", reject);
738
+ req.setTimeout(PROJECT_INFO_TIMEOUT_MS, () => {
739
+ req.destroy(new Error(`project_info timed out after ${PROJECT_INFO_TIMEOUT_MS / 1e3}s`));
740
+ });
741
+ req.on("error", (err) => {
742
+ const code = err.code;
743
+ const retryable = code === "ECONNRESET" || code === "ECONNREFUSED" || code === "ETIMEDOUT" || code === "ENOTFOUND" || code === "EAI_AGAIN" || /timed out/.test(err.message);
744
+ reject(retryable ? new ProjectInfoHttpError(0, true, err.message) : err);
745
+ });
247
746
  req.write(body);
248
747
  req.end();
249
748
  });
250
749
  }
750
+ async function callProjectInfo(args) {
751
+ let lastErr;
752
+ for (let attempt = 1; attempt <= PROJECT_INFO_MAX_ATTEMPTS; attempt++) {
753
+ try {
754
+ return await attemptProjectInfo(args);
755
+ } catch (err) {
756
+ lastErr = err;
757
+ const retryable = err instanceof ProjectInfoHttpError && err.retryable;
758
+ if (!retryable || attempt === PROJECT_INFO_MAX_ATTEMPTS) break;
759
+ const delayMs = 1e3 * 2 ** (attempt - 1);
760
+ info(`project_info attempt ${attempt}/${PROJECT_INFO_MAX_ATTEMPTS} failed (${err instanceof Error ? err.message : String(err)}) \u2014 retrying in ${delayMs}ms`);
761
+ await new Promise((r) => setTimeout(r, delayMs));
762
+ }
763
+ }
764
+ throw lastErr;
765
+ }
251
766
  function gitRemoteUrl(dir) {
252
767
  const res = (0, import_node_child_process.spawnSync)("git", ["-C", dir, "config", "--get", "remote.origin.url"], { encoding: "utf-8" });
253
768
  if (res.status !== 0) return null;
@@ -265,11 +780,11 @@ function normalizeRepoUrl(url) {
265
780
  }
266
781
  }
267
782
  function isGitRepo(dir) {
268
- return fs.existsSync(path.join(dir, ".git"));
783
+ return fs4.existsSync(path4.join(dir, ".git"));
269
784
  }
270
785
  function dirIsEmpty(dir) {
271
- if (!fs.existsSync(dir)) return true;
272
- return fs.readdirSync(dir).length === 0;
786
+ if (!fs4.existsSync(dir)) return true;
787
+ return fs4.readdirSync(dir).length === 0;
273
788
  }
274
789
  function cloneRepo(repoUrl, targetDir, hasGh) {
275
790
  const isGithub = /github\.com/i.test(repoUrl);
@@ -284,51 +799,59 @@ function cloneRepo(repoUrl, targetDir, hasGh) {
284
799
  args = ["clone", repoUrl, targetDir];
285
800
  info(`cloning via git: ${repoUrl} \u2192 ${targetDir}`);
286
801
  }
802
+ if (DRY_RUN) {
803
+ dryNote(`would run: ${cmd} ${args.join(" ")}`);
804
+ return;
805
+ }
287
806
  const res = (0, import_node_child_process.spawnSync)(cmd, args, { stdio: "inherit" });
288
807
  if (res.status !== 0) {
289
808
  fail(
290
809
  `Clone failed (${cmd} exited ${res.status}). For private repos make sure your GitHub auth is set up: \`gh auth login\` or an SSH key on your GitHub account.`
291
810
  );
292
811
  }
812
+ if (!fs4.existsSync(path4.join(targetDir, ".git"))) {
813
+ fail(`Clone reported success but .git is missing at ${targetDir}. Possible partial clone, filesystem issue, or sandboxing \u2014 investigate manually.`);
814
+ }
293
815
  ok(`cloned to ${targetDir}`);
294
816
  }
295
- function writeConfigFile(targetDir, cfg) {
296
- migrateLegacyCredFile(targetDir);
297
- const p = path.join(targetDir, CONFIG_FILENAME);
298
- const existed = fs.existsSync(p);
299
- fs.writeFileSync(p, JSON.stringify(cfg, null, 2) + "\n", "utf-8");
300
- try {
301
- fs.chmodSync(p, 384);
302
- } catch {
817
+ function writeConfigFile(targetDir, cfg, courseName) {
818
+ recoverCred(targetDir, getRecoveryOptions());
819
+ const p = path4.join(targetDir, CONFIG_FILENAME);
820
+ const existing = readCredFile(targetDir);
821
+ const isNew = existing === null;
822
+ const isUpdate = !isNew && Boolean(existing?.profiles?.[courseName]);
823
+ if (DRY_RUN) {
824
+ const verb = isNew ? "write" : isUpdate ? "update course" : "add course";
825
+ dryNote(`would ${verb} "${courseName}" in ${CONFIG_FILENAME} (org=${cfg.orgSlug}, project=${cfg.projectSlug}, url=${cfg.serverUrl})`);
826
+ return;
303
827
  }
304
- ok(`${existed ? "updated" : "wrote"} ${CONFIG_FILENAME}`);
828
+ const nested = upsertProfile(existing, courseName, cfg);
829
+ writeJsonAtomic(p, nested, 384);
830
+ const action = isNew ? "wrote" : isUpdate ? `updated course "${courseName}" in` : `added course "${courseName}" to`;
831
+ ok(`${action} ${CONFIG_FILENAME} (active: ${courseName})`);
305
832
  }
306
- function migrateLegacyCredFile(targetDir) {
307
- const legacy = path.join(targetDir, LEGACY_CONFIG_FILENAME);
308
- const dest = path.join(targetDir, CONFIG_FILENAME);
309
- if (!fs.existsSync(legacy) || fs.existsSync(dest)) return;
310
- let parsed;
833
+ var recoveryLog = { info, ok, dryNote };
834
+ function getRecoveryOptions() {
835
+ return { dryRun: DRY_RUN, log: recoveryLog };
836
+ }
837
+ function detectExistingBootstrap(targetDir) {
838
+ if (!fs4.existsSync(path4.join(targetDir, CONFIG_FILENAME))) {
839
+ return { bootstrapped: false };
840
+ }
841
+ const mcpPath = path4.join(targetDir, ".mcp.json");
842
+ if (!fs4.existsSync(mcpPath)) {
843
+ return { bootstrapped: false };
844
+ }
311
845
  try {
312
- parsed = JSON.parse(fs.readFileSync(legacy, "utf-8"));
846
+ const mcp = JSON.parse(fs4.readFileSync(mcpPath, "utf-8"));
847
+ if (mcp.mcpServers?.["launch-secure"]) {
848
+ return { bootstrapped: true, reason: `${CONFIG_FILENAME} present + launch-secure MCP entry in .mcp.json` };
849
+ }
313
850
  } catch {
314
- return;
315
851
  }
316
- const pat = parsed?.pat;
317
- if (typeof pat !== "string" || !pat.startsWith("ls_pat_")) return;
318
- fs.renameSync(legacy, dest);
319
- removeGitignoreLine(targetDir, LEGACY_CONFIG_FILENAME);
320
- ok(`migrated legacy ${LEGACY_CONFIG_FILENAME} \u2192 ${CONFIG_FILENAME} (the old name is now reserved for file-backed-config)`);
321
- }
322
- function removeGitignoreLine(targetDir, line) {
323
- const p = path.join(targetDir, ".gitignore");
324
- if (!fs.existsSync(p)) return;
325
- const before = fs.readFileSync(p, "utf-8");
326
- const after = before.split(/\r?\n/).filter((l) => l.trim() !== line).join("\n");
327
- if (after === before) return;
328
- fs.writeFileSync(p, after, "utf-8");
329
- ok(`removed ${line} from .gitignore (now reserved for file-backed-config)`);
852
+ return { bootstrapped: false };
330
853
  }
331
- var LAUNCH_SECURE_HEADERS_HELPER = `node -e 'const j=JSON.parse(require("fs").readFileSync(".launch-secure.cred.config","utf-8"));process.stdout.write(JSON.stringify({Authorization:"Bearer "+j.pat,"X-Org-Slug":j.orgSlug,"X-Project-Slug":j.projectSlug}))'`;
854
+ var LAUNCH_SECURE_HEADERS_HELPER = `node -e 'const j=JSON.parse(require("fs").readFileSync(".launch-secure.cred.config","utf-8"));const c=j.profiles&&j.active?j.profiles[j.active]:j;process.stdout.write(JSON.stringify({Authorization:"Bearer "+c.pat,"X-Org-Slug":c.orgSlug,"X-Project-Slug":c.projectSlug}))'`;
332
855
  function buildLaunchKitMcpEntries(cfg) {
333
856
  return {
334
857
  "launch-secure": {
@@ -339,6 +862,11 @@ function buildLaunchKitMcpEntries(cfg) {
339
862
  "launch-chart": {
340
863
  command: "npx",
341
864
  args: ["-y", "-p", LAUNCH_KIT_PKG, "launch-chart"],
865
+ // Tells launch-chart to also start its HTTP UI alongside the MCP, so
866
+ // users can open the chart viewer at localhost:<port> while queries
867
+ // hit the MCP. Without this, the MCP runs passively (queries work,
868
+ // no UI). I4 deep-merge preserves user-added env keys; this default
869
+ // ensures the auto-serve UX ships out of the box.
342
870
  env: { LAUNCH_CHART_AUTOSERVE: "1" }
343
871
  },
344
872
  "launch-deck": {
@@ -355,31 +883,61 @@ function buildLaunchKitMcpEntries(cfg) {
355
883
  }
356
884
  };
357
885
  }
886
+ function mergeMcpEntry(existing, ours) {
887
+ const merged = { ...existing, ...ours };
888
+ if (existing.headers || ours.headers) {
889
+ const authKeys = /* @__PURE__ */ new Set(["Authorization", "X-Org-Slug", "X-Project-Slug"]);
890
+ const baseHeaders = ours.headersHelper ? Object.fromEntries(Object.entries(existing.headers ?? {}).filter(([k]) => !authKeys.has(k))) : { ...existing.headers ?? {} };
891
+ const combinedHeaders = { ...baseHeaders, ...ours.headers ?? {} };
892
+ if (Object.keys(combinedHeaders).length > 0) merged.headers = combinedHeaders;
893
+ else delete merged.headers;
894
+ }
895
+ if (existing.env || ours.env) {
896
+ const combinedEnv = { ...existing.env ?? {}, ...ours.env ?? {} };
897
+ if (Object.keys(combinedEnv).length > 0) merged.env = combinedEnv;
898
+ else delete merged.env;
899
+ }
900
+ return merged;
901
+ }
358
902
  function mergeMcpFile(targetDir, launchKitEntries) {
359
- const p = path.join(targetDir, ".mcp.json");
360
- const hadExisting = fs.existsSync(p);
903
+ const p = path4.join(targetDir, ".mcp.json");
904
+ const hadExisting = fs4.existsSync(p);
361
905
  let existing = {};
362
906
  if (hadExisting) {
363
907
  try {
364
- existing = JSON.parse(fs.readFileSync(p, "utf-8"));
908
+ existing = JSON.parse(fs4.readFileSync(p, "utf-8"));
365
909
  } catch (err) {
366
910
  fail(`Could not parse existing .mcp.json: ${err instanceof Error ? err.message : String(err)}`);
367
911
  }
368
912
  }
369
913
  const existingServerCount = Object.keys(existing.mcpServers ?? {}).length;
370
914
  const merged = { ...existing, mcpServers: { ...existing.mcpServers ?? {} } };
915
+ const overwrites = [];
916
+ const additions = [];
371
917
  for (const [name, entry] of Object.entries(launchKitEntries)) {
372
- merged.mcpServers[name] = entry;
918
+ const existingEntry = merged.mcpServers[name];
919
+ if (existingEntry) {
920
+ overwrites.push(name);
921
+ merged.mcpServers[name] = mergeMcpEntry(existingEntry, entry);
922
+ } else {
923
+ additions.push(name);
924
+ merged.mcpServers[name] = entry;
925
+ }
926
+ }
927
+ if (DRY_RUN) {
928
+ const action2 = hadExisting && existingServerCount > 0 ? "would merge into" : "would write";
929
+ dryNote(`${action2} .mcp.json \u2014 overwriting [${overwrites.join(", ") || "none"}], adding [${additions.join(", ") || "none"}]`);
930
+ return;
373
931
  }
374
- fs.writeFileSync(p, JSON.stringify(merged, null, 2) + "\n", "utf-8");
932
+ fs4.writeFileSync(p, JSON.stringify(merged, null, 2) + "\n", "utf-8");
375
933
  const action = hadExisting && existingServerCount > 0 ? "merged into" : "wrote";
376
934
  ok(`${action} .mcp.json (${Object.keys(launchKitEntries).length} launch-kit entries)`);
377
935
  }
378
936
  function detectPackageManager(repoDir) {
379
- const pkgPath = path.join(repoDir, "package.json");
380
- if (!fs.existsSync(pkgPath)) return null;
937
+ const pkgPath = path4.join(repoDir, "package.json");
938
+ if (!fs4.existsSync(pkgPath)) return null;
381
939
  try {
382
- const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
940
+ const pkg = JSON.parse(fs4.readFileSync(pkgPath, "utf-8"));
383
941
  if (typeof pkg.packageManager === "string") {
384
942
  const name = pkg.packageManager.split("@")[0];
385
943
  const match = PACKAGE_MANAGERS.find((p) => p.name === name);
@@ -388,7 +946,7 @@ function detectPackageManager(repoDir) {
388
946
  }
389
947
  } catch {
390
948
  }
391
- const matches = PACKAGE_MANAGERS.map((pm) => ({ pm, lockfile: pm.lockfiles.find((lf) => fs.existsSync(path.join(repoDir, lf))) ?? null })).filter((m) => m.lockfile !== null);
949
+ const matches = PACKAGE_MANAGERS.map((pm) => ({ pm, lockfile: pm.lockfiles.find((lf) => fs4.existsSync(path4.join(repoDir, lf))) ?? null })).filter((m) => m.lockfile !== null);
392
950
  if (matches.length === 1) {
393
951
  return { pm: matches[0].pm, source: `lockfile ${matches[0].lockfile}` };
394
952
  }
@@ -397,8 +955,8 @@ function detectPackageManager(repoDir) {
397
955
  return { pm: matches[0].pm, source: `lockfile ${matches[0].lockfile} (multiple present)` };
398
956
  }
399
957
  for (const pm of PACKAGE_MANAGERS) {
400
- if (pm.workspaceFiles?.some((wf) => fs.existsSync(path.join(repoDir, wf)))) {
401
- return { pm, source: `workspace file (${pm.workspaceFiles.find((wf) => fs.existsSync(path.join(repoDir, wf)))})` };
958
+ if (pm.workspaceFiles?.some((wf) => fs4.existsSync(path4.join(repoDir, wf)))) {
959
+ return { pm, source: `workspace file (${pm.workspaceFiles.find((wf) => fs4.existsSync(path4.join(repoDir, wf)))})` };
402
960
  }
403
961
  }
404
962
  const npm = PACKAGE_MANAGERS.find((p) => p.name === "npm");
@@ -408,23 +966,43 @@ function runInstall(repoDir, detected) {
408
966
  const { pm } = detected;
409
967
  if (!which(pm.binary)) {
410
968
  fail(
411
- `${pm.name} not found on PATH. Configs and clone are intact. Install ${pm.name} (try \`corepack enable\` if you have Node \u226516), then run: cd ${path.basename(repoDir)} && ${pm.binary} ${pm.installArgs.join(" ")}`
969
+ `${pm.name} not found on PATH. Configs and clone are intact. Install ${pm.name} (try \`corepack enable\` if you have Node \u226516), then run: cd ${path4.basename(repoDir)} && ${pm.binary} ${pm.installArgs.join(" ")}`
412
970
  );
413
971
  }
414
972
  info(`running ${pm.binary} ${pm.installArgs.join(" ")} \u2026`);
973
+ if (DRY_RUN) {
974
+ dryNote(`would run: ${pm.binary} ${pm.installArgs.join(" ")} (cwd: ${repoDir})`);
975
+ return;
976
+ }
415
977
  const res = (0, import_node_child_process.spawnSync)(pm.binary, pm.installArgs, { cwd: repoDir, stdio: "inherit" });
416
978
  if (res.status !== 0) {
417
979
  fail(
418
- `${pm.name} install failed (exit ${res.status}). Configs and clone are intact \u2014 fix the underlying error and retry: cd ${path.basename(repoDir)} && ${pm.binary} ${pm.installArgs.join(" ")}`
980
+ `${pm.name} install failed (exit ${res.status}).
981
+
982
+ Half-init state \u2014 install didn't complete, but these files ARE on disk:
983
+ - ${path4.join(repoDir, CONFIG_FILENAME)} (cred file)
984
+ - ${path4.join(repoDir, ".mcp.json")} (5 launch-kit MCP entries merged)
985
+ - ${path4.join(repoDir, ".gitignore")} (cred line appended)
986
+ - clone at ${repoDir}
987
+
988
+ Scaffolds (recall, migrate-safety, marketplace, recall-hook) were NOT yet written.
989
+
990
+ To retry install only:
991
+ cd ${path4.basename(repoDir)} && ${pm.binary} ${pm.installArgs.join(" ")}
992
+
993
+ To re-run init after fixing the install error:
994
+ cd ${path4.basename(repoDir)} && npx @launchsecure/launch-kit init --dir=.
995
+
996
+ To fully reset: delete the files listed above and the clone, then re-init.`
419
997
  );
420
998
  }
421
999
  ok(`${pm.name} install complete`);
422
1000
  }
423
1001
  function hasOnboardScript(repoDir) {
424
- const pkgPath = path.join(repoDir, "package.json");
425
- if (!fs.existsSync(pkgPath)) return false;
1002
+ const pkgPath = path4.join(repoDir, "package.json");
1003
+ if (!fs4.existsSync(pkgPath)) return false;
426
1004
  try {
427
- const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
1005
+ const pkg = JSON.parse(fs4.readFileSync(pkgPath, "utf-8"));
428
1006
  return typeof pkg.scripts?.[ONBOARD_SCRIPT_NAME] === "string";
429
1007
  } catch {
430
1008
  return false;
@@ -432,91 +1010,430 @@ function hasOnboardScript(repoDir) {
432
1010
  }
433
1011
  function runRecallInit(repoDir) {
434
1012
  info(`scaffolding launch-recall (shadow git backup) \u2026`);
435
- const recallEntry = path.resolve(__dirname, "recall-entry.js");
436
- const useSibling = fs.existsSync(recallEntry);
1013
+ const recallEntry = path4.resolve(__dirname, "recall-entry.js");
1014
+ const useSibling = fs4.existsSync(recallEntry);
437
1015
  const cmd = useSibling ? process.execPath : "npx";
438
1016
  const args = useSibling ? [recallEntry, "init"] : ["-y", "-p", LAUNCH_KIT_PKG, "launch-recall", "init"];
1017
+ if (DRY_RUN) {
1018
+ dryNote(`would run launch-recall init: ${cmd} ${args.join(" ")} (cwd: ${repoDir})`);
1019
+ return;
1020
+ }
439
1021
  const res = (0, import_node_child_process.spawnSync)(cmd, args, { cwd: repoDir, stdio: "inherit" });
440
1022
  if (res.status !== 0) {
441
- info(`\u26A0 launch-recall init failed (exit ${res.status}). Main onboarding is complete \u2014 you can retry later: cd ${path.basename(repoDir)} && npx -y -p ${LAUNCH_KIT_PKG} launch-recall init`);
1023
+ info(`\u26A0 launch-recall init failed (exit ${res.status}). Main onboarding is complete \u2014 you can retry later: cd ${path4.basename(repoDir)} && npx -y -p ${LAUNCH_KIT_PKG} launch-recall init`);
442
1024
  return;
443
1025
  }
444
1026
  ok(`launch-recall ready (shadow git initialized)`);
445
1027
  }
446
1028
  function runOnboard(repoDir, pm) {
447
1029
  info(`running ${pm.binary} run ${ONBOARD_SCRIPT_NAME} \u2026`);
1030
+ if (DRY_RUN) {
1031
+ dryNote(`would run: ${pm.binary} run ${ONBOARD_SCRIPT_NAME} (cwd: ${repoDir})`);
1032
+ return;
1033
+ }
448
1034
  const res = (0, import_node_child_process.spawnSync)(pm.binary, ["run", ONBOARD_SCRIPT_NAME], { cwd: repoDir, stdio: "inherit" });
449
1035
  if (res.status !== 0) {
450
1036
  fail(
451
- `${pm.name} run ${ONBOARD_SCRIPT_NAME} failed (exit ${res.status}). Install completed but the onboard script errored. Fix and retry: cd ${path.basename(repoDir)} && ${pm.binary} run ${ONBOARD_SCRIPT_NAME}`
1037
+ `${pm.name} run ${ONBOARD_SCRIPT_NAME} failed (exit ${res.status}). Install completed but the onboard script errored. Fix and retry: cd ${path4.basename(repoDir)} && ${pm.binary} run ${ONBOARD_SCRIPT_NAME}`
452
1038
  );
453
1039
  }
454
1040
  ok(`${ONBOARD_SCRIPT_NAME} script complete`);
455
1041
  }
456
1042
  function ensureGitignoreLine(targetDir, line) {
457
- const p = path.join(targetDir, ".gitignore");
458
- let content = fs.existsSync(p) ? fs.readFileSync(p, "utf-8") : "";
1043
+ const p = path4.join(targetDir, ".gitignore");
1044
+ let content = fs4.existsSync(p) ? fs4.readFileSync(p, "utf-8") : "";
459
1045
  const lines = content.split(/\r?\n/);
460
1046
  if (lines.some((l) => l.trim() === line)) return;
461
1047
  if (content.length && !content.endsWith("\n")) content += "\n";
462
1048
  content += `${line}
463
1049
  `;
464
- fs.writeFileSync(p, content, "utf-8");
1050
+ if (DRY_RUN) {
1051
+ dryNote(`would append "${line}" to .gitignore`);
1052
+ return;
1053
+ }
1054
+ fs4.writeFileSync(p, content, "utf-8");
465
1055
  ok(`appended ${line} to .gitignore`);
466
1056
  }
467
- function copyScaffoldIfMissing(srcPath, destPath, label) {
468
- if (!fs.existsSync(srcPath)) return "missing-src";
469
- if (fs.existsSync(destPath)) {
470
- info(`${label} already present \u2014 leaving alone`);
471
- return "existed";
472
- }
473
- fs.mkdirSync(path.dirname(destPath), { recursive: true });
474
- fs.copyFileSync(srcPath, destPath);
475
- try {
476
- const srcMode = fs.statSync(srcPath).mode;
477
- fs.chmodSync(destPath, srcMode);
478
- } catch {
1057
+ function copyScaffoldDirAlways(srcDir, destDir, labelPrefix) {
1058
+ if (!fs4.existsSync(srcDir)) return;
1059
+ for (const entry of fs4.readdirSync(srcDir, { withFileTypes: true })) {
1060
+ const srcPath = path4.join(srcDir, entry.name);
1061
+ const destPath = path4.join(destDir, entry.name);
1062
+ const label = labelPrefix ? `${labelPrefix}/${entry.name}` : entry.name;
1063
+ if (entry.isDirectory()) {
1064
+ copyScaffoldDirAlways(srcPath, destPath, label);
1065
+ } else if (entry.isFile()) {
1066
+ const existed = fs4.existsSync(destPath);
1067
+ if (DRY_RUN) {
1068
+ dryNote(`would ${existed ? "refresh" : "write"} ${label}`);
1069
+ continue;
1070
+ }
1071
+ fs4.mkdirSync(path4.dirname(destPath), { recursive: true });
1072
+ fs4.copyFileSync(srcPath, destPath);
1073
+ try {
1074
+ const srcMode = fs4.statSync(srcPath).mode;
1075
+ fs4.chmodSync(destPath, srcMode);
1076
+ } catch {
1077
+ }
1078
+ ok(`${existed ? "refreshed" : "wrote"} ${label}`);
1079
+ }
479
1080
  }
480
- ok(`wrote ${label}`);
481
- return "wrote";
482
1081
  }
483
- function scaffoldMigrateSafety(targetDir) {
484
- const scaffoldsRoot = path.resolve(__dirname, "..", "..", "scaffolds", "migrate-safety");
485
- if (!fs.existsSync(scaffoldsRoot)) {
1082
+ function scaffoldMigrateSafety(targetDir, refreshScaffolds = false) {
1083
+ const scaffoldsRoot = path4.resolve(__dirname, "..", "..", "scaffolds", "migrate-safety");
1084
+ if (!fs4.existsSync(scaffoldsRoot)) {
486
1085
  info(`\u26A0 migrate-safety scaffolds not found at ${scaffoldsRoot} \u2014 skipping (this is a packaging bug; main onboarding is unaffected)`);
487
1086
  return;
488
1087
  }
489
1088
  const files = [
490
1089
  {
491
- src: path.join(scaffoldsRoot, ".github", "workflows", "backup-on-migration.yml"),
492
- dest: path.join(targetDir, ".github", "workflows", "backup-on-migration.yml"),
1090
+ src: path4.join(scaffoldsRoot, ".github", "workflows", "backup-on-migration.yml"),
1091
+ dest: path4.join(targetDir, ".github", "workflows", "backup-on-migration.yml"),
493
1092
  label: ".github/workflows/backup-on-migration.yml"
494
1093
  },
495
1094
  {
496
- src: path.join(scaffoldsRoot, "scripts", "migrate-with-backup.sh"),
497
- dest: path.join(targetDir, "scripts", "migrate-with-backup.sh"),
1095
+ src: path4.join(scaffoldsRoot, "scripts", "migrate-with-backup.sh"),
1096
+ dest: path4.join(targetDir, "scripts", "migrate-with-backup.sh"),
498
1097
  label: "scripts/migrate-with-backup.sh"
499
1098
  },
500
1099
  {
501
- src: path.join(scaffoldsRoot, "docs", "migrations-runbook.md"),
502
- dest: path.join(targetDir, "docs", "migrations-runbook.md"),
1100
+ src: path4.join(scaffoldsRoot, "docs", "migrations-runbook.md"),
1101
+ dest: path4.join(targetDir, "docs", "migrations-runbook.md"),
503
1102
  label: "docs/migrations-runbook.md"
504
1103
  }
505
1104
  ];
506
- info("scaffolding migrate-safety (pg_dump wrapper + GHA backup workflow + runbook) \u2026");
507
- for (const f of files) copyScaffoldIfMissing(f.src, f.dest, f.label);
1105
+ info(`scaffolding migrate-safety (pg_dump wrapper + GHA backup workflow + runbook)${refreshScaffolds ? " \u2014 --refresh-scaffolds active" : ""} \u2026`);
1106
+ for (const f of files) copyScaffoldDriftAware(f.src, f.dest, f.label, refreshScaffolds);
508
1107
  ensureGitignoreLine(targetDir, ".backups/");
509
1108
  ok("migrate-safety ready \u2014 see docs/migrations-runbook.md for db:migrate wiring + PROD_DATABASE_URL secret setup");
510
1109
  }
1110
+ function hashFile(p) {
1111
+ try {
1112
+ return crypto.createHash("sha256").update(fs4.readFileSync(p)).digest("hex");
1113
+ } catch {
1114
+ return null;
1115
+ }
1116
+ }
1117
+ function copyScaffoldDriftAware(srcPath, destPath, label, refreshScaffolds) {
1118
+ if (!fs4.existsSync(srcPath)) {
1119
+ info(`\u26A0 scaffold src missing for ${label} \u2014 skipping (packaging bug)`);
1120
+ return "missing-src";
1121
+ }
1122
+ if (!fs4.existsSync(destPath)) {
1123
+ if (DRY_RUN) {
1124
+ dryNote(`would write ${label}`);
1125
+ return "wrote";
1126
+ }
1127
+ fs4.mkdirSync(path4.dirname(destPath), { recursive: true });
1128
+ fs4.copyFileSync(srcPath, destPath);
1129
+ try {
1130
+ fs4.chmodSync(destPath, fs4.statSync(srcPath).mode);
1131
+ } catch {
1132
+ }
1133
+ ok(`wrote ${label}`);
1134
+ return "wrote";
1135
+ }
1136
+ const srcHash = hashFile(srcPath);
1137
+ const destHash = hashFile(destPath);
1138
+ if (srcHash && destHash && srcHash === destHash) {
1139
+ if (DRY_RUN) dryNote(`${label} in sync with shipped scaffold \u2014 no change`);
1140
+ else info(`${label} in sync with shipped scaffold \u2014 no change`);
1141
+ return "in-sync";
1142
+ }
1143
+ if (refreshScaffolds) {
1144
+ if (DRY_RUN) {
1145
+ dryNote(`would refresh ${label} (overrides local edits)`);
1146
+ return "drifted-refreshed";
1147
+ }
1148
+ fs4.copyFileSync(srcPath, destPath);
1149
+ try {
1150
+ fs4.chmodSync(destPath, fs4.statSync(srcPath).mode);
1151
+ } catch {
1152
+ }
1153
+ ok(`refreshed ${label} (overrode local edits \u2014 drift detected before write)`);
1154
+ return "drifted-refreshed";
1155
+ }
1156
+ info(`${label} differs from shipped scaffold (customized or older version) \u2014 preserving. Pass --refresh-scaffolds to overwrite.`);
1157
+ return "drifted-preserved";
1158
+ }
1159
+ var MARKETPLACE_ID = "launch-secure";
1160
+ var PLUGIN_ID = "kit";
1161
+ function isDogfoodMarketplace(targetDir) {
1162
+ const p = path4.join(targetDir, ".claude", "settings.json");
1163
+ if (!fs4.existsSync(p)) return { isDogfood: false };
1164
+ try {
1165
+ const settings = JSON.parse(fs4.readFileSync(p, "utf-8"));
1166
+ const existingPath = settings.extraKnownMarketplaces?.[MARKETPLACE_ID]?.source?.path;
1167
+ if (existingPath && existingPath !== "./.claude/marketplace") {
1168
+ return { isDogfood: true, existingPath };
1169
+ }
1170
+ return { isDogfood: false };
1171
+ } catch {
1172
+ return { isDogfood: false };
1173
+ }
1174
+ }
1175
+ function scaffoldLsMarketplace(targetDir) {
1176
+ const scaffoldsRoot = path4.resolve(__dirname, "..", "..", "scaffolds", "ls-marketplace");
1177
+ if (!fs4.existsSync(scaffoldsRoot)) {
1178
+ info(`\u26A0 ls-marketplace scaffolds not found at ${scaffoldsRoot} \u2014 skipping (this is a packaging bug; main onboarding is unaffected)`);
1179
+ return;
1180
+ }
1181
+ const dogfood = isDogfoodMarketplace(targetDir);
1182
+ if (dogfood.isDogfood) {
1183
+ info(`dogfood marketplace pointer detected (${dogfood.existingPath}) \u2014 skipping copy, only refreshing enabledPlugins`);
1184
+ wireLsSettings(targetDir);
1185
+ ok(`launch-secure marketplace (dogfood) \u2014 Claude Code loads commands from ${dogfood.existingPath}`);
1186
+ return;
1187
+ }
1188
+ const marketplaceRoot = path4.join(targetDir, ".claude", "marketplace");
1189
+ info("scaffolding launch-secure marketplace (Claude Code /kit: namespace \u2014 refreshes every /kit:* command found in the scaffold) \u2026");
1190
+ copyScaffoldDirAlways(scaffoldsRoot, marketplaceRoot, ".claude/marketplace");
1191
+ wireLsSettings(targetDir);
1192
+ ok(`launch-secure marketplace ready \u2014 open this repo in Claude Code, approve the "${MARKETPLACE_ID}" marketplace prompt, then try /kit:activate-beacon, /kit:standup, or /kit:show-mcp-status`);
1193
+ }
1194
+ function wireLsSettings(targetDir) {
1195
+ const p = path4.join(targetDir, ".claude", "settings.json");
1196
+ const hadExisting = fs4.existsSync(p);
1197
+ let existing = {};
1198
+ if (hadExisting) {
1199
+ try {
1200
+ existing = JSON.parse(fs4.readFileSync(p, "utf-8"));
1201
+ } catch (err) {
1202
+ fail(`Could not parse existing .claude/settings.json: ${err instanceof Error ? err.message : String(err)}`);
1203
+ }
1204
+ }
1205
+ const merged = { ...existing };
1206
+ const existingMarketplacePath = existing.extraKnownMarketplaces?.[MARKETPLACE_ID]?.source?.path;
1207
+ const targetPath = existingMarketplacePath ?? "./.claude/marketplace";
1208
+ if (existingMarketplacePath && existingMarketplacePath !== "./.claude/marketplace") {
1209
+ info(`preserving existing marketplace path: ${existingMarketplacePath} (likely dogfood \u2014 leaving alone)`);
1210
+ }
1211
+ merged.extraKnownMarketplaces = {
1212
+ ...existing.extraKnownMarketplaces ?? {},
1213
+ [MARKETPLACE_ID]: {
1214
+ source: { source: "directory", path: targetPath }
1215
+ }
1216
+ };
1217
+ const pluginKey = `${PLUGIN_ID}@${MARKETPLACE_ID}`;
1218
+ const existingEnabledPlugins = existing.enabledPlugins ?? {};
1219
+ merged.enabledPlugins = {
1220
+ ...existingEnabledPlugins,
1221
+ ...pluginKey in existingEnabledPlugins ? {} : { [pluginKey]: true }
1222
+ };
1223
+ if (pluginKey in existingEnabledPlugins && existingEnabledPlugins[pluginKey] === false) {
1224
+ info(`enabledPlugins["${pluginKey}"] is explicitly false \u2014 leaving disabled (re-enable manually to use /kit:* commands)`);
1225
+ }
1226
+ if (DRY_RUN) {
1227
+ dryNote(`would ${hadExisting ? "merge into" : "write"} .claude/settings.json (set extraKnownMarketplaces.${MARKETPLACE_ID} + enabledPlugins.${PLUGIN_ID}@${MARKETPLACE_ID}; preserves every other key)`);
1228
+ return;
1229
+ }
1230
+ fs4.mkdirSync(path4.dirname(p), { recursive: true });
1231
+ fs4.writeFileSync(p, JSON.stringify(merged, null, 2) + "\n", "utf-8");
1232
+ ok(`${hadExisting ? "merged into" : "wrote"} .claude/settings.json (extraKnownMarketplaces.${MARKETPLACE_ID} + enabledPlugins.${PLUGIN_ID}@${MARKETPLACE_ID})`);
1233
+ }
1234
+ var RECALL_HOOK_SIGNATURE = "ensure-recall.sh";
1235
+ var RECALL_HOOK_COMMAND = 'bash "${CLAUDE_PROJECT_DIR:-$PWD}/scripts/ensure-recall.sh"';
1236
+ function scaffoldRecallHook(targetDir) {
1237
+ const scaffoldsRoot = path4.resolve(__dirname, "..", "..", "scaffolds", "recall-hook");
1238
+ if (!fs4.existsSync(scaffoldsRoot)) {
1239
+ info(`\u26A0 recall-hook scaffolds not found at ${scaffoldsRoot} \u2014 skipping (this is a packaging bug; main onboarding is unaffected)`);
1240
+ return;
1241
+ }
1242
+ info("scaffolding recall-hook (SessionStart watcher-respawn hook + ensure-recall.sh) \u2026");
1243
+ copyScaffoldDirAlways(scaffoldsRoot, targetDir, "");
1244
+ wireRecallHook(targetDir);
1245
+ ok("recall-hook ready \u2014 opens with Claude Code will respawn the launch-recall watcher if it died between sessions");
1246
+ }
1247
+ function wireRecallHook(targetDir) {
1248
+ const p = path4.join(targetDir, ".claude", "settings.json");
1249
+ const hadExisting = fs4.existsSync(p);
1250
+ let existing = {};
1251
+ if (hadExisting) {
1252
+ try {
1253
+ existing = JSON.parse(fs4.readFileSync(p, "utf-8"));
1254
+ } catch (err) {
1255
+ fail(`Could not parse existing .claude/settings.json: ${err instanceof Error ? err.message : String(err)}`);
1256
+ }
1257
+ }
1258
+ const hooks = existing.hooks ?? {};
1259
+ const sessionStart = hooks.SessionStart ?? [];
1260
+ const alreadyWired = sessionStart.some(
1261
+ (group) => (group.hooks ?? []).some((h) => typeof h?.command === "string" && h.command.includes(RECALL_HOOK_SIGNATURE))
1262
+ );
1263
+ if (alreadyWired) {
1264
+ info(".claude/settings.json SessionStart hook already references ensure-recall.sh \u2014 leaving alone");
1265
+ return;
1266
+ }
1267
+ const newGroup = {
1268
+ hooks: [{ type: "command", command: RECALL_HOOK_COMMAND }]
1269
+ };
1270
+ const merged = {
1271
+ ...existing,
1272
+ hooks: {
1273
+ ...hooks,
1274
+ SessionStart: [...sessionStart, newGroup]
1275
+ }
1276
+ };
1277
+ if (DRY_RUN) {
1278
+ dryNote(`would append SessionStart hook to .claude/settings.json (bash scripts/ensure-recall.sh; preserves every other key + existing hooks)`);
1279
+ return;
1280
+ }
1281
+ fs4.mkdirSync(path4.dirname(p), { recursive: true });
1282
+ fs4.writeFileSync(p, JSON.stringify(merged, null, 2) + "\n", "utf-8");
1283
+ ok(`appended SessionStart hook to .claude/settings.json (bash scripts/ensure-recall.sh)`);
1284
+ }
1285
+ function tryActivateStatusline() {
1286
+ if (DRY_RUN) {
1287
+ dryNote(`would wrap ~/.claude/settings.json statusLine.command with launch-kit's MCP chip wrapper (skips silently if no statusline configured)`);
1288
+ return;
1289
+ }
1290
+ const res = activateStatusline();
1291
+ if (res.ok && res.outcome === "activated") {
1292
+ ok(`statusline wrapped \u2014 MCP chips will render alongside your existing statusline next session`);
1293
+ } else if (res.ok && res.outcome === "refreshed") {
1294
+ info(`statusline already wrapped \u2014 refreshed chip scripts`);
1295
+ }
1296
+ }
511
1297
  async function main() {
1298
+ const subcommand = process.argv[2];
1299
+ if (subcommand === "statusline") {
1300
+ const action = process.argv[3];
1301
+ if (!action || action === "--help" || action === "-h") {
1302
+ console.log("usage: launch-kit statusline activate [--show=recall,chart,deck,council] [--compact]");
1303
+ console.log(" launch-kit statusline deactivate");
1304
+ console.log("");
1305
+ console.log(" --show comma-separated subset of chips. Default: all four.");
1306
+ console.log(" --compact collapse to `mcp N/total` (green if all up, red otherwise).");
1307
+ console.log(" Re-run activate with different flags to change in place.");
1308
+ return;
1309
+ }
1310
+ let showArg;
1311
+ let compactArg = false;
1312
+ for (const a of process.argv.slice(4)) {
1313
+ if (a.startsWith("--show=")) showArg = a.slice("--show=".length);
1314
+ else if (a === "--compact") compactArg = true;
1315
+ else fail(`Unknown statusline flag: "${a}". Supported: --show=<csv>, --compact.`);
1316
+ }
1317
+ const { activateStatusline: activateStatusline2, deactivateStatusline: deactivateStatusline2 } = await Promise.resolve().then(() => (init_statusline_install(), statusline_install_exports));
1318
+ let res;
1319
+ if (action === "activate") res = activateStatusline2({ show: showArg, compact: compactArg });
1320
+ else if (action === "deactivate") res = deactivateStatusline2();
1321
+ else fail(`Unknown statusline action: "${action}". Supported: activate, deactivate.`);
1322
+ if (res.ok) ok(`statusline ${res.outcome} \u2014 ${res.message}`);
1323
+ else info(`statusline ${res.outcome} \u2014 ${res.message}`);
1324
+ return;
1325
+ }
512
1326
  const args = parseArgs(process.argv.slice(2));
513
1327
  if (args.help) {
514
- printHelp();
1328
+ if (subcommand === "refresh") printRefreshHelp();
1329
+ else printHelp();
515
1330
  return;
516
1331
  }
517
- const subcommand = process.argv[2];
518
- if (subcommand && subcommand !== "init" && !subcommand.startsWith("--")) {
519
- fail(`Unknown subcommand "${subcommand}". Only "init" is supported. Run with --help for usage.`);
1332
+ if (!subcommand || subcommand.startsWith("--")) {
1333
+ fail(`missing subcommand. Usage: launch-kit <init|refresh|statusline> [options]. Run with --help.`);
1334
+ }
1335
+ if (subcommand !== "init" && subcommand !== "refresh") {
1336
+ fail(`Unknown subcommand "${subcommand}". Supported: init, refresh, statusline. Run with --help for usage.`);
1337
+ }
1338
+ DRY_RUN = args.dryRun;
1339
+ if (DRY_RUN) {
1340
+ info("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
1341
+ info("DRY RUN \u2014 no files will be written, no commands will run.");
1342
+ info("Lines tagged (dry-run) show what would happen.");
1343
+ info("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
1344
+ }
1345
+ if (subcommand === "refresh") return mainRefresh(args);
1346
+ return mainInit(args);
1347
+ }
1348
+ async function mainRefresh(args) {
1349
+ const cwd = process.cwd();
1350
+ const targetDir = path4.resolve(args.targetDir ?? cwd);
1351
+ if (!fs4.existsSync(targetDir)) fail(`target dir does not exist: ${targetDir}`);
1352
+ let cred;
1353
+ let source;
1354
+ try {
1355
+ const recovery = recoverCred(targetDir, getRecoveryOptions());
1356
+ cred = recovery.cred;
1357
+ source = recovery.source;
1358
+ } catch (err) {
1359
+ fail(err instanceof Error ? err.message : String(err));
1360
+ }
1361
+ if (cred && source === "mcp") {
1362
+ info(`recovered cred from .mcp.json launch-secure headers (PAT + org + project + url)`);
1363
+ const courseName = inferCourseName(cred.serverUrl);
1364
+ const nested2 = upsertProfile(null, courseName, cred);
1365
+ if (DRY_RUN) {
1366
+ dryNote(`would write ${CONFIG_FILENAME} from recovered .mcp.json cred (course: ${courseName})`);
1367
+ } else {
1368
+ writeJsonAtomic(path4.join(targetDir, CONFIG_FILENAME), nested2, 384);
1369
+ ok(`wrote ${CONFIG_FILENAME} (course: ${courseName})`);
1370
+ }
1371
+ }
1372
+ if (!cred) {
1373
+ fail(
1374
+ `no ${CONFIG_FILENAME} found at ${targetDir}, and could not recover from .mcp.json. Refresh requires an existing cred or a hardcoded launch-secure MCP entry \u2014 run \`npx @launchsecure/launch-kit init --token=<pat> --org=<org> --project=<project> --dir=${path4.relative(cwd, targetDir) || "."}\` first.`
1375
+ );
1376
+ }
1377
+ const nested = toNested(cred);
1378
+ if (!nested) fail(`${CONFIG_FILENAME} is malformed or missing required fields (pat/orgSlug/projectSlug/serverUrl).`);
1379
+ const active = nested.profiles[nested.active];
1380
+ if (!active) fail(`${CONFIG_FILENAME} active profile "${nested.active}" is not present in profiles.`);
1381
+ info(`refreshing launch-kit in ${targetDir} (course: ${nested.active}, project: ${active.orgSlug}/${active.projectSlug}) \u2026`);
1382
+ const cfg = { pat: active.pat, orgSlug: active.orgSlug, projectSlug: active.projectSlug, serverUrl: active.serverUrl };
1383
+ mergeMcpFile(targetDir, buildLaunchKitMcpEntries(cfg));
1384
+ ensureGitignoreLine(targetDir, CONFIG_FILENAME);
1385
+ if (!args.noMigrateSafety) scaffoldMigrateSafety(targetDir, args.refreshScaffolds);
1386
+ if (!args.noLsMarketplace) scaffoldLsMarketplace(targetDir);
1387
+ if (!args.noRecallHook) scaffoldRecallHook(targetDir);
1388
+ tryActivateStatusline();
1389
+ console.log("");
1390
+ if (DRY_RUN) {
1391
+ info("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
1392
+ info(`DRY RUN COMPLETE \u2014 refresh would have applied the above; no files modified.`);
1393
+ info("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
1394
+ return;
1395
+ }
1396
+ ok(`refresh complete \u2014 restart Claude Code to pick up any new /kit:* commands`);
1397
+ if (!args.quiet) {
1398
+ console.log(`
1399
+ Skipped (refresh never runs these): clone, dependency install, onboard script, launch-recall init. Use \`npx @launchsecure/launch-kit init\` for a full bootstrap.
1400
+ ${getLaunchKitToolsGuide()}`);
1401
+ }
1402
+ }
1403
+ async function mainInit(args) {
1404
+ const probeDir = path4.resolve(args.targetDir ?? process.cwd());
1405
+ if (!args.force && fs4.existsSync(probeDir)) {
1406
+ const detection = detectExistingBootstrap(probeDir);
1407
+ if (detection.bootstrapped) {
1408
+ info(`detected existing bootstrap at ${probeDir} (${detection.reason})`);
1409
+ info(`delegating to refresh. Pass --force to re-init from scratch (will re-prompt for PAT if needed).`);
1410
+ return mainRefresh({ ...args, targetDir: probeDir });
1411
+ }
1412
+ }
1413
+ if (!args.token || !args.orgSlug || !args.projectSlug) {
1414
+ const recoveryDir = path4.resolve(args.targetDir ?? process.cwd());
1415
+ if (fs4.existsSync(recoveryDir)) {
1416
+ const { cred } = recoverCred(recoveryDir, getRecoveryOptions());
1417
+ const nested = cred ? toNested(cred) : null;
1418
+ const recovered = nested ? nested.profiles[nested.active] : cred;
1419
+ if (recovered) {
1420
+ if (!args.token && recovered.pat) {
1421
+ args.token = recovered.pat;
1422
+ info(`recovered --token from existing config in ${recoveryDir}`);
1423
+ }
1424
+ if (!args.orgSlug && recovered.orgSlug) {
1425
+ args.orgSlug = recovered.orgSlug;
1426
+ info(`recovered --org=${recovered.orgSlug} from existing config`);
1427
+ }
1428
+ if (!args.projectSlug && recovered.projectSlug) {
1429
+ args.projectSlug = recovered.projectSlug;
1430
+ info(`recovered --project=${recovered.projectSlug} from existing config`);
1431
+ }
1432
+ if (args.serverUrl === DEFAULT_SERVER_URL && recovered.serverUrl) {
1433
+ args.serverUrl = recovered.serverUrl;
1434
+ }
1435
+ }
1436
+ }
520
1437
  }
521
1438
  if (!args.token) {
522
1439
  const t = await prompt("LaunchSecure PAT (ls_pat_\u2026): ");
@@ -542,10 +1459,10 @@ async function main() {
542
1459
  }
543
1460
  const repoUrl = resolved.repositoryUrl;
544
1461
  const cwd = process.cwd();
545
- const targetDir = path.resolve(args.targetDir ?? path.join(cwd, resolved.projectSlug));
1462
+ const targetDir = path4.resolve(args.targetDir ?? path4.join(cwd, resolved.projectSlug));
546
1463
  const normalizedRemote = normalizeRepoUrl(repoUrl);
547
1464
  let skipClone = false;
548
- if (fs.existsSync(targetDir)) {
1465
+ if (fs4.existsSync(targetDir)) {
549
1466
  if (isGitRepo(targetDir)) {
550
1467
  const existingRemote = gitRemoteUrl(targetDir);
551
1468
  if (existingRemote && normalizeRepoUrl(existingRemote) === normalizedRemote) {
@@ -565,7 +1482,8 @@ async function main() {
565
1482
  projectSlug: resolved.projectSlug,
566
1483
  serverUrl: args.serverUrl
567
1484
  };
568
- writeConfigFile(targetDir, cfg);
1485
+ const courseName = args.course ?? inferCourseName(cfg.serverUrl);
1486
+ writeConfigFile(targetDir, cfg, courseName);
569
1487
  mergeMcpFile(targetDir, buildLaunchKitMcpEntries(cfg));
570
1488
  ensureGitignoreLine(targetDir, CONFIG_FILENAME);
571
1489
  let installSkippedReason = null;
@@ -577,30 +1495,41 @@ async function main() {
577
1495
  installSkippedReason = "no package.json found";
578
1496
  } else {
579
1497
  runInstall(targetDir, detected);
580
- if (hasOnboardScript(targetDir)) runOnboard(targetDir, detected.pm);
1498
+ if (!args.noOnboard && hasOnboardScript(targetDir)) runOnboard(targetDir, detected.pm);
581
1499
  }
582
1500
  const hasOnboard = hasOnboardScript(targetDir);
583
1501
  if (!args.noRecall) runRecallInit(targetDir);
584
- if (!args.noMigrateSafety) scaffoldMigrateSafety(targetDir);
585
- const relTarget = path.relative(cwd, targetDir) || ".";
1502
+ if (!args.noMigrateSafety) scaffoldMigrateSafety(targetDir, args.refreshScaffolds);
1503
+ if (!args.noLsMarketplace) scaffoldLsMarketplace(targetDir);
1504
+ if (!args.noRecallHook) scaffoldRecallHook(targetDir);
1505
+ tryActivateStatusline();
1506
+ const relTarget = path4.relative(cwd, targetDir) || ".";
586
1507
  console.log("");
1508
+ if (DRY_RUN) {
1509
+ info("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
1510
+ info(`DRY RUN COMPLETE \u2014 no files were modified, no commands ran.`);
1511
+ info(`Target: ${targetDir}`);
1512
+ info(`Re-run without --dry-run to apply the changes shown above.`);
1513
+ info("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
1514
+ return;
1515
+ }
587
1516
  ok(`done \u2014 ${resolved.projectName} is ready at ${targetDir}`);
588
1517
  if (installSkippedReason) {
589
1518
  const installLine = detected ? ` ${detected.pm.binary} ${detected.pm.installArgs.join(" ")}` : ` npm install # or your package manager of choice`;
590
- const onboardLine = hasOnboard && detected ? `
1519
+ const onboardLine = hasOnboard && detected && !args.noOnboard ? `
591
1520
  ${detected.pm.binary} run ${ONBOARD_SCRIPT_NAME} # project setup hook` : "";
592
1521
  console.log(`
593
1522
  Next steps (install skipped: ${installSkippedReason}):
594
1523
  cd ${relTarget}
595
1524
  ${installLine}${onboardLine}
596
- claude # launch Claude Code (5 MCPs wired)
597
- ${LAUNCH_KIT_TOOLS_GUIDE}`);
598
- } else {
1525
+ claude # launch Claude Code (5 MCPs wired)${args.quiet ? "" : `
1526
+ ${getLaunchKitToolsGuide()}`}`);
1527
+ } else if (!args.quiet) {
599
1528
  console.log(`
600
1529
  Next steps:
601
1530
  cd ${relTarget}
602
1531
  claude # launch Claude Code (5 MCPs wired)
603
- ${LAUNCH_KIT_TOOLS_GUIDE}`);
1532
+ ${getLaunchKitToolsGuide()}`);
604
1533
  }
605
1534
  }
606
1535
  main().catch((err) => {