@abdullahsahmad/work-kit 0.1.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 (76) hide show
  1. package/README.md +147 -0
  2. package/cli/bin/work-kit.mjs +18 -0
  3. package/cli/src/commands/complete.ts +123 -0
  4. package/cli/src/commands/completions.ts +137 -0
  5. package/cli/src/commands/context.ts +41 -0
  6. package/cli/src/commands/doctor.ts +79 -0
  7. package/cli/src/commands/init.test.ts +116 -0
  8. package/cli/src/commands/init.ts +184 -0
  9. package/cli/src/commands/loopback.ts +64 -0
  10. package/cli/src/commands/next.ts +172 -0
  11. package/cli/src/commands/observe.ts +144 -0
  12. package/cli/src/commands/setup.ts +159 -0
  13. package/cli/src/commands/status.ts +50 -0
  14. package/cli/src/commands/uninstall.ts +89 -0
  15. package/cli/src/commands/upgrade.ts +12 -0
  16. package/cli/src/commands/validate.ts +34 -0
  17. package/cli/src/commands/workflow.ts +125 -0
  18. package/cli/src/config/agent-map.ts +62 -0
  19. package/cli/src/config/loopback-routes.ts +45 -0
  20. package/cli/src/config/phases.ts +119 -0
  21. package/cli/src/context/extractor.test.ts +77 -0
  22. package/cli/src/context/extractor.ts +73 -0
  23. package/cli/src/context/prompt-builder.ts +70 -0
  24. package/cli/src/engine/loopbacks.test.ts +33 -0
  25. package/cli/src/engine/loopbacks.ts +32 -0
  26. package/cli/src/engine/parallel.ts +60 -0
  27. package/cli/src/engine/phases.ts +23 -0
  28. package/cli/src/engine/transitions.test.ts +117 -0
  29. package/cli/src/engine/transitions.ts +97 -0
  30. package/cli/src/index.ts +248 -0
  31. package/cli/src/observer/data.ts +237 -0
  32. package/cli/src/observer/renderer.ts +316 -0
  33. package/cli/src/observer/watcher.ts +99 -0
  34. package/cli/src/state/helpers.test.ts +91 -0
  35. package/cli/src/state/helpers.ts +65 -0
  36. package/cli/src/state/schema.ts +113 -0
  37. package/cli/src/state/store.ts +82 -0
  38. package/cli/src/state/validators.test.ts +105 -0
  39. package/cli/src/state/validators.ts +81 -0
  40. package/cli/src/utils/colors.ts +12 -0
  41. package/package.json +49 -0
  42. package/skills/auto-kit/SKILL.md +214 -0
  43. package/skills/build/SKILL.md +88 -0
  44. package/skills/build/stages/commit.md +43 -0
  45. package/skills/build/stages/core.md +48 -0
  46. package/skills/build/stages/integration.md +44 -0
  47. package/skills/build/stages/migration.md +41 -0
  48. package/skills/build/stages/red.md +44 -0
  49. package/skills/build/stages/refactor.md +48 -0
  50. package/skills/build/stages/setup.md +42 -0
  51. package/skills/build/stages/ui.md +51 -0
  52. package/skills/deploy/SKILL.md +62 -0
  53. package/skills/deploy/stages/merge.md +47 -0
  54. package/skills/deploy/stages/monitor.md +39 -0
  55. package/skills/deploy/stages/remediate.md +54 -0
  56. package/skills/full-kit/SKILL.md +195 -0
  57. package/skills/plan/SKILL.md +77 -0
  58. package/skills/plan/stages/architecture.md +53 -0
  59. package/skills/plan/stages/audit.md +58 -0
  60. package/skills/plan/stages/blueprint.md +60 -0
  61. package/skills/plan/stages/clarify.md +61 -0
  62. package/skills/plan/stages/investigate.md +47 -0
  63. package/skills/plan/stages/scope.md +46 -0
  64. package/skills/plan/stages/sketch.md +44 -0
  65. package/skills/plan/stages/ux-flow.md +49 -0
  66. package/skills/review/SKILL.md +104 -0
  67. package/skills/review/stages/compliance.md +48 -0
  68. package/skills/review/stages/handoff.md +59 -0
  69. package/skills/review/stages/performance.md +45 -0
  70. package/skills/review/stages/security.md +49 -0
  71. package/skills/review/stages/self-review.md +41 -0
  72. package/skills/test/SKILL.md +83 -0
  73. package/skills/test/stages/e2e.md +44 -0
  74. package/skills/test/stages/validate.md +51 -0
  75. package/skills/test/stages/verify.md +41 -0
  76. package/skills/wrap-up/SKILL.md +107 -0
@@ -0,0 +1,184 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { WorkKitState, PhaseState, PhaseName, PHASE_NAMES, SUBSTAGES_BY_PHASE, WorkflowStep, Classification } from "../state/schema.js";
4
+ import { writeState, writeStateMd, stateExists } from "../state/store.js";
5
+ import { buildFullWorkflow, buildDefaultWorkflow, skillFilePath } from "../config/phases.js";
6
+ import type { Action } from "../state/schema.js";
7
+
8
+ function toSlug(description: string): string {
9
+ return description
10
+ .toLowerCase()
11
+ .replace(/[^a-z0-9\s-]/g, "")
12
+ .replace(/\s+/g, "-")
13
+ .slice(0, 40)
14
+ .replace(/-+$/, "");
15
+ }
16
+
17
+ function buildPhases(workflow?: WorkflowStep[]): Record<PhaseName, PhaseState> {
18
+ const phases = {} as Record<PhaseName, PhaseState>;
19
+
20
+ for (const phase of PHASE_NAMES) {
21
+ const subStages: Record<string, { status: "pending" | "skipped" }> = {};
22
+ const allSubStages = SUBSTAGES_BY_PHASE[phase];
23
+
24
+ for (const ss of allSubStages) {
25
+ if (workflow) {
26
+ const step = workflow.find((s) => s.phase === phase && s.subStage === ss);
27
+ subStages[ss] = { status: step?.included ? "pending" : "skipped" };
28
+ } else {
29
+ subStages[ss] = { status: "pending" };
30
+ }
31
+ }
32
+
33
+ // Check if entire phase is skipped (all sub-stages skipped)
34
+ const allSkipped = Object.values(subStages).every((s) => s.status === "skipped");
35
+ phases[phase] = {
36
+ status: allSkipped ? "skipped" : "pending",
37
+ subStages,
38
+ };
39
+ }
40
+
41
+ return phases;
42
+ }
43
+
44
+ function generateStateMd(slug: string, branch: string, mode: string, description: string, classification?: string, workflow?: WorkflowStep[]): string {
45
+ const title = slug.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
46
+ const date = new Date().toISOString().split("T")[0];
47
+
48
+ let md = `# ${title}
49
+
50
+ **Slug:** ${slug}
51
+ **Branch:** ${branch}
52
+ **Started:** ${date}
53
+ **Mode:** ${mode}
54
+ `;
55
+
56
+ if (classification) {
57
+ md += `**Classification:** ${classification}\n`;
58
+ }
59
+
60
+ md += `**Phase:** plan
61
+ **Sub-stage:** clarify
62
+ **Status:** in-progress
63
+
64
+ ## Description
65
+ ${description}
66
+ `;
67
+
68
+ if (workflow) {
69
+ md += `\n## Workflow\n`;
70
+ for (const step of workflow) {
71
+ if (step.included) {
72
+ const label = `${step.phase.charAt(0).toUpperCase() + step.phase.slice(1)}: ${step.subStage.charAt(0).toUpperCase() + step.subStage.slice(1)}`;
73
+ md += `- [ ] ${label}\n`;
74
+ }
75
+ }
76
+ }
77
+
78
+ md += `
79
+ ## Criteria
80
+ <!-- Added during Plan/Clarify, checked off during test/review -->
81
+
82
+ ## Decisions
83
+ <!-- Append here whenever you choose between real alternatives -->
84
+ <!-- Format: **<context>**: chose <X> over <Y> — <why> -->
85
+
86
+ ## Deviations
87
+ <!-- Append here whenever implementation diverges from the Blueprint -->
88
+ <!-- Format: **<Blueprint step>**: <what changed> — <why> -->
89
+ `;
90
+
91
+ return md;
92
+ }
93
+
94
+ export function initCommand(options: {
95
+ mode: "full" | "auto";
96
+ description: string;
97
+ classification?: Classification;
98
+ worktreeRoot?: string;
99
+ }): Action {
100
+ const { mode, description, classification } = options;
101
+ const worktreeRoot = options.worktreeRoot || process.cwd();
102
+
103
+ // Guard: don't overwrite existing state
104
+ if (stateExists(worktreeRoot)) {
105
+ return {
106
+ action: "error",
107
+ message: "State already exists in this directory. Use `work-kit status` to check current state.",
108
+ suggestion: "To start fresh, delete .work-kit/state.json first.",
109
+ };
110
+ }
111
+
112
+ // Auto mode requires classification
113
+ if (mode === "auto" && !classification) {
114
+ return {
115
+ action: "error",
116
+ message: "Auto mode requires --classification (bug-fix, small-change, refactor, feature, large-feature).",
117
+ };
118
+ }
119
+
120
+ // Validate mode
121
+ if (mode !== "full" && mode !== "auto") {
122
+ return {
123
+ action: "error",
124
+ message: `Invalid mode "${mode}". Use "full" or "auto".`,
125
+ };
126
+ }
127
+
128
+ const slug = toSlug(description);
129
+ const branch = `feature/${slug}`;
130
+ const modeLabel = mode === "full" ? "full-kit" : "auto-kit";
131
+
132
+ // Build workflow
133
+ let workflow: WorkflowStep[] | undefined;
134
+ if (mode === "auto" && classification) {
135
+ workflow = buildDefaultWorkflow(classification);
136
+ } else if (mode === "full") {
137
+ workflow = buildFullWorkflow();
138
+ }
139
+
140
+ // Find first active sub-stage
141
+ let firstPhase: PhaseName = "plan";
142
+ let firstSubStage = "clarify";
143
+
144
+ if (workflow) {
145
+ const first = workflow.find((s) => s.included);
146
+ if (first) {
147
+ firstPhase = first.phase;
148
+ firstSubStage = first.subStage;
149
+ }
150
+ }
151
+
152
+ // Build state
153
+ const state: WorkKitState = {
154
+ version: 1,
155
+ slug,
156
+ branch,
157
+ started: new Date().toISOString().split("T")[0],
158
+ mode: modeLabel,
159
+ ...(classification && { classification }),
160
+ status: "in-progress",
161
+ currentPhase: firstPhase,
162
+ currentSubStage: firstSubStage,
163
+ phases: buildPhases(workflow),
164
+ ...(mode === "auto" && workflow && { workflow }),
165
+ loopbacks: [],
166
+ metadata: {
167
+ worktreeRoot,
168
+ mainRepoRoot: worktreeRoot, // will be set properly by caller
169
+ },
170
+ };
171
+
172
+ // Write state files
173
+ writeState(worktreeRoot, state);
174
+ writeStateMd(worktreeRoot, generateStateMd(slug, branch, modeLabel, description, classification, workflow));
175
+
176
+ return {
177
+ action: "spawn_agent",
178
+ phase: firstPhase,
179
+ subStage: firstSubStage,
180
+ skillFile: skillFilePath(firstPhase, firstSubStage),
181
+ agentPrompt: `You are starting the ${firstPhase} phase. Begin with the ${firstSubStage} sub-stage. Read the skill file and follow its instructions. Write outputs to .work-kit/state.md.`,
182
+ onComplete: `npx work-kit complete ${firstPhase}/${firstSubStage}`,
183
+ };
184
+ }
@@ -0,0 +1,64 @@
1
+ import { readState, writeState, findWorktreeRoot } from "../state/store.js";
2
+ import { parseLocation, resetToLocation } from "../state/helpers.js";
3
+ import type { Action } from "../state/schema.js";
4
+
5
+ const MAX_LOOPBACKS_PER_ROUTE = 2;
6
+
7
+ export function loopbackCommand(opts: {
8
+ from: string;
9
+ to: string;
10
+ reason: string;
11
+ worktreeRoot?: string;
12
+ }): Action {
13
+ const root = opts.worktreeRoot || findWorktreeRoot();
14
+ if (!root) {
15
+ return { action: "error", message: "No work-kit state found." };
16
+ }
17
+
18
+ const state = readState(root);
19
+ const from = parseLocation(opts.from);
20
+ const to = parseLocation(opts.to);
21
+
22
+ if (!state.phases[from.phase]?.subStages[from.subStage]) {
23
+ return { action: "error", message: `Invalid source: ${opts.from}` };
24
+ }
25
+ if (!state.phases[to.phase]?.subStages[to.subStage]) {
26
+ return { action: "error", message: `Invalid target: ${opts.to}` };
27
+ }
28
+
29
+ // Can't loop back to a skipped sub-stage
30
+ if (state.phases[to.phase].subStages[to.subStage].status === "skipped") {
31
+ return { action: "error", message: `Cannot loop back to ${opts.to} — it is skipped.` };
32
+ }
33
+
34
+ // Enforce max loopback count per route
35
+ const sameRouteCount = state.loopbacks.filter(
36
+ (lb) => lb.from.phase === from.phase && lb.from.subStage === from.subStage
37
+ && lb.to.phase === to.phase && lb.to.subStage === to.subStage
38
+ ).length;
39
+ if (sameRouteCount >= MAX_LOOPBACKS_PER_ROUTE) {
40
+ return {
41
+ action: "error",
42
+ message: `Max loopback count (${MAX_LOOPBACKS_PER_ROUTE}) reached for ${opts.from} → ${opts.to}. Proceeding with noted caveats.`,
43
+ };
44
+ }
45
+
46
+ state.loopbacks.push({
47
+ from,
48
+ to,
49
+ reason: opts.reason,
50
+ timestamp: new Date().toISOString(),
51
+ });
52
+
53
+ resetToLocation(state, to);
54
+ state.currentPhase = to.phase;
55
+ state.currentSubStage = to.subStage;
56
+ writeState(root, state);
57
+
58
+ return {
59
+ action: "loopback",
60
+ from,
61
+ to,
62
+ reason: opts.reason,
63
+ };
64
+ }
@@ -0,0 +1,172 @@
1
+ import { readState, writeState, findWorktreeRoot, readStateMd } from "../state/store.js";
2
+ import { determineNextStep } from "../engine/transitions.js";
3
+ import { validatePhasePrerequisites } from "../state/validators.js";
4
+ import { buildAgentPrompt } from "../context/prompt-builder.js";
5
+ import { getParallelGroup } from "../engine/parallel.js";
6
+ import { skillFilePath } from "../config/phases.js";
7
+ import type { Action, PhaseName, WorkKitState } from "../state/schema.js";
8
+
9
+ export function nextCommand(worktreeRoot?: string): Action {
10
+ const root = worktreeRoot || findWorktreeRoot();
11
+ if (!root) {
12
+ return { action: "error", message: "No work-kit state found. Run `work-kit init` first." };
13
+ }
14
+
15
+ const state = readState(root);
16
+
17
+ if (state.status === "completed") {
18
+ return { action: "complete", message: "Work-kit is already complete." };
19
+ }
20
+
21
+ if (state.status === "failed") {
22
+ return { action: "error", message: "Work-kit is in failed state.", suggestion: "Review the state and restart." };
23
+ }
24
+
25
+ const step = determineNextStep(state);
26
+
27
+ switch (step.type) {
28
+ case "complete":
29
+ return { action: "complete", message: step.message! };
30
+
31
+ case "wait-for-user":
32
+ return { action: "wait_for_user", message: step.message! };
33
+
34
+ case "phase-boundary": {
35
+ const phase = step.phase!;
36
+
37
+ const validation = validatePhasePrerequisites(state, phase);
38
+ if (!validation.valid) {
39
+ return {
40
+ action: "error",
41
+ message: validation.message,
42
+ suggestion: `Complete ${validation.missingPrerequisite} first.`,
43
+ };
44
+ }
45
+
46
+ state.currentPhase = phase;
47
+ state.phases[phase].status = "in-progress";
48
+ state.phases[phase].startedAt = new Date().toISOString();
49
+
50
+ const subStages = Object.entries(state.phases[phase].subStages);
51
+ const firstActive = subStages.find(([_, ss]) => ss.status === "pending");
52
+
53
+ if (!firstActive) {
54
+ return { action: "error", message: `No pending sub-stages in ${phase}` };
55
+ }
56
+
57
+ const [subStage] = firstActive;
58
+ state.currentSubStage = subStage;
59
+ state.phases[phase].subStages[subStage].status = "in-progress";
60
+ state.phases[phase].subStages[subStage].startedAt = new Date().toISOString();
61
+ writeState(root, state);
62
+
63
+ return buildSpawnAction(root, state, phase, subStage);
64
+ }
65
+
66
+ case "sub-stage": {
67
+ const phase = step.phase!;
68
+ const subStage = step.subStage!;
69
+
70
+ state.currentPhase = phase;
71
+ state.currentSubStage = subStage;
72
+ if (state.phases[phase].status === "pending") {
73
+ state.phases[phase].status = "in-progress";
74
+ state.phases[phase].startedAt = new Date().toISOString();
75
+ }
76
+ state.phases[phase].subStages[subStage].status = "in-progress";
77
+ state.phases[phase].subStages[subStage].startedAt = new Date().toISOString();
78
+ writeState(root, state);
79
+
80
+ return buildSpawnAction(root, state, phase, subStage);
81
+ }
82
+
83
+ default:
84
+ return { action: "error", message: `Unknown step type: ${step.type}` };
85
+ }
86
+ }
87
+
88
+ function buildSpawnAction(root: string, state: WorkKitState, phase: PhaseName, subStage: string): Action {
89
+ // Read state.md once for all prompt builds
90
+ const stateMd = readStateMd(root);
91
+ const parallelGroup = getParallelGroup(phase, subStage, state);
92
+
93
+ if (parallelGroup) {
94
+ const agents = parallelGroup.parallel
95
+ .filter((ss) => {
96
+ const ssState = state.phases[phase].subStages[ss];
97
+ return ssState && ssState.status !== "skipped" && ssState.status !== "completed";
98
+ })
99
+ .map((ss) => ({
100
+ phase,
101
+ subStage: ss,
102
+ skillFile: skillFilePath(phase, ss),
103
+ agentPrompt: buildAgentPrompt(root, state, phase, ss, stateMd),
104
+ outputFile: `.work-kit/${phase}-${ss}.md`,
105
+ }));
106
+
107
+ // If all parallel members were filtered out, fall through to single agent
108
+ if (agents.length === 0) {
109
+ // Skip to thenSequential if it exists, otherwise nothing to do
110
+ if (parallelGroup.thenSequential) {
111
+ const seqSS = parallelGroup.thenSequential;
112
+ return {
113
+ action: "spawn_agent",
114
+ phase,
115
+ subStage: seqSS,
116
+ skillFile: skillFilePath(phase, seqSS),
117
+ agentPrompt: buildAgentPrompt(root, state, phase, seqSS, stateMd),
118
+ onComplete: `npx work-kit complete ${phase}/${seqSS}`,
119
+ };
120
+ }
121
+ return { action: "error", message: `No active sub-stages in parallel group for ${phase}` };
122
+ }
123
+
124
+ // If only 1 agent remains, run as single agent (no need for parallel)
125
+ if (agents.length === 1 && !parallelGroup.thenSequential) {
126
+ const agent = agents[0];
127
+ return {
128
+ action: "spawn_agent",
129
+ phase: agent.phase,
130
+ subStage: agent.subStage,
131
+ skillFile: agent.skillFile,
132
+ agentPrompt: agent.agentPrompt,
133
+ onComplete: `npx work-kit complete ${agent.phase}/${agent.subStage}`,
134
+ };
135
+ }
136
+
137
+ for (const agent of agents) {
138
+ state.phases[phase].subStages[agent.subStage].status = "in-progress";
139
+ state.phases[phase].subStages[agent.subStage].startedAt = new Date().toISOString();
140
+ }
141
+
142
+ const thenSequential = parallelGroup.thenSequential
143
+ ? {
144
+ phase,
145
+ subStage: parallelGroup.thenSequential,
146
+ skillFile: skillFilePath(phase, parallelGroup.thenSequential),
147
+ agentPrompt: buildAgentPrompt(root, state, phase, parallelGroup.thenSequential, stateMd),
148
+ }
149
+ : undefined;
150
+
151
+ writeState(root, state);
152
+
153
+ return {
154
+ action: "spawn_parallel_agents",
155
+ agents,
156
+ thenSequential,
157
+ onComplete: `npx work-kit complete ${phase}/${parallelGroup.thenSequential || parallelGroup.parallel[parallelGroup.parallel.length - 1]}`,
158
+ };
159
+ }
160
+
161
+ const skill = skillFilePath(phase, subStage);
162
+ const prompt = buildAgentPrompt(root, state, phase, subStage, stateMd);
163
+
164
+ return {
165
+ action: "spawn_agent",
166
+ phase,
167
+ subStage,
168
+ skillFile: skill,
169
+ agentPrompt: prompt,
170
+ onComplete: `npx work-kit complete ${phase}/${subStage}`,
171
+ };
172
+ }
@@ -0,0 +1,144 @@
1
+ import * as path from "node:path";
2
+ import { execFileSync } from "node:child_process";
3
+ import {
4
+ renderDashboard,
5
+ enterAlternateScreen,
6
+ exitAlternateScreen,
7
+ moveCursorHome,
8
+ renderTooSmall,
9
+ } from "../observer/renderer.js";
10
+ import { collectDashboardData } from "../observer/data.js";
11
+ import { startWatching } from "../observer/watcher.js";
12
+
13
+ function findMainRepoRoot(startDir: string): string {
14
+ // Find the git toplevel
15
+ try {
16
+ const result = execFileSync("git", ["rev-parse", "--show-toplevel"], {
17
+ cwd: startDir,
18
+ encoding: "utf-8",
19
+ timeout: 5000,
20
+ });
21
+ return result.trim();
22
+ } catch {
23
+ return startDir;
24
+ }
25
+ }
26
+
27
+ export async function observeCommand(opts: { mainRepo?: string }): Promise<void> {
28
+ const mainRepoRoot = opts.mainRepo
29
+ ? path.resolve(opts.mainRepo)
30
+ : findMainRepoRoot(process.cwd());
31
+
32
+ let scrollOffset = 0;
33
+ let cleanedUp = false;
34
+
35
+ function cleanup(): void {
36
+ if (cleanedUp) return;
37
+ cleanedUp = true;
38
+
39
+ // Restore terminal
40
+ exitAlternateScreen();
41
+ if (process.stdin.isTTY && process.stdin.setRawMode) {
42
+ try { process.stdin.setRawMode(false); } catch { /* ignore */ }
43
+ }
44
+ process.stdin.removeAllListeners("data");
45
+ }
46
+
47
+ function render(): void {
48
+ const width = process.stdout.columns || 80;
49
+ const height = process.stdout.rows || 24;
50
+
51
+ if (width < 60 || height < 10) {
52
+ process.stdout.write(renderTooSmall(width, height));
53
+ return;
54
+ }
55
+
56
+ const data = collectDashboardData(mainRepoRoot, watcher.getWorktrees());
57
+ const frame = moveCursorHome() + renderDashboard(data, width, height, scrollOffset);
58
+ process.stdout.write(frame);
59
+ }
60
+
61
+ // Enter alternate screen
62
+ enterAlternateScreen();
63
+
64
+ // Set up signal handlers
65
+ const onSignal = () => {
66
+ cleanup();
67
+ process.exit(0);
68
+ };
69
+ process.on("SIGINT", onSignal);
70
+ process.on("SIGTERM", onSignal);
71
+
72
+ let watcher!: ReturnType<typeof startWatching>;
73
+
74
+ try {
75
+ // Set up file watching (before initial render so worktrees are cached)
76
+ watcher = startWatching(mainRepoRoot, () => {
77
+ render();
78
+ });
79
+
80
+ // Initial render
81
+ render();
82
+
83
+ // Handle terminal resize
84
+ process.stdout.on("resize", () => {
85
+ render();
86
+ });
87
+
88
+ // Set up keyboard input
89
+ if (process.stdin.isTTY && process.stdin.setRawMode) {
90
+ process.stdin.setRawMode(true);
91
+ process.stdin.resume();
92
+ process.stdin.setEncoding("utf-8");
93
+
94
+ await new Promise<void>((resolve) => {
95
+ process.stdin.on("data", (key: string) => {
96
+ // Ctrl+C
97
+ if (key === "\x03") {
98
+ watcher.stop();
99
+ resolve();
100
+ return;
101
+ }
102
+
103
+ // 'q' to quit
104
+ if (key === "q" || key === "Q") {
105
+ watcher.stop();
106
+ resolve();
107
+ return;
108
+ }
109
+
110
+ // 'r' to refresh
111
+ if (key === "r" || key === "R") {
112
+ scrollOffset = 0;
113
+ render();
114
+ return;
115
+ }
116
+
117
+ // Up arrow: \x1b[A
118
+ if (key === "\x1b[A") {
119
+ scrollOffset = Math.max(0, scrollOffset - 1);
120
+ render();
121
+ return;
122
+ }
123
+
124
+ // Down arrow: \x1b[B
125
+ if (key === "\x1b[B") {
126
+ scrollOffset++;
127
+ render();
128
+ return;
129
+ }
130
+ });
131
+ });
132
+ } else {
133
+ // Non-TTY: just keep running until interrupted
134
+ await new Promise<void>((resolve) => {
135
+ process.on("SIGINT", () => {
136
+ watcher.stop();
137
+ resolve();
138
+ });
139
+ });
140
+ }
141
+ } finally {
142
+ cleanup();
143
+ }
144
+ }