@promptctl/cc-candybar 1.42.0 → 1.43.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.
Files changed (126) hide show
  1. package/README.md +4 -0
  2. package/dist/index.mjs +81 -80
  3. package/package.json +5 -6
  4. package/src/check.ts +0 -478
  5. package/src/cli-flags.ts +0 -8
  6. package/src/click/wire.ts +0 -158
  7. package/src/config/action.ts +0 -329
  8. package/src/config/cli.ts +0 -71
  9. package/src/config/default-dsl-config.ts +0 -1645
  10. package/src/config/disclosure.ts +0 -170
  11. package/src/config/dsl-loader.ts +0 -339
  12. package/src/config/dsl-types.ts +0 -581
  13. package/src/config/edit-chrome.ts +0 -559
  14. package/src/config/help.ts +0 -151
  15. package/src/config/ident.ts +0 -22
  16. package/src/config/layout-ops.ts +0 -177
  17. package/src/config/loader/actions.ts +0 -972
  18. package/src/config/loader/cache.ts +0 -206
  19. package/src/config/loader/cross-ref.ts +0 -714
  20. package/src/config/loader/cycles.ts +0 -148
  21. package/src/config/loader/diagnostics.ts +0 -99
  22. package/src/config/loader/discovery.ts +0 -182
  23. package/src/config/loader/edit-mode.ts +0 -137
  24. package/src/config/loader/emit-schema.ts +0 -68
  25. package/src/config/loader/globals.ts +0 -269
  26. package/src/config/loader/helpers.ts +0 -48
  27. package/src/config/loader/layout.ts +0 -693
  28. package/src/config/loader/looks.ts +0 -96
  29. package/src/config/loader/menu-synth.ts +0 -435
  30. package/src/config/loader/merge.ts +0 -115
  31. package/src/config/loader/persist-target.ts +0 -67
  32. package/src/config/loader/presets.ts +0 -119
  33. package/src/config/loader/refs.ts +0 -100
  34. package/src/config/loader/reserved-namespace.ts +0 -38
  35. package/src/config/loader/segments.ts +0 -120
  36. package/src/config/loader/validate-core.ts +0 -737
  37. package/src/config/loader/variables.ts +0 -260
  38. package/src/config/menu-keys.ts +0 -139
  39. package/src/config/option-domain.ts +0 -164
  40. package/src/config/presets.ts +0 -326
  41. package/src/config/settings-menu.ts +0 -775
  42. package/src/daemon/acquire.ts +0 -684
  43. package/src/daemon/cache/git.ts +0 -649
  44. package/src/daemon/cache/render.ts +0 -623
  45. package/src/daemon/cache/session-usage-store.ts +0 -720
  46. package/src/daemon/cache/watchers.ts +0 -249
  47. package/src/daemon/client-debug.ts +0 -120
  48. package/src/daemon/client-stats.ts +0 -130
  49. package/src/daemon/client-transport.ts +0 -273
  50. package/src/daemon/client.ts +0 -78
  51. package/src/daemon/config-overrides-store.ts +0 -663
  52. package/src/daemon/debug-types.ts +0 -91
  53. package/src/daemon/debug.ts +0 -264
  54. package/src/daemon/fork-bomb-breaker.ts +0 -351
  55. package/src/daemon/limits.ts +0 -211
  56. package/src/daemon/log.ts +0 -81
  57. package/src/daemon/parent-watchdog.ts +0 -87
  58. package/src/daemon/paths.ts +0 -211
  59. package/src/daemon/process-fingerprint.ts +0 -146
  60. package/src/daemon/protocol.ts +0 -292
  61. package/src/daemon/render-payload.ts +0 -1256
  62. package/src/daemon/server.ts +0 -1330
  63. package/src/daemon/session-state-file.ts +0 -108
  64. package/src/daemon/session-state.ts +0 -237
  65. package/src/daemon/socket-lease.ts +0 -209
  66. package/src/daemon/socket-ownership.ts +0 -209
  67. package/src/daemon/stats.ts +0 -235
  68. package/src/daemon/verbs/config-validators.ts +0 -250
  69. package/src/daemon/verbs/index.ts +0 -706
  70. package/src/daemon/verbs/state-validators.ts +0 -249
  71. package/src/daemon/verbs/validator-registry.ts +0 -457
  72. package/src/demo/dsl.ts +0 -143
  73. package/src/demo/mock-data.ts +0 -67
  74. package/src/demo/statusline.json5 +0 -94
  75. package/src/dsl/node-registry.ts +0 -374
  76. package/src/dsl/render.ts +0 -803
  77. package/src/help-text.ts +0 -90
  78. package/src/index.ts +0 -210
  79. package/src/install/index.ts +0 -541
  80. package/src/proc/launch.ts +0 -459
  81. package/src/proc/stats-handle.ts +0 -13
  82. package/src/render/action.ts +0 -883
  83. package/src/render/active-segment.ts +0 -78
  84. package/src/render/diagnostic-style.ts +0 -23
  85. package/src/render/diagnostic-text.ts +0 -77
  86. package/src/render/error-glyph.ts +0 -53
  87. package/src/render/menu.ts +0 -257
  88. package/src/render/outcome-plan.ts +0 -45
  89. package/src/render/picker.ts +0 -372
  90. package/src/render/segment-color.ts +0 -74
  91. package/src/render/split-lines.ts +0 -51
  92. package/src/render/strip.ts +0 -228
  93. package/src/segments/cache.ts +0 -131
  94. package/src/segments/context.ts +0 -190
  95. package/src/segments/git.ts +0 -1084
  96. package/src/segments/metrics.ts +0 -187
  97. package/src/segments/pricing.ts +0 -452
  98. package/src/segments/session.ts +0 -23
  99. package/src/segments/tmux.ts +0 -74
  100. package/src/template-engine/cells.ts +0 -90
  101. package/src/template-engine/colors.ts +0 -124
  102. package/src/template-engine/engine.ts +0 -108
  103. package/src/template-engine/funcs.ts +0 -232
  104. package/src/template-engine/index.ts +0 -11
  105. package/src/template-engine/layout.ts +0 -133
  106. package/src/template-engine/scope.ts +0 -62
  107. package/src/template-engine/sparkline.ts +0 -79
  108. package/src/themes/index.ts +0 -20
  109. package/src/themes/palette-resolvers.ts +0 -84
  110. package/src/themes/policy.ts +0 -393
  111. package/src/utils/cache.ts +0 -206
  112. package/src/utils/claude.ts +0 -683
  113. package/src/utils/color-support.ts +0 -118
  114. package/src/utils/formatters.ts +0 -99
  115. package/src/utils/logger.ts +0 -5
  116. package/src/utils/outcome.ts +0 -33
  117. package/src/utils/schema-validator.ts +0 -126
  118. package/src/utils/single-flight.ts +0 -57
  119. package/src/utils/terminal-width.ts +0 -51
  120. package/src/utils/terminal.ts +0 -11
  121. package/src/utils/transcript-fs.ts +0 -279
  122. package/src/var-system/index.ts +0 -24
  123. package/src/var-system/sources.ts +0 -1047
  124. package/src/var-system/store.ts +0 -223
  125. package/src/var-system/types.ts +0 -57
  126. package/src/version.ts +0 -17
@@ -1,541 +0,0 @@
1
- import fs from "node:fs";
2
- import path from "node:path";
3
- import os from "node:os";
4
- import { createRequire } from "node:module";
5
- import { launchSync } from "../proc/launch";
6
- import { tryClickViaDaemon } from "../daemon/client";
7
- import type { PermanentOutcome } from "../daemon/client-transport";
8
- import { obtainDaemonKick } from "../daemon/acquire";
9
- import { URL_SCHEME, VERB_COPY } from "../click/wire";
10
- import { DISCLOSURE_GLYPH_CLOSED } from "../config/disclosure";
11
- import { PACKAGE_VERSION } from "../version";
12
-
13
- const PACKAGE_NAME = "@promptctl/cc-candybar";
14
- const BUNDLE_ID = "com.cccandybar.url-handler";
15
- const APP_NAME = "CCCandybarURLHandler";
16
-
17
- // [LAW:one-source-of-truth] `install` writes no renderer flags into
18
- // ~/.claude/settings.json. All authoring lives in `.cc-candybar.json5` or
19
- // `.cc-candybar.json` (see resolveDslConfigPath — both extensions accepted,
20
- // .json5 preferred); the install command's job is staging the runtime,
21
- // wiring the URL handler, and pointing settings at the staged entry.
22
- const DEFAULT_INSTALL_ARGS: readonly string[] = [];
23
-
24
- // [LAW:one-type-per-behavior] One platform → one package name; the render
25
- // entry is the same contract everywhere, only the artifact differs.
26
- const PLATFORM_PACKAGES: Record<string, string> = {
27
- "darwin-arm64": "@promptctl/cc-candybar-darwin-arm64",
28
- "darwin-x64": "@promptctl/cc-candybar-darwin-x64",
29
- "linux-x64": "@promptctl/cc-candybar-linux-x64",
30
- "linux-arm64": "@promptctl/cc-candybar-linux-arm64",
31
- };
32
-
33
- function shellEscape(arg: string): string {
34
- // Safe characters that don't need quoting in any reasonable shell.
35
- if (/^[A-Za-z0-9_./=,:-]+$/.test(arg)) return arg;
36
- return `'${arg.replace(/'/g, "'\\''")}'`;
37
- }
38
-
39
- function buildStatusLineCommand(
40
- binPath: string,
41
- rendererArgs: readonly string[],
42
- ): string {
43
- return [binPath, ...rendererArgs].map(shellEscape).join(" ");
44
- }
45
-
46
- function appBundlePath(): string {
47
- return path.join(os.homedir(), "Applications", `${APP_NAME}.app`);
48
- }
49
-
50
- function settingsJsonPath(): string {
51
- return path.join(os.homedir(), ".claude", "settings.json");
52
- }
53
-
54
- function ensureMacOS(): void {
55
- if (process.platform !== "darwin") {
56
- throw new Error(
57
- `URL handler installation requires macOS (found platform: ${process.platform}).`,
58
- );
59
- }
60
- }
61
-
62
- // [LAW:one-source-of-truth] The staged runtime lives at ONE stable path per
63
- // platform, outside any package manager's store — pnpm cache pruning or a
64
- // version bump can never yank the files the statusline and daemon run from.
65
- function supportDir(): string {
66
- if (process.platform === "darwin") {
67
- return path.join(
68
- os.homedir(),
69
- "Library",
70
- "Application Support",
71
- "CCCandybar",
72
- );
73
- }
74
- const xdgData =
75
- process.env.XDG_DATA_HOME ?? path.join(os.homedir(), ".local", "share");
76
- return path.join(xdgData, "cc-candybar");
77
- }
78
-
79
- function stagedBinPath(): string {
80
- return path.join(supportDir(), "bin", "cc-candybar");
81
- }
82
-
83
- function stagedDistPath(): string {
84
- return path.join(supportDir(), "dist", "index.mjs");
85
- }
86
-
87
- function appleScriptSource(nodePath: string, scriptPath: string): string {
88
- // Bake absolute paths into the AppleScript so click-time invocation doesn't
89
- // depend on PATH or install-time cache state. The dist bundle is fully
90
- // self-contained (tsdown noExternal), so no NODE_PATH is needed.
91
- const escNode = nodePath.replace(/"/g, '\\"');
92
- const escScript = scriptPath.replace(/"/g, '\\"');
93
- return [
94
- "on open location L",
95
- `\tdo shell script "'${escNode}' '${escScript}' url-handle " & quoted form of L`,
96
- "end open location",
97
- ].join("\n");
98
- }
99
-
100
- // [LAW:one-source-of-truth] The bundle that contains *this* function is
101
- // the thing we need to stage. Two invocation paths reach us:
102
- // - via the bin shim: process.argv[1] = ".../bin/cc-candybar" which
103
- // dynamically imports ../dist/index.mjs — resolve the sibling dist.
104
- // - direct node: process.argv[1] = ".../dist/index.mjs". Use as-is.
105
- export function locateBundledDist(argv1: string | undefined): string {
106
- if (!argv1) {
107
- throw new Error("install: process.argv[1] not set");
108
- }
109
- if (argv1.endsWith(".mjs") || argv1.endsWith(".js")) {
110
- return argv1;
111
- }
112
- // Treat argv[1] as a bin shim and assume sibling dist/index.mjs.
113
- return path.resolve(path.dirname(argv1), "..", "dist", "index.mjs");
114
- }
115
-
116
- // The staged render entry: the prebuilt native binary when this platform has
117
- // one, else the node bin shim. Same contract either way — read hookData on
118
- // stdin, resolve ../dist/index.mjs by adjacency — so downstream code never
119
- // cares which flavor was staged. [LAW:one-type-per-behavior]
120
- type RenderEntry =
121
- | { kind: "native"; sourcePath: string }
122
- | { kind: "node-shim"; sourcePath: string };
123
-
124
- function resolveRenderEntry(sourceDist: string): RenderEntry {
125
- const key = `${process.platform}-${process.arch}`;
126
- const pkgName = PLATFORM_PACKAGES[key];
127
- if (pkgName) {
128
- // Anchored to the bundle's real location, not this module's compiled
129
- // form (which ts-jest loads as CJS where import.meta is illegal) — the
130
- // platform package lives in node_modules beside the installed dist.
131
- const require = createRequire(sourceDist);
132
- try {
133
- return {
134
- kind: "native",
135
- sourcePath: require.resolve(`${pkgName}/bin/cc-candybar`),
136
- };
137
- } catch {
138
- // Optional dependency absent (unsupported install, pruned optionals).
139
- // Falls through to the node shim — announced by the caller, never
140
- // silent. [LAW:no-silent-failure]
141
- }
142
- }
143
- return {
144
- kind: "node-shim",
145
- sourcePath: path.resolve(
146
- path.dirname(sourceDist),
147
- "..",
148
- "bin",
149
- "cc-candybar",
150
- ),
151
- };
152
- }
153
-
154
- function stageFile(source: string, dest: string): void {
155
- // Re-running install FROM the staged runtime makes source === dest;
156
- // copyFileSync would truncate the file onto itself. Identity is "already
157
- // staged", not an error.
158
- if (path.resolve(source) === path.resolve(dest)) return;
159
- fs.copyFileSync(source, dest);
160
- }
161
-
162
- // [LAW:one-source-of-truth] The staged file is the authority on what got
163
- // staged. resolveRenderEntry's kind describes the *source lookup*, and on a
164
- // re-run from the staged runtime that lookup resolves the identity path
165
- // (source === dest), preserving whatever is on disk — possibly a native
166
- // binary from a prior install that the lookup couldn't see. So the announced
167
- // kind derives from the artifact itself: both flavors are ours, and the node
168
- // shim is a "#!" script while the native binary is Mach-O/ELF.
169
- function stagedEntryKind(binPath: string): RenderEntry["kind"] {
170
- const fd = fs.openSync(binPath, "r");
171
- try {
172
- const magic = Buffer.alloc(2);
173
- const bytesRead = fs.readSync(fd, magic, 0, 2, 0);
174
- // [LAW:no-silent-failure] Under 2 bytes neither flavor exists — the file
175
- // is a corrupt artifact (a crashed prior copy preserved by the identity
176
- // path), not a native binary.
177
- if (bytesRead < 2) {
178
- throw new Error(
179
- `install: staged render entry at ${binPath} is truncated ` +
180
- `(${bytesRead} byte(s)). Re-run: pnpm dlx ${PACKAGE_NAME}@latest install`,
181
- );
182
- }
183
- return magic.toString("latin1") === "#!" ? "node-shim" : "native";
184
- } finally {
185
- fs.closeSync(fd);
186
- }
187
- }
188
-
189
- export interface StagedRuntime {
190
- binPath: string;
191
- distPath: string;
192
- entryKind: RenderEntry["kind"];
193
- }
194
-
195
- // Stage the full runtime at the stable path: dist/index.mjs (daemon + CLI
196
- // bundle) and bin/cc-candybar (render entry) as adjacent files. Adjacency IS
197
- // the contract — every entry flavor locates the bundle via ../dist/index.mjs.
198
- export function runStageRuntime(): StagedRuntime {
199
- const sourceDist = locateBundledDist(process.argv[1]);
200
- if (!fs.existsSync(sourceDist)) {
201
- throw new Error(
202
- `install: bundled dist not found at ${sourceDist}. Reinstall the package.`,
203
- );
204
- }
205
-
206
- const entry = resolveRenderEntry(sourceDist);
207
- if (!fs.existsSync(entry.sourcePath)) {
208
- throw new Error(
209
- `install: render entry not found at ${entry.sourcePath}. Reinstall the package.`,
210
- );
211
- }
212
-
213
- fs.mkdirSync(path.dirname(stagedDistPath()), { recursive: true });
214
- fs.mkdirSync(path.dirname(stagedBinPath()), { recursive: true });
215
- stageFile(sourceDist, stagedDistPath());
216
- stageFile(entry.sourcePath, stagedBinPath());
217
- fs.chmodSync(stagedBinPath(), 0o755);
218
-
219
- // The pre-1.21 layout kept a second copy of the bundle as url-handler.mjs;
220
- // dist/index.mjs is the one staged bundle now. Remove the stale copy so it
221
- // can't drift. [LAW:one-source-of-truth]
222
- fs.rmSync(path.join(supportDir(), "url-handler.mjs"), { force: true });
223
-
224
- const stagedKind = stagedEntryKind(stagedBinPath());
225
- process.stdout.write(
226
- `Staged cc-candybar v${PACKAGE_VERSION} runtime at ${supportDir()}\n` +
227
- (stagedKind === "native"
228
- ? ` render entry: native binary (${process.platform}-${process.arch})\n`
229
- : ` render entry: node shim (no native binary for ${process.platform}-${process.arch}; renders are correct but pay node startup)\n`),
230
- );
231
- return {
232
- binPath: stagedBinPath(),
233
- distPath: stagedDistPath(),
234
- entryKind: stagedKind,
235
- };
236
- }
237
-
238
- function infoPlistPatch(): Array<{ key: string; xml: string }> {
239
- return [
240
- {
241
- key: "CFBundleIdentifier",
242
- xml: `<string>${BUNDLE_ID}</string>`,
243
- },
244
- {
245
- key: "CFBundleURLTypes",
246
- xml: [
247
- "<array>",
248
- " <dict>",
249
- " <key>CFBundleURLName</key>",
250
- ` <string>Claude Powerline Click Action</string>`,
251
- " <key>CFBundleURLSchemes</key>",
252
- " <array>",
253
- ` <string>${URL_SCHEME}</string>`,
254
- " </array>",
255
- " </dict>",
256
- "</array>",
257
- ].join("\n"),
258
- },
259
- ];
260
- }
261
-
262
- // Callers establish the macOS precondition ([LAW:single-enforcer] — the
263
- // subcommand entry checks before any side effect; runInstall reaches here
264
- // only through its darwin dispatch).
265
- function installUrlHandlerFrom(stagedDist: string): void {
266
- const bundle = appBundlePath();
267
- fs.mkdirSync(path.dirname(bundle), { recursive: true });
268
-
269
- // If a previous handler exists, remove it so osacompile can write fresh.
270
- if (fs.existsSync(bundle)) {
271
- fs.rmSync(bundle, { recursive: true, force: true });
272
- }
273
-
274
- process.stdout.write(`Building ${bundle}\n`);
275
- const osa = launchSync({
276
- bin: "/usr/bin/osacompile",
277
- args: ["-o", bundle, "-e", appleScriptSource(process.execPath, stagedDist)],
278
- category: "install.osacompile",
279
- });
280
- if (!osa.ok) {
281
- process.stderr.write(osa.stderr);
282
- throw new Error(`osacompile failed (${osa.reason})`);
283
- }
284
-
285
- const plistPath = path.join(bundle, "Contents", "Info.plist");
286
-
287
- for (const { key } of infoPlistPatch()) {
288
- // plutil errors if the key already exists; pre-delete so the operation is
289
- // idempotent. Ignore failures (key may not exist on a fresh build).
290
- launchSync({
291
- bin: "/usr/bin/plutil",
292
- args: ["-remove", key, plistPath],
293
- category: "install.plutil",
294
- });
295
- }
296
-
297
- for (const { key, xml } of infoPlistPatch()) {
298
- const r = launchSync({
299
- bin: "/usr/bin/plutil",
300
- args: ["-insert", key, "-xml", xml, plistPath],
301
- category: "install.plutil",
302
- });
303
- if (!r.ok) {
304
- process.stderr.write(r.stderr);
305
- throw new Error(`plutil -insert ${key} failed (${r.reason})`);
306
- }
307
- }
308
-
309
- process.stdout.write(`Registering ${URL_SCHEME}:// with Launch Services\n`);
310
- const lsr = launchSync({
311
- bin: "/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister",
312
- args: ["-f", bundle],
313
- category: "install.lsregister",
314
- });
315
- if (!lsr.ok) {
316
- process.stderr.write(lsr.stderr);
317
- throw new Error(`lsregister failed (${lsr.reason})`);
318
- }
319
-
320
- process.stdout.write(`✓ ${APP_NAME}.app installed and registered.\n`);
321
- process.stdout.write(
322
- ` Test: open '${URL_SCHEME}://hello-world' && pbpaste\n`,
323
- );
324
- }
325
-
326
- export function runInstallUrlHandler(): void {
327
- // Precondition before any side effect: on a non-mac this fails with zero
328
- // files written, not after staging the runtime.
329
- ensureMacOS();
330
- const staged = runStageRuntime();
331
- installUrlHandlerFrom(staged.distPath);
332
- }
333
-
334
- interface ParsedUrl {
335
- verb: string;
336
- value: string;
337
- }
338
-
339
- // [LAW:dataflow-not-control-flow] Parse the URL into a {verb, value} pair
340
- // without using `new URL`, which lowercases hosts (would mangle case-sensitive
341
- // session ids). Format: cc-candybar://<verb>/<tail> | cc-candybar://<value>
342
- // (bare → copy). The verb ends at the FIRST `/`; everything after is the raw
343
- // value. The dispatch effect list rides as `dispatch/e=…&e=…`, so its query-
344
- // style payload is just the tail — `?` is NOT a delimiter, it is ordinary data
345
- // in a bare-copy value (`cc-candybar://hello?world` copies "hello?world").
346
- //
347
- // [LAW:single-enforcer] Only the VERB is decoded here. The value is passed RAW
348
- // to the daemon; each verb's handler decodes its own value at its boundary (the
349
- // verb that owns the structure owns its decode). A whole-value decode here would
350
- // un-escape structural separators inside a nested value — the exact hazard that
351
- // made compound clicks unrepresentable — so it is deliberately absent.
352
- export function parseHandlerUrl(
353
- rawUrl: string,
354
- scheme: string = URL_SCHEME,
355
- ): ParsedUrl {
356
- const prefix = `${scheme}://`;
357
- if (!rawUrl.startsWith(prefix)) {
358
- throw new Error(`expected ${prefix} scheme, got: ${rawUrl}`);
359
- }
360
- const rest = rawUrl.slice(prefix.length);
361
- const slash = rest.indexOf("/");
362
- if (slash === -1) {
363
- return { verb: VERB_COPY, value: rest };
364
- }
365
- return {
366
- verb: decodeURIComponent(rest.slice(0, slash)),
367
- value: rest.slice(slash + 1),
368
- };
369
- }
370
-
371
- // [LAW:single-enforcer] url-handle is a thin IPC shim: parse the URL, send
372
- // the click request to the daemon, exit. There is NO in-process verb
373
- // dispatch and NO direct disk mutation. The daemon is the only writer of
374
- // click-side state ([LAW:one-source-of-truth] for SessionState); kicking a
375
- // daemon on a transient failure is the only recovery, and it's
376
- // fire-and-forget so the next click hits a warm daemon.
377
- //
378
- // A `permanent` daemon outcome (BAD_REQUEST for an unknown verb,
379
- // VERSION_MISMATCH against a future daemon) exits non-zero with the daemon's
380
- // error message so the failure is visible — never silently swallowed by a
381
- // local fallback that would diverge from the daemon's truth.
382
- export async function runUrlHandle(rawUrl: string | undefined): Promise<void> {
383
- if (!rawUrl) {
384
- process.stderr.write("url-handle: missing URL argument.\n");
385
- process.exit(1);
386
- }
387
-
388
- let parsed: ParsedUrl;
389
- try {
390
- parsed = parseHandlerUrl(rawUrl);
391
- } catch (err) {
392
- process.stderr.write(
393
- `url-handle: ${err instanceof Error ? err.message : String(err)}\n`,
394
- );
395
- process.exit(1);
396
- }
397
-
398
- const outcome = await tryClickViaDaemon(parsed.verb, parsed.value);
399
- if (outcome.kind === "ok") {
400
- process.exit(0);
401
- }
402
-
403
- if (outcome.kind === "transient") {
404
- // [LAW:dataflow-not-control-flow] Fire-and-forget kick; the user's click
405
- // is lost (the daemon couldn't service it), but the next click hits a
406
- // warm daemon. Mirrors the render-path's transient recovery.
407
- obtainDaemonKick();
408
- process.stderr.write(
409
- `url-handle: daemon unavailable (${outcome.cause}: ${outcome.message})\n`,
410
- );
411
- process.exit(1);
412
- }
413
-
414
- // [LAW:dataflow-not-control-flow] Format each permanent cause from its
415
- // own typed payload, not by probing for "message" on a generic outcome.
416
- // The PermanentOutcome union already discriminates by `cause`; the switch
417
- // mirrors that discriminator one-to-one and pulls the right fields.
418
- process.stderr.write(formatPermanent(outcome) + "\n");
419
- process.exit(1);
420
- }
421
-
422
- // [LAW:single-enforcer] One place that turns a PermanentOutcome into a
423
- // human-readable diagnostic. Each cause carries its own payload (version
424
- // mismatch carries the protocol numbers; everything else carries a
425
- // message); the formatter consumes exactly the fields the cause defines.
426
- function formatPermanent(outcome: PermanentOutcome): string {
427
- switch (outcome.cause) {
428
- case "version_mismatch":
429
- return `url-handle: daemon rejected click (version mismatch: client v${outcome.clientV} ≠ daemon v${outcome.daemonV})`;
430
- case "bad_request":
431
- return `url-handle: daemon rejected click (bad request: ${outcome.message})`;
432
- case "render_failed":
433
- return `url-handle: daemon rejected click (handler failed: ${outcome.message})`;
434
- case "malformed_response":
435
- return `url-handle: daemon rejected click (malformed response: ${outcome.message})`;
436
- }
437
- }
438
-
439
- // [LAW:effects-at-boundaries] Pure string builder — runInstall performs the
440
- // actual write. Kept separate so the message content is testable without
441
- // driving the full (fs + Launch Services) install side effects.
442
- // [LAW:one-source-of-truth] The disclosure glyph comes from config/disclosure.ts,
443
- // the same constant the theme/look picker itself renders with, so this tip
444
- // can't drift from what the bundled default bar actually shows.
445
- function installSuccessMessage(): string {
446
- return (
447
- `✓ install complete.\n` +
448
- ` Restart Claude Code to pick up the new statusline.\n` +
449
- ` Tip: every bar carries a settings menu — click ☰ ${DISCLOSURE_GLYPH_CLOSED} for preset\n` +
450
- ` switching, edit mode, and clickable theme/look/style/wrap/padding controls.\n`
451
- );
452
- }
453
-
454
- export function runInstall(rendererArgs: string[]): void {
455
- const force = rendererArgs.includes("--force");
456
- const filteredArgs = rendererArgs.filter((a) => a !== "--force");
457
-
458
- const argsToInstall =
459
- filteredArgs.length > 0 ? filteredArgs : [...DEFAULT_INSTALL_ARGS];
460
-
461
- const staged = runStageRuntime();
462
-
463
- if (process.platform === "darwin") {
464
- installUrlHandlerFrom(staged.distPath);
465
- } else {
466
- process.stdout.write(
467
- "Skipping URL handler (cmd-click verbs are macOS-only).\n",
468
- );
469
- }
470
-
471
- updateClaudeSettings(staged.binPath, argsToInstall, force);
472
-
473
- process.stdout.write(installSuccessMessage());
474
- }
475
-
476
- function updateClaudeSettings(
477
- binPath: string,
478
- rendererArgs: readonly string[],
479
- force: boolean,
480
- overridePath?: string,
481
- ): void {
482
- const target = overridePath ?? settingsJsonPath();
483
- fs.mkdirSync(path.dirname(target), { recursive: true });
484
-
485
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
486
- let settings: Record<string, any> = {};
487
- if (fs.existsSync(target)) {
488
- try {
489
- settings = JSON.parse(fs.readFileSync(target, "utf-8"));
490
- } catch (err) {
491
- throw new Error(
492
- `Could not parse ${target}: ${err instanceof Error ? err.message : String(err)}`,
493
- );
494
- }
495
- }
496
-
497
- const existing = settings.statusLine?.command as string | undefined;
498
- // [LAW:one-source-of-truth] Detection: a command we (or a prior version of
499
- // us) wrote either starts with the legacy `pnpm dlx` form (an open prefix —
500
- // a version suffix follows) or has the staged bin path — quoted or bare —
501
- // as its entire first token. Any other value is a user customization we
502
- // must not silently destroy.
503
- // [LAW:types-are-the-program] Token, not prefix: a bare startsWith(binPath)
504
- // would also claim `<binPath>-backup …` as ours and overwrite it.
505
- const managedTokens = [binPath, shellEscape(binPath)];
506
- const isOurs =
507
- typeof existing === "string" &&
508
- (existing.startsWith(`pnpm dlx ${PACKAGE_NAME}@`) ||
509
- managedTokens.some(
510
- (token) => existing === token || existing.startsWith(`${token} `),
511
- ));
512
-
513
- if (existing && !isOurs && !force) {
514
- process.stderr.write(
515
- `Skipping settings.json update: existing statusLine.command appears customized.\n` +
516
- ` Current: ${existing}\n` +
517
- ` To overwrite, re-run with --force.\n`,
518
- );
519
- return;
520
- }
521
-
522
- settings.statusLine = {
523
- type: "command",
524
- command: buildStatusLineCommand(binPath, rendererArgs),
525
- };
526
-
527
- fs.writeFileSync(target, JSON.stringify(settings, null, 2));
528
- process.stdout.write(`Updated ${target}\n`);
529
- }
530
-
531
- // Exports for testing
532
- export const __test__ = {
533
- shellEscape,
534
- buildStatusLineCommand,
535
- DEFAULT_INSTALL_ARGS,
536
- updateClaudeSettings,
537
- resolveRenderEntry,
538
- stageFile,
539
- stagedEntryKind,
540
- installSuccessMessage,
541
- };