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
package/src/utils/config.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
16
|
-
|
|
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
|
-
|
|
19
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
*
|
|
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:
|
|
66
|
-
maxRetries:
|
|
67
|
-
autoCommit:
|
|
379
|
+
timeout: config.timeout,
|
|
380
|
+
maxRetries: config.maxRetries,
|
|
381
|
+
autoCommit: config.autoCommit,
|
|
68
382
|
};
|
|
69
383
|
}
|
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
import {
|
|
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
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|
|
138
|
+
* Call Claude to generate a single project name.
|
|
87
139
|
*/
|
|
88
140
|
async function callSonnetForName(description: string): Promise<string | null> {
|
|
89
|
-
|
|
90
|
-
|
|
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
|
|
146
|
+
* Call Claude to generate multiple project names.
|
|
111
147
|
*/
|
|
112
148
|
async function callSonnetForMultipleNames(description: string): Promise<string[]> {
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
.
|
|
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
|
|
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
|
+
}
|