@promptctl/cc-candybar 1.22.0 → 1.24.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.
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import os from "node:os";
4
+ import { createRequire } from "node:module";
4
5
  import { launchSync } from "../proc/launch";
5
6
  import { tryClickViaDaemon } from "../daemon/client";
6
7
  import type { PermanentOutcome } from "../daemon/client-transport";
@@ -8,9 +9,7 @@ import { obtainDaemonKick } from "../daemon/acquire";
8
9
  import { URL_SCHEME, VERB_COPY } from "../click/wire";
9
10
 
10
11
  // [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.
12
+ // from package.json the single version stamp install output reports.
14
13
  declare const __PACKAGE_VERSION__: string;
15
14
  const PACKAGE_VERSION =
16
15
  typeof __PACKAGE_VERSION__ !== "undefined" ? __PACKAGE_VERSION__ : "dev";
@@ -20,28 +19,32 @@ const BUNDLE_ID = "com.cccandybar.url-handler";
20
19
  const APP_NAME = "CCCandybarURLHandler";
21
20
 
22
21
  // [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.
22
+ // ~/.claude/settings.json. All authoring lives in `.cc-candybar.json5` or
23
+ // `.cc-candybar.json` (see resolveDslConfigPath both extensions accepted,
24
+ // .json5 preferred); the install command's job is staging the runtime,
25
+ // wiring the URL handler, and pointing settings at the staged entry.
30
26
  const DEFAULT_INSTALL_ARGS: readonly string[] = [];
31
27
 
28
+ // [LAW:one-type-per-behavior] One platform → one package name; the render
29
+ // entry is the same contract everywhere, only the artifact differs.
30
+ const PLATFORM_PACKAGES: Record<string, string> = {
31
+ "darwin-arm64": "@promptctl/cc-candybar-darwin-arm64",
32
+ "darwin-x64": "@promptctl/cc-candybar-darwin-x64",
33
+ "linux-x64": "@promptctl/cc-candybar-linux-x64",
34
+ "linux-arm64": "@promptctl/cc-candybar-linux-arm64",
35
+ };
36
+
32
37
  function shellEscape(arg: string): string {
33
38
  // Safe characters that don't need quoting in any reasonable shell.
34
39
  if (/^[A-Za-z0-9_./=,:-]+$/.test(arg)) return arg;
35
40
  return `'${arg.replace(/'/g, "'\\''")}'`;
36
41
  }
37
42
 
38
- function buildStatusLineCommand(rendererArgs: readonly string[]): string {
39
- return [
40
- "pnpm",
41
- "dlx",
42
- `${PACKAGE_NAME}@${PACKAGE_VERSION}`,
43
- ...rendererArgs.map(shellEscape),
44
- ].join(" ");
43
+ function buildStatusLineCommand(
44
+ binPath: string,
45
+ rendererArgs: readonly string[],
46
+ ): string {
47
+ return [binPath, ...rendererArgs].map(shellEscape).join(" ");
45
48
  }
46
49
 
47
50
  function appBundlePath(): string {
@@ -60,51 +63,52 @@ function ensureMacOS(): void {
60
63
  }
61
64
  }
62
65
 
66
+ // [LAW:one-source-of-truth] The staged runtime lives at ONE stable path per
67
+ // platform, outside any package manager's store — pnpm cache pruning or a
68
+ // version bump can never yank the files the statusline and daemon run from.
63
69
  function supportDir(): string {
64
- return path.join(
65
- os.homedir(),
66
- "Library",
67
- "Application Support",
68
- "CCCandybar",
69
- );
70
+ if (process.platform === "darwin") {
71
+ return path.join(
72
+ os.homedir(),
73
+ "Library",
74
+ "Application Support",
75
+ "CCCandybar",
76
+ );
77
+ }
78
+ const xdgData =
79
+ process.env.XDG_DATA_HOME ?? path.join(os.homedir(), ".local", "share");
80
+ return path.join(xdgData, "cc-candybar");
70
81
  }
71
82
 
72
- function stableScriptPath(): string {
73
- return path.join(supportDir(), "url-handler.mjs");
83
+ function stagedBinPath(): string {
84
+ return path.join(supportDir(), "bin", "cc-candybar");
74
85
  }
75
86
 
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
+ function stagedDistPath(): string {
88
+ return path.join(supportDir(), "dist", "index.mjs");
89
+ }
90
+
91
+ function appleScriptSource(nodePath: string, scriptPath: string): string {
92
+ // Bake absolute paths into the AppleScript so click-time invocation doesn't
93
+ // depend on PATH or install-time cache state. The dist bundle is fully
94
+ // self-contained (tsdown noExternal), so no NODE_PATH is needed.
87
95
  const escNode = nodePath.replace(/"/g, '\\"');
88
96
  const escScript = scriptPath.replace(/"/g, '\\"');
89
- const escModules = nodeModulesPath.replace(/"/g, '\\"');
90
97
  return [
91
98
  "on open location L",
92
- `\tdo shell script "NODE_PATH='${escModules}' '${escNode}' '${escScript}' url-handle " & quoted form of L`,
99
+ `\tdo shell script "'${escNode}' '${escScript}' url-handle " & quoted form of L`,
93
100
  "end open location",
94
101
  ].join("\n");
95
102
  }
96
103
 
97
104
  // [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:
105
+ // the thing we need to stage. Two invocation paths reach us:
100
106
  // - 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.
107
+ // dynamically imports ../dist/index.mjs resolve the sibling dist.
104
108
  // - direct node: process.argv[1] = ".../dist/index.mjs". Use as-is.
105
109
  export function locateBundledDist(argv1: string | undefined): string {
106
110
  if (!argv1) {
107
- throw new Error("install-url-handler: process.argv[1] not set");
111
+ throw new Error("install: process.argv[1] not set");
108
112
  }
109
113
  if (argv1.endsWith(".mjs") || argv1.endsWith(".js")) {
110
114
  return argv1;
@@ -113,17 +117,126 @@ export function locateBundledDist(argv1: string | undefined): string {
113
117
  return path.resolve(path.dirname(argv1), "..", "dist", "index.mjs");
114
118
  }
115
119
 
116
- function copyDistToStableLocation(): string {
117
- const source = locateBundledDist(process.argv[1]);
118
- if (!fs.existsSync(source)) {
120
+ // The staged render entry: the prebuilt native binary when this platform has
121
+ // one, else the node bin shim. Same contract either way — read hookData on
122
+ // stdin, resolve ../dist/index.mjs by adjacency — so downstream code never
123
+ // cares which flavor was staged. [LAW:one-type-per-behavior]
124
+ type RenderEntry =
125
+ | { kind: "native"; sourcePath: string }
126
+ | { kind: "node-shim"; sourcePath: string };
127
+
128
+ function resolveRenderEntry(sourceDist: string): RenderEntry {
129
+ const key = `${process.platform}-${process.arch}`;
130
+ const pkgName = PLATFORM_PACKAGES[key];
131
+ if (pkgName) {
132
+ // Anchored to the bundle's real location, not this module's compiled
133
+ // form (which ts-jest loads as CJS where import.meta is illegal) — the
134
+ // platform package lives in node_modules beside the installed dist.
135
+ const require = createRequire(sourceDist);
136
+ try {
137
+ return {
138
+ kind: "native",
139
+ sourcePath: require.resolve(`${pkgName}/bin/cc-candybar`),
140
+ };
141
+ } catch {
142
+ // Optional dependency absent (unsupported install, pruned optionals).
143
+ // Falls through to the node shim — announced by the caller, never
144
+ // silent. [LAW:no-silent-failure]
145
+ }
146
+ }
147
+ return {
148
+ kind: "node-shim",
149
+ sourcePath: path.resolve(
150
+ path.dirname(sourceDist),
151
+ "..",
152
+ "bin",
153
+ "cc-candybar",
154
+ ),
155
+ };
156
+ }
157
+
158
+ function stageFile(source: string, dest: string): void {
159
+ // Re-running install FROM the staged runtime makes source === dest;
160
+ // copyFileSync would truncate the file onto itself. Identity is "already
161
+ // staged", not an error.
162
+ if (path.resolve(source) === path.resolve(dest)) return;
163
+ fs.copyFileSync(source, dest);
164
+ }
165
+
166
+ // [LAW:one-source-of-truth] The staged file is the authority on what got
167
+ // staged. resolveRenderEntry's kind describes the *source lookup*, and on a
168
+ // re-run from the staged runtime that lookup resolves the identity path
169
+ // (source === dest), preserving whatever is on disk — possibly a native
170
+ // binary from a prior install that the lookup couldn't see. So the announced
171
+ // kind derives from the artifact itself: both flavors are ours, and the node
172
+ // shim is a "#!" script while the native binary is Mach-O/ELF.
173
+ function stagedEntryKind(binPath: string): RenderEntry["kind"] {
174
+ const fd = fs.openSync(binPath, "r");
175
+ try {
176
+ const magic = Buffer.alloc(2);
177
+ const bytesRead = fs.readSync(fd, magic, 0, 2, 0);
178
+ // [LAW:no-silent-failure] Under 2 bytes neither flavor exists — the file
179
+ // is a corrupt artifact (a crashed prior copy preserved by the identity
180
+ // path), not a native binary.
181
+ if (bytesRead < 2) {
182
+ throw new Error(
183
+ `install: staged render entry at ${binPath} is truncated ` +
184
+ `(${bytesRead} byte(s)). Re-run: pnpm dlx ${PACKAGE_NAME}@latest install`,
185
+ );
186
+ }
187
+ return magic.toString("latin1") === "#!" ? "node-shim" : "native";
188
+ } finally {
189
+ fs.closeSync(fd);
190
+ }
191
+ }
192
+
193
+ export interface StagedRuntime {
194
+ binPath: string;
195
+ distPath: string;
196
+ entryKind: RenderEntry["kind"];
197
+ }
198
+
199
+ // Stage the full runtime at the stable path: dist/index.mjs (daemon + CLI
200
+ // bundle) and bin/cc-candybar (render entry) as adjacent files. Adjacency IS
201
+ // the contract — every entry flavor locates the bundle via ../dist/index.mjs.
202
+ export function runStageRuntime(): StagedRuntime {
203
+ const sourceDist = locateBundledDist(process.argv[1]);
204
+ if (!fs.existsSync(sourceDist)) {
205
+ throw new Error(
206
+ `install: bundled dist not found at ${sourceDist}. Reinstall the package.`,
207
+ );
208
+ }
209
+
210
+ const entry = resolveRenderEntry(sourceDist);
211
+ if (!fs.existsSync(entry.sourcePath)) {
119
212
  throw new Error(
120
- `install-url-handler: bundled dist not found at ${source}. Reinstall the package.`,
213
+ `install: render entry not found at ${entry.sourcePath}. Reinstall the package.`,
121
214
  );
122
215
  }
123
- fs.mkdirSync(supportDir(), { recursive: true });
124
- const dest = stableScriptPath();
125
- fs.copyFileSync(source, dest);
126
- return dest;
216
+
217
+ fs.mkdirSync(path.dirname(stagedDistPath()), { recursive: true });
218
+ fs.mkdirSync(path.dirname(stagedBinPath()), { recursive: true });
219
+ stageFile(sourceDist, stagedDistPath());
220
+ stageFile(entry.sourcePath, stagedBinPath());
221
+ fs.chmodSync(stagedBinPath(), 0o755);
222
+
223
+ // The pre-1.21 layout kept a second copy of the bundle as url-handler.mjs;
224
+ // dist/index.mjs is the one staged bundle now. Remove the stale copy so it
225
+ // can't drift. [LAW:one-source-of-truth]
226
+ fs.rmSync(path.join(supportDir(), "url-handler.mjs"), { force: true });
227
+
228
+ const stagedKind = stagedEntryKind(stagedBinPath());
229
+ process.stdout.write(
230
+ `Staged cc-candybar v${PACKAGE_VERSION} runtime at ${supportDir()}\n` +
231
+ (stagedKind === "native"
232
+ ? ` render entry: native binary (${process.platform}-${process.arch})\n`
233
+ : ` render entry: node shim (no native binary for ${process.platform}-${process.arch}; renders are correct but pay node startup)\n`),
234
+ );
235
+ return {
236
+ binPath: stagedBinPath(),
237
+ distPath: stagedDistPath(),
238
+ entryKind: stagedKind,
239
+ };
127
240
  }
128
241
 
129
242
  function infoPlistPatch(): Array<{ key: string; xml: string }> {
@@ -150,23 +263,10 @@ function infoPlistPatch(): Array<{ key: string; xml: string }> {
150
263
  ];
151
264
  }
152
265
 
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
-
266
+ // Callers establish the macOS precondition ([LAW:single-enforcer] the
267
+ // subcommand entry checks before any side effect; runInstall reaches here
268
+ // only through its darwin dispatch).
269
+ function installUrlHandlerFrom(stagedDist: string): void {
170
270
  const bundle = appBundlePath();
171
271
  fs.mkdirSync(path.dirname(bundle), { recursive: true });
172
272
 
@@ -178,12 +278,7 @@ export function runInstallUrlHandler(): void {
178
278
  process.stdout.write(`Building ${bundle}\n`);
179
279
  const osa = launchSync({
180
280
  bin: "/usr/bin/osacompile",
181
- args: [
182
- "-o",
183
- bundle,
184
- "-e",
185
- appleScriptSource(process.execPath, stableScript, nodeModules),
186
- ],
281
+ args: ["-o", bundle, "-e", appleScriptSource(process.execPath, stagedDist)],
187
282
  category: "install.osacompile",
188
283
  });
189
284
  if (!osa.ok) {
@@ -232,6 +327,14 @@ export function runInstallUrlHandler(): void {
232
327
  );
233
328
  }
234
329
 
330
+ export function runInstallUrlHandler(): void {
331
+ // Precondition before any side effect: on a non-mac this fails with zero
332
+ // files written, not after staging the runtime.
333
+ ensureMacOS();
334
+ const staged = runStageRuntime();
335
+ installUrlHandlerFrom(staged.distPath);
336
+ }
337
+
235
338
  interface ParsedUrl {
236
339
  verb: string;
237
340
  value: string;
@@ -338,16 +441,23 @@ function formatPermanent(outcome: PermanentOutcome): string {
338
441
  }
339
442
 
340
443
  export function runInstall(rendererArgs: string[]): void {
341
- ensureMacOS();
342
-
343
444
  const force = rendererArgs.includes("--force");
344
445
  const filteredArgs = rendererArgs.filter((a) => a !== "--force");
345
446
 
346
447
  const argsToInstall =
347
448
  filteredArgs.length > 0 ? filteredArgs : [...DEFAULT_INSTALL_ARGS];
348
449
 
349
- runInstallUrlHandler();
350
- updateClaudeSettings(argsToInstall, force);
450
+ const staged = runStageRuntime();
451
+
452
+ if (process.platform === "darwin") {
453
+ installUrlHandlerFrom(staged.distPath);
454
+ } else {
455
+ process.stdout.write(
456
+ "Skipping URL handler (cmd-click verbs are macOS-only).\n",
457
+ );
458
+ }
459
+
460
+ updateClaudeSettings(staged.binPath, argsToInstall, force);
351
461
 
352
462
  process.stdout.write(`✓ install complete.\n`);
353
463
  process.stdout.write(
@@ -356,6 +466,7 @@ export function runInstall(rendererArgs: string[]): void {
356
466
  }
357
467
 
358
468
  function updateClaudeSettings(
469
+ binPath: string,
359
470
  rendererArgs: readonly string[],
360
471
  force: boolean,
361
472
  overridePath?: string,
@@ -376,12 +487,20 @@ function updateClaudeSettings(
376
487
  }
377
488
 
378
489
  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}@`;
490
+ // [LAW:one-source-of-truth] Detection: a command we (or a prior version of
491
+ // us) wrote either starts with the legacy `pnpm dlx` form (an open prefix
492
+ // a version suffix follows) or has the staged bin path — quoted or bare —
493
+ // as its entire first token. Any other value is a user customization we
494
+ // must not silently destroy.
495
+ // [LAW:types-are-the-program] Token, not prefix: a bare startsWith(binPath)
496
+ // would also claim `<binPath>-backup …` as ours and overwrite it.
497
+ const managedTokens = [binPath, shellEscape(binPath)];
383
498
  const isOurs =
384
- typeof existing === "string" && existing.startsWith(managedPrefix);
499
+ typeof existing === "string" &&
500
+ (existing.startsWith(`pnpm dlx ${PACKAGE_NAME}@`) ||
501
+ managedTokens.some(
502
+ (token) => existing === token || existing.startsWith(`${token} `),
503
+ ));
385
504
 
386
505
  if (existing && !isOurs && !force) {
387
506
  process.stderr.write(
@@ -394,7 +513,7 @@ function updateClaudeSettings(
394
513
 
395
514
  settings.statusLine = {
396
515
  type: "command",
397
- command: buildStatusLineCommand(rendererArgs),
516
+ command: buildStatusLineCommand(binPath, rendererArgs),
398
517
  };
399
518
 
400
519
  fs.writeFileSync(target, JSON.stringify(settings, null, 2));
@@ -407,4 +526,7 @@ export const __test__ = {
407
526
  buildStatusLineCommand,
408
527
  DEFAULT_INSTALL_ARGS,
409
528
  updateClaudeSettings,
529
+ resolveRenderEntry,
530
+ stageFile,
531
+ stagedEntryKind,
410
532
  };