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