@promptctl/cc-candybar 1.25.0 → 1.27.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 (45) hide show
  1. package/dist/index.mjs +83 -76
  2. package/package.json +6 -6
  3. package/schema/cc-candybar.schema.json +193 -4
  4. package/src/check.ts +49 -27
  5. package/src/click/wire.ts +16 -0
  6. package/src/config/action.ts +57 -22
  7. package/src/config/default-dsl-config.ts +424 -55
  8. package/src/config/dsl-loader.ts +14 -2
  9. package/src/config/dsl-types.ts +59 -0
  10. package/src/config/loader/actions.ts +283 -109
  11. package/src/config/loader/cross-ref.ts +148 -28
  12. package/src/config/loader/emit-schema.ts +2 -0
  13. package/src/config/loader/globals.ts +118 -31
  14. package/src/config/loader/merge.ts +58 -1
  15. package/src/config/loader/persist-target.ts +32 -0
  16. package/src/config/loader/presets.ts +107 -0
  17. package/src/config/option-domain.ts +164 -0
  18. package/src/config/presets.ts +156 -0
  19. package/src/daemon/cache/git.ts +1 -1
  20. package/src/daemon/cache/render.ts +61 -7
  21. package/src/daemon/config-overrides-store.ts +322 -0
  22. package/src/daemon/paths.ts +10 -0
  23. package/src/daemon/render-payload.ts +84 -19
  24. package/src/daemon/server.ts +68 -55
  25. package/src/daemon/verbs/config-validators.ts +127 -0
  26. package/src/daemon/verbs/index.ts +129 -2
  27. package/src/daemon/verbs/state-validators.ts +98 -586
  28. package/src/daemon/verbs/validator-registry.ts +457 -0
  29. package/src/demo/dsl.ts +17 -10
  30. package/src/dsl/node-registry.ts +54 -39
  31. package/src/dsl/render.ts +158 -46
  32. package/src/help-text.ts +59 -0
  33. package/src/index.ts +2 -47
  34. package/src/install/index.ts +18 -4
  35. package/src/render/action.ts +155 -33
  36. package/src/render/active-segment.ts +78 -0
  37. package/src/render/menu.ts +16 -11
  38. package/src/render/picker.ts +51 -13
  39. package/src/render/segment-color.ts +74 -0
  40. package/src/segments/git.ts +389 -48
  41. package/src/template-engine/colors.ts +67 -45
  42. package/src/template-engine/engine.ts +11 -12
  43. package/src/themes/index.ts +1 -4
  44. package/src/themes/palette-resolvers.ts +22 -30
  45. package/src/themes/policy.ts +37 -16
package/src/dsl/render.ts CHANGED
@@ -11,7 +11,7 @@
11
11
  // the input values (kind discriminators, layout length, palette presence)
12
12
  // govern output, not whether operations run.
13
13
 
14
- import type { RichText, PaletteResolver, ThemeKey } from "@promptctl/rich-js";
14
+ import type { RichText, Palette, ThemeKey } from "@promptctl/rich-js";
15
15
  import { ColorSpec, Style, lighten, IDENTITY } from "@promptctl/rich-js";
16
16
  import type { Engine, Template } from "@promptctl/go-template-js";
17
17
  import type {
@@ -21,6 +21,8 @@ import type {
21
21
  LayoutNode,
22
22
  } from "../config/dsl-types.js";
23
23
  import { HUE_STEP_VAR } from "../config/dsl-types.js";
24
+ import { perConfigDomainsFor } from "../config/option-domain.js";
25
+ import { PRESET_FLOOR, presetNames, presetRoot } from "../config/presets.js";
24
26
  import type { VariableStore } from "../var-system/store.js";
25
27
  import type { SourceRegistry } from "../var-system/sources.js";
26
28
  import {
@@ -30,11 +32,12 @@ import {
30
32
  } from "../var-system/sources.js";
31
33
  import type { BuildLineOptions } from "../render/strip.js";
32
34
  import { DEFAULT_PADDING, renderStripCells } from "../render/strip.js";
33
- import { resolverForThemeName } from "../themes/index.js";
35
+ import { paletteForThemeName } from "../themes/index.js";
34
36
  import { buildScope } from "../template-engine/scope.js";
35
37
  import {
36
38
  createCcCandybarEngine,
37
39
  evaluateWhen,
40
+ resolveSegmentColors,
38
41
  } from "../template-engine/index.js";
39
42
  import {
40
43
  compileActions,
@@ -47,6 +50,11 @@ import {
47
50
  collectMenuDrops,
48
51
  type MenuRuntime,
49
52
  } from "../render/menu.js";
53
+ import {
54
+ createActiveSegmentRef,
55
+ type ActiveSegmentRef,
56
+ } from "../render/active-segment.js";
57
+ import { segmentColorFuncs } from "../render/segment-color.js";
50
58
  // [LAW:one-way-deps] The node-type registry sits below this driver: it owns the
51
59
  // compiled node shapes + each kind's compile/render, dispatched via nodeType().
52
60
  // render.ts threads the recursion (compileChild/renderChild) + the hue counter in
@@ -71,14 +79,27 @@ import {
71
79
  // owns node behavior); this driver only assembles + walks them.
72
80
  export interface CompiledConfig {
73
81
  readonly segments: CompiledSegments;
74
- readonly root: CompiledNode;
75
- // [LAW:locality-or-seam] The menu runtime the engine's `menu` func closes over,
76
- // surfaced here so renderDsl can publish each segment's placement into it before
77
- // that segment's template evaluates. One instance per compiled config; its
78
- // `current` is mutated synchronously within a single renderDsl walk (renders are
79
- // sequential + synchronous, so no cross-render leak) the spatial cousin of the
80
- // hue cursor, one owner. [LAW:no-ambient-temporal-coupling]
82
+ // [LAW:dataflow-not-control-flow] EVERY preset's layout, compiled up front and
83
+ // keyed by preset name the render selects one by name rather than compiling
84
+ // per session. This is the same move `looks` makes one level down (every
85
+ // look's ThemeKey is resolved at load; the render picks one), and it is what
86
+ // lets a per-SESSION preset pick ride a per-ENTRY compilation: one RenderCache
87
+ // entry serves many sessions, so nothing session-shaped may be compiled here.
88
+ // Total over `presetNames` — every selectable name, floor included — so the
89
+ // lookup needs no absent case. A preset declaring no `root` of its own maps
90
+ // to the config's own compiled root: the identity element, not a special
91
+ // case.
92
+ readonly roots: ReadonlyMap<string, CompiledNode>;
93
+ // [LAW:locality-or-seam] The menu runtime the engine's `menu` func closes over.
81
94
  readonly menuRuntime: MenuRuntime;
95
+ // [LAW:one-source-of-truth] The single "which segment is rendering" record
96
+ // every segment-scoped template function reads — the menu's identity, the
97
+ // `color` func's palette, the `bgOf` func's background. Surfaced here so the
98
+ // walk can publish into it. One instance per compiled config; mutated
99
+ // synchronously within a single renderDsl walk (renders are sequential +
100
+ // synchronous, so no cross-render leak) — the spatial cousin of the hue
101
+ // cursor, one owner. [LAW:no-ambient-temporal-coupling]
102
+ readonly activeSegment: ActiveSegmentRef;
82
103
  // [LAW:types-are-the-program] Variable declaration failures that did NOT
83
104
  // prevent the config from loading (type mismatches, bad defaults). The
84
105
  // affected variables are absent from the store; segments that reference them
@@ -296,31 +317,56 @@ export function registerDslConfig(
296
317
  // [LAW:single-enforcer] Forward the caller's clock (the daemon's `() => new
297
318
  // Date()`, a test's frozen clock) to the one engine. Omitted ⇒ undefined ⇒
298
319
  // createCcCandybarEngine applies its single default; no second default literal.
320
+ // [LAW:one-source-of-truth] ONE record for "which segment is rendering", read
321
+ // by every segment-scoped template function: `{{ menu }}` takes its identity
322
+ // from the name, `{{ color }}` its palette, `{{ bgOf }}` its background. A
323
+ // per-feature pointer would let two features disagree about which segment is
324
+ // current. Built before the engine so the funcs can close over it; `current`
325
+ // stays null until a render walk publishes one.
326
+ const activeSegment = createActiveSegmentRef();
299
327
  // [LAW:locality-or-seam] The menu runtime shares the action runtime (a menu's
300
- // glyph + body resolve from the same compiled table + store) and carries the
301
- // walk-published current placement. Built before the engine so the `menu` func
302
- // can close over it; `current` stays null until a render walk publishes one.
328
+ // glyph + body resolve from the same compiled table + store) and reads the
329
+ // active segment through the shared record above.
303
330
  const menuRuntime: MenuRuntime = {
304
331
  action: actionRuntime,
305
- current: null,
332
+ activeSegment,
306
333
  };
307
334
  // [LAW:one-source-of-truth] The config's look names — the one PER-CONFIG
308
- // option domain. Computed once here and fed to BOTH consumers (the compiled
309
- // set-option domains below and the `looks()` binding), so the rendered
310
- // options, a hand-authored `range looks`, and the derived click gate (which
311
- // reads the same config in deriveActionValidators) trace to one map.
335
+ // option domain. Fed to every consumer (the `looks()` binding below, and
336
+ // via perConfigDomainsFor, the SAME construction cross-ref.ts and
337
+ // state-validators.ts use the compiled set-option domains), so the
338
+ // rendered options, a hand-authored `range looks`, and the derived click
339
+ // gate (which reads the same config in deriveActionValidators) trace to
340
+ // one source.
312
341
  const lookNames = Object.keys(config.looks);
342
+ const presetOptions = presetNames(config.presets);
343
+ const perConfigDomains = perConfigDomainsFor(config);
313
344
  const engine = createCcCandybarEngine(
314
- undefined,
315
345
  {
316
346
  ...actionFuncs(actionRuntime),
317
347
  ...pickerFuncs(actionRuntime),
318
348
  ...menuFuncs(menuRuntime),
349
+ // [LAW:one-source-of-truth] `{{ color }}` reads the palette of the
350
+ // segment currently rendering — the same palette its `bg:`/`fg:` resolve
351
+ // from, published by the walk. Binding it to a palette captured HERE
352
+ // (registration runs once per config load, renders happen per tick) was
353
+ // the two-clocks bug this seam exists to close: a session theme click, a
354
+ // look, or a per-segment hue rotation moved a segment's background while
355
+ // every in-body color stayed where it was, so one segment painted from
356
+ // two palettes at once. Reading live costs nothing structurally — FuncMap
357
+ // bodies run at evaluate time, so parse-once/evaluate-many is untouched.
358
+ // `{{ bgOf }}` rides the same record. [LAW:rich-js-owns-color-math]
359
+ ...segmentColorFuncs(activeSegment),
319
360
  // [LAW:one-type-per-behavior] The per-config sibling of the static
320
361
  // themes()/styles() bindings (template-engine/funcs.ts): zero-arg
321
362
  // projection of the "looks" option domain. Injected here — not in the
322
363
  // static FuncMap — because the domain is this config's looks block.
323
364
  looks: { fn: () => lookNames, argTypes: [] },
365
+ // The presets domain's twin of the binding above — same per-config
366
+ // reason, same shape. A hand-authored `range presets` and a
367
+ // `{{ menu "applyPreset" }}` therefore enumerate the same names the
368
+ // derived click gate admits.
369
+ presets: { fn: () => presetOptions, argTypes: [] },
324
370
  },
325
371
  opts?.clock,
326
372
  );
@@ -363,7 +409,7 @@ export function registerDslConfig(
363
409
  parse,
364
410
  config.actions,
365
411
  stateKeyToVar,
366
- lookNames,
412
+ perConfigDomains,
367
413
  );
368
414
 
369
415
  // [LAW:dataflow-not-control-flow] One variable failing to declare does not
@@ -427,9 +473,9 @@ export function registerDslConfig(
427
473
  // per-render basePalette; folding globals.palette in here too would freeze
428
474
  // it per segment and the stale copy would shadow basePalette, so a session
429
475
  // theme change could never recolor the bar.
430
- paletteResolver:
476
+ palette:
431
477
  seg.palette !== undefined
432
- ? resolverForThemeName(seg.palette)
478
+ ? paletteForThemeName(seg.palette)
433
479
  : undefined,
434
480
  };
435
481
  }
@@ -463,9 +509,25 @@ export function registerDslConfig(
463
509
  return nodeType(node.kind).compile(node, cctx);
464
510
  };
465
511
 
512
+ // [LAW:one-source-of-truth] One compiled tree per declared preset, built
513
+ // through the SAME compileNode the config's own root goes through —
514
+ // `presetRoot` resolves the fragment's `root` or falls back to the config's,
515
+ // so the floor preset (the empty fragment) needs no arm and no absent case
516
+ // downstream.
517
+ // Keyed by the SAME domain the menu renders and the click gate admits, so
518
+ // every selectable name has a compiled tree [LAW:one-source-of-truth].
519
+ const roots = new Map<string, CompiledNode>();
520
+ for (const name of presetOptions) {
521
+ // The path travels WITH the tree, so a preset that stages the config's own
522
+ // root diagnoses under `root` — the place its author actually wrote it.
523
+ const { node, path } = presetRoot(config, name);
524
+ roots.set(name, compileNode(node, path));
525
+ }
526
+
466
527
  return {
467
528
  segments: compiled,
468
- root: compileNode(config.root, "root"),
529
+ roots,
530
+ activeSegment,
469
531
  menuRuntime,
470
532
  loadWarnings,
471
533
  };
@@ -545,24 +607,44 @@ export interface RenderObservers {
545
607
  readonly onSegmentError?: (segName: string, message: string) => void;
546
608
  }
547
609
 
610
+ // [LAW:locality-or-seam] The per-render RESOLUTION the caller performs and hands
611
+ // down — the values that are neither config (compiled once) nor payload (input
612
+ // data), but the session's live choices resolved against the config: which
613
+ // theme-adaptation, which preset. Bundled as ONE named bag for exactly the
614
+ // reason RenderObservers is: `look` arrived as a positional tail, `preset` would
615
+ // have been a second one, and the next resolution a third — each a signature
616
+ // every caller re-counts. A new per-render choice is now one field here.
617
+ //
618
+ // Both fields default to their domain's own identity element, so an omitting
619
+ // caller (a compile-only test, the demo) renders the unadapted config — a true
620
+ // default, not a fallback [LAW:no-silent-failure].
621
+ export interface RenderSelection {
622
+ // The resolved look, as a ThemeKey: effectiveLookName over SessionState/
623
+ // globals, then lookKeyByName — resolved by the caller exactly how basePalette
624
+ // is. IDENTITY is the "none" look. Composed with each segment's hue shift into
625
+ // ONE transposition.
626
+ readonly look?: ThemeKey;
627
+ // The resolved preset NAME: effectivePresetName over SessionState/globals,
628
+ // collapsed to the floor if stale. Selects which of `compiled.roots` this
629
+ // render walks. The name (not the fragment) crosses this seam because the
630
+ // fragment's two halves land in two different places — the root here, the
631
+ // globals in `opts`/the payload — and one name keeps them from disagreeing.
632
+ readonly preset?: string;
633
+ }
634
+
548
635
  export function renderDsl(
549
636
  config: ValidatedConfig,
550
637
  compiled: CompiledConfig,
551
638
  store: VariableStore,
552
639
  registry: SourceRegistry,
553
640
  payload: unknown,
554
- basePalette: PaletteResolver,
641
+ basePalette: Palette,
555
642
  opts: BuildLineOptions,
556
643
  observers?: RenderObservers,
557
- // [LAW:dataflow-not-control-flow] The render's look (the session's chosen
558
- // theme-adaptation), resolved per render by the caller — effectiveLookName
559
- // over SessionState/globals, then lookKeyByName — exactly how basePalette
560
- // resolves. IDENTITY is the domain's own no-adaptation element (the "none"
561
- // look), so an omitting caller renders unadapted — a true default, not a
562
- // fallback. Composed with each segment's hue shift into ONE transposition.
563
- look: ThemeKey = IDENTITY,
644
+ selection?: RenderSelection,
564
645
  ): string {
565
646
  const { perSegmentSink, onSegmentError } = observers ?? {};
647
+ const { look = IDENTITY, preset = PRESET_FLOOR } = selection ?? {};
566
648
  // [LAW:one-source-of-truth] Inject the usable width as `term.cols` from the
567
649
  // SAME opts.width the strip wraps to (below), so a width-paginated widget reads
568
650
  // the exact wrap width — never a cached or independently-measured copy. This is
@@ -626,19 +708,34 @@ export function renderDsl(
626
708
  : undefined;
627
709
  };
628
710
 
629
- // [LAW:single-enforcer] The menu seam, owned here. `beginSegment` publishes the
630
- // segment name a `{{ menu }}` needs to derive its identity; `collectDrops` reads
631
- // the open bodies the menus carried as metadata on their evaluated fragments and
632
- // clears the published placement. The runtime's `current` is set/cleared around
633
- // each segment eval by the walk only never ambient.
634
- // [LAW:no-ambient-temporal-coupling]
635
- const beginSegment = (segName: string): void => {
636
- compiled.menuRuntime.current = { segName };
637
- };
638
- const collectDrops = (
639
- fragments: readonly RichText[],
640
- ): readonly RichText[] => {
641
- compiled.menuRuntime.current = null;
711
+ // [LAW:single-enforcer] The segment seam, owned here as a symmetric pair.
712
+ // `enterSegment` establishes everything a segment's templates may ask about
713
+ // themselves the name `{{ menu }}` derives its identity from, the palette
714
+ // `{{ color }}` resolves against, the background `{{ bgOf }}` returns and
715
+ // returns the resolved base Style. `exitSegment` collects the menu bodies the
716
+ // fragments carried as metadata and tears the record back down.
717
+ //
718
+ // [LAW:no-ambient-temporal-coupling] The record is set and cleared around each
719
+ // segment's evaluation by the walk ONLY, so "which segment am I in" is owned
720
+ // state with one writer, never ambient context a reader has to hope is
721
+ // current. Enter/exit are a pair by construction: every path that publishes
722
+ // goes through the first, every path that finishes goes through the second.
723
+ const enterSegment = (
724
+ segName: string,
725
+ palette: Palette,
726
+ bgTemplate: Template<RichText> | undefined,
727
+ fgTemplate: Template<RichText> | undefined,
728
+ ): Style =>
729
+ resolveSegmentColors(
730
+ compiled.activeSegment,
731
+ segName,
732
+ palette,
733
+ bgTemplate,
734
+ fgTemplate,
735
+ scope,
736
+ );
737
+ const exitSegment = (fragments: readonly RichText[]): readonly RichText[] => {
738
+ compiled.activeSegment.current = null;
642
739
  return collectMenuDrops(fragments);
643
740
  };
644
741
 
@@ -662,8 +759,8 @@ export function renderDsl(
662
759
  nextHueShift,
663
760
  perSegmentSink,
664
761
  onSegmentError,
665
- beginSegment,
666
- collectDrops,
762
+ enterSegment,
763
+ exitSegment,
667
764
  focusTint,
668
765
  lookupSegment,
669
766
  renderChild: renderNode,
@@ -676,7 +773,22 @@ export function renderDsl(
676
773
  // emit a "\n"-bearing string (FlexStrip width-overflow wrap); joining the per-
677
774
  // line results with "\n" splices those in place — byte-identical to serializing
678
775
  // each leaf row independently, since the cells and their order are unchanged.
679
- return renderNode(compiled.root, true)
776
+ // [LAW:no-defensive-null-guards] The active preset's compiled tree. By the
777
+ // time a name reaches here it must be a member: effectivePresetName collapses
778
+ // unknown names to the floor, and every merged config declares the floor — so
779
+ // the throw is the loud failure for a broken invariant (a hand-built config
780
+ // missing the bundled presets block), never a silent fall back to some other
781
+ // arrangement than the one the bar's own label claims is active.
782
+ const root = compiled.roots.get(preset);
783
+ if (root === undefined) {
784
+ throw new Error(
785
+ `Preset "${preset}" has no compiled layout — registerDslConfig compiles ` +
786
+ `one per declared preset and effectivePresetName collapses unknown ` +
787
+ `names to "${PRESET_FLOOR}"; a miss here is merge/policy drift ` +
788
+ `(have: ${[...compiled.roots.keys()].join(", ")})`,
789
+ );
790
+ }
791
+ return renderNode(root, true)
680
792
  .map((line) => renderStripCells(line, opts))
681
793
  .join("\n");
682
794
  }
@@ -0,0 +1,59 @@
1
+ import { DISCLOSURE_GLYPH_CLOSED } from "./config/disclosure";
2
+
3
+ // [LAW:effects-at-boundaries] Pure data, no I/O — index.ts owns the console.log
4
+ // effect. Kept as its own module so the text is importable (and testable) without
5
+ // pulling in index.ts's top-level `main()` call.
6
+ // [LAW:one-source-of-truth] The disclosure glyph comes from config/disclosure.ts
7
+ // (the same constant the theme/look picker itself renders with), so this text
8
+ // can't drift from what a user actually sees on the bar.
9
+ export const HELP_TEXT = `
10
+ cc-candybar - Beautiful powerline statusline for Claude Code
11
+
12
+ Usage: cc-candybar [options]
13
+
14
+ Standalone Commands:
15
+ -h, --help Show this help
16
+
17
+ Debugging:
18
+ CC_CANDYBAR_DEBUG=1 Enable debug logging for troubleshooting
19
+
20
+ Configuration:
21
+ Layout and segment options are defined in .cc-candybar.json5 (place in your
22
+ project dir, cwd, or ~/.config/cc-candybar/config.json5). Use CC_CANDYBAR_CONFIG
23
+ to point at a specific file. See the default config for all available options:
24
+ node dist/index.mjs debug --project-dir . --cwd .
25
+
26
+ The bundled default bar ships a settings drawer — no config needed. Click
27
+ ⚙ settings ${DISCLOSURE_GLYPH_CLOSED} on the bar to reveal a clickable theme/look picker and
28
+ other display options.
29
+
30
+ Subcommands:
31
+ install One-shot setup: stages the runtime (native render
32
+ binary + dist bundle) at a stable path, creates the
33
+ URL handler app + cc-candybar:// scheme (macOS), and
34
+ writes the staged entry as the statusLine command in
35
+ ~/.claude/settings.json. Re-run to update.
36
+ install-url-handler Just stage the runtime and create + register the URL
37
+ handler app (macOS only).
38
+ url-handle URL Internal — invoked by the URL handler app on
39
+ cmd-click. Parses cc-candybar://<verb>/<value> and
40
+ dispatches (currently: copy to clipboard).
41
+ daemon-stats [--json] Query the running daemon for runtime stats:
42
+ uptime, RSS, cache hit rates, watcher count,
43
+ request totals. Does not spawn a daemon.
44
+
45
+ Config tooling:
46
+ check [config-file] Validate a config on the full render pipeline (parse
47
+ → merge → validate → register → render) with no
48
+ daemon. With no path, checks the same file the daemon
49
+ would load from here. Exit 0 clean (warnings on
50
+ stderr), 1 invalid, 2 unreadable. "lint" is an alias.
51
+ schema Print the JSON Schema for the config file shape
52
+ (.cc-candybar.json5). Point an editor's $schema at it
53
+ for autocomplete + structural validation.
54
+ vars [--json] Declared variables: source kind, value, last error.
55
+ segments [--json] Segment templates and their last rendered output.
56
+ config [--json] The effective merged config. (All three query the
57
+ running daemon; none spawn one.)
58
+
59
+ `;
package/src/index.ts CHANGED
@@ -15,6 +15,7 @@ import { runSchema } from "./config/cli";
15
15
  import { runCheck } from "./check";
16
16
  import { obtainDaemonKick } from "./daemon/acquire";
17
17
  import { planOutcome } from "./render/outcome-plan";
18
+ import { HELP_TEXT } from "./help-text";
18
19
 
19
20
  // Read terminal width from the live shell context (no subprocess). Returns
20
21
  // undefined when nothing reliable is available; the daemon falls back to its
@@ -36,53 +37,7 @@ function detectTermCols(): number | undefined {
36
37
  }
37
38
 
38
39
  function showHelpText(): void {
39
- console.log(`
40
- cc-candybar - Beautiful powerline statusline for Claude Code
41
-
42
- Usage: cc-candybar [options]
43
-
44
- Standalone Commands:
45
- -h, --help Show this help
46
-
47
- Debugging:
48
- CC_CANDYBAR_DEBUG=1 Enable debug logging for troubleshooting
49
-
50
- Configuration:
51
- Layout and segment options are defined in .cc-candybar.json5 (place in your
52
- project dir, cwd, or ~/.config/cc-candybar/config.json5). Use CC_CANDYBAR_CONFIG
53
- to point at a specific file. See the default config for all available options:
54
- node dist/index.mjs debug --project-dir . --cwd .
55
-
56
- Subcommands:
57
- install One-shot setup: stages the runtime (native render
58
- binary + dist bundle) at a stable path, creates the
59
- URL handler app + cc-candybar:// scheme (macOS), and
60
- writes the staged entry as the statusLine command in
61
- ~/.claude/settings.json. Re-run to update.
62
- install-url-handler Just stage the runtime and create + register the URL
63
- handler app (macOS only).
64
- url-handle URL Internal — invoked by the URL handler app on
65
- cmd-click. Parses cc-candybar://<verb>/<value> and
66
- dispatches (currently: copy to clipboard).
67
- daemon-stats [--json] Query the running daemon for runtime stats:
68
- uptime, RSS, cache hit rates, watcher count,
69
- request totals. Does not spawn a daemon.
70
-
71
- Config tooling:
72
- check [config-file] Validate a config on the full render pipeline (parse
73
- → merge → validate → register → render) with no
74
- daemon. With no path, checks the same file the daemon
75
- would load from here. Exit 0 clean (warnings on
76
- stderr), 1 invalid, 2 unreadable. "lint" is an alias.
77
- schema Print the JSON Schema for the config file shape
78
- (.cc-candybar.json5). Point an editor's $schema at it
79
- for autocomplete + structural validation.
80
- vars [--json] Declared variables: source kind, value, last error.
81
- segments [--json] Segment templates and their last rendered output.
82
- config [--json] The effective merged config. (All three query the
83
- running daemon; none spawn one.)
84
-
85
- `);
40
+ console.log(HELP_TEXT);
86
41
  }
87
42
 
88
43
  async function main(): Promise<void> {
@@ -7,6 +7,7 @@ import { tryClickViaDaemon } from "../daemon/client";
7
7
  import type { PermanentOutcome } from "../daemon/client-transport";
8
8
  import { obtainDaemonKick } from "../daemon/acquire";
9
9
  import { URL_SCHEME, VERB_COPY } from "../click/wire";
10
+ import { DISCLOSURE_GLYPH_CLOSED } from "../config/disclosure";
10
11
 
11
12
  // [LAW:one-source-of-truth] Replaced at build time by tsdown's `define` option
12
13
  // from package.json — the single version stamp install output reports.
@@ -440,6 +441,21 @@ function formatPermanent(outcome: PermanentOutcome): string {
440
441
  }
441
442
  }
442
443
 
444
+ // [LAW:effects-at-boundaries] Pure string builder — runInstall performs the
445
+ // actual write. Kept separate so the message content is testable without
446
+ // driving the full (fs + Launch Services) install side effects.
447
+ // [LAW:one-source-of-truth] The disclosure glyph comes from config/disclosure.ts,
448
+ // the same constant the theme/look picker itself renders with, so this tip
449
+ // can't drift from what the bundled default bar actually shows.
450
+ function installSuccessMessage(): string {
451
+ return (
452
+ `✓ install complete.\n` +
453
+ ` Restart Claude Code to pick up the new statusline.\n` +
454
+ ` Tip: the default bar has a settings drawer — click ⚙ settings\n` +
455
+ ` ${DISCLOSURE_GLYPH_CLOSED} to reveal a clickable theme/look picker and other display options.\n`
456
+ );
457
+ }
458
+
443
459
  export function runInstall(rendererArgs: string[]): void {
444
460
  const force = rendererArgs.includes("--force");
445
461
  const filteredArgs = rendererArgs.filter((a) => a !== "--force");
@@ -459,10 +475,7 @@ export function runInstall(rendererArgs: string[]): void {
459
475
 
460
476
  updateClaudeSettings(staged.binPath, argsToInstall, force);
461
477
 
462
- process.stdout.write(`✓ install complete.\n`);
463
- process.stdout.write(
464
- ` Restart Claude Code to pick up the new statusline.\n`,
465
- );
478
+ process.stdout.write(installSuccessMessage());
466
479
  }
467
480
 
468
481
  function updateClaudeSettings(
@@ -529,4 +542,5 @@ export const __test__ = {
529
542
  resolveRenderEntry,
530
543
  stageFile,
531
544
  stagedEntryKind,
545
+ installSuccessMessage,
532
546
  };