rafcode 3.0.0 → 3.8.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 (235) hide show
  1. package/.claude/settings.local.json +3 -1
  2. package/CLAUDE.md +0 -1
  3. package/RAF/38-dual-wielder/decisions.md +9 -0
  4. package/RAF/38-dual-wielder/input.md +6 -1
  5. package/RAF/38-dual-wielder/outcomes/8-e2e-test-codex-provider.md +139 -0
  6. package/RAF/38-dual-wielder/plans/8-e2e-test-codex-provider.md +95 -0
  7. package/RAF/39-pathless-rover/decisions.md +16 -0
  8. package/RAF/39-pathless-rover/input.md +2 -0
  9. package/RAF/39-pathless-rover/outcomes/1-fix-codex-stream-renderer.md +21 -0
  10. package/RAF/39-pathless-rover/outcomes/2-wire-provider-flag.md +28 -0
  11. package/RAF/39-pathless-rover/outcomes/3-remove-worktree-flag-do.md +41 -0
  12. package/RAF/39-pathless-rover/outcomes/4-remove-worktree-flag-plan-amend.md +30 -0
  13. package/RAF/39-pathless-rover/outcomes/5-update-prompts-and-docs.md +26 -0
  14. package/RAF/39-pathless-rover/plans/1-fix-codex-stream-renderer.md +43 -0
  15. package/RAF/39-pathless-rover/plans/2-wire-provider-flag.md +48 -0
  16. package/RAF/39-pathless-rover/plans/3-remove-worktree-flag-do.md +41 -0
  17. package/RAF/39-pathless-rover/plans/4-remove-worktree-flag-plan-amend.md +43 -0
  18. package/RAF/39-pathless-rover/plans/5-update-prompts-and-docs.md +31 -0
  19. package/RAF/40-numeric-order-fix/decisions.md +7 -0
  20. package/RAF/40-numeric-order-fix/input.md +19 -0
  21. package/RAF/40-numeric-order-fix/outcomes/1-fix-numeric-sort-order.md +18 -0
  22. package/RAF/40-numeric-order-fix/outcomes/2-add-npm-keywords.md +10 -0
  23. package/RAF/40-numeric-order-fix/plans/1-fix-numeric-sort-order.md +48 -0
  24. package/RAF/40-numeric-order-fix/plans/2-add-npm-keywords.md +23 -0
  25. package/RAF/41-echo-chamber/decisions.md +13 -0
  26. package/RAF/41-echo-chamber/input.md +4 -0
  27. package/RAF/41-echo-chamber/outcomes/1-update-codex-model-defaults.md +24 -0
  28. package/RAF/41-echo-chamber/outcomes/2-e2e-test-codex-provider.md +74 -0
  29. package/RAF/41-echo-chamber/plans/1-update-codex-model-defaults.md +28 -0
  30. package/RAF/41-echo-chamber/plans/2-e2e-test-codex-provider.md +103 -0
  31. package/RAF/42-patch-parade/decisions.md +29 -0
  32. package/RAF/42-patch-parade/input.md +9 -0
  33. package/RAF/42-patch-parade/outcomes/1-fix-codex-model-resolution.md +36 -0
  34. package/RAF/42-patch-parade/outcomes/2-fix-provider-aware-name-generation.md +31 -0
  35. package/RAF/42-patch-parade/outcomes/3-fix-codex-error-event-rendering.md +32 -0
  36. package/RAF/42-patch-parade/outcomes/4-update-cli-help-docs.md +28 -0
  37. package/RAF/42-patch-parade/outcomes/5-update-default-codex-models-to-gpt-5-4.md +33 -0
  38. package/RAF/42-patch-parade/outcomes/6-unify-model-config-schema.md +89 -0
  39. package/RAF/42-patch-parade/plans/1-fix-codex-model-resolution.md +35 -0
  40. package/RAF/42-patch-parade/plans/2-fix-provider-aware-name-generation.md +38 -0
  41. package/RAF/42-patch-parade/plans/3-fix-codex-error-event-rendering.md +32 -0
  42. package/RAF/42-patch-parade/plans/4-update-cli-help-docs.md +31 -0
  43. package/RAF/42-patch-parade/plans/5-update-default-codex-models-to-gpt-5-4.md +35 -0
  44. package/RAF/42-patch-parade/plans/6-unify-model-config-schema.md +46 -0
  45. package/RAF/43-swiss-army/decisions.md +34 -0
  46. package/RAF/43-swiss-army/input.md +7 -0
  47. package/RAF/43-swiss-army/outcomes/1-fix-model-validation.md +21 -0
  48. package/RAF/43-swiss-army/outcomes/2-update-commit-format.md +31 -0
  49. package/RAF/43-swiss-army/outcomes/3-wire-reasoning-effort.md +28 -0
  50. package/RAF/43-swiss-army/outcomes/4-remove-provider-flag.md +27 -0
  51. package/RAF/43-swiss-army/outcomes/5-config-wizard-validation.md +23 -0
  52. package/RAF/43-swiss-army/outcomes/6-add-fast-mode.md +32 -0
  53. package/RAF/43-swiss-army/outcomes/7-config-preset.md +31 -0
  54. package/RAF/43-swiss-army/plans/1-fix-model-validation.md +38 -0
  55. package/RAF/43-swiss-army/plans/2-update-commit-format.md +46 -0
  56. package/RAF/43-swiss-army/plans/3-wire-reasoning-effort.md +39 -0
  57. package/RAF/43-swiss-army/plans/4-remove-provider-flag.md +43 -0
  58. package/RAF/43-swiss-army/plans/5-config-wizard-validation.md +42 -0
  59. package/RAF/43-swiss-army/plans/6-add-fast-mode.md +46 -0
  60. package/RAF/43-swiss-army/plans/7-config-preset.md +51 -0
  61. package/RAF/44-config-api-change/decisions.md +22 -0
  62. package/RAF/44-config-api-change/input.md +5 -0
  63. package/RAF/44-config-api-change/outcomes/1-restructure-config-subcommands.md +19 -0
  64. package/RAF/44-config-api-change/outcomes/2-move-preset-under-config.md +17 -0
  65. package/RAF/44-config-api-change/outcomes/3-update-existing-tests-for-config-api.md +14 -0
  66. package/RAF/44-config-api-change/outcomes/4-update-config-command-docs.md +11 -0
  67. package/RAF/44-config-api-change/outcomes/5-fix-codex-name-generation.md +18 -0
  68. package/RAF/44-config-api-change/plans/1-restructure-config-subcommands.md +37 -0
  69. package/RAF/44-config-api-change/plans/2-move-preset-under-config.md +38 -0
  70. package/RAF/44-config-api-change/plans/3-update-existing-tests-for-config-api.md +38 -0
  71. package/RAF/44-config-api-change/plans/4-update-config-command-docs.md +36 -0
  72. package/RAF/44-config-api-change/plans/5-fix-codex-name-generation.md +49 -0
  73. package/RAF/45-signal-cairn/decisions.md +7 -0
  74. package/RAF/45-signal-cairn/input.md +2 -0
  75. package/RAF/45-signal-cairn/outcomes/1-rename-provider-to-harness.md +19 -0
  76. package/RAF/45-signal-cairn/outcomes/2-normalize-model-display-names.md +18 -0
  77. package/RAF/45-signal-cairn/plans/1-rename-provider-to-harness.md +40 -0
  78. package/RAF/45-signal-cairn/plans/2-normalize-model-display-names.md +41 -0
  79. package/RAF/45-signal-lantern/decisions.md +10 -0
  80. package/RAF/45-signal-lantern/input.md +2 -0
  81. package/RAF/45-signal-lantern/outcomes/1-add-effort-and-fast-to-do-model-display.md +15 -0
  82. package/RAF/45-signal-lantern/outcomes/2-capture-codex-post-run-token-usage.md +15 -0
  83. package/RAF/45-signal-lantern/outcomes/3-show-codex-token-summaries-without-fake-cost.md +14 -0
  84. package/RAF/45-signal-lantern/plans/1-add-effort-and-fast-to-do-model-display.md +38 -0
  85. package/RAF/45-signal-lantern/plans/2-capture-codex-post-run-token-usage.md +37 -0
  86. package/RAF/45-signal-lantern/plans/3-show-codex-token-summaries-without-fake-cost.md +40 -0
  87. package/RAF/46-lantern-arc/decisions.md +19 -0
  88. package/RAF/46-lantern-arc/input.md +6 -0
  89. package/RAF/46-lantern-arc/outcomes/1-remove-spark-alias.md +16 -0
  90. package/RAF/46-lantern-arc/outcomes/2-clean-up-worktree-plan-command.md +30 -0
  91. package/RAF/46-lantern-arc/outcomes/3-fix-token-usage-accumulation.md +32 -0
  92. package/RAF/46-lantern-arc/outcomes/4-display-effort-in-compact-mode.md +22 -0
  93. package/RAF/46-lantern-arc/outcomes/5-codex-fast-mode-research.md +38 -0
  94. package/RAF/46-lantern-arc/outcomes/6-optimize-llm-prompts.md +39 -0
  95. package/RAF/46-lantern-arc/plans/1-remove-spark-alias.md +38 -0
  96. package/RAF/46-lantern-arc/plans/2-clean-up-worktree-plan-command.md +33 -0
  97. package/RAF/46-lantern-arc/plans/3-fix-token-usage-accumulation.md +33 -0
  98. package/RAF/46-lantern-arc/plans/4-display-effort-in-compact-mode.md +28 -0
  99. package/RAF/46-lantern-arc/plans/5-codex-fast-mode-research.md +34 -0
  100. package/RAF/46-lantern-arc/plans/6-optimize-llm-prompts.md +48 -0
  101. package/RAF/47-signal-trim/decisions.md +13 -0
  102. package/RAF/47-signal-trim/input.md +2 -0
  103. package/RAF/47-signal-trim/plans/1-remove-cache-from-status.md +73 -0
  104. package/README.md +50 -63
  105. package/dist/commands/config.d.ts.map +1 -1
  106. package/dist/commands/config.js +47 -49
  107. package/dist/commands/config.js.map +1 -1
  108. package/dist/commands/do.d.ts +2 -0
  109. package/dist/commands/do.d.ts.map +1 -1
  110. package/dist/commands/do.js +91 -230
  111. package/dist/commands/do.js.map +1 -1
  112. package/dist/commands/plan.d.ts.map +1 -1
  113. package/dist/commands/plan.js +54 -259
  114. package/dist/commands/plan.js.map +1 -1
  115. package/dist/commands/preset.d.ts +3 -0
  116. package/dist/commands/preset.d.ts.map +1 -0
  117. package/dist/commands/preset.js +158 -0
  118. package/dist/commands/preset.js.map +1 -0
  119. package/dist/core/claude-runner.d.ts +2 -0
  120. package/dist/core/claude-runner.d.ts.map +1 -1
  121. package/dist/core/claude-runner.js +36 -12
  122. package/dist/core/claude-runner.js.map +1 -1
  123. package/dist/core/codex-runner.d.ts +1 -0
  124. package/dist/core/codex-runner.d.ts.map +1 -1
  125. package/dist/core/codex-runner.js +26 -7
  126. package/dist/core/codex-runner.js.map +1 -1
  127. package/dist/core/failure-analyzer.js +2 -1
  128. package/dist/core/failure-analyzer.js.map +1 -1
  129. package/dist/core/git.d.ts +2 -2
  130. package/dist/core/git.d.ts.map +1 -1
  131. package/dist/core/git.js +53 -3
  132. package/dist/core/git.js.map +1 -1
  133. package/dist/core/project-manager.d.ts.map +1 -1
  134. package/dist/core/project-manager.js +2 -2
  135. package/dist/core/project-manager.js.map +1 -1
  136. package/dist/core/pull-request.js +5 -5
  137. package/dist/core/pull-request.js.map +1 -1
  138. package/dist/core/runner-factory.d.ts +4 -4
  139. package/dist/core/runner-factory.d.ts.map +1 -1
  140. package/dist/core/runner-factory.js +8 -8
  141. package/dist/core/runner-factory.js.map +1 -1
  142. package/dist/core/runner-interface.d.ts +1 -1
  143. package/dist/core/runner-types.d.ts +17 -4
  144. package/dist/core/runner-types.d.ts.map +1 -1
  145. package/dist/core/state-derivation.js +3 -3
  146. package/dist/core/state-derivation.js.map +1 -1
  147. package/dist/parsers/codex-stream-renderer.d.ts +28 -4
  148. package/dist/parsers/codex-stream-renderer.d.ts.map +1 -1
  149. package/dist/parsers/codex-stream-renderer.js +110 -0
  150. package/dist/parsers/codex-stream-renderer.js.map +1 -1
  151. package/dist/prompts/amend.d.ts +0 -1
  152. package/dist/prompts/amend.d.ts.map +1 -1
  153. package/dist/prompts/amend.js +31 -104
  154. package/dist/prompts/amend.js.map +1 -1
  155. package/dist/prompts/execution.d.ts.map +1 -1
  156. package/dist/prompts/execution.js +17 -34
  157. package/dist/prompts/execution.js.map +1 -1
  158. package/dist/prompts/planning.d.ts.map +1 -1
  159. package/dist/prompts/planning.js +23 -123
  160. package/dist/prompts/planning.js.map +1 -1
  161. package/dist/types/config.d.ts +33 -32
  162. package/dist/types/config.d.ts.map +1 -1
  163. package/dist/types/config.js +14 -28
  164. package/dist/types/config.js.map +1 -1
  165. package/dist/utils/config.d.ts +36 -16
  166. package/dist/utils/config.d.ts.map +1 -1
  167. package/dist/utils/config.js +209 -104
  168. package/dist/utils/config.js.map +1 -1
  169. package/dist/utils/name-generator.d.ts.map +1 -1
  170. package/dist/utils/name-generator.js +25 -12
  171. package/dist/utils/name-generator.js.map +1 -1
  172. package/dist/utils/paths.d.ts +5 -0
  173. package/dist/utils/paths.d.ts.map +1 -1
  174. package/dist/utils/paths.js +9 -0
  175. package/dist/utils/paths.js.map +1 -1
  176. package/dist/utils/terminal-symbols.d.ts +15 -2
  177. package/dist/utils/terminal-symbols.d.ts.map +1 -1
  178. package/dist/utils/terminal-symbols.js +36 -4
  179. package/dist/utils/terminal-symbols.js.map +1 -1
  180. package/dist/utils/token-tracker.d.ts +6 -1
  181. package/dist/utils/token-tracker.d.ts.map +1 -1
  182. package/dist/utils/token-tracker.js +84 -51
  183. package/dist/utils/token-tracker.js.map +1 -1
  184. package/dist/utils/validation.d.ts +1 -2
  185. package/dist/utils/validation.d.ts.map +1 -1
  186. package/dist/utils/validation.js +4 -25
  187. package/dist/utils/validation.js.map +1 -1
  188. package/package.json +7 -2
  189. package/src/commands/config.ts +60 -63
  190. package/src/commands/do.ts +96 -262
  191. package/src/commands/plan.ts +55 -279
  192. package/src/commands/preset.ts +186 -0
  193. package/src/core/claude-runner.ts +45 -5
  194. package/src/core/codex-runner.ts +32 -7
  195. package/src/core/failure-analyzer.ts +2 -1
  196. package/src/core/git.ts +57 -3
  197. package/src/core/project-manager.ts +2 -1
  198. package/src/core/pull-request.ts +5 -5
  199. package/src/core/runner-factory.ts +9 -9
  200. package/src/core/runner-interface.ts +1 -1
  201. package/src/core/runner-types.ts +17 -4
  202. package/src/core/state-derivation.ts +3 -3
  203. package/src/parsers/codex-stream-renderer.ts +149 -4
  204. package/src/prompts/amend.ts +30 -105
  205. package/src/prompts/config-docs.md +206 -62
  206. package/src/prompts/execution.ts +17 -34
  207. package/src/prompts/planning.ts +23 -124
  208. package/src/types/config.ts +47 -59
  209. package/src/utils/config.ts +248 -115
  210. package/src/utils/name-generator.ts +29 -13
  211. package/src/utils/paths.ts +10 -0
  212. package/src/utils/terminal-symbols.ts +46 -6
  213. package/src/utils/token-tracker.ts +96 -57
  214. package/src/utils/validation.ts +5 -30
  215. package/tests/unit/amend-prompt.test.ts +3 -2
  216. package/tests/unit/claude-runner-interactive.test.ts +21 -3
  217. package/tests/unit/claude-runner.test.ts +39 -0
  218. package/tests/unit/codex-runner.test.ts +163 -0
  219. package/tests/unit/codex-stream-renderer.test.ts +127 -0
  220. package/tests/unit/command-output.test.ts +57 -0
  221. package/tests/unit/commit-planning-artifacts-worktree.test.ts +24 -7
  222. package/tests/unit/commit-planning-artifacts.test.ts +26 -4
  223. package/tests/unit/config-command.test.ts +215 -303
  224. package/tests/unit/config.test.ts +319 -235
  225. package/tests/unit/dependency-integration.test.ts +27 -1
  226. package/tests/unit/do-model-display.test.ts +35 -0
  227. package/tests/unit/execution-prompt.test.ts +49 -19
  228. package/tests/unit/name-generator.test.ts +82 -12
  229. package/tests/unit/plan-command-auto-flag.test.ts +7 -10
  230. package/tests/unit/plan-command.test.ts +14 -17
  231. package/tests/unit/planning-prompt.test.ts +9 -8
  232. package/tests/unit/terminal-symbols.test.ts +94 -3
  233. package/tests/unit/token-tracker.test.ts +180 -1
  234. package/tests/unit/validation.test.ts +9 -41
  235. package/tests/unit/worktree-flag-override.test.ts +0 -186
@@ -1,403 +1,315 @@
1
1
  import * as fs from 'node:fs';
2
2
  import * as path from 'node:path';
3
- import * as os from 'node:os';
4
3
  import { Command } from 'commander';
5
- import { createConfigCommand } from '../../src/commands/config.js';
6
- import {
7
- validateConfig,
8
- ConfigValidationError,
9
- resolveConfig,
10
- getModel,
11
- resetConfigCache,
12
- } from '../../src/utils/config.js';
13
- import { DEFAULT_CONFIG } from '../../src/types/config.js';
4
+ import { jest } from '@jest/globals';
5
+
6
+ const mockRunInteractive = jest.fn<() => Promise<number>>();
7
+ const mockCreateRunner = jest.fn(() => ({
8
+ runInteractive: mockRunInteractive,
9
+ }));
10
+ const mockShutdownHandler = {
11
+ init: jest.fn(),
12
+ registerClaudeRunner: jest.fn(),
13
+ };
14
+ const mockLogger = {
15
+ info: jest.fn(),
16
+ success: jest.fn(),
17
+ warn: jest.fn(),
18
+ error: jest.fn(),
19
+ newline: jest.fn(),
20
+ };
21
+
22
+ let confirmAnswer = 'y';
23
+ const suiteHomeDir = fs.mkdtempSync(path.join('/tmp', 'raf-config-home-'));
24
+ let mockHomeDir = suiteHomeDir;
25
+
26
+ jest.unstable_mockModule('node:os', () => ({
27
+ homedir: () => mockHomeDir,
28
+ tmpdir: () => '/tmp',
29
+ }));
30
+
31
+ jest.unstable_mockModule('../../src/core/runner-factory.js', () => ({
32
+ createRunner: mockCreateRunner,
33
+ }));
34
+
35
+ jest.unstable_mockModule('../../src/core/shutdown-handler.js', () => ({
36
+ shutdownHandler: mockShutdownHandler,
37
+ }));
38
+
39
+ jest.unstable_mockModule('../../src/utils/logger.js', () => ({
40
+ logger: mockLogger,
41
+ }));
42
+
43
+ jest.unstable_mockModule('node:readline', () => ({
44
+ createInterface: jest.fn(() => ({
45
+ question: (_message: string, callback: (answer: string) => void) => callback(confirmAnswer),
46
+ close: jest.fn(),
47
+ })),
48
+ }));
49
+
50
+ const { createConfigCommand } = await import('../../src/commands/config.js');
51
+ const { resetConfigCache, resolveConfig, validateConfig, ConfigValidationError } = await import('../../src/utils/config.js');
52
+ const { DEFAULT_CONFIG } = await import('../../src/types/config.js');
14
53
 
15
54
  describe('Config Command', () => {
16
55
  let tempDir: string;
17
56
 
57
+ function configPath(): string {
58
+ return path.join(tempDir, '.raf', 'raf.config.json');
59
+ }
60
+
61
+ async function parseConfigCommand(args: string[]): Promise<void> {
62
+ const command = createConfigCommand();
63
+ command.exitOverride();
64
+ await command.parseAsync(args, { from: 'user' });
65
+ }
66
+
18
67
  beforeEach(() => {
19
- tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'raf-config-cmd-test-'));
68
+ tempDir = suiteHomeDir;
69
+ mockHomeDir = suiteHomeDir;
70
+ fs.rmSync(path.join(tempDir, '.raf'), { recursive: true, force: true });
71
+ confirmAnswer = 'y';
20
72
  resetConfigCache();
73
+ mockRunInteractive.mockReset().mockResolvedValue(0);
74
+ mockCreateRunner.mockClear();
75
+ mockShutdownHandler.init.mockClear();
76
+ mockShutdownHandler.registerClaudeRunner.mockClear();
77
+ mockLogger.info.mockClear();
78
+ mockLogger.success.mockClear();
79
+ mockLogger.warn.mockClear();
80
+ mockLogger.error.mockClear();
81
+ mockLogger.newline.mockClear();
21
82
  });
22
83
 
23
84
  afterEach(() => {
24
- fs.rmSync(tempDir, { recursive: true, force: true });
85
+ fs.rmSync(path.join(tempDir, '.raf'), { recursive: true, force: true });
25
86
  resetConfigCache();
26
87
  });
27
88
 
89
+ afterAll(() => {
90
+ fs.rmSync(suiteHomeDir, { recursive: true, force: true });
91
+ });
92
+
28
93
  describe('Command setup', () => {
29
94
  it('should create a command named "config"', () => {
30
95
  const cmd = createConfigCommand();
31
96
  expect(cmd.name()).toBe('config');
32
97
  });
33
98
 
34
- it('should have a description', () => {
35
- const cmd = createConfigCommand();
36
- expect(cmd.description()).toBeTruthy();
37
- expect(cmd.description()).toContain('config');
38
- });
39
-
40
- it('should accept a variadic prompt argument', () => {
41
- const cmd = createConfigCommand();
42
- const args = cmd.registeredArguments;
43
- expect(args.length).toBe(1);
44
- expect(args[0]!.variadic).toBe(true);
45
- });
46
-
47
- it('should have a --reset option', () => {
99
+ it('should expose get, set, reset, wizard, and preset subcommands', () => {
48
100
  const cmd = createConfigCommand();
49
- const resetOption = cmd.options.find((o) => o.long === '--reset');
50
- expect(resetOption).toBeDefined();
101
+ expect(cmd.commands.map((subcommand) => subcommand.name())).toEqual(['get', 'set', 'reset', 'wizard', 'preset']);
51
102
  });
52
103
 
53
- it('should have a --get option', () => {
104
+ it('should not keep the old root-level flags or prompt argument', () => {
54
105
  const cmd = createConfigCommand();
55
- const getOption = cmd.options.find((o) => o.long === '--get');
56
- expect(getOption).toBeDefined();
106
+ expect(cmd.options).toHaveLength(0);
107
+ expect(cmd.registeredArguments).toHaveLength(0);
57
108
  });
58
109
 
59
- it('should have a --set option', () => {
110
+ it('should define wizard with a variadic prompt argument', () => {
60
111
  const cmd = createConfigCommand();
61
- const setOption = cmd.options.find((o) => o.long === '--set');
62
- expect(setOption).toBeDefined();
112
+ const wizard = cmd.commands.find((subcommand) => subcommand.name() === 'wizard');
113
+ expect(wizard).toBeDefined();
114
+ expect(wizard!.registeredArguments).toHaveLength(1);
115
+ expect(wizard!.registeredArguments[0]!.variadic).toBe(true);
63
116
  });
64
117
 
65
118
  it('should register in a parent program', () => {
66
119
  const program = new Command();
67
120
  program.addCommand(createConfigCommand());
68
- const configCmd = program.commands.find((c) => c.name() === 'config');
121
+ const configCmd = program.commands.find((command) => command.name() === 'config');
69
122
  expect(configCmd).toBeDefined();
70
- });
71
- });
72
-
73
- describe('Post-session validation logic', () => {
74
- it('should accept valid config with model override', () => {
75
- const config = { models: { execute: 'sonnet' } };
76
- expect(() => validateConfig(config)).not.toThrow();
77
- });
78
-
79
- it('should accept valid config with effortMapping override', () => {
80
- const config = { effortMapping: { low: 'sonnet', medium: 'opus' } };
81
- expect(() => validateConfig(config)).not.toThrow();
82
- });
83
-
84
- it('should accept valid config with timeout', () => {
85
- const config = { timeout: 120 };
86
- expect(() => validateConfig(config)).not.toThrow();
87
- });
88
-
89
- it('should reject config with unknown keys', () => {
90
- const config = { unknownKey: true };
91
- expect(() => validateConfig(config)).toThrow(ConfigValidationError);
92
- });
93
-
94
- it('should reject config with invalid model name', () => {
95
- const config = { models: { execute: 'gpt-4' } };
96
- expect(() => validateConfig(config)).toThrow(ConfigValidationError);
97
- });
98
-
99
- it('should reject config with invalid effortMapping model', () => {
100
- const config = { effortMapping: { low: 'gpt-4' } };
101
- expect(() => validateConfig(config)).toThrow(ConfigValidationError);
102
- });
103
-
104
- it('should reject non-object config', () => {
105
- expect(() => validateConfig('string')).toThrow(ConfigValidationError);
106
- expect(() => validateConfig(null)).toThrow(ConfigValidationError);
107
- expect(() => validateConfig([])).toThrow(ConfigValidationError);
108
- });
109
-
110
- it('should accept an empty config (all defaults)', () => {
111
- expect(() => validateConfig({})).not.toThrow();
112
- });
113
- });
114
-
115
- describe('Reset flow - file operations', () => {
116
- it('should be able to delete config file', () => {
117
- const configPath = path.join(tempDir, 'raf.config.json');
118
- fs.writeFileSync(configPath, JSON.stringify({ timeout: 90 }, null, 2));
119
- expect(fs.existsSync(configPath)).toBe(true);
120
-
121
- fs.unlinkSync(configPath);
122
- expect(fs.existsSync(configPath)).toBe(false);
123
+ expect(program.commands.find((command) => command.name() === 'preset')).toBeUndefined();
123
124
  });
124
125
 
125
- it('should handle non-existent config file gracefully', () => {
126
- const configPath = path.join(tempDir, 'raf.config.json');
127
- expect(fs.existsSync(configPath)).toBe(false);
128
- // Reset when no file exists should not throw
126
+ it('should nest preset save/load/list/delete under config', () => {
127
+ const cmd = createConfigCommand();
128
+ const preset = cmd.commands.find((subcommand) => subcommand.name() === 'preset');
129
+ expect(preset).toBeDefined();
130
+ expect(preset!.commands.map((subcommand) => subcommand.name())).toEqual(['save', 'load', 'list', 'delete']);
129
131
  });
130
132
  });
131
133
 
132
- describe('Config file round-trip', () => {
133
- it('should write and read valid config', () => {
134
- const configPath = path.join(tempDir, 'raf.config.json');
135
- const config = { models: { execute: 'sonnet' as const }, timeout: 90 };
134
+ describe('config get', () => {
135
+ it('prints the resolved config when no key is provided', async () => {
136
+ fs.mkdirSync(path.dirname(configPath()), { recursive: true });
137
+ fs.writeFileSync(configPath(), JSON.stringify({ timeout: 120 }, null, 2));
136
138
 
137
- fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
138
- const content = fs.readFileSync(configPath, 'utf-8');
139
- const parsed = JSON.parse(content);
139
+ const consoleSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
140
140
 
141
- expect(parsed.models.execute).toBe('sonnet');
142
- expect(parsed.timeout).toBe(90);
143
- expect(() => validateConfig(parsed)).not.toThrow();
144
- });
141
+ await parseConfigCommand(['get']);
145
142
 
146
- it('should detect invalid JSON after write', () => {
147
- const configPath = path.join(tempDir, 'raf.config.json');
148
- fs.writeFileSync(configPath, '{ invalid json }}}');
143
+ expect(consoleSpy).toHaveBeenCalledTimes(1);
144
+ const printed = JSON.parse(consoleSpy.mock.calls[0]![0] as string);
145
+ expect(printed.timeout).toBe(120);
146
+ expect(printed.models.execute).toEqual(DEFAULT_CONFIG.models.execute);
149
147
 
150
- const content = fs.readFileSync(configPath, 'utf-8');
151
- expect(() => JSON.parse(content)).toThrow(SyntaxError);
148
+ consoleSpy.mockRestore();
152
149
  });
153
150
 
154
- it('should detect validation errors after write', () => {
155
- const configPath = path.join(tempDir, 'raf.config.json');
156
- fs.writeFileSync(configPath, JSON.stringify({ badKey: true }, null, 2));
157
-
158
- const content = fs.readFileSync(configPath, 'utf-8');
159
- const parsed = JSON.parse(content);
160
- expect(() => validateConfig(parsed)).toThrow(ConfigValidationError);
161
- });
162
- });
151
+ it('prints a resolved dot-notation value', async () => {
152
+ fs.mkdirSync(path.dirname(configPath()), { recursive: true });
153
+ fs.writeFileSync(configPath(), JSON.stringify({ models: { plan: { model: 'haiku', harness: 'claude' } } }, null, 2));
163
154
 
164
- describe('System prompt construction', () => {
165
- it('should indicate no config when file does not exist', () => {
166
- const configPath = path.join(tempDir, 'raf.config.json');
167
- const exists = fs.existsSync(configPath);
168
- const state = exists
169
- ? fs.readFileSync(configPath, 'utf-8')
170
- : 'No config file exists yet.';
171
- expect(state).toContain('No config file');
172
- });
155
+ const consoleSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
173
156
 
174
- it('should include config contents when file exists', () => {
175
- const configPath = path.join(tempDir, 'raf.config.json');
176
- const config = { timeout: 120, worktree: true };
177
- fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
157
+ await parseConfigCommand(['get', 'models.plan.model']);
178
158
 
179
- const content = fs.readFileSync(configPath, 'utf-8');
180
- expect(content).toContain('"timeout": 120');
181
- expect(content).toContain('"worktree": true');
159
+ expect(consoleSpy).toHaveBeenCalledWith('haiku');
160
+ consoleSpy.mockRestore();
182
161
  });
183
162
  });
184
163
 
185
- describe('Error recovery - invalid config fallback', () => {
186
- // These tests verify the behaviors that runConfigSession relies on for error recovery
187
- // The config command catches errors from getModel/getEffort and falls back to defaults
188
-
189
- it('should throw on invalid JSON when resolving config', () => {
190
- const configPath = path.join(tempDir, 'raf.config.json');
191
- fs.writeFileSync(configPath, '{ invalid json }}}');
192
-
193
- expect(() => resolveConfig(configPath)).toThrow(SyntaxError);
194
- });
195
-
196
- it('should throw on schema validation failure when resolving config', () => {
197
- const configPath = path.join(tempDir, 'raf.config.json');
198
- fs.writeFileSync(configPath, JSON.stringify({ unknownKey: true }));
199
-
200
- expect(() => resolveConfig(configPath)).toThrow(ConfigValidationError);
201
- });
164
+ describe('config set', () => {
165
+ it('writes values with the existing parsing and validation behavior', async () => {
166
+ await parseConfigCommand(['set', 'timeout', '45']);
202
167
 
203
- it('should have valid default fallback values for config scenario', () => {
204
- // These are the values that runConfigSession uses when config loading fails
205
- expect(DEFAULT_CONFIG.models.config).toBe('sonnet');
206
- // effortMapping defaults used for per-task model resolution
207
- expect(DEFAULT_CONFIG.effortMapping.medium).toBe('opus');
168
+ const saved = JSON.parse(fs.readFileSync(configPath(), 'utf-8'));
169
+ expect(saved).toEqual({ timeout: 45 });
170
+ expect(resolveConfig(configPath()).timeout).toBe(45);
208
171
  });
209
172
 
210
- it('should be able to read raw file contents even when config is invalid JSON', () => {
211
- // This verifies that getCurrentConfigState can still read the broken file
212
- // so Claude can see and help fix it
213
- const configPath = path.join(tempDir, 'raf.config.json');
214
- const invalidContent = '{ "broken": true, }'; // trailing comma = invalid
215
- fs.writeFileSync(configPath, invalidContent);
173
+ it('prunes the config file when a value is reset to its default', async () => {
174
+ fs.mkdirSync(path.dirname(configPath()), { recursive: true });
175
+ fs.writeFileSync(configPath(), JSON.stringify({ timeout: 45 }, null, 2));
216
176
 
217
- // File is readable even though it's invalid JSON
218
- const content = fs.readFileSync(configPath, 'utf-8');
219
- expect(content).toBe(invalidContent);
220
- });
221
-
222
- it('should be able to read raw file contents even when config fails schema validation', () => {
223
- const configPath = path.join(tempDir, 'raf.config.json');
224
- const invalidContent = JSON.stringify({ badKey: 'value' }, null, 2);
225
- fs.writeFileSync(configPath, invalidContent);
177
+ await parseConfigCommand(['set', 'timeout', String(DEFAULT_CONFIG.timeout)]);
226
178
 
227
- // File is readable even though it fails validation
228
- const content = fs.readFileSync(configPath, 'utf-8');
229
- expect(JSON.parse(content)).toEqual({ badKey: 'value' });
179
+ expect(fs.existsSync(configPath())).toBe(false);
230
180
  });
231
181
 
232
- it('resetConfigCache should clear the cached config', () => {
233
- // This is used by runConfigSession to clear a broken cached config
234
- // so subsequent operations don't fail
235
- const configPath = path.join(tempDir, 'valid.json');
236
- fs.writeFileSync(configPath, JSON.stringify({ timeout: 99 }));
237
-
238
- // Load the config
239
- const config1 = resolveConfig(configPath);
240
- expect(config1.timeout).toBe(99);
241
-
242
- // Write different content
243
- fs.writeFileSync(configPath, JSON.stringify({ timeout: 120 }));
182
+ it('warns when fast mode is set on a codex entry', async () => {
183
+ await parseConfigCommand(['set', 'models.execute', '{"model":"gpt-5.4","harness":"codex","fast":true}']);
244
184
 
245
- // Without reset, we'd still get cached value (but resolveConfig doesn't use cache)
246
- // This test verifies resetConfigCache exists and can be called
247
- resetConfigCache();
248
-
249
- // After reset, we should get new value
250
- const config2 = resolveConfig(configPath);
251
- expect(config2.timeout).toBe(120);
185
+ expect(mockLogger.warn).toHaveBeenCalledWith(
186
+ expect.stringContaining('models.execute.fast is enabled but ignored because Codex does not support fast mode')
187
+ );
252
188
  });
253
189
  });
254
190
 
255
- describe('--get flag', () => {
256
- it('should return full config when no key is provided', () => {
257
- const configPath = path.join(tempDir, 'raf.config.json');
258
- fs.writeFileSync(configPath, JSON.stringify({ timeout: 120 }, null, 2));
191
+ describe('config reset', () => {
192
+ it('deletes the config file after confirmation', async () => {
193
+ fs.mkdirSync(path.dirname(configPath()), { recursive: true });
194
+ fs.writeFileSync(configPath(), JSON.stringify({ timeout: 45 }, null, 2));
259
195
 
260
- const config = resolveConfig(configPath);
261
- expect(config.timeout).toBe(120);
262
- expect(config.models.execute).toBe(DEFAULT_CONFIG.models.execute);
196
+ await parseConfigCommand(['reset']);
263
197
 
264
- // Verify full config has all expected top-level keys
265
- expect(config).toHaveProperty('models');
266
- expect(config).toHaveProperty('effortMapping');
267
- expect(config).toHaveProperty('timeout');
198
+ expect(fs.existsSync(configPath())).toBe(false);
268
199
  });
269
200
 
270
- it('should return specific value for dot-notation key', () => {
271
- const configPath = path.join(tempDir, 'raf.config.json');
272
- fs.writeFileSync(configPath, JSON.stringify({ models: { plan: 'sonnet' } }, null, 2));
273
-
274
- const config = resolveConfig(configPath);
275
- expect(config.models.plan).toBe('sonnet');
276
- });
201
+ it('keeps the config file when confirmation is declined', async () => {
202
+ confirmAnswer = 'n';
203
+ fs.mkdirSync(path.dirname(configPath()), { recursive: true });
204
+ fs.writeFileSync(configPath(), JSON.stringify({ timeout: 45 }, null, 2));
277
205
 
278
- it('should handle nested keys', () => {
279
- const configPath = path.join(tempDir, 'raf.config.json');
280
- fs.writeFileSync(configPath, JSON.stringify({ display: { showCacheTokens: false } }, null, 2));
206
+ await parseConfigCommand(['reset']);
281
207
 
282
- const config = resolveConfig(configPath);
283
- expect(config.display.showCacheTokens).toBe(false);
208
+ expect(fs.existsSync(configPath())).toBe(true);
284
209
  });
285
210
  });
286
211
 
287
- describe('--set flag', () => {
288
- it('should set a string value', () => {
289
- const configPath = path.join(tempDir, 'raf.config.json');
290
-
291
- // Start with empty config
292
- expect(fs.existsSync(configPath)).toBe(false);
212
+ describe('config wizard', () => {
213
+ it('launches the interactive session only from the wizard subcommand', async () => {
214
+ await parseConfigCommand(['wizard', 'show', 'my', 'config']);
293
215
 
294
- // Simulate setting models.plan to sonnet
295
- const userConfig: Record<string, unknown> = {};
296
- const keys = 'models.plan'.split('.');
297
- let current: Record<string, unknown> = userConfig;
298
- for (let i = 0; i < keys.length - 1; i++) {
299
- const key = keys[i]!;
300
- current[key] = {};
301
- current = current[key] as Record<string, unknown>;
302
- }
303
- current[keys[keys.length - 1]!] = 'sonnet';
304
-
305
- fs.writeFileSync(configPath, JSON.stringify(userConfig, null, 2));
306
-
307
- const config = resolveConfig(configPath);
308
- expect(config.models.plan).toBe('sonnet');
216
+ expect(mockCreateRunner).toHaveBeenCalledWith(expect.objectContaining(DEFAULT_CONFIG.models.config));
217
+ expect(mockRunInteractive).toHaveBeenCalledWith(
218
+ expect.any(String),
219
+ 'show my config',
220
+ { dangerouslySkipPermissions: true }
221
+ );
309
222
  });
310
223
 
311
- it('should set a number value', () => {
312
- const configPath = path.join(tempDir, 'raf.config.json');
224
+ it('preserves broken-config recovery behavior and updated guidance', async () => {
225
+ fs.mkdirSync(path.dirname(configPath()), { recursive: true });
226
+ fs.writeFileSync(configPath(), '{ "timeout": 45, }');
313
227
 
314
- const userConfig = { timeout: 120 };
315
- fs.writeFileSync(configPath, JSON.stringify(userConfig, null, 2));
228
+ await parseConfigCommand(['wizard']);
316
229
 
317
- const config = resolveConfig(configPath);
318
- expect(config.timeout).toBe(120);
230
+ expect(mockCreateRunner).toHaveBeenCalledWith(expect.objectContaining(DEFAULT_CONFIG.models.config));
231
+ expect(mockRunInteractive).toHaveBeenCalledWith(
232
+ expect.stringContaining('{ "timeout": 45, }'),
233
+ 'Show me my current config and help me make changes.',
234
+ { dangerouslySkipPermissions: true }
235
+ );
236
+ expect(mockLogger.warn).toHaveBeenCalledWith(expect.stringContaining('raf config reset'));
319
237
  });
320
238
 
321
- it('should set a boolean value', () => {
322
- const configPath = path.join(tempDir, 'raf.config.json');
323
-
324
- const userConfig = { autoCommit: false };
325
- fs.writeFileSync(configPath, JSON.stringify(userConfig, null, 2));
326
-
327
- const config = resolveConfig(configPath);
328
- expect(config.autoCommit).toBe(false);
329
- });
239
+ it('does not launch the interactive session from bare config', async () => {
240
+ const stdoutSpy = jest.spyOn(process.stdout, 'write').mockImplementation(() => true);
330
241
 
331
- it('should remove key when value matches default', () => {
332
- const configPath = path.join(tempDir, 'raf.config.json');
242
+ await parseConfigCommand([]);
333
243
 
334
- // Set a non-default value first
335
- fs.writeFileSync(configPath, JSON.stringify({ models: { plan: 'sonnet' } }, null, 2));
336
- let config = resolveConfig(configPath);
337
- expect(config.models.plan).toBe('sonnet');
244
+ expect(mockRunInteractive).not.toHaveBeenCalled();
245
+ expect(stdoutSpy).toHaveBeenCalled();
246
+ const helpOutput = stdoutSpy.mock.calls.map(([chunk]) => String(chunk)).join('');
247
+ expect(helpOutput).toContain('wizard');
248
+ expect(helpOutput).toContain('get');
249
+ expect(helpOutput).toContain('preset');
338
250
 
339
- // Now set back to default (opus)
340
- fs.writeFileSync(configPath, JSON.stringify({}, null, 2));
341
- config = resolveConfig(configPath);
342
- expect(config.models.plan).toBe(DEFAULT_CONFIG.models.plan);
251
+ stdoutSpy.mockRestore();
343
252
  });
253
+ });
344
254
 
345
- it('should remove empty parent objects after key removal', () => {
346
- const configPath = path.join(tempDir, 'raf.config.json');
347
-
348
- // Start with a models override
349
- const userConfig = { models: { plan: 'sonnet' } };
350
- fs.writeFileSync(configPath, JSON.stringify(userConfig, null, 2));
255
+ describe('config preset', () => {
256
+ it('saves, lists, loads, and deletes presets with the nested command', async () => {
257
+ fs.mkdirSync(path.dirname(configPath()), { recursive: true });
258
+ fs.writeFileSync(
259
+ configPath(),
260
+ JSON.stringify({ timeout: 45, models: { execute: { model: 'sonnet', harness: 'claude' } } }, null, 2)
261
+ );
351
262
 
352
- // Remove the override (simulating setting to default)
353
- fs.writeFileSync(configPath, JSON.stringify({}, null, 2));
263
+ await parseConfigCommand(['preset', 'save', 'team-default']);
354
264
 
355
- const content = fs.readFileSync(configPath, 'utf-8');
356
- const parsed = JSON.parse(content);
265
+ const presetPath = path.join(tempDir, '.raf', 'presets', 'team-default.json');
266
+ expect(fs.existsSync(presetPath)).toBe(true);
267
+ expect(JSON.parse(fs.readFileSync(presetPath, 'utf-8'))).toEqual(
268
+ JSON.parse(fs.readFileSync(configPath(), 'utf-8'))
269
+ );
357
270
 
358
- // Should be empty object
359
- expect(Object.keys(parsed).length).toBe(0);
360
- });
271
+ await parseConfigCommand(['preset', 'list']);
272
+ expect(mockLogger.info).toHaveBeenCalledWith(expect.stringContaining('team-default'));
361
273
 
362
- it('should validate config after modification', () => {
363
- const configPath = path.join(tempDir, 'raf.config.json');
274
+ fs.writeFileSync(configPath(), JSON.stringify({ timeout: 5 }, null, 2));
364
275
 
365
- // Valid config
366
- const validConfig = { models: { execute: 'sonnet' } };
367
- fs.writeFileSync(configPath, JSON.stringify(validConfig, null, 2));
368
- expect(() => validateConfig(validConfig)).not.toThrow();
276
+ await parseConfigCommand(['preset', 'load', 'team-default']);
277
+ expect(JSON.parse(fs.readFileSync(configPath(), 'utf-8'))).toEqual(
278
+ JSON.parse(fs.readFileSync(presetPath, 'utf-8'))
279
+ );
369
280
 
370
- // Invalid config
371
- const invalidConfig = { models: { execute: 'invalid-model' } };
372
- fs.writeFileSync(configPath, JSON.stringify(invalidConfig, null, 2));
373
- expect(() => validateConfig(invalidConfig)).toThrow(ConfigValidationError);
281
+ await parseConfigCommand(['preset', 'delete', 'team-default']);
282
+ expect(fs.existsSync(presetPath)).toBe(false);
374
283
  });
375
284
 
376
- it('should delete config file when it becomes empty', () => {
377
- const configPath = path.join(tempDir, 'raf.config.json');
285
+ it('uses updated guidance in preset runtime messages', async () => {
286
+ await parseConfigCommand(['preset', 'list']);
287
+ expect(mockLogger.info).toHaveBeenCalledWith('No presets saved. Use `raf config preset save <name>` to create one.');
378
288
 
379
- // Create a config file
380
- fs.writeFileSync(configPath, JSON.stringify({ timeout: 120 }, null, 2));
381
- expect(fs.existsSync(configPath)).toBe(true);
289
+ const exitSpy = jest.spyOn(process, 'exit').mockImplementation((() => {
290
+ throw new Error('process.exit');
291
+ }) as typeof process.exit);
382
292
 
383
- // Simulate removing all keys (setting everything to defaults)
384
- fs.writeFileSync(configPath, JSON.stringify({}, null, 2));
293
+ await expect(parseConfigCommand(['preset', 'load', 'missing'])).rejects.toThrow('process.exit');
294
+ expect(mockLogger.error).toHaveBeenCalledWith(
295
+ 'Preset "missing" not found. Run `raf config preset list` to see available presets.'
296
+ );
385
297
 
386
- // Check if file still exists (in the actual implementation, empty configs are deleted)
387
- // For this test, we just verify the file operations work
388
- const content = fs.readFileSync(configPath, 'utf-8');
389
- expect(JSON.parse(content)).toEqual({});
298
+ exitSpy.mockRestore();
390
299
  });
300
+ });
391
301
 
392
- it('should handle nested value updates', () => {
393
- const configPath = path.join(tempDir, 'raf.config.json');
394
-
395
- // Set a nested value
396
- const userConfig = { display: { showCacheTokens: false } };
397
- fs.writeFileSync(configPath, JSON.stringify(userConfig, null, 2));
302
+ describe('Validation helpers used by config flows', () => {
303
+ it('accepts valid partial configs', () => {
304
+ expect(() => validateConfig({ models: { execute: { model: 'sonnet', harness: 'claude' } } })).not.toThrow();
305
+ expect(() => validateConfig({ effortMapping: { low: { model: 'sonnet', harness: 'claude' } } })).not.toThrow();
306
+ expect(() => validateConfig({ timeout: 120 })).not.toThrow();
307
+ });
398
308
 
399
- const config = resolveConfig(configPath);
400
- expect(config.display.showCacheTokens).toBe(false);
309
+ it('rejects invalid configs', () => {
310
+ expect(() => validateConfig({ unknownKey: true })).toThrow(ConfigValidationError);
311
+ expect(() => validateConfig({ models: { execute: { model: 'gpt-4', harness: 'codex' } } })).toThrow(ConfigValidationError);
312
+ expect(() => validateConfig('string')).toThrow(ConfigValidationError);
401
313
  });
402
314
  });
403
315
  });