gflows 0.1.18 → 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 (48) hide show
  1. package/AGENTS.md +78 -0
  2. package/CHANGELOG.md +40 -0
  3. package/README.md +279 -505
  4. package/llms.txt +22 -0
  5. package/package.json +29 -23
  6. package/src/cli.ts +21 -367
  7. package/src/commands/abort.ts +39 -0
  8. package/src/commands/bump.ts +10 -10
  9. package/src/commands/completion.ts +14 -4
  10. package/src/commands/config.ts +123 -0
  11. package/src/commands/continue.ts +40 -0
  12. package/src/commands/delete.ts +4 -4
  13. package/src/commands/doctor.ts +143 -0
  14. package/src/commands/finish.ts +363 -137
  15. package/src/commands/help.ts +29 -21
  16. package/src/commands/init.ts +99 -18
  17. package/src/commands/list.ts +6 -1
  18. package/src/commands/mcp.ts +238 -0
  19. package/src/commands/pr.ts +120 -0
  20. package/src/commands/schema.ts +98 -0
  21. package/src/commands/start.ts +33 -31
  22. package/src/commands/status.ts +70 -51
  23. package/src/commands/switch.ts +14 -19
  24. package/src/commands/sync.ts +160 -0
  25. package/src/commands/undo.ts +78 -0
  26. package/src/commands/version.ts +3 -15
  27. package/src/commands/viz.ts +18 -0
  28. package/src/dispatch.ts +21 -0
  29. package/src/errors.ts +55 -12
  30. package/src/flow.ts +157 -0
  31. package/src/git.ts +135 -8
  32. package/src/index.ts +24 -1
  33. package/src/interactive.ts +209 -0
  34. package/src/out.ts +11 -4
  35. package/src/package-scripts.ts +73 -0
  36. package/src/parse.ts +430 -0
  37. package/src/prompts.ts +89 -0
  38. package/src/recommend.ts +132 -0
  39. package/src/run-state.ts +165 -0
  40. package/src/tui/HubHome.tsx +343 -0
  41. package/src/tui/HubShell.tsx +186 -0
  42. package/src/tui/flows.tsx +140 -0
  43. package/src/tui/hub.ts +89 -0
  44. package/src/tui/prompts.tsx +207 -0
  45. package/src/types.ts +62 -3
  46. package/src/ui.ts +132 -0
  47. package/src/version.ts +24 -0
  48. package/src/viz.ts +294 -0
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Multi-step Ink wizards for hub actions (start / finish / sync).
3
+ * @module tui/flows
4
+ */
5
+
6
+ import type React from "react";
7
+ import { useState } from "react";
8
+ import type { BranchType } from "../types.js";
9
+ import { InkConfirm, InkSelect, InkText, WizardFrame } from "./prompts.js";
10
+
11
+ const BRANCH_TYPES: BranchType[] = ["feature", "bugfix", "chore", "release", "hotfix", "spike"];
12
+
13
+ /**
14
+ * Collects argv for `gflows start …` entirely inside Ink.
15
+ */
16
+ export function StartFlow({
17
+ onDone,
18
+ onCancel,
19
+ }: {
20
+ onDone: (argv: string[]) => void;
21
+ onCancel: () => void;
22
+ }): React.ReactElement {
23
+ const [step, setStep] = useState<"type" | "name" | "push" | "fromMain">("type");
24
+ const [type, setType] = useState<BranchType>("feature");
25
+ const [name, setName] = useState("");
26
+ const [push, setPush] = useState(false);
27
+
28
+ if (step === "type") {
29
+ return (
30
+ <WizardFrame title="Start new work">
31
+ <InkSelect
32
+ message="Branch type"
33
+ options={BRANCH_TYPES.map((t) => ({ value: t, label: t }))}
34
+ onCancel={onCancel}
35
+ onSubmit={(t) => {
36
+ setType(t);
37
+ setStep("name");
38
+ }}
39
+ />
40
+ </WizardFrame>
41
+ );
42
+ }
43
+
44
+ if (step === "name") {
45
+ return (
46
+ <WizardFrame title={`Start · ${type}`}>
47
+ <InkText
48
+ message={type === "release" || type === "hotfix" ? "Version (vX.Y.Z)" : "Branch name"}
49
+ placeholder={type === "release" || type === "hotfix" ? "v1.0.0" : "my-thing"}
50
+ onCancel={onCancel}
51
+ onSubmit={(n) => {
52
+ setName(n);
53
+ setStep("push");
54
+ }}
55
+ />
56
+ </WizardFrame>
57
+ );
58
+ }
59
+
60
+ if (step === "push") {
61
+ return (
62
+ <WizardFrame title={`Start · ${type}/${name}`}>
63
+ <InkConfirm
64
+ message="Push after create?"
65
+ initialValue={false}
66
+ onCancel={onCancel}
67
+ onSubmit={(p) => {
68
+ setPush(p);
69
+ if (type === "bugfix") setStep("fromMain");
70
+ else onDone(["start", type, name, p ? "-p" : "-P"]);
71
+ }}
72
+ />
73
+ </WizardFrame>
74
+ );
75
+ }
76
+
77
+ // fromMain
78
+ return (
79
+ <WizardFrame title={`Start · bugfix/${name}`}>
80
+ <InkConfirm
81
+ message="Base from main (production fix)?"
82
+ initialValue={false}
83
+ onCancel={onCancel}
84
+ onSubmit={(fromMain) => {
85
+ const argv = ["start", type, name, push ? "-p" : "-P"];
86
+ if (fromMain) argv.push("-o", "main");
87
+ onDone(argv);
88
+ }}
89
+ />
90
+ </WizardFrame>
91
+ );
92
+ }
93
+
94
+ /**
95
+ * Collects argv for `gflows finish …` entirely inside Ink.
96
+ */
97
+ export function FinishFlow({
98
+ onDone,
99
+ onCancel,
100
+ }: {
101
+ onDone: (argv: string[]) => void;
102
+ onCancel: () => void;
103
+ }): React.ReactElement {
104
+ return (
105
+ <WizardFrame title="Finish / merge branch">
106
+ <InkConfirm
107
+ message="Push after finish?"
108
+ initialValue={false}
109
+ onCancel={onCancel}
110
+ onSubmit={(push) => {
111
+ onDone(["finish", "-y", push ? "-p" : "-P"]);
112
+ }}
113
+ />
114
+ </WizardFrame>
115
+ );
116
+ }
117
+
118
+ /**
119
+ * Collects argv for `gflows sync …` entirely inside Ink.
120
+ */
121
+ export function SyncFlow({
122
+ onDone,
123
+ onCancel,
124
+ }: {
125
+ onDone: (argv: string[]) => void;
126
+ onCancel: () => void;
127
+ }): React.ReactElement {
128
+ return (
129
+ <WizardFrame title="Sync with base">
130
+ <InkConfirm
131
+ message="Rebase onto base (instead of merge)?"
132
+ initialValue={false}
133
+ onCancel={onCancel}
134
+ onSubmit={(rebase) => {
135
+ onDone(["sync", ...(rebase ? ["--rebase"] : []), "--force"]);
136
+ }}
137
+ />
138
+ </WizardFrame>
139
+ );
140
+ }
package/src/tui/hub.ts ADDED
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Ink hub runner — wizards stay in Ink; only git dispatch drops to the main screen.
3
+ * @module tui/hub
4
+ */
5
+
6
+ import { render } from "ink";
7
+ import React from "react";
8
+ import { dispatch } from "../dispatch.js";
9
+ import { type HubSessionResult, HubShell } from "./HubShell.js";
10
+
11
+ /**
12
+ * Whether stdin/stdout can host the Ink hub.
13
+ */
14
+ export function canUseTui(): boolean {
15
+ return Boolean(process.stdin.isTTY && process.stdout.isTTY);
16
+ }
17
+
18
+ /**
19
+ * Runs the Ink fullscreen hub until quit.
20
+ * @returns true if the TUI ran, false if caller should use the legacy menu.
21
+ */
22
+ export async function runTuiHub(cwd: string): Promise<boolean> {
23
+ if (!canUseTui()) return false;
24
+
25
+ for (;;) {
26
+ const result = await runHubSession(cwd);
27
+ if (result.kind === "quit") break;
28
+
29
+ if (result.kind === "run") {
30
+ console.log("");
31
+ try {
32
+ await dispatch(cwd, result.argv);
33
+ } catch (err) {
34
+ console.error("gflows:", err instanceof Error ? err.message : String(err));
35
+ }
36
+ console.log("");
37
+ await waitEnterRaw("Press enter to return to hub…");
38
+ }
39
+ }
40
+
41
+ return true;
42
+ }
43
+
44
+ function runHubSession(cwd: string): Promise<HubSessionResult> {
45
+ return new Promise((resolve) => {
46
+ let settled = false;
47
+ const done = (result: HubSessionResult) => {
48
+ if (settled) return;
49
+ settled = true;
50
+ resolve(result);
51
+ };
52
+ const instance = render(
53
+ React.createElement(HubShell, {
54
+ cwd,
55
+ onDone: (result) => {
56
+ done(result);
57
+ instance.unmount();
58
+ },
59
+ }),
60
+ );
61
+ void instance.waitUntilExit().then(() => {
62
+ done({ kind: "quit" });
63
+ });
64
+ });
65
+ }
66
+
67
+ /**
68
+ * Raw stdin “press enter” (no Clack / no Ink).
69
+ */
70
+ function waitEnterRaw(label: string): Promise<void> {
71
+ return new Promise((resolve) => {
72
+ process.stdout.write(`${label}\n`);
73
+ if (typeof process.stdin.setRawMode === "function") {
74
+ process.stdin.setRawMode(true);
75
+ }
76
+ process.stdin.resume();
77
+ const onData = (chunk: string | Buffer) => {
78
+ const s = typeof chunk === "string" ? chunk : chunk.toString("utf8");
79
+ if (s === "\r" || s === "\n" || s === "\x03") {
80
+ process.stdin.off("data", onData);
81
+ if (typeof process.stdin.setRawMode === "function") {
82
+ process.stdin.setRawMode(false);
83
+ }
84
+ resolve();
85
+ }
86
+ };
87
+ process.stdin.on("data", onData);
88
+ });
89
+ }
@@ -0,0 +1,207 @@
1
+ /**
2
+ * Ink-native prompts used inside the hub (no Clack drop-out).
3
+ * @module tui/prompts
4
+ */
5
+
6
+ import { Box, Text, useInput } from "ink";
7
+ import type React from "react";
8
+ import { useState } from "react";
9
+
10
+ const ACCENT = "#E88C4A";
11
+ const MUTED = "#8A8A8A";
12
+ const FG = "#E6E6E6";
13
+
14
+ /** Option for {@link InkSelect}. */
15
+ export interface InkSelectOption<T extends string> {
16
+ value: T;
17
+ label: string;
18
+ hint?: string;
19
+ }
20
+
21
+ /**
22
+ * Arrow-key select list.
23
+ */
24
+ export function InkSelect<T extends string>({
25
+ message,
26
+ options,
27
+ initialIndex = 0,
28
+ onSubmit,
29
+ onCancel,
30
+ }: {
31
+ message: string;
32
+ options: InkSelectOption<T>[];
33
+ initialIndex?: number;
34
+ onSubmit: (value: T) => void;
35
+ onCancel?: () => void;
36
+ }): React.ReactElement {
37
+ const [selected, setSelected] = useState(
38
+ Math.min(Math.max(0, initialIndex), Math.max(0, options.length - 1)),
39
+ );
40
+
41
+ useInput((ch, key) => {
42
+ if (key.escape || (key.ctrl && ch === "c")) {
43
+ onCancel?.();
44
+ return;
45
+ }
46
+ if (key.upArrow) {
47
+ setSelected((i) => Math.max(0, i - 1));
48
+ return;
49
+ }
50
+ if (key.downArrow) {
51
+ setSelected((i) => Math.min(options.length - 1, i + 1));
52
+ return;
53
+ }
54
+ if (key.return) {
55
+ const opt = options[selected];
56
+ if (opt) onSubmit(opt.value);
57
+ }
58
+ });
59
+
60
+ return (
61
+ <Box flexDirection="column">
62
+ <Text bold color={FG}>
63
+ {message}
64
+ </Text>
65
+ {options.map((opt, i) => {
66
+ const active = i === selected;
67
+ return (
68
+ <Text key={opt.value} color={active ? FG : MUTED} bold={active}>
69
+ {active ? <Text color={ACCENT}>❯ </Text> : " "}
70
+ {opt.label}
71
+ {opt.hint ? <Text color={MUTED}>{` ${opt.hint}`}</Text> : null}
72
+ </Text>
73
+ );
74
+ })}
75
+ <Text color={MUTED}>↑↓ · enter · esc cancel</Text>
76
+ </Box>
77
+ );
78
+ }
79
+
80
+ /**
81
+ * Yes/no confirm (←/→ or y/n).
82
+ */
83
+ export function InkConfirm({
84
+ message,
85
+ initialValue = true,
86
+ onSubmit,
87
+ onCancel,
88
+ }: {
89
+ message: string;
90
+ initialValue?: boolean;
91
+ onSubmit: (value: boolean) => void;
92
+ onCancel?: () => void;
93
+ }): React.ReactElement {
94
+ const [value, setValue] = useState(initialValue);
95
+
96
+ useInput((ch, key) => {
97
+ if (key.escape || (key.ctrl && ch === "c")) {
98
+ onCancel?.();
99
+ return;
100
+ }
101
+ if (key.leftArrow || key.rightArrow || ch === " ") {
102
+ setValue((v) => !v);
103
+ return;
104
+ }
105
+ if (ch === "y" || ch === "Y") {
106
+ onSubmit(true);
107
+ return;
108
+ }
109
+ if (ch === "n" || ch === "N") {
110
+ onSubmit(false);
111
+ return;
112
+ }
113
+ if (key.return) onSubmit(value);
114
+ });
115
+
116
+ return (
117
+ <Box flexDirection="column">
118
+ <Text bold color={FG}>
119
+ {message}
120
+ </Text>
121
+ <Text>
122
+ <Text color={value ? ACCENT : MUTED} bold={value}>
123
+ {value ? "●" : "○"} Yes
124
+ </Text>
125
+ {" "}
126
+ <Text color={!value ? ACCENT : MUTED} bold={!value}>
127
+ {!value ? "●" : "○"} No
128
+ </Text>
129
+ </Text>
130
+ <Text color={MUTED}>←→ / y n · enter · esc cancel</Text>
131
+ </Box>
132
+ );
133
+ }
134
+
135
+ /**
136
+ * Single-line text input.
137
+ */
138
+ export function InkText({
139
+ message,
140
+ placeholder,
141
+ initialValue = "",
142
+ onSubmit,
143
+ onCancel,
144
+ }: {
145
+ message: string;
146
+ placeholder?: string;
147
+ initialValue?: string;
148
+ onSubmit: (value: string) => void;
149
+ onCancel?: () => void;
150
+ }): React.ReactElement {
151
+ const [value, setValue] = useState(initialValue);
152
+
153
+ useInput((ch, key) => {
154
+ if (key.escape || (key.ctrl && ch === "c")) {
155
+ onCancel?.();
156
+ return;
157
+ }
158
+ if (key.return) {
159
+ const out = value.trim() || (placeholder ?? "");
160
+ if (out) onSubmit(out);
161
+ return;
162
+ }
163
+ if (key.backspace || key.delete) {
164
+ setValue((s) => [...s].slice(0, -1).join(""));
165
+ return;
166
+ }
167
+ if (ch && !key.ctrl && !key.meta && ch >= " ") {
168
+ setValue((s) => s + ch);
169
+ }
170
+ });
171
+
172
+ return (
173
+ <Box flexDirection="column">
174
+ <Text bold color={FG}>
175
+ {message}
176
+ </Text>
177
+ <Text>
178
+ <Text color={ACCENT}>❯ </Text>
179
+ {value || <Text color={MUTED}>{placeholder ?? ""}</Text>}
180
+ <Text color={ACCENT}>█</Text>
181
+ </Text>
182
+ <Text color={MUTED}>type · enter · esc cancel</Text>
183
+ </Box>
184
+ );
185
+ }
186
+
187
+ /**
188
+ * Framed wizard chrome around a prompt step.
189
+ */
190
+ export function WizardFrame({
191
+ title,
192
+ children,
193
+ }: {
194
+ title: string;
195
+ children: React.ReactNode;
196
+ }): React.ReactElement {
197
+ return (
198
+ <Box flexDirection="column" borderStyle="round" borderColor={ACCENT} paddingX={1} paddingY={0}>
199
+ <Text bold color={ACCENT}>
200
+ {title}
201
+ </Text>
202
+ <Box marginTop={1} flexDirection="column">
203
+ {children}
204
+ </Box>
205
+ </Box>
206
+ );
207
+ }
package/src/types.ts CHANGED
@@ -34,7 +34,42 @@ export type Command =
34
34
  | "completion"
35
35
  | "status"
36
36
  | "help"
37
- | "version";
37
+ | "version"
38
+ | "sync"
39
+ | "pr"
40
+ | "doctor"
41
+ | "config"
42
+ | "schema"
43
+ | "continue"
44
+ | "undo"
45
+ | "abort"
46
+ | "mcp"
47
+ | "viz";
48
+
49
+ /** All CLI commands in display order. */
50
+ export const ALL_COMMANDS: Command[] = [
51
+ "init",
52
+ "start",
53
+ "finish",
54
+ "switch",
55
+ "delete",
56
+ "list",
57
+ "bump",
58
+ "sync",
59
+ "pr",
60
+ "viz",
61
+ "doctor",
62
+ "config",
63
+ "schema",
64
+ "continue",
65
+ "undo",
66
+ "abort",
67
+ "completion",
68
+ "status",
69
+ "help",
70
+ "version",
71
+ "mcp",
72
+ ];
38
73
 
39
74
  /** Branch prefix overrides per type (e.g. "feature" -> "feature/"). */
40
75
  export interface BranchPrefixes {
@@ -75,6 +110,9 @@ export type BumpDirection = "up" | "down";
75
110
  /** Bump type (semver segment). */
76
111
  export type BumpType = "patch" | "minor" | "major";
77
112
 
113
+ /** Config subcommand for `gflows config`. */
114
+ export type ConfigAction = "get" | "set";
115
+
78
116
  /** Parsed CLI arguments after resolving command, type, name, and flags. */
79
117
  export interface ParsedArgs {
80
118
  command: Command;
@@ -92,6 +130,12 @@ export interface ParsedArgs {
92
130
  bumpDirection?: BumpDirection;
93
131
  /** Bump type (patch | minor | major). */
94
132
  bumpType?: BumpType;
133
+ /** Config get/set action. */
134
+ configAction?: ConfigAction;
135
+ /** Config key (main, dev, remote, or prefixes.<type>). */
136
+ configKey?: string;
137
+ /** Config value for set. */
138
+ configValue?: string;
95
139
  // Common flags
96
140
  push: boolean;
97
141
  noPush: boolean;
@@ -107,7 +151,9 @@ export interface ParsedArgs {
107
151
  quiet: boolean;
108
152
  force: boolean;
109
153
  path: string | undefined;
110
- // start
154
+ /** Raw --from / -o value (base branch override). */
155
+ from: string | undefined;
156
+ /** True when --from points at main (configured or literal). */
111
157
  fromMain: boolean;
112
158
  // finish
113
159
  noFf: boolean;
@@ -117,8 +163,21 @@ export interface ParsedArgs {
117
163
  noTag: boolean;
118
164
  tagMessage: string | undefined;
119
165
  message: string | undefined;
120
- // list
166
+ squash: boolean;
167
+ preview: boolean;
168
+ bumpOnFinish: boolean;
169
+ // list / status
121
170
  includeRemote: boolean;
171
+ json: boolean;
172
+ // sync
173
+ rebase: boolean;
122
174
  // switch: explicit mode when uncommitted (overrides prompt)
123
175
  switchMode?: "restore" | "clean" | "cancel" | "move" | "destroy";
176
+ /**
177
+ * Optional package.json script name to wire to `gflows` during init
178
+ * (e.g. `"g"` → `bun run g -- start feature x`). Undefined = ask (TTY) or skip.
179
+ */
180
+ scriptAlias: string | undefined;
181
+ /** When true, never add a package.json script alias during init. */
182
+ noScriptAlias: boolean;
124
183
  }
package/src/ui.ts ADDED
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Modern terminal chrome shared by viz and the interactive hub.
3
+ * Lightweight ANSI panels (Claude Code / Codex–style), no TUI framework.
4
+ * @module ui
5
+ */
6
+
7
+ const RESET = "\x1b[0m";
8
+ const BOLD = "\x1b[1m";
9
+ const DIM = "\x1b[2m";
10
+ const CYAN = "\x1b[36m";
11
+ const GREEN = "\x1b[32m";
12
+ const YELLOW = "\x1b[33m";
13
+ const MAGENTA = "\x1b[35m";
14
+ const BLUE = "\x1b[34m";
15
+
16
+ /**
17
+ * Whether stdout can use ANSI color.
18
+ */
19
+ export function colorEnabled(): boolean {
20
+ if (process.env.NO_COLOR) return false;
21
+ return typeof process.stdout.isTTY === "boolean" && process.stdout.isTTY;
22
+ }
23
+
24
+ /**
25
+ * Wraps text in an ANSI code when color is enabled.
26
+ */
27
+ export function paint(code: string, text: string): string {
28
+ return colorEnabled() ? `${code}${text}${RESET}` : text;
29
+ }
30
+
31
+ /** Shared ANSI codes for callers that compose styles. */
32
+ export const ansi = {
33
+ reset: RESET,
34
+ bold: BOLD,
35
+ dim: DIM,
36
+ cyan: CYAN,
37
+ green: GREEN,
38
+ yellow: YELLOW,
39
+ magenta: MAGENTA,
40
+ blue: BLUE,
41
+ } as const;
42
+
43
+ const ESC = "\u001b";
44
+ const ANSI_RE = new RegExp(`${ESC}\\[[0-9;]*m`, "g");
45
+ const ANSI_PREFIX_RE = new RegExp(`^${ESC}\\[[0-9;]*m`);
46
+
47
+ /**
48
+ * Visible length of a string ignoring ANSI escape sequences.
49
+ */
50
+ export function visibleLength(text: string): number {
51
+ return text.replace(ANSI_RE, "").length;
52
+ }
53
+
54
+ /**
55
+ * Pads or soft-truncates a line to a target visible width.
56
+ */
57
+ export function padVisible(text: string, width: number): string {
58
+ const len = visibleLength(text);
59
+ if (len === width) return text;
60
+ if (len < width) return text + " ".repeat(width - len);
61
+ let out = "";
62
+ let vis = 0;
63
+ let i = 0;
64
+ while (i < text.length && vis < width - 1) {
65
+ if (text[i] === ESC) {
66
+ const m = text.slice(i).match(ANSI_PREFIX_RE);
67
+ if (m) {
68
+ out += m[0];
69
+ i += m[0].length;
70
+ continue;
71
+ }
72
+ }
73
+ out += text[i];
74
+ vis++;
75
+ i++;
76
+ }
77
+ return `${out}…`;
78
+ }
79
+
80
+ /**
81
+ * Renders a rounded status panel (title bar + body rows).
82
+ */
83
+ export function renderPanel(title: string, body: string[], width = 56): string[] {
84
+ const inner = Math.max(24, width - 2);
85
+ const labeled = `─ ${title} `;
86
+ const fill = Math.max(0, inner - visibleLength(labeled));
87
+ const top = paint(CYAN + BOLD, `╭${labeled}${"─".repeat(fill)}╮`);
88
+ const bottom = paint(DIM, `╰${"─".repeat(inner)}╯`);
89
+ const rows = body.map((line) => {
90
+ const content = padVisible(` ${line}`, inner);
91
+ return `${paint(DIM, "│")}${content}${paint(DIM, "│")}`;
92
+ });
93
+ return [top, ...rows, bottom];
94
+ }
95
+
96
+ /**
97
+ * Section header.
98
+ */
99
+ export function section(title: string): string {
100
+ return paint(BOLD, title);
101
+ }
102
+
103
+ /**
104
+ * Dim horizontal rule.
105
+ */
106
+ export function rule(width = 56): string {
107
+ return paint(DIM, "─".repeat(width));
108
+ }
109
+
110
+ /**
111
+ * Accent arrow for recommended next step.
112
+ */
113
+ export function recommendLine(label: string): string {
114
+ return `${paint(GREEN + BOLD, "→")} ${paint(BOLD, label)}`;
115
+ }
116
+
117
+ /**
118
+ * Chip / badge text.
119
+ */
120
+ export function chip(text: string, tone: "ok" | "warn" | "info" | "muted" = "muted"): string {
121
+ const code = tone === "ok" ? GREEN : tone === "warn" ? YELLOW : tone === "info" ? CYAN : DIM;
122
+ return paint(code, `[${text}]`);
123
+ }
124
+
125
+ /**
126
+ * Keyboard hint footer.
127
+ */
128
+ export function keyHints(pairs: Array<[string, string]>): string {
129
+ return pairs
130
+ .map(([key, label]) => `${paint(BOLD, key)} ${paint(DIM, label)}`)
131
+ .join(paint(DIM, " · "));
132
+ }
package/src/version.ts ADDED
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Package version helper for CLI chrome and schema.
3
+ * @module version
4
+ */
5
+
6
+ import { readFileSync } from "node:fs";
7
+ import { dirname, join } from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+
10
+ const __dirname = dirname(fileURLToPath(import.meta.url));
11
+
12
+ /**
13
+ * Reads the installed/package version from package.json.
14
+ */
15
+ export function getVersion(): string {
16
+ try {
17
+ const pkgPath = join(__dirname, "..", "package.json");
18
+ const raw = readFileSync(pkgPath, "utf-8");
19
+ const pkg = JSON.parse(raw) as { version?: string };
20
+ return pkg.version ?? "0.0.0";
21
+ } catch {
22
+ return "0.0.0";
23
+ }
24
+ }