@promptctl/cc-candybar 1.0.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 (111) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +145 -0
  3. package/bin/cc-candybar +6 -0
  4. package/dist/index.mjs +185 -0
  5. package/package.json +99 -0
  6. package/plugin/.claude-plugin/plugin.json +11 -0
  7. package/plugin/bin/preview.sh +305 -0
  8. package/plugin/commands/candybar.md +403 -0
  9. package/plugin/templates/config-essential.json +36 -0
  10. package/plugin/templates/config-full.json +55 -0
  11. package/plugin/templates/config-standard.json +39 -0
  12. package/plugin/templates/config-tui-compact.json +48 -0
  13. package/plugin/templates/config-tui-full.json +89 -0
  14. package/plugin/templates/config-tui-standard.json +56 -0
  15. package/plugin/templates/config-tui.json +18 -0
  16. package/plugin/templates/nerd-fonts-sample.txt +5 -0
  17. package/schema/cc-candybar.schema.json +1379 -0
  18. package/src/click/wire.ts +113 -0
  19. package/src/config/action.ts +91 -0
  20. package/src/config/cli.ts +170 -0
  21. package/src/config/default-dsl-config.ts +661 -0
  22. package/src/config/dsl-loader.ts +265 -0
  23. package/src/config/dsl-types.ts +425 -0
  24. package/src/config/loader/actions.ts +530 -0
  25. package/src/config/loader/cache.ts +206 -0
  26. package/src/config/loader/cross-ref.ts +326 -0
  27. package/src/config/loader/cycles.ts +148 -0
  28. package/src/config/loader/diagnostics.ts +99 -0
  29. package/src/config/loader/discovery.ts +182 -0
  30. package/src/config/loader/emit-schema.ts +63 -0
  31. package/src/config/loader/globals.ts +42 -0
  32. package/src/config/loader/helpers.ts +48 -0
  33. package/src/config/loader/layout.ts +688 -0
  34. package/src/config/loader/merge.ts +40 -0
  35. package/src/config/loader/refs.ts +96 -0
  36. package/src/config/loader/segments.ts +120 -0
  37. package/src/config/loader/validate-core.ts +674 -0
  38. package/src/config/loader/variables.ts +260 -0
  39. package/src/daemon/acquire.ts +411 -0
  40. package/src/daemon/cache/git.ts +553 -0
  41. package/src/daemon/cache/render.ts +449 -0
  42. package/src/daemon/cache/session-usage-store.ts +446 -0
  43. package/src/daemon/cache/watchers.ts +245 -0
  44. package/src/daemon/client-debug.ts +120 -0
  45. package/src/daemon/client-stats.ts +129 -0
  46. package/src/daemon/client-transport.ts +273 -0
  47. package/src/daemon/client.ts +75 -0
  48. package/src/daemon/debug-types.ts +91 -0
  49. package/src/daemon/debug.ts +264 -0
  50. package/src/daemon/limits.ts +154 -0
  51. package/src/daemon/log.ts +69 -0
  52. package/src/daemon/parent-watchdog.ts +80 -0
  53. package/src/daemon/paths.ts +127 -0
  54. package/src/daemon/protocol.ts +235 -0
  55. package/src/daemon/render-payload.ts +611 -0
  56. package/src/daemon/server.ts +1103 -0
  57. package/src/daemon/session-state-file.ts +108 -0
  58. package/src/daemon/session-state.ts +237 -0
  59. package/src/daemon/stats.ts +229 -0
  60. package/src/daemon/verbs/index.ts +458 -0
  61. package/src/daemon/verbs/state-validators.ts +708 -0
  62. package/src/demo/dsl.ts +117 -0
  63. package/src/demo/mock-data.ts +67 -0
  64. package/src/demo/statusline.json5 +92 -0
  65. package/src/dsl/node-registry.ts +281 -0
  66. package/src/dsl/render.ts +558 -0
  67. package/src/index.ts +206 -0
  68. package/src/install/index.ts +410 -0
  69. package/src/proc/launch.ts +451 -0
  70. package/src/proc/stats-handle.ts +13 -0
  71. package/src/render/action.ts +458 -0
  72. package/src/render/diagnostic-style.ts +23 -0
  73. package/src/render/diagnostic-text.ts +77 -0
  74. package/src/render/error-glyph.ts +53 -0
  75. package/src/render/outcome-plan.ts +45 -0
  76. package/src/render/picker.ts +231 -0
  77. package/src/render/split-lines.ts +51 -0
  78. package/src/render/strip.ts +103 -0
  79. package/src/segments/cache.ts +131 -0
  80. package/src/segments/context.ts +190 -0
  81. package/src/segments/git.ts +561 -0
  82. package/src/segments/metrics.ts +101 -0
  83. package/src/segments/pricing.ts +452 -0
  84. package/src/segments/session.ts +188 -0
  85. package/src/segments/tmux.ts +74 -0
  86. package/src/template-engine/cells.ts +90 -0
  87. package/src/template-engine/colors.ts +102 -0
  88. package/src/template-engine/engine.ts +108 -0
  89. package/src/template-engine/funcs.ts +216 -0
  90. package/src/template-engine/index.ts +11 -0
  91. package/src/template-engine/layout.ts +112 -0
  92. package/src/template-engine/scope.ts +62 -0
  93. package/src/themes/index.ts +19 -0
  94. package/src/themes/palette-resolvers.ts +86 -0
  95. package/src/themes/policy.ts +79 -0
  96. package/src/themes/session-random.ts +88 -0
  97. package/src/utils/cache.ts +206 -0
  98. package/src/utils/claude.ts +616 -0
  99. package/src/utils/color-support.ts +118 -0
  100. package/src/utils/formatters.ts +77 -0
  101. package/src/utils/logger.ts +5 -0
  102. package/src/utils/outcome.ts +33 -0
  103. package/src/utils/schema-validator.ts +126 -0
  104. package/src/utils/single-flight.ts +57 -0
  105. package/src/utils/terminal-width.ts +43 -0
  106. package/src/utils/terminal.ts +11 -0
  107. package/src/utils/transcript-fs.ts +162 -0
  108. package/src/var-system/index.ts +24 -0
  109. package/src/var-system/sources.ts +1038 -0
  110. package/src/var-system/store.ts +223 -0
  111. package/src/var-system/types.ts +57 -0
package/src/index.ts ADDED
@@ -0,0 +1,206 @@
1
+ #!/usr/bin/env node
2
+
3
+ import type { ClaudeHookData } from "./utils/claude";
4
+
5
+ import process from "node:process";
6
+ import { json } from "node:stream/consumers";
7
+ import { debug } from "./utils/logger";
8
+ import { runInstall, runInstallUrlHandler, runUrlHandle } from "./install";
9
+ import { runDaemon } from "./daemon/server";
10
+ import { tryRenderViaDaemon } from "./daemon/client";
11
+ import { runDaemonStats } from "./daemon/client-stats";
12
+ import { runDebug } from "./daemon/client-debug";
13
+ import { isDebugWhat } from "./daemon/debug-types";
14
+ import { runLint, runSchema } from "./config/cli";
15
+ import { obtainDaemonKick } from "./daemon/acquire";
16
+ import { planOutcome } from "./render/outcome-plan";
17
+
18
+ // Read terminal width from the live shell context (no subprocess). Returns
19
+ // undefined when nothing reliable is available; the daemon falls back to its
20
+ // own pure lookup chain in that case. Always-COLUMNS-first because Bash
21
+ // exports it on resize and Claude Code propagates it to hook commands.
22
+ // stderr (not stdout) is the TTY-side fallback: when invoked as a Claude
23
+ // statusline hook, stdin is the hook JSON pipe and stdout is the captured
24
+ // statusline pipe, leaving stderr as the only stream still attached to the
25
+ // parent terminal. Mirrors the Rust client's TIOCGWINSZ-on-STDERR_FILENO.
26
+ function detectTermCols(): number | undefined {
27
+ const env = process.env.COLUMNS;
28
+ if (env) {
29
+ const n = parseInt(env, 10);
30
+ if (!isNaN(n) && n > 0) return n;
31
+ }
32
+ const cols = process.stderr.columns;
33
+ if (cols && cols > 0) return cols;
34
+ return undefined;
35
+ }
36
+
37
+ function showHelpText(): void {
38
+ console.log(`
39
+ cc-candybar - Beautiful powerline statusline for Claude Code
40
+
41
+ Usage: cc-candybar [options]
42
+
43
+ Standalone Commands:
44
+ -h, --help Show this help
45
+
46
+ Debugging:
47
+ CC_CANDYBAR_DEBUG=1 Enable debug logging for troubleshooting
48
+
49
+ Configuration:
50
+ Layout and segment options are defined in .cc-candybar.json5 (place in your
51
+ project dir, cwd, or ~/.config/cc-candybar/config.json5). Use CC_CANDYBAR_CONFIG
52
+ to point at a specific file. See the default config for all available options:
53
+ node dist/index.mjs debug --project-dir . --cwd .
54
+
55
+ Subcommands (macOS):
56
+ install One-shot setup: creates the URL handler app, registers
57
+ the cc-candybar:// scheme, and writes the statusLine
58
+ command into ~/.claude/settings.json.
59
+ install-url-handler Just create + register the URL handler app
60
+ (~/Applications/CCCandybarURLHandler.app).
61
+ url-handle URL Internal — invoked by the URL handler app on
62
+ cmd-click. Parses cc-candybar://<verb>/<value> and
63
+ dispatches (currently: copy to clipboard).
64
+ daemon-stats [--json] Query the running daemon for runtime stats:
65
+ uptime, RSS, cache hit rates, watcher count,
66
+ request totals. Does not spawn a daemon.
67
+
68
+ Config tooling:
69
+ lint <config-file> Validate a config file (parse + cross-refs + cycles)
70
+ with no daemon. Exit 0 valid, 1 invalid, 2 unreadable.
71
+ schema Print the JSON Schema for the config file shape
72
+ (.cc-candybar.json5). Point an editor's $schema at it
73
+ for autocomplete + structural validation.
74
+ vars [--json] Declared variables: source kind, value, last error.
75
+ segments [--json] Segment templates and their last rendered output.
76
+ config [--json] The effective merged config. (All three query the
77
+ running daemon; none spawn one.)
78
+
79
+ `);
80
+ }
81
+
82
+ async function main(): Promise<void> {
83
+ try {
84
+ const showHelp =
85
+ process.argv.includes("--help") || process.argv.includes("-h");
86
+
87
+ if (showHelp) {
88
+ showHelpText();
89
+ process.exit(0);
90
+ }
91
+
92
+ // [LAW:dataflow-not-control-flow] Subcommand dispatch is data: argv[2]
93
+ // selects the handler. Each handler short-circuits via process.exit().
94
+ // Default fallthrough = the existing stdin-driven render flow.
95
+ const subcommand = process.argv[2];
96
+ if (subcommand === "install") {
97
+ runInstall(process.argv.slice(3));
98
+ process.exit(0);
99
+ }
100
+ if (subcommand === "install-url-handler") {
101
+ runInstallUrlHandler();
102
+ process.exit(0);
103
+ }
104
+ if (subcommand === "url-handle") {
105
+ await runUrlHandle(process.argv[3]);
106
+ return;
107
+ }
108
+ if (subcommand === "daemon") {
109
+ runDaemon();
110
+ return; // daemon owns its own lifecycle
111
+ }
112
+ if (subcommand === "daemon-stats") {
113
+ await runDaemonStats(process.argv.slice(3));
114
+ process.exit(0);
115
+ }
116
+ if (subcommand === "lint") {
117
+ runLint(process.argv.slice(3)); // owns its own exit code (0/1/2)
118
+ return;
119
+ }
120
+ if (subcommand === "schema") {
121
+ runSchema(); // owns its own exit code
122
+ return;
123
+ }
124
+ // [LAW:dataflow-not-control-flow] vars/segments/config are ONE handler
125
+ // parameterized by `what` — the subcommand name IS the DebugWhat. The guard
126
+ // is the canonical list (debug-types), so a new debug projection is reachable
127
+ // here with no second-site edit.
128
+ if (isDebugWhat(subcommand)) {
129
+ await runDebug(subcommand, process.argv.slice(3));
130
+ process.exit(0);
131
+ }
132
+
133
+ if (process.stdin.isTTY === true) {
134
+ console.error(`Error: This tool requires input from Claude Code
135
+
136
+ cc-candybar is designed to be used as a Claude Code statusLine command.
137
+ It reads hook data from stdin and outputs formatted statusline.
138
+
139
+ Add to ~/.claude/settings.json:
140
+ {
141
+ "statusLine": {
142
+ "type": "command",
143
+ "command": "cc-candybar --style=powerline"
144
+ }
145
+ }
146
+
147
+ Run with --help for more options.
148
+
149
+ To test output manually:
150
+ echo '{"session_id":"test-session","workspace":{"project_dir":"/path/to/project"},"model":{"id":"claude-sonnet-4-5","display_name":"Claude"}}' | cc-candybar --style=powerline`);
151
+ process.exit(1);
152
+ }
153
+
154
+ debug(`Working directory: ${process.cwd()}`);
155
+ debug(`Process args:`, process.argv);
156
+
157
+ const hookData = (await json(process.stdin)) as ClaudeHookData;
158
+ debug(`Received hook data:`, JSON.stringify(hookData, null, 2));
159
+
160
+ if (!hookData) {
161
+ console.error("Error: No input data received from stdin");
162
+ showHelpText();
163
+ process.exit(1);
164
+ }
165
+
166
+ // [LAW:one-source-of-truth] The daemon is the *only* renderer. The CLI is
167
+ // a dumb relay: forward stdin to the daemon, print whatever comes back.
168
+ // There is no inline render path — two renderers would drift (the CLI has
169
+ // no shared gitService/usageProvider, no per-session state, no warm
170
+ // caches). On daemon miss we spawn detached and emit empty output; the
171
+ // next status-line refresh hits the warm daemon and renders for real.
172
+ //
173
+ // [LAW:single-enforcer] Terminal width is captured here, in the user's
174
+ // shell environment, then trusted by the daemon. The daemon's own env
175
+ // reflects whichever shell launched it minutes/hours ago, so it can't
176
+ // measure the active terminal — only the live client can.
177
+ const outcome = await tryRenderViaDaemon(
178
+ hookData,
179
+ process.argv,
180
+ process.cwd(),
181
+ detectTermCols(),
182
+ );
183
+ // [LAW:types-are-the-program] Three variants, one per outcome kind. The
184
+ // "kick on every failure" pattern was the load-bearing half of the
185
+ // 452-corpse spiral (kz8.5) — kicking on `permanent` failures keeps
186
+ // respawning a daemon that will refuse the next request identically.
187
+ // [LAW:dataflow-not-control-flow] planOutcome maps each variant to a
188
+ // plan value (output, kick, debug); the side effects below run against
189
+ // the plan in fixed order. Variability lives in the data.
190
+ const plan = planOutcome(outcome);
191
+ if (plan.debug !== null) {
192
+ debug(plan.debug);
193
+ }
194
+ if (plan.kick) {
195
+ obtainDaemonKick();
196
+ }
197
+ process.stdout.write(plan.output);
198
+ process.exit(0);
199
+ } catch (error) {
200
+ const errorMessage = error instanceof Error ? error.message : String(error);
201
+ console.error("Error generating statusline:", errorMessage);
202
+ process.exit(1);
203
+ }
204
+ }
205
+
206
+ main();
@@ -0,0 +1,410 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import os from "node:os";
4
+ import { launchSync } from "../proc/launch";
5
+ import { tryClickViaDaemon } from "../daemon/client";
6
+ import type { PermanentOutcome } from "../daemon/client-transport";
7
+ import { obtainDaemonKick } from "../daemon/acquire";
8
+ import { URL_SCHEME, VERB_COPY } from "../click/wire";
9
+
10
+ // [LAW:one-source-of-truth] Replaced at build time by tsdown's `define` option
11
+ // from package.json. The pinned version is what we write into settings.json so
12
+ // pnpm's content-addressable cache key changes on every release — no stale
13
+ // versions sticking around because of `@latest` resolution.
14
+ declare const __PACKAGE_VERSION__: string;
15
+ const PACKAGE_VERSION =
16
+ typeof __PACKAGE_VERSION__ !== "undefined" ? __PACKAGE_VERSION__ : "dev";
17
+
18
+ const PACKAGE_NAME = "@promptctl/cc-candybar";
19
+ const BUNDLE_ID = "com.cccandybar.url-handler";
20
+ const APP_NAME = "CCCandybarURLHandler";
21
+
22
+ // [LAW:one-source-of-truth] `install` writes no renderer flags into
23
+ // ~/.claude/settings.json. The previous CLI override apparatus (--layout,
24
+ // --tray, --display, --show, --segment) is gone (bzh.2). All authoring now
25
+ // lives in `.cc-candybar.json5` or `.cc-candybar.json` (see
26
+ // resolveDslConfigPath — both extensions are accepted, .json5 preferred);
27
+ // the install command's job is just to wire the URL handler and the daemon
28
+ // entry. To customize, users edit a config file at one of the resolution
29
+ // paths or copy src/demo/statusline.json5 as a starting point.
30
+ const DEFAULT_INSTALL_ARGS: readonly string[] = [];
31
+
32
+ function shellEscape(arg: string): string {
33
+ // Safe characters that don't need quoting in any reasonable shell.
34
+ if (/^[A-Za-z0-9_./=,:-]+$/.test(arg)) return arg;
35
+ return `'${arg.replace(/'/g, "'\\''")}'`;
36
+ }
37
+
38
+ function buildStatusLineCommand(rendererArgs: readonly string[]): string {
39
+ return [
40
+ "pnpm",
41
+ "dlx",
42
+ `${PACKAGE_NAME}@${PACKAGE_VERSION}`,
43
+ ...rendererArgs.map(shellEscape),
44
+ ].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
+ function supportDir(): string {
64
+ return path.join(
65
+ os.homedir(),
66
+ "Library",
67
+ "Application Support",
68
+ "CCCandybar",
69
+ );
70
+ }
71
+
72
+ function stableScriptPath(): string {
73
+ return path.join(supportDir(), "url-handler.mjs");
74
+ }
75
+
76
+ function appleScriptSource(
77
+ nodePath: string,
78
+ scriptPath: string,
79
+ nodeModulesPath: string,
80
+ ): string {
81
+ // [LAW:no-shared-mutable-globals] Bake absolute paths into the AppleScript
82
+ // so click-time invocation doesn't depend on PATH, pnpm dlx cache state, or
83
+ // a global npm install. The script path is a stable copy under
84
+ // ~/Library/Application Support/CCCandybar that we own. NODE_PATH
85
+ // points at the project's node_modules so the handler can resolve deps
86
+ // (rich-js etc.) without being co-located with a package.json.
87
+ const escNode = nodePath.replace(/"/g, '\\"');
88
+ const escScript = scriptPath.replace(/"/g, '\\"');
89
+ const escModules = nodeModulesPath.replace(/"/g, '\\"');
90
+ return [
91
+ "on open location L",
92
+ `\tdo shell script "NODE_PATH='${escModules}' '${escNode}' '${escScript}' url-handle " & quoted form of L`,
93
+ "end open location",
94
+ ].join("\n");
95
+ }
96
+
97
+ // [LAW:one-source-of-truth] The bundle that contains *this* function is
98
+ // the thing we need to copy to a stable location. Two invocation paths
99
+ // reach us:
100
+ // - via the bin shim: process.argv[1] = ".../bin/cc-candybar" which
101
+ // does `import '../dist/index.mjs'`. Copying the shim itself would
102
+ // break — its relative import wouldn't resolve from the new location.
103
+ // So resolve to the sibling dist/index.mjs.
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-url-handler: 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
+ function copyDistToStableLocation(): string {
117
+ const source = locateBundledDist(process.argv[1]);
118
+ if (!fs.existsSync(source)) {
119
+ throw new Error(
120
+ `install-url-handler: bundled dist not found at ${source}. Reinstall the package.`,
121
+ );
122
+ }
123
+ fs.mkdirSync(supportDir(), { recursive: true });
124
+ const dest = stableScriptPath();
125
+ fs.copyFileSync(source, dest);
126
+ return dest;
127
+ }
128
+
129
+ function infoPlistPatch(): Array<{ key: string; xml: string }> {
130
+ return [
131
+ {
132
+ key: "CFBundleIdentifier",
133
+ xml: `<string>${BUNDLE_ID}</string>`,
134
+ },
135
+ {
136
+ key: "CFBundleURLTypes",
137
+ xml: [
138
+ "<array>",
139
+ " <dict>",
140
+ " <key>CFBundleURLName</key>",
141
+ ` <string>Claude Powerline Click Action</string>`,
142
+ " <key>CFBundleURLSchemes</key>",
143
+ " <array>",
144
+ ` <string>${URL_SCHEME}</string>`,
145
+ " </array>",
146
+ " </dict>",
147
+ "</array>",
148
+ ].join("\n"),
149
+ },
150
+ ];
151
+ }
152
+
153
+ export function runInstallUrlHandler(): void {
154
+ ensureMacOS();
155
+
156
+ const stableScript = copyDistToStableLocation();
157
+ process.stdout.write(`Copied dist to ${stableScript}\n`);
158
+
159
+ // [LAW:one-source-of-truth] Derive node_modules from the source dist path
160
+ // (dist/index.mjs → ../node_modules). The stable copy lives elsewhere but
161
+ // needs the same node_modules to resolve deps at click time.
162
+ const sourceDist = locateBundledDist(process.argv[1]);
163
+ const nodeModules = path.join(path.dirname(sourceDist), "..", "node_modules");
164
+ if (!fs.existsSync(nodeModules)) {
165
+ throw new Error(
166
+ `install-url-handler: node_modules not found at ${nodeModules}. Install deps first.`,
167
+ );
168
+ }
169
+
170
+ const bundle = appBundlePath();
171
+ fs.mkdirSync(path.dirname(bundle), { recursive: true });
172
+
173
+ // If a previous handler exists, remove it so osacompile can write fresh.
174
+ if (fs.existsSync(bundle)) {
175
+ fs.rmSync(bundle, { recursive: true, force: true });
176
+ }
177
+
178
+ process.stdout.write(`Building ${bundle}\n`);
179
+ const osa = launchSync({
180
+ bin: "/usr/bin/osacompile",
181
+ args: [
182
+ "-o",
183
+ bundle,
184
+ "-e",
185
+ appleScriptSource(process.execPath, stableScript, nodeModules),
186
+ ],
187
+ category: "install.osacompile",
188
+ });
189
+ if (!osa.ok) {
190
+ process.stderr.write(osa.stderr);
191
+ throw new Error(`osacompile failed (${osa.reason})`);
192
+ }
193
+
194
+ const plistPath = path.join(bundle, "Contents", "Info.plist");
195
+
196
+ for (const { key } of infoPlistPatch()) {
197
+ // plutil errors if the key already exists; pre-delete so the operation is
198
+ // idempotent. Ignore failures (key may not exist on a fresh build).
199
+ launchSync({
200
+ bin: "/usr/bin/plutil",
201
+ args: ["-remove", key, plistPath],
202
+ category: "install.plutil",
203
+ });
204
+ }
205
+
206
+ for (const { key, xml } of infoPlistPatch()) {
207
+ const r = launchSync({
208
+ bin: "/usr/bin/plutil",
209
+ args: ["-insert", key, "-xml", xml, plistPath],
210
+ category: "install.plutil",
211
+ });
212
+ if (!r.ok) {
213
+ process.stderr.write(r.stderr);
214
+ throw new Error(`plutil -insert ${key} failed (${r.reason})`);
215
+ }
216
+ }
217
+
218
+ process.stdout.write(`Registering ${URL_SCHEME}:// with Launch Services\n`);
219
+ const lsr = launchSync({
220
+ bin: "/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister",
221
+ args: ["-f", bundle],
222
+ category: "install.lsregister",
223
+ });
224
+ if (!lsr.ok) {
225
+ process.stderr.write(lsr.stderr);
226
+ throw new Error(`lsregister failed (${lsr.reason})`);
227
+ }
228
+
229
+ process.stdout.write(`✓ ${APP_NAME}.app installed and registered.\n`);
230
+ process.stdout.write(
231
+ ` Test: open '${URL_SCHEME}://hello-world' && pbpaste\n`,
232
+ );
233
+ }
234
+
235
+ interface ParsedUrl {
236
+ verb: string;
237
+ value: string;
238
+ }
239
+
240
+ // [LAW:dataflow-not-control-flow] Parse the URL into a {verb, value} pair
241
+ // without using `new URL`, which lowercases hosts (would mangle case-sensitive
242
+ // session ids). Format: cc-candybar://<verb>/<tail> | cc-candybar://<value>
243
+ // (bare → copy). The verb ends at the FIRST `/`; everything after is the raw
244
+ // value. The dispatch effect list rides as `dispatch/e=…&e=…`, so its query-
245
+ // style payload is just the tail — `?` is NOT a delimiter, it is ordinary data
246
+ // in a bare-copy value (`cc-candybar://hello?world` copies "hello?world").
247
+ //
248
+ // [LAW:single-enforcer] Only the VERB is decoded here. The value is passed RAW
249
+ // to the daemon; each verb's handler decodes its own value at its boundary (the
250
+ // verb that owns the structure owns its decode). A whole-value decode here would
251
+ // un-escape structural separators inside a nested value — the exact hazard that
252
+ // made compound clicks unrepresentable — so it is deliberately absent.
253
+ export function parseHandlerUrl(
254
+ rawUrl: string,
255
+ scheme: string = URL_SCHEME,
256
+ ): ParsedUrl {
257
+ const prefix = `${scheme}://`;
258
+ if (!rawUrl.startsWith(prefix)) {
259
+ throw new Error(`expected ${prefix} scheme, got: ${rawUrl}`);
260
+ }
261
+ const rest = rawUrl.slice(prefix.length);
262
+ const slash = rest.indexOf("/");
263
+ if (slash === -1) {
264
+ return { verb: VERB_COPY, value: rest };
265
+ }
266
+ return {
267
+ verb: decodeURIComponent(rest.slice(0, slash)),
268
+ value: rest.slice(slash + 1),
269
+ };
270
+ }
271
+
272
+ // [LAW:single-enforcer] url-handle is a thin IPC shim: parse the URL, send
273
+ // the click request to the daemon, exit. There is NO in-process verb
274
+ // dispatch and NO direct disk mutation. The daemon is the only writer of
275
+ // click-side state ([LAW:one-source-of-truth] for SessionState); kicking a
276
+ // daemon on a transient failure is the only recovery, and it's
277
+ // fire-and-forget so the next click hits a warm daemon.
278
+ //
279
+ // A `permanent` daemon outcome (BAD_REQUEST for an unknown verb,
280
+ // VERSION_MISMATCH against a future daemon) exits non-zero with the daemon's
281
+ // error message so the failure is visible — never silently swallowed by a
282
+ // local fallback that would diverge from the daemon's truth.
283
+ export async function runUrlHandle(rawUrl: string | undefined): Promise<void> {
284
+ if (!rawUrl) {
285
+ process.stderr.write("url-handle: missing URL argument.\n");
286
+ process.exit(1);
287
+ }
288
+
289
+ let parsed: ParsedUrl;
290
+ try {
291
+ parsed = parseHandlerUrl(rawUrl);
292
+ } catch (err) {
293
+ process.stderr.write(
294
+ `url-handle: ${err instanceof Error ? err.message : String(err)}\n`,
295
+ );
296
+ process.exit(1);
297
+ }
298
+
299
+ const outcome = await tryClickViaDaemon(parsed.verb, parsed.value);
300
+ if (outcome.kind === "ok") {
301
+ process.exit(0);
302
+ }
303
+
304
+ if (outcome.kind === "transient") {
305
+ // [LAW:dataflow-not-control-flow] Fire-and-forget kick; the user's click
306
+ // is lost (the daemon couldn't service it), but the next click hits a
307
+ // warm daemon. Mirrors the render-path's transient recovery.
308
+ obtainDaemonKick();
309
+ process.stderr.write(
310
+ `url-handle: daemon unavailable (${outcome.cause}: ${outcome.message})\n`,
311
+ );
312
+ process.exit(1);
313
+ }
314
+
315
+ // [LAW:dataflow-not-control-flow] Format each permanent cause from its
316
+ // own typed payload, not by probing for "message" on a generic outcome.
317
+ // The PermanentOutcome union already discriminates by `cause`; the switch
318
+ // mirrors that discriminator one-to-one and pulls the right fields.
319
+ process.stderr.write(formatPermanent(outcome) + "\n");
320
+ process.exit(1);
321
+ }
322
+
323
+ // [LAW:single-enforcer] One place that turns a PermanentOutcome into a
324
+ // human-readable diagnostic. Each cause carries its own payload (version
325
+ // mismatch carries the protocol numbers; everything else carries a
326
+ // message); the formatter consumes exactly the fields the cause defines.
327
+ function formatPermanent(outcome: PermanentOutcome): string {
328
+ switch (outcome.cause) {
329
+ case "version_mismatch":
330
+ return `url-handle: daemon rejected click (version mismatch: client v${outcome.clientV} ≠ daemon v${outcome.daemonV})`;
331
+ case "bad_request":
332
+ return `url-handle: daemon rejected click (bad request: ${outcome.message})`;
333
+ case "render_failed":
334
+ return `url-handle: daemon rejected click (handler failed: ${outcome.message})`;
335
+ case "malformed_response":
336
+ return `url-handle: daemon rejected click (malformed response: ${outcome.message})`;
337
+ }
338
+ }
339
+
340
+ export function runInstall(rendererArgs: string[]): void {
341
+ ensureMacOS();
342
+
343
+ const force = rendererArgs.includes("--force");
344
+ const filteredArgs = rendererArgs.filter((a) => a !== "--force");
345
+
346
+ const argsToInstall =
347
+ filteredArgs.length > 0 ? filteredArgs : [...DEFAULT_INSTALL_ARGS];
348
+
349
+ runInstallUrlHandler();
350
+ updateClaudeSettings(argsToInstall, force);
351
+
352
+ process.stdout.write(`✓ install complete.\n`);
353
+ process.stdout.write(
354
+ ` Restart Claude Code to pick up the new statusline.\n`,
355
+ );
356
+ }
357
+
358
+ function updateClaudeSettings(
359
+ rendererArgs: readonly string[],
360
+ force: boolean,
361
+ overridePath?: string,
362
+ ): void {
363
+ const target = overridePath ?? settingsJsonPath();
364
+ fs.mkdirSync(path.dirname(target), { recursive: true });
365
+
366
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
367
+ let settings: Record<string, any> = {};
368
+ if (fs.existsSync(target)) {
369
+ try {
370
+ settings = JSON.parse(fs.readFileSync(target, "utf-8"));
371
+ } catch (err) {
372
+ throw new Error(
373
+ `Could not parse ${target}: ${err instanceof Error ? err.message : String(err)}`,
374
+ );
375
+ }
376
+ }
377
+
378
+ const existing = settings.statusLine?.command as string | undefined;
379
+ // [LAW:one-source-of-truth] Detection: if the existing command starts with
380
+ // our package prefix, we (or a prior version of us) wrote it. Any other
381
+ // value is a user customization we must not silently destroy.
382
+ const managedPrefix = `pnpm dlx ${PACKAGE_NAME}@`;
383
+ const isOurs =
384
+ typeof existing === "string" && existing.startsWith(managedPrefix);
385
+
386
+ if (existing && !isOurs && !force) {
387
+ process.stderr.write(
388
+ `Skipping settings.json update: existing statusLine.command appears customized.\n` +
389
+ ` Current: ${existing}\n` +
390
+ ` To overwrite, re-run with --force.\n`,
391
+ );
392
+ return;
393
+ }
394
+
395
+ settings.statusLine = {
396
+ type: "command",
397
+ command: buildStatusLineCommand(rendererArgs),
398
+ };
399
+
400
+ fs.writeFileSync(target, JSON.stringify(settings, null, 2));
401
+ process.stdout.write(`Updated ${target}\n`);
402
+ }
403
+
404
+ // Exports for testing
405
+ export const __test__ = {
406
+ shellEscape,
407
+ buildStatusLineCommand,
408
+ DEFAULT_INSTALL_ARGS,
409
+ updateClaudeSettings,
410
+ };