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
@@ -1,10 +1,31 @@
1
1
  import * as fs from 'node:fs';
2
2
  import * as path from 'node:path';
3
3
  import * as os from 'node:os';
4
- import { RafConfig, DEFAULT_RAF_CONFIG } from '../types/config.js';
4
+ import {
5
+ RafConfig,
6
+ DEFAULT_CONFIG,
7
+ DEFAULT_RAF_CONFIG,
8
+ UserConfig,
9
+ VALID_MODEL_ALIASES,
10
+ FULL_MODEL_ID_PATTERN,
11
+ VALID_EFFORTS,
12
+ ClaudeModelName,
13
+ EffortLevel,
14
+ ModelScenario,
15
+ EffortScenario,
16
+ CommitFormatType,
17
+ PricingCategory,
18
+ ModelPricing,
19
+ PricingConfig,
20
+ } from '../types/config.js';
5
21
 
22
+ const CONFIG_DIR = path.join(os.homedir(), '.raf');
6
23
  const CONFIG_FILENAME = 'raf.config.json';
7
24
 
25
+ export function getConfigPath(): string {
26
+ return path.join(CONFIG_DIR, CONFIG_FILENAME);
27
+ }
28
+
8
29
  /**
9
30
  * Get the path to Claude CLI settings file.
10
31
  */
@@ -12,25 +33,320 @@ export function getClaudeSettingsPath(): string {
12
33
  return path.join(os.homedir(), '.claude', 'settings.json');
13
34
  }
14
35
 
15
- export function loadConfig(rafDir: string): RafConfig {
16
- const configPath = path.join(rafDir, CONFIG_FILENAME);
36
+ // ---- Validation ----
37
+
38
+ const VALID_TOP_LEVEL_KEYS = new Set<string>([
39
+ 'models', 'effort', 'timeout', 'maxRetries', 'autoCommit',
40
+ 'worktree', 'commitFormat', 'claudeCommand', 'pricing',
41
+ ]);
42
+
43
+ const VALID_PRICING_CATEGORIES = new Set<string>(['opus', 'sonnet', 'haiku']);
44
+ const VALID_PRICING_FIELDS = new Set<string>(['inputPerMTok', 'outputPerMTok', 'cacheReadPerMTok', 'cacheCreatePerMTok']);
17
45
 
18
- if (!fs.existsSync(configPath)) {
19
- return { ...DEFAULT_RAF_CONFIG };
46
+ const VALID_MODEL_KEYS = new Set<string>([
47
+ 'plan', 'execute', 'nameGeneration', 'failureAnalysis', 'prGeneration', 'config',
48
+ ]);
49
+
50
+ const VALID_EFFORT_KEYS = new Set<string>([
51
+ 'plan', 'execute', 'nameGeneration', 'failureAnalysis', 'prGeneration', 'config',
52
+ ]);
53
+
54
+ const VALID_COMMIT_FORMAT_KEYS = new Set<string>(['task', 'plan', 'amend', 'prefix']);
55
+
56
+ export class ConfigValidationError extends Error {
57
+ constructor(message: string) {
58
+ super(message);
59
+ this.name = 'ConfigValidationError';
20
60
  }
61
+ }
21
62
 
22
- try {
23
- const content = fs.readFileSync(configPath, 'utf-8');
24
- const userConfig = JSON.parse(content) as Partial<RafConfig>;
25
- return { ...DEFAULT_RAF_CONFIG, ...userConfig };
26
- } catch {
27
- return { ...DEFAULT_RAF_CONFIG };
63
+ function checkUnknownKeys(obj: Record<string, unknown>, validKeys: Set<string>, prefix: string): void {
64
+ for (const key of Object.keys(obj)) {
65
+ if (!validKeys.has(key)) {
66
+ throw new ConfigValidationError(`Unknown config key: ${prefix ? `${prefix}.` : ''}${key}`);
67
+ }
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Check whether a string is a valid model name — either a short alias or a full model ID.
73
+ */
74
+ export function isValidModelName(value: string): boolean {
75
+ return (VALID_MODEL_ALIASES as readonly string[]).includes(value) || FULL_MODEL_ID_PATTERN.test(value);
76
+ }
77
+
78
+ export function validateConfig(config: unknown): UserConfig {
79
+ if (config === null || typeof config !== 'object' || Array.isArray(config)) {
80
+ throw new ConfigValidationError('Config must be a JSON object');
81
+ }
82
+
83
+ const obj = config as Record<string, unknown>;
84
+ checkUnknownKeys(obj, VALID_TOP_LEVEL_KEYS, '');
85
+
86
+ // models
87
+ if (obj.models !== undefined) {
88
+ if (typeof obj.models !== 'object' || obj.models === null || Array.isArray(obj.models)) {
89
+ throw new ConfigValidationError('models must be an object');
90
+ }
91
+ const models = obj.models as Record<string, unknown>;
92
+ checkUnknownKeys(models, VALID_MODEL_KEYS, 'models');
93
+ for (const [key, val] of Object.entries(models)) {
94
+ if (typeof val !== 'string' || !isValidModelName(val)) {
95
+ throw new ConfigValidationError(
96
+ `models.${key} must be a short alias (${VALID_MODEL_ALIASES.join(', ')}) or a full model ID (e.g., claude-sonnet-4-5-20250929)`
97
+ );
98
+ }
99
+ }
100
+ }
101
+
102
+ // effort
103
+ if (obj.effort !== undefined) {
104
+ if (typeof obj.effort !== 'object' || obj.effort === null || Array.isArray(obj.effort)) {
105
+ throw new ConfigValidationError('effort must be an object');
106
+ }
107
+ const effort = obj.effort as Record<string, unknown>;
108
+ checkUnknownKeys(effort, VALID_EFFORT_KEYS, 'effort');
109
+ for (const [key, val] of Object.entries(effort)) {
110
+ if (typeof val !== 'string' || !(VALID_EFFORTS as readonly string[]).includes(val)) {
111
+ throw new ConfigValidationError(`effort.${key} must be one of: ${VALID_EFFORTS.join(', ')}`);
112
+ }
113
+ }
114
+ }
115
+
116
+ // timeout
117
+ if (obj.timeout !== undefined) {
118
+ if (typeof obj.timeout !== 'number' || obj.timeout <= 0 || !Number.isFinite(obj.timeout)) {
119
+ throw new ConfigValidationError('timeout must be a positive number');
120
+ }
28
121
  }
122
+
123
+ // maxRetries
124
+ if (obj.maxRetries !== undefined) {
125
+ if (typeof obj.maxRetries !== 'number' || obj.maxRetries < 0 || !Number.isInteger(obj.maxRetries)) {
126
+ throw new ConfigValidationError('maxRetries must be a non-negative integer');
127
+ }
128
+ }
129
+
130
+ // autoCommit
131
+ if (obj.autoCommit !== undefined) {
132
+ if (typeof obj.autoCommit !== 'boolean') {
133
+ throw new ConfigValidationError('autoCommit must be a boolean');
134
+ }
135
+ }
136
+
137
+ // worktree
138
+ if (obj.worktree !== undefined) {
139
+ if (typeof obj.worktree !== 'boolean') {
140
+ throw new ConfigValidationError('worktree must be a boolean');
141
+ }
142
+ }
143
+
144
+ // commitFormat
145
+ if (obj.commitFormat !== undefined) {
146
+ if (typeof obj.commitFormat !== 'object' || obj.commitFormat === null || Array.isArray(obj.commitFormat)) {
147
+ throw new ConfigValidationError('commitFormat must be an object');
148
+ }
149
+ const cf = obj.commitFormat as Record<string, unknown>;
150
+ checkUnknownKeys(cf, VALID_COMMIT_FORMAT_KEYS, 'commitFormat');
151
+ for (const [key, val] of Object.entries(cf)) {
152
+ if (typeof val !== 'string') {
153
+ throw new ConfigValidationError(`commitFormat.${key} must be a string`);
154
+ }
155
+ }
156
+ }
157
+
158
+ // claudeCommand
159
+ if (obj.claudeCommand !== undefined) {
160
+ if (typeof obj.claudeCommand !== 'string' || obj.claudeCommand.trim() === '') {
161
+ throw new ConfigValidationError('claudeCommand must be a non-empty string');
162
+ }
163
+ }
164
+
165
+ // pricing
166
+ if (obj.pricing !== undefined) {
167
+ if (typeof obj.pricing !== 'object' || obj.pricing === null || Array.isArray(obj.pricing)) {
168
+ throw new ConfigValidationError('pricing must be an object');
169
+ }
170
+ const pricing = obj.pricing as Record<string, unknown>;
171
+ checkUnknownKeys(pricing, VALID_PRICING_CATEGORIES, 'pricing');
172
+ for (const [category, catVal] of Object.entries(pricing)) {
173
+ if (typeof catVal !== 'object' || catVal === null || Array.isArray(catVal)) {
174
+ throw new ConfigValidationError(`pricing.${category} must be an object`);
175
+ }
176
+ const fields = catVal as Record<string, unknown>;
177
+ checkUnknownKeys(fields, VALID_PRICING_FIELDS, `pricing.${category}`);
178
+ for (const [field, val] of Object.entries(fields)) {
179
+ if (typeof val !== 'number' || val < 0 || !Number.isFinite(val)) {
180
+ throw new ConfigValidationError(`pricing.${category}.${field} must be a non-negative number`);
181
+ }
182
+ }
183
+ }
184
+ }
185
+
186
+ return config as UserConfig;
187
+ }
188
+
189
+ // ---- Deep merge ----
190
+
191
+ function deepMerge(defaults: RafConfig, overrides: UserConfig): RafConfig {
192
+ const result = { ...defaults };
193
+
194
+ if (overrides.models) {
195
+ result.models = { ...defaults.models, ...overrides.models };
196
+ }
197
+ if (overrides.effort) {
198
+ result.effort = { ...defaults.effort, ...overrides.effort };
199
+ }
200
+ if (overrides.commitFormat) {
201
+ result.commitFormat = { ...defaults.commitFormat, ...overrides.commitFormat };
202
+ }
203
+ if (overrides.pricing) {
204
+ result.pricing = {
205
+ opus: { ...defaults.pricing.opus, ...overrides.pricing.opus },
206
+ sonnet: { ...defaults.pricing.sonnet, ...overrides.pricing.sonnet },
207
+ haiku: { ...defaults.pricing.haiku, ...overrides.pricing.haiku },
208
+ };
209
+ }
210
+ if (overrides.timeout !== undefined) result.timeout = overrides.timeout;
211
+ if (overrides.maxRetries !== undefined) result.maxRetries = overrides.maxRetries;
212
+ if (overrides.autoCommit !== undefined) result.autoCommit = overrides.autoCommit;
213
+ if (overrides.worktree !== undefined) result.worktree = overrides.worktree;
214
+ if (overrides.claudeCommand !== undefined) result.claudeCommand = overrides.claudeCommand;
215
+
216
+ return result;
29
217
  }
30
218
 
31
- export function saveConfig(rafDir: string, config: RafConfig): void {
32
- const configPath = path.join(rafDir, CONFIG_FILENAME);
33
- fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
219
+ // ---- Config loading ----
220
+
221
+ /**
222
+ * Resolve the final config by loading from ~/.raf/raf.config.json and merging with defaults.
223
+ * Throws ConfigValidationError if the file contains invalid values.
224
+ */
225
+ export function resolveConfig(configPath?: string): RafConfig {
226
+ const filePath = configPath ?? getConfigPath();
227
+
228
+ if (!fs.existsSync(filePath)) {
229
+ return { ...DEFAULT_CONFIG, models: { ...DEFAULT_CONFIG.models }, effort: { ...DEFAULT_CONFIG.effort }, commitFormat: { ...DEFAULT_CONFIG.commitFormat } };
230
+ }
231
+
232
+ const content = fs.readFileSync(filePath, 'utf-8');
233
+ const parsed: unknown = JSON.parse(content);
234
+ const validated = validateConfig(parsed);
235
+ return deepMerge(DEFAULT_CONFIG, validated);
236
+ }
237
+
238
+ /**
239
+ * @deprecated Use resolveConfig() instead. Kept for backward compatibility.
240
+ */
241
+ export function loadConfig(_rafDir: string): { defaultTimeout: number; defaultMaxRetries: number; autoCommit: boolean; claudeCommand: string } {
242
+ return { ...DEFAULT_RAF_CONFIG };
243
+ }
244
+
245
+ export function saveConfig(configPath: string, config: UserConfig): void {
246
+ const dir = path.dirname(configPath);
247
+ if (!fs.existsSync(dir)) {
248
+ fs.mkdirSync(dir, { recursive: true });
249
+ }
250
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
251
+ }
252
+
253
+ // ---- Helper accessors ----
254
+
255
+ let _cachedConfig: RafConfig | null = null;
256
+
257
+ /**
258
+ * Get the resolved config, caching the result for the process lifetime.
259
+ * Call resetConfigCache() in tests to clear.
260
+ */
261
+ export function getResolvedConfig(): RafConfig {
262
+ if (!_cachedConfig) {
263
+ _cachedConfig = resolveConfig();
264
+ }
265
+ return _cachedConfig;
266
+ }
267
+
268
+ export function resetConfigCache(): void {
269
+ _cachedConfig = null;
270
+ }
271
+
272
+ export function getModel(scenario: ModelScenario): ClaudeModelName {
273
+ return getResolvedConfig().models[scenario];
274
+ }
275
+
276
+ export function getEffort(scenario: EffortScenario): EffortLevel {
277
+ return getResolvedConfig().effort[scenario];
278
+ }
279
+
280
+ export function getCommitFormat(type: CommitFormatType): string {
281
+ return getResolvedConfig().commitFormat[type];
282
+ }
283
+
284
+ export function getCommitPrefix(): string {
285
+ return getResolvedConfig().commitFormat.prefix;
286
+ }
287
+
288
+ export function getTimeout(): number {
289
+ return getResolvedConfig().timeout;
290
+ }
291
+
292
+ export function getMaxRetries(): number {
293
+ return getResolvedConfig().maxRetries;
294
+ }
295
+
296
+ export function getAutoCommit(): boolean {
297
+ return getResolvedConfig().autoCommit;
298
+ }
299
+
300
+ export function getWorktreeDefault(): boolean {
301
+ return getResolvedConfig().worktree;
302
+ }
303
+
304
+ export function getClaudeCommand(): string {
305
+ return getResolvedConfig().claudeCommand;
306
+ }
307
+
308
+ /**
309
+ * Map a full model ID (e.g., `claude-opus-4-6`) or short alias to a pricing category.
310
+ * Returns null if the model cannot be mapped.
311
+ */
312
+ export function resolveModelPricingCategory(modelId: string): PricingCategory | null {
313
+ // Short aliases map directly
314
+ if (modelId === 'opus' || modelId === 'sonnet' || modelId === 'haiku') {
315
+ return modelId;
316
+ }
317
+ // Full model IDs: extract family from `claude-{family}-{version}`
318
+ const match = modelId.match(/^claude-([a-z]+)-/);
319
+ if (match) {
320
+ const family = match[1];
321
+ if (family === 'opus' || family === 'sonnet' || family === 'haiku') {
322
+ return family;
323
+ }
324
+ }
325
+ return null;
326
+ }
327
+
328
+ /**
329
+ * Get pricing config for a specific model category.
330
+ */
331
+ export function getPricing(category: PricingCategory): ModelPricing {
332
+ return getResolvedConfig().pricing[category];
333
+ }
334
+
335
+ /**
336
+ * Get the full pricing config.
337
+ */
338
+ export function getPricingConfig(): PricingConfig {
339
+ return getResolvedConfig().pricing;
340
+ }
341
+
342
+ /**
343
+ * Render a commit message template by replacing {placeholder} tokens with values.
344
+ * Unknown placeholders are left as-is.
345
+ */
346
+ export function renderCommitMessage(template: string, variables: Record<string, string>): string {
347
+ return template.replace(/\{(\w+)\}/g, (match, key: string) => {
348
+ return variables[key] ?? match;
349
+ });
34
350
  }
35
351
 
36
352
  export function getEditor(): string {
@@ -39,8 +355,6 @@ export function getEditor(): string {
39
355
 
40
356
  /**
41
357
  * Get the Claude model name from Claude CLI settings.
42
- * Returns the model name or null if not found.
43
- * @param settingsPath Optional path to settings file (for testing)
44
358
  */
45
359
  export function getClaudeModel(settingsPath?: string): string | null {
46
360
  const filePath = settingsPath ?? getClaudeSettingsPath();
@@ -57,13 +371,13 @@ export function getClaudeModel(settingsPath?: string): string | null {
57
371
  }
58
372
 
59
373
  /**
60
- * Get runtime configuration for task execution.
61
- * Returns default values which can be overridden by command line options.
374
+ * @deprecated Use getTimeout(), getMaxRetries(), getAutoCommit() instead.
62
375
  */
63
376
  export function getConfig(): { timeout: number; maxRetries: number; autoCommit: boolean } {
377
+ const config = getResolvedConfig();
64
378
  return {
65
- timeout: DEFAULT_RAF_CONFIG.defaultTimeout,
66
- maxRetries: DEFAULT_RAF_CONFIG.defaultMaxRetries,
67
- autoCommit: DEFAULT_RAF_CONFIG.autoCommit,
379
+ timeout: config.timeout,
380
+ maxRetries: config.maxRetries,
381
+ autoCommit: config.autoCommit,
68
382
  };
69
383
  }
@@ -1,8 +1,7 @@
1
- import { execSync } from 'node:child_process';
1
+ import { spawn } from 'node:child_process';
2
2
  import { logger } from './logger.js';
3
3
  import { sanitizeProjectName } from './validation.js';
4
-
5
- const SONNET_MODEL = 'sonnet';
4
+ import { getModel, getClaudeCommand } from './config.js';
6
5
 
7
6
  const NAME_GENERATION_PROMPT = `Generate a short, punchy, creative project name (1-3 words, kebab-case).
8
7
 
@@ -40,7 +39,60 @@ Output format: ONLY output 5 names, one per line, no numbers, no explanations, n
40
39
  Project description:`;
41
40
 
42
41
  /**
43
- * Generate a single project name using Claude Sonnet.
42
+ * Run Claude CLI with the given prompt and return stdout.
43
+ * Uses spawn with --no-session-persistence to avoid cluttering session history.
44
+ */
45
+ function runClaudePrint(prompt: string): Promise<string | null> {
46
+ return new Promise((resolve) => {
47
+ const model = getModel('nameGeneration');
48
+ const cmd = getClaudeCommand();
49
+
50
+ const proc = spawn(cmd, [
51
+ '--model', model,
52
+ '--no-session-persistence',
53
+ '-p',
54
+ prompt,
55
+ ], {
56
+ stdio: ['ignore', 'pipe', 'pipe'],
57
+ });
58
+
59
+ let stdout = '';
60
+ let stderr = '';
61
+
62
+ const timeoutHandle = setTimeout(() => {
63
+ proc.kill('SIGTERM');
64
+ }, 30000);
65
+
66
+ proc.stdout.on('data', (data) => {
67
+ stdout += data.toString();
68
+ });
69
+
70
+ proc.stderr.on('data', (data) => {
71
+ stderr += data.toString();
72
+ });
73
+
74
+ proc.on('close', (exitCode) => {
75
+ clearTimeout(timeoutHandle);
76
+ if (exitCode !== 0) {
77
+ if (stderr) {
78
+ logger.debug(`Claude CLI stderr: ${stderr}`);
79
+ }
80
+ resolve(null);
81
+ } else {
82
+ resolve(stdout.trim());
83
+ }
84
+ });
85
+
86
+ proc.on('error', (error) => {
87
+ clearTimeout(timeoutHandle);
88
+ logger.debug(`Claude CLI spawn error: ${error}`);
89
+ resolve(null);
90
+ });
91
+ });
92
+ }
93
+
94
+ /**
95
+ * Generate a single project name using Claude.
44
96
  * Falls back to extracting words from the description if the API call fails.
45
97
  */
46
98
  export async function generateProjectName(description: string): Promise<string> {
@@ -54,7 +106,7 @@ export async function generateProjectName(description: string): Promise<string>
54
106
  }
55
107
  }
56
108
  } catch (error) {
57
- logger.debug(`Failed to generate name with Sonnet: ${error}`);
109
+ logger.debug(`Failed to generate name with Claude: ${error}`);
58
110
  }
59
111
 
60
112
  // Fallback to extracting words from description
@@ -62,7 +114,7 @@ export async function generateProjectName(description: string): Promise<string>
62
114
  }
63
115
 
64
116
  /**
65
- * Generate multiple project name suggestions using Claude Sonnet.
117
+ * Generate multiple project name suggestions using Claude.
66
118
  * Returns 3-5 unique names with varied styles.
67
119
  */
68
120
  export async function generateProjectNames(description: string): Promise<string[]> {
@@ -73,7 +125,7 @@ export async function generateProjectNames(description: string): Promise<string[
73
125
  return names;
74
126
  }
75
127
  } catch (error) {
76
- logger.debug(`Failed to generate names with Sonnet: ${error}`);
128
+ logger.debug(`Failed to generate names with Claude: ${error}`);
77
129
  }
78
130
 
79
131
  // Fallback: generate a single fallback name
@@ -83,80 +135,41 @@ export async function generateProjectNames(description: string): Promise<string[
83
135
  }
84
136
 
85
137
  /**
86
- * Call Claude Sonnet to generate a single project name.
138
+ * Call Claude to generate a single project name.
87
139
  */
88
140
  async function callSonnetForName(description: string): Promise<string | null> {
89
- try {
90
- const fullPrompt = `${NAME_GENERATION_PROMPT}\n${description}`;
91
-
92
- // Use claude CLI with --model sonnet and --print for non-interactive output
93
- const result = execSync(
94
- `claude --model ${SONNET_MODEL} --print "${escapeShellArg(fullPrompt)}"`,
95
- {
96
- encoding: 'utf-8',
97
- timeout: 30000, // 30 second timeout
98
- stdio: ['pipe', 'pipe', 'pipe'],
99
- }
100
- );
101
-
102
- return result.trim();
103
- } catch (error) {
104
- logger.debug(`Sonnet API call failed: ${error}`);
105
- return null;
106
- }
141
+ const fullPrompt = `${NAME_GENERATION_PROMPT}\n${description}`;
142
+ return runClaudePrint(fullPrompt);
107
143
  }
108
144
 
109
145
  /**
110
- * Call Claude Sonnet to generate multiple project names.
146
+ * Call Claude to generate multiple project names.
111
147
  */
112
148
  async function callSonnetForMultipleNames(description: string): Promise<string[]> {
113
- try {
114
- const fullPrompt = `${MULTI_NAME_GENERATION_PROMPT}\n${description}`;
115
-
116
- const result = execSync(
117
- `claude --model ${SONNET_MODEL} --print "${escapeShellArg(fullPrompt)}"`,
118
- {
119
- encoding: 'utf-8',
120
- timeout: 30000,
121
- stdio: ['pipe', 'pipe', 'pipe'],
122
- }
123
- );
124
-
125
- // Parse the multiline response
126
- const lines = result
127
- .trim()
128
- .split('\n')
129
- .map((line) => line.trim())
130
- .filter((line) => line.length > 0);
131
-
132
- // Sanitize and validate each name
133
- const validNames: string[] = [];
134
- for (const line of lines) {
135
- const sanitized = sanitizeGeneratedName(line);
136
- if (sanitized && !validNames.includes(sanitized)) {
137
- validNames.push(sanitized);
138
- }
139
- }
149
+ const fullPrompt = `${MULTI_NAME_GENERATION_PROMPT}\n${description}`;
150
+ const result = await runClaudePrint(fullPrompt);
140
151
 
141
- // Return 3-5 names
142
- return validNames.slice(0, 5);
143
- } catch (error) {
144
- logger.debug(`Sonnet API call for multiple names failed: ${error}`);
152
+ if (!result) {
145
153
  return [];
146
154
  }
147
- }
148
155
 
149
- /**
150
- * Escape a string for use as a shell argument.
151
- */
152
- function escapeShellArg(arg: string): string {
153
- // Replace double quotes with escaped double quotes
154
- // Replace backslashes with escaped backslashes
155
- return arg
156
- .replace(/\\/g, '\\\\')
157
- .replace(/"/g, '\\"')
158
- .replace(/\$/g, '\\$')
159
- .replace(/`/g, '\\`');
156
+ // Parse the multiline response
157
+ const lines = result
158
+ .split('\n')
159
+ .map((line) => line.trim())
160
+ .filter((line) => line.length > 0);
161
+
162
+ // Sanitize and validate each name
163
+ const validNames: string[] = [];
164
+ for (const line of lines) {
165
+ const sanitized = sanitizeGeneratedName(line);
166
+ if (sanitized && !validNames.includes(sanitized)) {
167
+ validNames.push(sanitized);
168
+ }
169
+ }
170
+
171
+ // Return 3-5 names
172
+ return validNames.slice(0, 5);
160
173
  }
161
174
 
162
175
  /**
@@ -208,4 +221,4 @@ function generateFallbackName(description: string): string {
208
221
  }
209
222
 
210
223
  // Export for testing
211
- export { sanitizeGeneratedName, escapeShellArg };
224
+ export { sanitizeGeneratedName };
@@ -4,6 +4,8 @@
4
4
  */
5
5
 
6
6
  import { formatElapsedTime } from './timer.js';
7
+ import type { UsageData } from '../types/config.js';
8
+ import type { CostBreakdown } from './token-tracker.js';
7
9
 
8
10
  /**
9
11
  * Visual symbols for terminal output using dots/symbols style.
@@ -125,3 +127,69 @@ export function formatProgressBar(tasks: TaskStatus[]): string {
125
127
 
126
128
  return tasks.map((status) => SYMBOLS[status]).join('');
127
129
  }
130
+
131
+ /**
132
+ * Formats a number with thousands separators (e.g., 12345 -> "12,345").
133
+ */
134
+ export function formatNumber(n: number): string {
135
+ return n.toLocaleString('en-US');
136
+ }
137
+
138
+ /**
139
+ * Formats a cost in USD with 2-4 decimal places.
140
+ * Uses 2 decimals for values >= $0.01, 4 decimals for smaller values.
141
+ */
142
+ export function formatCost(cost: number): string {
143
+ if (cost === 0) return '$0.00';
144
+ if (cost < 0.01) return `$${cost.toFixed(4)}`;
145
+ return `$${cost.toFixed(2)}`;
146
+ }
147
+
148
+ /**
149
+ * Formats a per-task token usage summary line.
150
+ * Example: " Tokens: 5,234 in / 1,023 out | Cache: 18,500 read | Est. cost: $0.42"
151
+ */
152
+ export function formatTaskTokenSummary(usage: UsageData, cost: CostBreakdown): string {
153
+ const parts: string[] = [];
154
+ parts.push(`Tokens: ${formatNumber(usage.inputTokens)} in / ${formatNumber(usage.outputTokens)} out`);
155
+
156
+ const cacheTotal = usage.cacheReadInputTokens + usage.cacheCreationInputTokens;
157
+ if (cacheTotal > 0) {
158
+ if (usage.cacheReadInputTokens > 0 && usage.cacheCreationInputTokens > 0) {
159
+ parts.push(`Cache: ${formatNumber(usage.cacheReadInputTokens)} read / ${formatNumber(usage.cacheCreationInputTokens)} created`);
160
+ } else if (usage.cacheReadInputTokens > 0) {
161
+ parts.push(`Cache: ${formatNumber(usage.cacheReadInputTokens)} read`);
162
+ } else {
163
+ parts.push(`Cache: ${formatNumber(usage.cacheCreationInputTokens)} created`);
164
+ }
165
+ }
166
+
167
+ parts.push(`Est. cost: ${formatCost(cost.totalCost)}`);
168
+ return ` ${parts.join(' | ')}`;
169
+ }
170
+
171
+ /**
172
+ * Formats the grand total token usage summary block.
173
+ * Displayed after all tasks complete.
174
+ */
175
+ export function formatTokenTotalSummary(usage: UsageData, cost: CostBreakdown): string {
176
+ const lines: string[] = [];
177
+ const divider = '── Token Usage Summary ──────────────────';
178
+ lines.push(divider);
179
+ lines.push(`Total tokens: ${formatNumber(usage.inputTokens)} in / ${formatNumber(usage.outputTokens)} out`);
180
+
181
+ if (usage.cacheReadInputTokens > 0 || usage.cacheCreationInputTokens > 0) {
182
+ const cacheParts: string[] = [];
183
+ if (usage.cacheReadInputTokens > 0) {
184
+ cacheParts.push(`${formatNumber(usage.cacheReadInputTokens)} read`);
185
+ }
186
+ if (usage.cacheCreationInputTokens > 0) {
187
+ cacheParts.push(`${formatNumber(usage.cacheCreationInputTokens)} created`);
188
+ }
189
+ lines.push(`Cache: ${cacheParts.join(' / ')}`);
190
+ }
191
+
192
+ lines.push(`Estimated cost: ${formatCost(cost.totalCost)}`);
193
+ lines.push('─────────────────────────────────────────');
194
+ return lines.join('\n');
195
+ }