@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.
- package/LICENSE +21 -0
- package/README.md +145 -0
- package/bin/cc-candybar +6 -0
- package/dist/index.mjs +185 -0
- package/package.json +99 -0
- package/plugin/.claude-plugin/plugin.json +11 -0
- package/plugin/bin/preview.sh +305 -0
- package/plugin/commands/candybar.md +403 -0
- package/plugin/templates/config-essential.json +36 -0
- package/plugin/templates/config-full.json +55 -0
- package/plugin/templates/config-standard.json +39 -0
- package/plugin/templates/config-tui-compact.json +48 -0
- package/plugin/templates/config-tui-full.json +89 -0
- package/plugin/templates/config-tui-standard.json +56 -0
- package/plugin/templates/config-tui.json +18 -0
- package/plugin/templates/nerd-fonts-sample.txt +5 -0
- package/schema/cc-candybar.schema.json +1379 -0
- package/src/click/wire.ts +113 -0
- package/src/config/action.ts +91 -0
- package/src/config/cli.ts +170 -0
- package/src/config/default-dsl-config.ts +661 -0
- package/src/config/dsl-loader.ts +265 -0
- package/src/config/dsl-types.ts +425 -0
- package/src/config/loader/actions.ts +530 -0
- package/src/config/loader/cache.ts +206 -0
- package/src/config/loader/cross-ref.ts +326 -0
- package/src/config/loader/cycles.ts +148 -0
- package/src/config/loader/diagnostics.ts +99 -0
- package/src/config/loader/discovery.ts +182 -0
- package/src/config/loader/emit-schema.ts +63 -0
- package/src/config/loader/globals.ts +42 -0
- package/src/config/loader/helpers.ts +48 -0
- package/src/config/loader/layout.ts +688 -0
- package/src/config/loader/merge.ts +40 -0
- package/src/config/loader/refs.ts +96 -0
- package/src/config/loader/segments.ts +120 -0
- package/src/config/loader/validate-core.ts +674 -0
- package/src/config/loader/variables.ts +260 -0
- package/src/daemon/acquire.ts +411 -0
- package/src/daemon/cache/git.ts +553 -0
- package/src/daemon/cache/render.ts +449 -0
- package/src/daemon/cache/session-usage-store.ts +446 -0
- package/src/daemon/cache/watchers.ts +245 -0
- package/src/daemon/client-debug.ts +120 -0
- package/src/daemon/client-stats.ts +129 -0
- package/src/daemon/client-transport.ts +273 -0
- package/src/daemon/client.ts +75 -0
- package/src/daemon/debug-types.ts +91 -0
- package/src/daemon/debug.ts +264 -0
- package/src/daemon/limits.ts +154 -0
- package/src/daemon/log.ts +69 -0
- package/src/daemon/parent-watchdog.ts +80 -0
- package/src/daemon/paths.ts +127 -0
- package/src/daemon/protocol.ts +235 -0
- package/src/daemon/render-payload.ts +611 -0
- package/src/daemon/server.ts +1103 -0
- package/src/daemon/session-state-file.ts +108 -0
- package/src/daemon/session-state.ts +237 -0
- package/src/daemon/stats.ts +229 -0
- package/src/daemon/verbs/index.ts +458 -0
- package/src/daemon/verbs/state-validators.ts +708 -0
- package/src/demo/dsl.ts +117 -0
- package/src/demo/mock-data.ts +67 -0
- package/src/demo/statusline.json5 +92 -0
- package/src/dsl/node-registry.ts +281 -0
- package/src/dsl/render.ts +558 -0
- package/src/index.ts +206 -0
- package/src/install/index.ts +410 -0
- package/src/proc/launch.ts +451 -0
- package/src/proc/stats-handle.ts +13 -0
- package/src/render/action.ts +458 -0
- package/src/render/diagnostic-style.ts +23 -0
- package/src/render/diagnostic-text.ts +77 -0
- package/src/render/error-glyph.ts +53 -0
- package/src/render/outcome-plan.ts +45 -0
- package/src/render/picker.ts +231 -0
- package/src/render/split-lines.ts +51 -0
- package/src/render/strip.ts +103 -0
- package/src/segments/cache.ts +131 -0
- package/src/segments/context.ts +190 -0
- package/src/segments/git.ts +561 -0
- package/src/segments/metrics.ts +101 -0
- package/src/segments/pricing.ts +452 -0
- package/src/segments/session.ts +188 -0
- package/src/segments/tmux.ts +74 -0
- package/src/template-engine/cells.ts +90 -0
- package/src/template-engine/colors.ts +102 -0
- package/src/template-engine/engine.ts +108 -0
- package/src/template-engine/funcs.ts +216 -0
- package/src/template-engine/index.ts +11 -0
- package/src/template-engine/layout.ts +112 -0
- package/src/template-engine/scope.ts +62 -0
- package/src/themes/index.ts +19 -0
- package/src/themes/palette-resolvers.ts +86 -0
- package/src/themes/policy.ts +79 -0
- package/src/themes/session-random.ts +88 -0
- package/src/utils/cache.ts +206 -0
- package/src/utils/claude.ts +616 -0
- package/src/utils/color-support.ts +118 -0
- package/src/utils/formatters.ts +77 -0
- package/src/utils/logger.ts +5 -0
- package/src/utils/outcome.ts +33 -0
- package/src/utils/schema-validator.ts +126 -0
- package/src/utils/single-flight.ts +57 -0
- package/src/utils/terminal-width.ts +43 -0
- package/src/utils/terminal.ts +11 -0
- package/src/utils/transcript-fs.ts +162 -0
- package/src/var-system/index.ts +24 -0
- package/src/var-system/sources.ts +1038 -0
- package/src/var-system/store.ts +223 -0
- 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
|
+
};
|