rafcode 2.1.1 → 2.2.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 (120) hide show
  1. package/.claude/settings.local.json +4 -1
  2. package/CLAUDE.md +59 -11
  3. package/RAF/ahslfe-config-wizard/decisions.md +34 -0
  4. package/RAF/ahslfe-config-wizard/input.md +1 -0
  5. package/RAF/ahslfe-config-wizard/outcomes/01-define-config-schema.md +38 -0
  6. package/RAF/ahslfe-config-wizard/outcomes/02-refactor-codebase-to-use-config.md +67 -0
  7. package/RAF/ahslfe-config-wizard/outcomes/03-create-config-documentation.md +37 -0
  8. package/RAF/ahslfe-config-wizard/outcomes/04-implement-raf-config-command.md +47 -0
  9. package/RAF/ahslfe-config-wizard/outcomes/05-update-claude-md.md +26 -0
  10. package/RAF/ahslfe-config-wizard/plans/01-define-config-schema.md +73 -0
  11. package/RAF/ahslfe-config-wizard/plans/02-refactor-codebase-to-use-config.md +74 -0
  12. package/RAF/ahslfe-config-wizard/plans/03-create-config-documentation.md +57 -0
  13. package/RAF/ahslfe-config-wizard/plans/04-implement-raf-config-command.md +66 -0
  14. package/RAF/ahslfe-config-wizard/plans/05-update-claude-md.md +60 -0
  15. package/RAF/ahstvo-token-tracker/decisions.md +44 -0
  16. package/RAF/ahstvo-token-tracker/input.md +3 -0
  17. package/RAF/ahstvo-token-tracker/outcomes/01-full-model-id-support.md +43 -0
  18. package/RAF/ahstvo-token-tracker/outcomes/02-name-generation-no-session.md +33 -0
  19. package/RAF/ahstvo-token-tracker/outcomes/03-unify-stream-json-execution.md +48 -0
  20. package/RAF/ahstvo-token-tracker/outcomes/04-token-tracking-cost-calculation.md +53 -0
  21. package/RAF/ahstvo-token-tracker/outcomes/05-token-cost-console-reporting.md +57 -0
  22. package/RAF/ahstvo-token-tracker/outcomes/06-runtime-verbose-toggle.md +53 -0
  23. package/RAF/ahstvo-token-tracker/outcomes/07-readme-config-docs.md +36 -0
  24. package/RAF/ahstvo-token-tracker/plans/01-full-model-id-support.md +35 -0
  25. package/RAF/ahstvo-token-tracker/plans/02-name-generation-no-session.md +36 -0
  26. package/RAF/ahstvo-token-tracker/plans/03-unify-stream-json-execution.md +44 -0
  27. package/RAF/ahstvo-token-tracker/plans/04-token-tracking-cost-calculation.md +56 -0
  28. package/RAF/ahstvo-token-tracker/plans/05-token-cost-console-reporting.md +55 -0
  29. package/RAF/ahstvo-token-tracker/plans/06-runtime-verbose-toggle.md +48 -0
  30. package/RAF/ahstvo-token-tracker/plans/07-readme-config-docs.md +44 -0
  31. package/README.md +34 -0
  32. package/dist/commands/config.d.ts +3 -0
  33. package/dist/commands/config.d.ts.map +1 -0
  34. package/dist/commands/config.js +173 -0
  35. package/dist/commands/config.js.map +1 -0
  36. package/dist/commands/do.d.ts.map +1 -1
  37. package/dist/commands/do.js +47 -6
  38. package/dist/commands/do.js.map +1 -1
  39. package/dist/commands/plan.d.ts.map +1 -1
  40. package/dist/commands/plan.js +3 -2
  41. package/dist/commands/plan.js.map +1 -1
  42. package/dist/core/claude-runner.d.ts +19 -2
  43. package/dist/core/claude-runner.d.ts.map +1 -1
  44. package/dist/core/claude-runner.js +43 -96
  45. package/dist/core/claude-runner.js.map +1 -1
  46. package/dist/core/failure-analyzer.d.ts.map +1 -1
  47. package/dist/core/failure-analyzer.js +6 -3
  48. package/dist/core/failure-analyzer.js.map +1 -1
  49. package/dist/core/git.d.ts.map +1 -1
  50. package/dist/core/git.js +10 -3
  51. package/dist/core/git.js.map +1 -1
  52. package/dist/core/pull-request.d.ts +1 -1
  53. package/dist/core/pull-request.d.ts.map +1 -1
  54. package/dist/core/pull-request.js +7 -4
  55. package/dist/core/pull-request.js.map +1 -1
  56. package/dist/index.js +2 -0
  57. package/dist/index.js.map +1 -1
  58. package/dist/parsers/stream-renderer.d.ts +16 -1
  59. package/dist/parsers/stream-renderer.d.ts.map +1 -1
  60. package/dist/parsers/stream-renderer.js +34 -4
  61. package/dist/parsers/stream-renderer.js.map +1 -1
  62. package/dist/prompts/execution.d.ts.map +1 -1
  63. package/dist/prompts/execution.js +11 -1
  64. package/dist/prompts/execution.js.map +1 -1
  65. package/dist/types/config.d.ts +95 -4
  66. package/dist/types/config.d.ts.map +1 -1
  67. package/dist/types/config.js +63 -3
  68. package/dist/types/config.js.map +1 -1
  69. package/dist/utils/config.d.ts +59 -7
  70. package/dist/utils/config.d.ts.map +1 -1
  71. package/dist/utils/config.js +276 -21
  72. package/dist/utils/config.js.map +1 -1
  73. package/dist/utils/name-generator.d.ts +3 -7
  74. package/dist/utils/name-generator.d.ts.map +1 -1
  75. package/dist/utils/name-generator.js +75 -61
  76. package/dist/utils/name-generator.js.map +1 -1
  77. package/dist/utils/terminal-symbols.d.ts +21 -0
  78. package/dist/utils/terminal-symbols.d.ts.map +1 -1
  79. package/dist/utils/terminal-symbols.js +62 -0
  80. package/dist/utils/terminal-symbols.js.map +1 -1
  81. package/dist/utils/token-tracker.d.ts +45 -0
  82. package/dist/utils/token-tracker.d.ts.map +1 -0
  83. package/dist/utils/token-tracker.js +107 -0
  84. package/dist/utils/token-tracker.js.map +1 -0
  85. package/dist/utils/validation.d.ts +5 -5
  86. package/dist/utils/validation.d.ts.map +1 -1
  87. package/dist/utils/validation.js +10 -6
  88. package/dist/utils/validation.js.map +1 -1
  89. package/dist/utils/verbose-toggle.d.ts +33 -0
  90. package/dist/utils/verbose-toggle.d.ts.map +1 -0
  91. package/dist/utils/verbose-toggle.js +94 -0
  92. package/dist/utils/verbose-toggle.js.map +1 -0
  93. package/package.json +1 -1
  94. package/src/commands/config.ts +204 -0
  95. package/src/commands/do.ts +56 -5
  96. package/src/commands/plan.ts +3 -2
  97. package/src/core/claude-runner.ts +59 -115
  98. package/src/core/failure-analyzer.ts +6 -3
  99. package/src/core/git.ts +10 -3
  100. package/src/core/pull-request.ts +7 -4
  101. package/src/index.ts +2 -0
  102. package/src/parsers/stream-renderer.ts +54 -4
  103. package/src/prompts/config-docs.md +331 -0
  104. package/src/prompts/execution.ts +13 -1
  105. package/src/types/config.ts +156 -7
  106. package/src/utils/config.ts +335 -21
  107. package/src/utils/name-generator.ts +84 -71
  108. package/src/utils/terminal-symbols.ts +68 -0
  109. package/src/utils/token-tracker.ts +135 -0
  110. package/src/utils/validation.ts +15 -10
  111. package/src/utils/verbose-toggle.ts +103 -0
  112. package/tests/unit/claude-runner.test.ts +171 -7
  113. package/tests/unit/config-command.test.ts +163 -0
  114. package/tests/unit/config.test.ts +608 -30
  115. package/tests/unit/name-generator.test.ts +99 -75
  116. package/tests/unit/pull-request.test.ts +2 -0
  117. package/tests/unit/stream-renderer.test.ts +83 -0
  118. package/tests/unit/terminal-symbols.test.ts +157 -0
  119. package/tests/unit/token-tracker.test.ts +352 -0
  120. package/tests/unit/verbose-toggle.test.ts +204 -0
@@ -0,0 +1,135 @@
1
+ import { UsageData, PricingConfig } from '../types/config.js';
2
+ import { resolveModelPricingCategory, getPricingConfig } from './config.js';
3
+
4
+ /** Cost breakdown for a single task or accumulated total. */
5
+ export interface CostBreakdown {
6
+ inputCost: number;
7
+ outputCost: number;
8
+ cacheReadCost: number;
9
+ cacheCreateCost: number;
10
+ totalCost: number;
11
+ }
12
+
13
+ /** Per-task usage snapshot stored by the tracker. */
14
+ export interface TaskUsageEntry {
15
+ taskId: string;
16
+ usage: UsageData;
17
+ cost: CostBreakdown;
18
+ }
19
+
20
+ /**
21
+ * Accumulates token usage across multiple task executions and calculates costs
22
+ * using configurable per-model pricing.
23
+ */
24
+ export class TokenTracker {
25
+ private entries: TaskUsageEntry[] = [];
26
+ private pricingConfig: PricingConfig;
27
+
28
+ constructor(pricingConfig?: PricingConfig) {
29
+ this.pricingConfig = pricingConfig ?? getPricingConfig();
30
+ }
31
+
32
+ /**
33
+ * Record usage data from a completed task.
34
+ */
35
+ addTask(taskId: string, usage: UsageData): TaskUsageEntry {
36
+ const cost = this.calculateCost(usage);
37
+ const entry: TaskUsageEntry = { taskId, usage, cost };
38
+ this.entries.push(entry);
39
+ return entry;
40
+ }
41
+
42
+ /**
43
+ * Get all recorded task entries.
44
+ */
45
+ getEntries(): readonly TaskUsageEntry[] {
46
+ return this.entries;
47
+ }
48
+
49
+ /**
50
+ * Get accumulated totals across all tasks.
51
+ */
52
+ getTotals(): { usage: UsageData; cost: CostBreakdown } {
53
+ const totalUsage: UsageData = {
54
+ inputTokens: 0,
55
+ outputTokens: 0,
56
+ cacheReadInputTokens: 0,
57
+ cacheCreationInputTokens: 0,
58
+ modelUsage: {},
59
+ };
60
+ const totalCost: CostBreakdown = {
61
+ inputCost: 0,
62
+ outputCost: 0,
63
+ cacheReadCost: 0,
64
+ cacheCreateCost: 0,
65
+ totalCost: 0,
66
+ };
67
+
68
+ for (const entry of this.entries) {
69
+ totalUsage.inputTokens += entry.usage.inputTokens;
70
+ totalUsage.outputTokens += entry.usage.outputTokens;
71
+ totalUsage.cacheReadInputTokens += entry.usage.cacheReadInputTokens;
72
+ totalUsage.cacheCreationInputTokens += entry.usage.cacheCreationInputTokens;
73
+
74
+ // Merge per-model usage
75
+ for (const [modelId, modelUsage] of Object.entries(entry.usage.modelUsage)) {
76
+ const existing = totalUsage.modelUsage[modelId];
77
+ if (existing) {
78
+ existing.inputTokens += modelUsage.inputTokens;
79
+ existing.outputTokens += modelUsage.outputTokens;
80
+ existing.cacheReadInputTokens += modelUsage.cacheReadInputTokens;
81
+ existing.cacheCreationInputTokens += modelUsage.cacheCreationInputTokens;
82
+ } else {
83
+ totalUsage.modelUsage[modelId] = { ...modelUsage };
84
+ }
85
+ }
86
+
87
+ totalCost.inputCost += entry.cost.inputCost;
88
+ totalCost.outputCost += entry.cost.outputCost;
89
+ totalCost.cacheReadCost += entry.cost.cacheReadCost;
90
+ totalCost.cacheCreateCost += entry.cost.cacheCreateCost;
91
+ totalCost.totalCost += entry.cost.totalCost;
92
+ }
93
+
94
+ return { usage: totalUsage, cost: totalCost };
95
+ }
96
+
97
+ /**
98
+ * Calculate cost for a given UsageData using per-model pricing.
99
+ * Uses per-model breakdown when available, falls back to aggregate with sonnet pricing.
100
+ */
101
+ calculateCost(usage: UsageData): CostBreakdown {
102
+ const result: CostBreakdown = {
103
+ inputCost: 0,
104
+ outputCost: 0,
105
+ cacheReadCost: 0,
106
+ cacheCreateCost: 0,
107
+ totalCost: 0,
108
+ };
109
+
110
+ const modelEntries = Object.entries(usage.modelUsage);
111
+
112
+ if (modelEntries.length > 0) {
113
+ // Use per-model breakdown for accurate pricing
114
+ for (const [modelId, modelUsage] of modelEntries) {
115
+ const category = resolveModelPricingCategory(modelId);
116
+ const pricing = this.pricingConfig[category ?? 'sonnet'];
117
+
118
+ result.inputCost += (modelUsage.inputTokens / 1_000_000) * pricing.inputPerMTok;
119
+ result.outputCost += (modelUsage.outputTokens / 1_000_000) * pricing.outputPerMTok;
120
+ result.cacheReadCost += (modelUsage.cacheReadInputTokens / 1_000_000) * pricing.cacheReadPerMTok;
121
+ result.cacheCreateCost += (modelUsage.cacheCreationInputTokens / 1_000_000) * pricing.cacheCreatePerMTok;
122
+ }
123
+ } else {
124
+ // Fallback: use aggregate totals with sonnet pricing
125
+ const pricing = this.pricingConfig.sonnet;
126
+ result.inputCost = (usage.inputTokens / 1_000_000) * pricing.inputPerMTok;
127
+ result.outputCost = (usage.outputTokens / 1_000_000) * pricing.outputPerMTok;
128
+ result.cacheReadCost = (usage.cacheReadInputTokens / 1_000_000) * pricing.cacheReadPerMTok;
129
+ result.cacheCreateCost = (usage.cacheCreationInputTokens / 1_000_000) * pricing.cacheCreatePerMTok;
130
+ }
131
+
132
+ result.totalCost = result.inputCost + result.outputCost + result.cacheReadCost + result.cacheCreateCost;
133
+ return result;
134
+ }
135
+ }
@@ -2,6 +2,9 @@ import * as fs from 'node:fs';
2
2
  import * as path from 'node:path';
3
3
  import { execSync } from 'node:child_process';
4
4
  import { logger } from './logger.js';
5
+ import type { ClaudeModelName, ModelScenario } from '../types/config.js';
6
+ import { VALID_MODEL_ALIASES, FULL_MODEL_ID_PATTERN } from '../types/config.js';
7
+ import { getModel } from './config.js';
5
8
 
6
9
  export interface ValidationResult {
7
10
  valid: boolean;
@@ -88,19 +91,21 @@ export function reportValidation(result: ValidationResult): void {
88
91
  }
89
92
  }
90
93
 
91
- const VALID_MODELS = ['sonnet', 'haiku', 'opus'] as const;
94
+ /** @deprecated Use ClaudeModelName from types/config.js instead */
95
+ export type ValidModelName = ClaudeModelName;
92
96
 
93
- export type ValidModelName = (typeof VALID_MODELS)[number];
94
-
95
- export function validateModelName(model: string): ValidModelName | null {
97
+ export function validateModelName(model: string): ClaudeModelName | null {
96
98
  const normalized = model.toLowerCase();
97
- if (VALID_MODELS.includes(normalized as ValidModelName)) {
98
- return normalized as ValidModelName;
99
+ if ((VALID_MODEL_ALIASES as readonly string[]).includes(normalized)) {
100
+ return normalized as ClaudeModelName;
101
+ }
102
+ if (FULL_MODEL_ID_PATTERN.test(normalized)) {
103
+ return normalized as ClaudeModelName;
99
104
  }
100
105
  return null;
101
106
  }
102
107
 
103
- export function resolveModelOption(model?: string, sonnet?: boolean): ValidModelName {
108
+ export function resolveModelOption(model?: string, sonnet?: boolean, scenario: ModelScenario = 'execute'): ClaudeModelName {
104
109
  // Check for conflicting flags
105
110
  if (model && sonnet) {
106
111
  throw new Error('Cannot specify both --model and --sonnet flags');
@@ -115,11 +120,11 @@ export function resolveModelOption(model?: string, sonnet?: boolean): ValidModel
115
120
  if (model) {
116
121
  const validated = validateModelName(model);
117
122
  if (!validated) {
118
- throw new Error(`Invalid model name: "${model}". Valid options: ${VALID_MODELS.join(', ')}`);
123
+ throw new Error(`Invalid model name: "${model}". Valid options: ${VALID_MODEL_ALIASES.join(', ')} or a full model ID (e.g., claude-sonnet-4-5-20250929)`);
119
124
  }
120
125
  return validated;
121
126
  }
122
127
 
123
- // Default to opus
124
- return 'opus';
128
+ // Default from config
129
+ return getModel(scenario);
125
130
  }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Runtime verbose toggle for task execution.
3
+ *
4
+ * Listens for Tab keypress on process.stdin to toggle verbose display on/off.
5
+ * When verbose is on, tool-use activity lines from stream-json are displayed.
6
+ * When verbose is off, they are suppressed (but data is still captured).
7
+ *
8
+ * Requires a TTY stdin. Silently skips setup when stdin is not a TTY (e.g., piped input).
9
+ */
10
+
11
+ import { logger } from './logger.js';
12
+
13
+ export class VerboseToggle {
14
+ private _verbose: boolean;
15
+ private _active = false;
16
+ private _dataHandler: ((data: Buffer) => void) | null = null;
17
+
18
+ constructor(initialVerbose: boolean) {
19
+ this._verbose = initialVerbose;
20
+ }
21
+
22
+ /** Current verbose display state. */
23
+ get isVerbose(): boolean {
24
+ return this._verbose;
25
+ }
26
+
27
+ /** Whether the toggle listener is currently active. */
28
+ get isActive(): boolean {
29
+ return this._active;
30
+ }
31
+
32
+ /**
33
+ * Start listening for Tab keypress on stdin.
34
+ * Sets stdin to raw mode to capture individual keypresses.
35
+ * Shows a hint message about the toggle.
36
+ *
37
+ * No-op if stdin is not a TTY or if already active.
38
+ */
39
+ start(): void {
40
+ if (this._active) return;
41
+ if (!process.stdin.isTTY) return;
42
+
43
+ try {
44
+ process.stdin.setRawMode(true);
45
+ } catch {
46
+ // Cannot set raw mode — skip
47
+ return;
48
+ }
49
+
50
+ process.stdin.resume();
51
+
52
+ this._dataHandler = (data: Buffer) => {
53
+ for (let i = 0; i < data.length; i++) {
54
+ const byte = data[i];
55
+
56
+ if (byte === 0x09) {
57
+ // Tab key
58
+ this._verbose = !this._verbose;
59
+ const state = this._verbose ? 'on' : 'off';
60
+ logger.dim(` [verbose: ${state}]`);
61
+ } else if (byte === 0x03) {
62
+ // Ctrl+C — re-emit SIGINT so the shutdown handler catches it
63
+ process.emit('SIGINT');
64
+ }
65
+ }
66
+ };
67
+
68
+ process.stdin.on('data', this._dataHandler);
69
+ this._active = true;
70
+
71
+ // Show toggle hint
72
+ logger.dim(' Press Tab to toggle verbose mode');
73
+ }
74
+
75
+ /**
76
+ * Stop listening and restore stdin to normal mode.
77
+ * Safe to call multiple times.
78
+ */
79
+ stop(): void {
80
+ if (!this._active) return;
81
+
82
+ if (this._dataHandler) {
83
+ process.stdin.off('data', this._dataHandler);
84
+ this._dataHandler = null;
85
+ }
86
+
87
+ try {
88
+ if (process.stdin.isTTY) {
89
+ process.stdin.setRawMode(false);
90
+ }
91
+ } catch {
92
+ // Ignore — stdin may already be closed
93
+ }
94
+
95
+ try {
96
+ process.stdin.pause();
97
+ } catch {
98
+ // Ignore
99
+ }
100
+
101
+ this._active = false;
102
+ }
103
+ }
@@ -305,8 +305,8 @@ describe('ClaudeRunner', () => {
305
305
  const runner = new ClaudeRunner();
306
306
  const runPromise = runner.run('test prompt', { timeout: 60 });
307
307
 
308
- // Emit context overflow message
309
- mockProc.stdout.emit('data', Buffer.from('Error: context length exceeded'));
308
+ // Emit context overflow message (with newline so NDJSON line buffer processes it)
309
+ mockProc.stdout.emit('data', Buffer.from('Error: context length exceeded\n'));
310
310
 
311
311
  const result = await runPromise;
312
312
  expect(result.contextOverflow).toBe(true);
@@ -333,7 +333,8 @@ describe('ClaudeRunner', () => {
333
333
  const runner = new ClaudeRunner();
334
334
  const runPromise = runner.run('test prompt', { timeout: 60 });
335
335
 
336
- mockProc.stdout.emit('data', Buffer.from(`Error: ${pattern}`));
336
+ // Trailing newline needed for NDJSON line buffer processing
337
+ mockProc.stdout.emit('data', Buffer.from(`Error: ${pattern}\n`));
337
338
 
338
339
  const result = await runPromise;
339
340
  expect(result.contextOverflow).toBe(true);
@@ -696,7 +697,7 @@ describe('ClaudeRunner', () => {
696
697
  expect(spawnArgs).toContain('--verbose');
697
698
  });
698
699
 
699
- it('should NOT include --output-format or --verbose flags in run()', async () => {
700
+ it('should include --output-format stream-json and --verbose flags in run() (unified stream-json)', async () => {
700
701
  const mockProc = createMockProcess();
701
702
  mockSpawn.mockReturnValue(mockProc);
702
703
 
@@ -707,9 +708,9 @@ describe('ClaudeRunner', () => {
707
708
  await runPromise;
708
709
 
709
710
  const spawnArgs = mockSpawn.mock.calls[0][1] as string[];
710
- expect(spawnArgs).not.toContain('--output-format');
711
- expect(spawnArgs).not.toContain('stream-json');
712
- expect(spawnArgs).not.toContain('--verbose');
711
+ expect(spawnArgs).toContain('--output-format');
712
+ expect(spawnArgs).toContain('stream-json');
713
+ expect(spawnArgs).toContain('--verbose');
713
714
  });
714
715
 
715
716
  it('should extract text from NDJSON assistant events', async () => {
@@ -756,6 +757,169 @@ describe('ClaudeRunner', () => {
756
757
  });
757
758
  });
758
759
 
760
+ describe('usage data extraction', () => {
761
+ function createMockProcess() {
762
+ const stdout = new EventEmitter();
763
+ const stderr = new EventEmitter();
764
+ const proc = new EventEmitter() as any;
765
+ proc.stdout = stdout;
766
+ proc.stderr = stderr;
767
+ proc.kill = jest.fn();
768
+ return proc;
769
+ }
770
+
771
+ it('should return usageData from run() when result event has usage', async () => {
772
+ const mockProc = createMockProcess();
773
+ mockSpawn.mockReturnValue(mockProc);
774
+
775
+ const runner = new ClaudeRunner();
776
+ const runPromise = runner.run('test prompt', { timeout: 60 });
777
+
778
+ const assistantEvent = JSON.stringify({
779
+ type: 'assistant',
780
+ message: { content: [{ type: 'text', text: 'Done' }] },
781
+ });
782
+ const resultEvent = JSON.stringify({
783
+ type: 'result',
784
+ usage: { input_tokens: 1000, output_tokens: 500, cache_read_input_tokens: 200, cache_creation_input_tokens: 100 },
785
+ modelUsage: { 'claude-opus-4-6': { inputTokens: 1000, outputTokens: 500, cacheReadInputTokens: 200, cacheCreationInputTokens: 100 } },
786
+ });
787
+
788
+ mockProc.stdout.emit('data', Buffer.from(assistantEvent + '\n' + resultEvent + '\n'));
789
+ mockProc.emit('close', 0);
790
+
791
+ const result = await runPromise;
792
+ expect(result.usageData).toBeDefined();
793
+ expect(result.usageData!.inputTokens).toBe(1000);
794
+ expect(result.usageData!.outputTokens).toBe(500);
795
+ expect(result.usageData!.modelUsage['claude-opus-4-6']).toEqual({
796
+ inputTokens: 1000,
797
+ outputTokens: 500,
798
+ cacheReadInputTokens: 200,
799
+ cacheCreationInputTokens: 100,
800
+ });
801
+ });
802
+
803
+ it('should return usageData from runVerbose() when result event has usage', async () => {
804
+ const mockProc = createMockProcess();
805
+ mockSpawn.mockReturnValue(mockProc);
806
+
807
+ const runner = new ClaudeRunner();
808
+ const runPromise = runner.runVerbose('test prompt', { timeout: 60 });
809
+
810
+ const resultEvent = JSON.stringify({
811
+ type: 'result',
812
+ usage: { input_tokens: 2000, output_tokens: 800 },
813
+ modelUsage: { 'claude-sonnet-4-5-20250929': { inputTokens: 2000, outputTokens: 800 } },
814
+ });
815
+
816
+ mockProc.stdout.emit('data', Buffer.from(resultEvent + '\n'));
817
+ mockProc.emit('close', 0);
818
+
819
+ const result = await runPromise;
820
+ expect(result.usageData).toBeDefined();
821
+ expect(result.usageData!.inputTokens).toBe(2000);
822
+ expect(result.usageData!.outputTokens).toBe(800);
823
+ });
824
+
825
+ it('should return undefined usageData when no result event', async () => {
826
+ const mockProc = createMockProcess();
827
+ mockSpawn.mockReturnValue(mockProc);
828
+
829
+ const runner = new ClaudeRunner();
830
+ const runPromise = runner.run('test prompt', { timeout: 60 });
831
+
832
+ const assistantEvent = JSON.stringify({
833
+ type: 'assistant',
834
+ message: { content: [{ type: 'text', text: 'Output' }] },
835
+ });
836
+
837
+ mockProc.stdout.emit('data', Buffer.from(assistantEvent + '\n'));
838
+ mockProc.emit('close', 0);
839
+
840
+ const result = await runPromise;
841
+ expect(result.usageData).toBeUndefined();
842
+ });
843
+
844
+ it('should suppress display in run() but still capture usage data', async () => {
845
+ const mockProc = createMockProcess();
846
+ mockSpawn.mockReturnValue(mockProc);
847
+
848
+ // Spy on stdout.write to verify no display output in non-verbose mode
849
+ const writeSpy = jest.spyOn(process.stdout, 'write').mockImplementation(() => true);
850
+
851
+ const runner = new ClaudeRunner();
852
+ const runPromise = runner.run('test prompt', { timeout: 60 });
853
+
854
+ const toolEvent = JSON.stringify({
855
+ type: 'assistant',
856
+ message: { content: [
857
+ { type: 'text', text: 'Working...' },
858
+ { type: 'tool_use', name: 'Read', input: { file_path: '/test.ts' } },
859
+ ] },
860
+ });
861
+ const resultEvent = JSON.stringify({
862
+ type: 'result',
863
+ usage: { input_tokens: 100, output_tokens: 50 },
864
+ });
865
+
866
+ mockProc.stdout.emit('data', Buffer.from(toolEvent + '\n' + resultEvent + '\n'));
867
+ mockProc.emit('close', 0);
868
+
869
+ const result = await runPromise;
870
+
871
+ // No display output (run() is non-verbose)
872
+ expect(writeSpy).not.toHaveBeenCalled();
873
+
874
+ // But usage data is still captured
875
+ expect(result.usageData).toBeDefined();
876
+ expect(result.usageData!.inputTokens).toBe(100);
877
+
878
+ // And text content is captured
879
+ expect(result.output).toContain('Working...');
880
+
881
+ writeSpy.mockRestore();
882
+ });
883
+
884
+ it('should use verboseCheck callback to dynamically control display', async () => {
885
+ const mockProc = createMockProcess();
886
+ mockSpawn.mockReturnValue(mockProc);
887
+
888
+ const writeSpy = jest.spyOn(process.stdout, 'write').mockImplementation(() => true);
889
+
890
+ let dynamicVerbose = false;
891
+ const runner = new ClaudeRunner();
892
+ const runPromise = runner.run('test prompt', {
893
+ timeout: 60,
894
+ verboseCheck: () => dynamicVerbose,
895
+ });
896
+
897
+ // First event with verbose OFF — should not display
898
+ const event1 = JSON.stringify({
899
+ type: 'assistant',
900
+ message: { content: [{ type: 'tool_use', name: 'Read', input: { file_path: '/a.ts' } }] },
901
+ });
902
+ mockProc.stdout.emit('data', Buffer.from(event1 + '\n'));
903
+ expect(writeSpy).not.toHaveBeenCalled();
904
+
905
+ // Toggle verbose ON
906
+ dynamicVerbose = true;
907
+
908
+ // Second event with verbose ON — should display
909
+ const event2 = JSON.stringify({
910
+ type: 'assistant',
911
+ message: { content: [{ type: 'tool_use', name: 'Read', input: { file_path: '/b.ts' } }] },
912
+ });
913
+ mockProc.stdout.emit('data', Buffer.from(event2 + '\n'));
914
+ expect(writeSpy).toHaveBeenCalled();
915
+
916
+ mockProc.emit('close', 0);
917
+ await runPromise;
918
+
919
+ writeSpy.mockRestore();
920
+ });
921
+ });
922
+
759
923
  describe('retry isolation (timeout per attempt)', () => {
760
924
  function createMockProcess() {
761
925
  const stdout = new EventEmitter();
@@ -0,0 +1,163 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import * as os from 'node:os';
4
+ import { Command } from 'commander';
5
+ import { createConfigCommand } from '../../src/commands/config.js';
6
+ import { validateConfig, ConfigValidationError } from '../../src/utils/config.js';
7
+
8
+ describe('Config Command', () => {
9
+ let tempDir: string;
10
+
11
+ beforeEach(() => {
12
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'raf-config-cmd-test-'));
13
+ });
14
+
15
+ afterEach(() => {
16
+ fs.rmSync(tempDir, { recursive: true, force: true });
17
+ });
18
+
19
+ describe('Command setup', () => {
20
+ it('should create a command named "config"', () => {
21
+ const cmd = createConfigCommand();
22
+ expect(cmd.name()).toBe('config');
23
+ });
24
+
25
+ it('should have a description', () => {
26
+ const cmd = createConfigCommand();
27
+ expect(cmd.description()).toBeTruthy();
28
+ expect(cmd.description()).toContain('config');
29
+ });
30
+
31
+ it('should accept a variadic prompt argument', () => {
32
+ const cmd = createConfigCommand();
33
+ const args = cmd.registeredArguments;
34
+ expect(args.length).toBe(1);
35
+ expect(args[0]!.variadic).toBe(true);
36
+ });
37
+
38
+ it('should have a --reset option', () => {
39
+ const cmd = createConfigCommand();
40
+ const resetOption = cmd.options.find((o) => o.long === '--reset');
41
+ expect(resetOption).toBeDefined();
42
+ });
43
+
44
+ it('should register in a parent program', () => {
45
+ const program = new Command();
46
+ program.addCommand(createConfigCommand());
47
+ const configCmd = program.commands.find((c) => c.name() === 'config');
48
+ expect(configCmd).toBeDefined();
49
+ });
50
+ });
51
+
52
+ describe('Post-session validation logic', () => {
53
+ it('should accept valid config with model override', () => {
54
+ const config = { models: { execute: 'sonnet' } };
55
+ expect(() => validateConfig(config)).not.toThrow();
56
+ });
57
+
58
+ it('should accept valid config with effort override', () => {
59
+ const config = { effort: { plan: 'low' } };
60
+ expect(() => validateConfig(config)).not.toThrow();
61
+ });
62
+
63
+ it('should accept valid config with timeout', () => {
64
+ const config = { timeout: 120 };
65
+ expect(() => validateConfig(config)).not.toThrow();
66
+ });
67
+
68
+ it('should reject config with unknown keys', () => {
69
+ const config = { unknownKey: true };
70
+ expect(() => validateConfig(config)).toThrow(ConfigValidationError);
71
+ });
72
+
73
+ it('should reject config with invalid model name', () => {
74
+ const config = { models: { execute: 'gpt-4' } };
75
+ expect(() => validateConfig(config)).toThrow(ConfigValidationError);
76
+ });
77
+
78
+ it('should reject config with invalid effort level', () => {
79
+ const config = { effort: { plan: 'max' } };
80
+ expect(() => validateConfig(config)).toThrow(ConfigValidationError);
81
+ });
82
+
83
+ it('should reject non-object config', () => {
84
+ expect(() => validateConfig('string')).toThrow(ConfigValidationError);
85
+ expect(() => validateConfig(null)).toThrow(ConfigValidationError);
86
+ expect(() => validateConfig([])).toThrow(ConfigValidationError);
87
+ });
88
+
89
+ it('should accept an empty config (all defaults)', () => {
90
+ expect(() => validateConfig({})).not.toThrow();
91
+ });
92
+ });
93
+
94
+ describe('Reset flow - file operations', () => {
95
+ it('should be able to delete config file', () => {
96
+ const configPath = path.join(tempDir, 'raf.config.json');
97
+ fs.writeFileSync(configPath, JSON.stringify({ timeout: 90 }, null, 2));
98
+ expect(fs.existsSync(configPath)).toBe(true);
99
+
100
+ fs.unlinkSync(configPath);
101
+ expect(fs.existsSync(configPath)).toBe(false);
102
+ });
103
+
104
+ it('should handle non-existent config file gracefully', () => {
105
+ const configPath = path.join(tempDir, 'raf.config.json');
106
+ expect(fs.existsSync(configPath)).toBe(false);
107
+ // Reset when no file exists should not throw
108
+ });
109
+ });
110
+
111
+ describe('Config file round-trip', () => {
112
+ it('should write and read valid config', () => {
113
+ const configPath = path.join(tempDir, 'raf.config.json');
114
+ const config = { models: { execute: 'sonnet' as const }, timeout: 90 };
115
+
116
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
117
+ const content = fs.readFileSync(configPath, 'utf-8');
118
+ const parsed = JSON.parse(content);
119
+
120
+ expect(parsed.models.execute).toBe('sonnet');
121
+ expect(parsed.timeout).toBe(90);
122
+ expect(() => validateConfig(parsed)).not.toThrow();
123
+ });
124
+
125
+ it('should detect invalid JSON after write', () => {
126
+ const configPath = path.join(tempDir, 'raf.config.json');
127
+ fs.writeFileSync(configPath, '{ invalid json }}}');
128
+
129
+ const content = fs.readFileSync(configPath, 'utf-8');
130
+ expect(() => JSON.parse(content)).toThrow(SyntaxError);
131
+ });
132
+
133
+ it('should detect validation errors after write', () => {
134
+ const configPath = path.join(tempDir, 'raf.config.json');
135
+ fs.writeFileSync(configPath, JSON.stringify({ badKey: true }, null, 2));
136
+
137
+ const content = fs.readFileSync(configPath, 'utf-8');
138
+ const parsed = JSON.parse(content);
139
+ expect(() => validateConfig(parsed)).toThrow(ConfigValidationError);
140
+ });
141
+ });
142
+
143
+ describe('System prompt construction', () => {
144
+ it('should indicate no config when file does not exist', () => {
145
+ const configPath = path.join(tempDir, 'raf.config.json');
146
+ const exists = fs.existsSync(configPath);
147
+ const state = exists
148
+ ? fs.readFileSync(configPath, 'utf-8')
149
+ : 'No config file exists yet.';
150
+ expect(state).toContain('No config file');
151
+ });
152
+
153
+ it('should include config contents when file exists', () => {
154
+ const configPath = path.join(tempDir, 'raf.config.json');
155
+ const config = { timeout: 120, worktree: true };
156
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
157
+
158
+ const content = fs.readFileSync(configPath, 'utf-8');
159
+ expect(content).toContain('"timeout": 120');
160
+ expect(content).toContain('"worktree": true');
161
+ });
162
+ });
163
+ });