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.
- package/.claude/settings.local.json +4 -1
- package/CLAUDE.md +59 -11
- package/RAF/ahslfe-config-wizard/decisions.md +34 -0
- package/RAF/ahslfe-config-wizard/input.md +1 -0
- package/RAF/ahslfe-config-wizard/outcomes/01-define-config-schema.md +38 -0
- package/RAF/ahslfe-config-wizard/outcomes/02-refactor-codebase-to-use-config.md +67 -0
- package/RAF/ahslfe-config-wizard/outcomes/03-create-config-documentation.md +37 -0
- package/RAF/ahslfe-config-wizard/outcomes/04-implement-raf-config-command.md +47 -0
- package/RAF/ahslfe-config-wizard/outcomes/05-update-claude-md.md +26 -0
- package/RAF/ahslfe-config-wizard/plans/01-define-config-schema.md +73 -0
- package/RAF/ahslfe-config-wizard/plans/02-refactor-codebase-to-use-config.md +74 -0
- package/RAF/ahslfe-config-wizard/plans/03-create-config-documentation.md +57 -0
- package/RAF/ahslfe-config-wizard/plans/04-implement-raf-config-command.md +66 -0
- package/RAF/ahslfe-config-wizard/plans/05-update-claude-md.md +60 -0
- package/RAF/ahstvo-token-tracker/decisions.md +44 -0
- package/RAF/ahstvo-token-tracker/input.md +3 -0
- package/RAF/ahstvo-token-tracker/outcomes/01-full-model-id-support.md +43 -0
- package/RAF/ahstvo-token-tracker/outcomes/02-name-generation-no-session.md +33 -0
- package/RAF/ahstvo-token-tracker/outcomes/03-unify-stream-json-execution.md +48 -0
- package/RAF/ahstvo-token-tracker/outcomes/04-token-tracking-cost-calculation.md +53 -0
- package/RAF/ahstvo-token-tracker/outcomes/05-token-cost-console-reporting.md +57 -0
- package/RAF/ahstvo-token-tracker/outcomes/06-runtime-verbose-toggle.md +53 -0
- package/RAF/ahstvo-token-tracker/outcomes/07-readme-config-docs.md +36 -0
- package/RAF/ahstvo-token-tracker/plans/01-full-model-id-support.md +35 -0
- package/RAF/ahstvo-token-tracker/plans/02-name-generation-no-session.md +36 -0
- package/RAF/ahstvo-token-tracker/plans/03-unify-stream-json-execution.md +44 -0
- package/RAF/ahstvo-token-tracker/plans/04-token-tracking-cost-calculation.md +56 -0
- package/RAF/ahstvo-token-tracker/plans/05-token-cost-console-reporting.md +55 -0
- package/RAF/ahstvo-token-tracker/plans/06-runtime-verbose-toggle.md +48 -0
- package/RAF/ahstvo-token-tracker/plans/07-readme-config-docs.md +44 -0
- package/README.md +34 -0
- package/dist/commands/config.d.ts +3 -0
- package/dist/commands/config.d.ts.map +1 -0
- package/dist/commands/config.js +173 -0
- package/dist/commands/config.js.map +1 -0
- package/dist/commands/do.d.ts.map +1 -1
- package/dist/commands/do.js +47 -6
- package/dist/commands/do.js.map +1 -1
- package/dist/commands/plan.d.ts.map +1 -1
- package/dist/commands/plan.js +3 -2
- package/dist/commands/plan.js.map +1 -1
- package/dist/core/claude-runner.d.ts +19 -2
- package/dist/core/claude-runner.d.ts.map +1 -1
- package/dist/core/claude-runner.js +43 -96
- package/dist/core/claude-runner.js.map +1 -1
- package/dist/core/failure-analyzer.d.ts.map +1 -1
- package/dist/core/failure-analyzer.js +6 -3
- package/dist/core/failure-analyzer.js.map +1 -1
- package/dist/core/git.d.ts.map +1 -1
- package/dist/core/git.js +10 -3
- package/dist/core/git.js.map +1 -1
- package/dist/core/pull-request.d.ts +1 -1
- package/dist/core/pull-request.d.ts.map +1 -1
- package/dist/core/pull-request.js +7 -4
- package/dist/core/pull-request.js.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/parsers/stream-renderer.d.ts +16 -1
- package/dist/parsers/stream-renderer.d.ts.map +1 -1
- package/dist/parsers/stream-renderer.js +34 -4
- package/dist/parsers/stream-renderer.js.map +1 -1
- package/dist/prompts/execution.d.ts.map +1 -1
- package/dist/prompts/execution.js +11 -1
- package/dist/prompts/execution.js.map +1 -1
- package/dist/types/config.d.ts +95 -4
- package/dist/types/config.d.ts.map +1 -1
- package/dist/types/config.js +63 -3
- package/dist/types/config.js.map +1 -1
- package/dist/utils/config.d.ts +59 -7
- package/dist/utils/config.d.ts.map +1 -1
- package/dist/utils/config.js +276 -21
- package/dist/utils/config.js.map +1 -1
- package/dist/utils/name-generator.d.ts +3 -7
- package/dist/utils/name-generator.d.ts.map +1 -1
- package/dist/utils/name-generator.js +75 -61
- package/dist/utils/name-generator.js.map +1 -1
- package/dist/utils/terminal-symbols.d.ts +21 -0
- package/dist/utils/terminal-symbols.d.ts.map +1 -1
- package/dist/utils/terminal-symbols.js +62 -0
- package/dist/utils/terminal-symbols.js.map +1 -1
- package/dist/utils/token-tracker.d.ts +45 -0
- package/dist/utils/token-tracker.d.ts.map +1 -0
- package/dist/utils/token-tracker.js +107 -0
- package/dist/utils/token-tracker.js.map +1 -0
- package/dist/utils/validation.d.ts +5 -5
- package/dist/utils/validation.d.ts.map +1 -1
- package/dist/utils/validation.js +10 -6
- package/dist/utils/validation.js.map +1 -1
- package/dist/utils/verbose-toggle.d.ts +33 -0
- package/dist/utils/verbose-toggle.d.ts.map +1 -0
- package/dist/utils/verbose-toggle.js +94 -0
- package/dist/utils/verbose-toggle.js.map +1 -0
- package/package.json +1 -1
- package/src/commands/config.ts +204 -0
- package/src/commands/do.ts +56 -5
- package/src/commands/plan.ts +3 -2
- package/src/core/claude-runner.ts +59 -115
- package/src/core/failure-analyzer.ts +6 -3
- package/src/core/git.ts +10 -3
- package/src/core/pull-request.ts +7 -4
- package/src/index.ts +2 -0
- package/src/parsers/stream-renderer.ts +54 -4
- package/src/prompts/config-docs.md +331 -0
- package/src/prompts/execution.ts +13 -1
- package/src/types/config.ts +156 -7
- package/src/utils/config.ts +335 -21
- package/src/utils/name-generator.ts +84 -71
- package/src/utils/terminal-symbols.ts +68 -0
- package/src/utils/token-tracker.ts +135 -0
- package/src/utils/validation.ts +15 -10
- package/src/utils/verbose-toggle.ts +103 -0
- package/tests/unit/claude-runner.test.ts +171 -7
- package/tests/unit/config-command.test.ts +163 -0
- package/tests/unit/config.test.ts +608 -30
- package/tests/unit/name-generator.test.ts +99 -75
- package/tests/unit/pull-request.test.ts +2 -0
- package/tests/unit/stream-renderer.test.ts +83 -0
- package/tests/unit/terminal-symbols.test.ts +157 -0
- package/tests/unit/token-tracker.test.ts +352 -0
- 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
|
+
}
|
package/src/utils/validation.ts
CHANGED
|
@@ -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
|
-
|
|
94
|
+
/** @deprecated Use ClaudeModelName from types/config.js instead */
|
|
95
|
+
export type ValidModelName = ClaudeModelName;
|
|
92
96
|
|
|
93
|
-
export
|
|
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 (
|
|
98
|
-
return normalized as
|
|
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):
|
|
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: ${
|
|
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
|
|
124
|
-
return
|
|
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
|
-
|
|
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
|
|
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).
|
|
711
|
-
expect(spawnArgs).
|
|
712
|
-
expect(spawnArgs).
|
|
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
|
+
});
|