@avantmedia/af 0.0.1

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 (57) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +539 -0
  3. package/af +2 -0
  4. package/bun-upgrade.ts +130 -0
  5. package/commands/bun.ts +55 -0
  6. package/commands/changes.ts +35 -0
  7. package/commands/e2e.ts +12 -0
  8. package/commands/help.ts +236 -0
  9. package/commands/install-extension.ts +133 -0
  10. package/commands/jira.ts +577 -0
  11. package/commands/licenses.ts +32 -0
  12. package/commands/npm.ts +55 -0
  13. package/commands/scaffold.ts +105 -0
  14. package/commands/setup.tsx +156 -0
  15. package/commands/spec.ts +405 -0
  16. package/commands/stop-hook.ts +90 -0
  17. package/commands/todo.ts +208 -0
  18. package/commands/versions.ts +150 -0
  19. package/commands/watch.ts +344 -0
  20. package/commands/worktree.ts +424 -0
  21. package/components/change-select.tsx +71 -0
  22. package/components/confirm.tsx +41 -0
  23. package/components/file-conflict.tsx +52 -0
  24. package/components/input.tsx +53 -0
  25. package/components/layout.tsx +70 -0
  26. package/components/messages.tsx +48 -0
  27. package/components/progress.tsx +71 -0
  28. package/components/select.tsx +90 -0
  29. package/components/status-display.tsx +74 -0
  30. package/components/table.tsx +79 -0
  31. package/generated/setup-manifest.ts +67 -0
  32. package/git-worktree.ts +184 -0
  33. package/main.ts +12 -0
  34. package/npm-upgrade.ts +117 -0
  35. package/package.json +83 -0
  36. package/resources/copy-prompt-reporter.ts +443 -0
  37. package/router.ts +220 -0
  38. package/setup/.claude/commands/commit-work.md +47 -0
  39. package/setup/.claude/commands/complete-work.md +34 -0
  40. package/setup/.claude/commands/e2e.md +29 -0
  41. package/setup/.claude/commands/start-work.md +51 -0
  42. package/setup/.claude/skills/pm/SKILL.md +294 -0
  43. package/setup/.claude/skills/pm/templates/api-endpoint.md +69 -0
  44. package/setup/.claude/skills/pm/templates/bug-fix.md +77 -0
  45. package/setup/.claude/skills/pm/templates/feature.md +87 -0
  46. package/setup/.claude/skills/pm/templates/ui-component.md +78 -0
  47. package/utils/change-select-render.tsx +44 -0
  48. package/utils/claude.ts +9 -0
  49. package/utils/config.ts +58 -0
  50. package/utils/env.ts +53 -0
  51. package/utils/git.ts +120 -0
  52. package/utils/ink-render.tsx +50 -0
  53. package/utils/openspec.ts +54 -0
  54. package/utils/output.ts +104 -0
  55. package/utils/proposal.ts +160 -0
  56. package/utils/resources.ts +64 -0
  57. package/utils/setup-files.ts +230 -0
@@ -0,0 +1,105 @@
1
+ import { existsSync, writeFileSync } from 'node:fs';
2
+ import { error, success } from '../utils/output.ts';
3
+
4
+ /**
5
+ * Docker Compose overlay file content for E2E testing.
6
+ * Adds migrate-seed init container that runs migrations before services start.
7
+ */
8
+ const TEST_COMPOSE_CONTENT = `# Docker Compose overlay for E2E testing
9
+ # Adds migrate-seed init container that runs migrations before services start
10
+ #
11
+ # Usage: docker compose -f docker-compose.yml -f docker-compose.test.yml --profile testing up -d --wait
12
+
13
+ services:
14
+ migrate-seed:
15
+ profiles:
16
+ - testing
17
+ build:
18
+ context: .
19
+ dockerfile: Dockerfile.worker
20
+ command:
21
+ - bun
22
+ - run
23
+ - migrate-and-seed
24
+ working_dir: /code/apps/worker
25
+ restart: "no"
26
+ depends_on:
27
+ db:
28
+ condition: service_healthy
29
+ environment:
30
+ NODE_ENV: production
31
+ DB_HOST: db
32
+ DB_PORT: 5432
33
+ DB_NAME: postgres
34
+ DB_USER: postgres
35
+ DB_PASSWORD: u7-6wAaIR.2S
36
+ DB_NO_SSL: 1
37
+ networks:
38
+ - app-network
39
+
40
+ # Override hosting-server to wait for migrations
41
+ hosting-server:
42
+ depends_on:
43
+ db:
44
+ condition: service_healthy
45
+ migrate-seed:
46
+ condition: service_completed_successfully
47
+
48
+ # Override e2e to wait for migrations
49
+ e2e:
50
+ depends_on:
51
+ db:
52
+ condition: service_healthy
53
+ hosting-server:
54
+ condition: service_healthy
55
+ migrate-seed:
56
+ condition: service_completed_successfully
57
+ volumes:
58
+ - ./e2e/tests/visual-baselines:/workspace/tests/visual-baselines
59
+ `;
60
+
61
+ const OUTPUT_FILE = 'docker-compose.test.yml';
62
+
63
+ /**
64
+ * Handle the 'scaffold test-compose' command.
65
+ * Generates a docker-compose.test.yml file for E2E testing.
66
+ *
67
+ * @returns Exit code (0 for success, 1 for error)
68
+ */
69
+ export function handleScaffoldTestCompose(): number {
70
+ // Check if file already exists
71
+ if (existsSync(OUTPUT_FILE)) {
72
+ error(`${OUTPUT_FILE} already exists`);
73
+ return 1;
74
+ }
75
+
76
+ // Write the file
77
+ writeFileSync(OUTPUT_FILE, TEST_COMPOSE_CONTENT);
78
+ success(`Created ${OUTPUT_FILE}`);
79
+ return 0;
80
+ }
81
+
82
+ /**
83
+ * Handle the 'scaffold' command.
84
+ * Routes to the appropriate subcommand handler.
85
+ *
86
+ * @param args - Command arguments (subcommand and additional args)
87
+ * @returns Exit code (0 for success, 1 for error)
88
+ */
89
+ export function handleScaffold(args: string[]): number {
90
+ const [subcommand] = args;
91
+
92
+ if (!subcommand) {
93
+ error('Error: scaffold command requires a subcommand');
94
+ console.error("Run 'af help scaffold' for more information.");
95
+ return 1;
96
+ }
97
+
98
+ if (subcommand === 'test-compose') {
99
+ return handleScaffoldTestCompose();
100
+ }
101
+
102
+ error(`Error: Unknown scaffold subcommand: ${subcommand}`);
103
+ console.error("Run 'af help scaffold' for available subcommands.");
104
+ return 1;
105
+ }
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Setup command handler.
3
+ * Copies bundled configuration files to Claude and OpenCode directories.
4
+ */
5
+
6
+ import { homedir } from 'node:os';
7
+ import { render } from '../utils/ink-render.tsx';
8
+ import { FileConflict } from '../components/file-conflict.tsx';
9
+ import {
10
+ performSetup,
11
+ listSetupFiles,
12
+ getTargetDir,
13
+ getOpenCodeDir,
14
+ getSetupFileCount,
15
+ getOpenCodeCommandCount,
16
+ type ConflictResolution,
17
+ } from '../utils/setup-files.ts';
18
+ import { success, error, info, header, section, listItem, warn } from '../utils/output.ts';
19
+
20
+ /**
21
+ * Prompt user for conflict resolution using interactive component.
22
+ */
23
+ function promptConflict(targetPath: string): Promise<ConflictResolution> {
24
+ return new Promise(resolve => {
25
+ const { unmount } = render(
26
+ <FileConflict
27
+ filePath={targetPath}
28
+ onResolve={resolution => {
29
+ unmount();
30
+ resolve(resolution);
31
+ }}
32
+ />,
33
+ );
34
+ });
35
+ }
36
+
37
+ /**
38
+ * Format a path for display, replacing home directory with ~.
39
+ */
40
+ function formatPath(path: string): string {
41
+ const home = homedir();
42
+ if (path.startsWith(home)) {
43
+ return path.replace(home, '~');
44
+ }
45
+ return path;
46
+ }
47
+
48
+ /**
49
+ * Handle the 'setup' command.
50
+ * Copies configuration files to ~/.claude/ and ~/.config/opencode/.
51
+ *
52
+ * Options:
53
+ * --list, -l List files that would be copied without copying
54
+ * --force, -f Overwrite all existing files without prompting
55
+ *
56
+ * @param args - Command arguments
57
+ * @returns Exit code (0 for success, 1 for error)
58
+ */
59
+ export async function handleSetup(args: string[]): Promise<number> {
60
+ // Parse flags
61
+ const forceFlag = args.includes('--force') || args.includes('-f');
62
+ const listFlag = args.includes('--list') || args.includes('-l');
63
+
64
+ const claudeDir = `${formatPath(getTargetDir())}/.claude`;
65
+ const openCodeDir = formatPath(getOpenCodeDir());
66
+
67
+ // List mode - show what would be copied
68
+ if (listFlag) {
69
+ header('Files to copy');
70
+ console.log();
71
+ info('Targets:');
72
+ listItem(`Claude: ${claudeDir} (commands + skills)`);
73
+ listItem(`OpenCode: ${openCodeDir} (commands only, skills shared)`);
74
+ console.log();
75
+
76
+ const files = listSetupFiles();
77
+
78
+ section('Claude files');
79
+ for (const { file, exists } of files) {
80
+ const status = exists ? ' (exists)' : '';
81
+ listItem(`${file.relativePath}${status}`);
82
+ }
83
+
84
+ // Show OpenCode command files
85
+ const commandFiles = files.filter(f => f.openCodePath);
86
+ if (commandFiles.length > 0) {
87
+ console.log();
88
+ section('OpenCode command files');
89
+ for (const { openCodePath, openCodeExists } of commandFiles) {
90
+ const status = openCodeExists ? ' (exists)' : '';
91
+ listItem(`${formatPath(openCodePath!)}${status}`);
92
+ }
93
+ }
94
+
95
+ const existingCount = files.filter(f => f.exists).length;
96
+ const openCodeExisting = commandFiles.filter(f => f.openCodeExists).length;
97
+ console.log();
98
+ info(
99
+ `${files.length} Claude files (${existingCount} exist), ` +
100
+ `${commandFiles.length} OpenCode files (${openCodeExisting} exist)`,
101
+ );
102
+
103
+ return 0;
104
+ }
105
+
106
+ // Perform setup
107
+ header('Setting up AI agent configurations');
108
+ console.log();
109
+ info('Targets:');
110
+ listItem(`Claude: ${claudeDir}`);
111
+ listItem(`OpenCode: ${openCodeDir} (commands only)`);
112
+ console.log();
113
+ info(`Files: ${getSetupFileCount()} Claude + ${getOpenCodeCommandCount()} OpenCode`);
114
+ console.log();
115
+
116
+ const result = await performSetup(async targetPath => {
117
+ if (forceFlag) {
118
+ return 'overwrite';
119
+ }
120
+ return promptConflict(targetPath);
121
+ });
122
+
123
+ // Report results
124
+ if (result.copied.length > 0) {
125
+ section('Copied files');
126
+ for (const path of result.copied) {
127
+ listItem(formatPath(path), '+');
128
+ }
129
+ }
130
+
131
+ if (result.skipped.length > 0) {
132
+ section('Skipped files');
133
+ for (const path of result.skipped) {
134
+ listItem(formatPath(path), '-');
135
+ }
136
+ }
137
+
138
+ if (result.errors.length > 0) {
139
+ section('Errors');
140
+ for (const { path, error: err } of result.errors) {
141
+ error(` ${path}: ${err}`);
142
+ }
143
+ return 1;
144
+ }
145
+
146
+ console.log();
147
+ if (result.copied.length > 0) {
148
+ success(`Setup complete! ${result.copied.length} files copied.`);
149
+ } else if (result.skipped.length > 0) {
150
+ warn(`Setup complete. All ${result.skipped.length} files were skipped.`);
151
+ } else {
152
+ info('Nothing to do.');
153
+ }
154
+
155
+ return 0;
156
+ }
@@ -0,0 +1,405 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { getAgentCommand } from '../utils/claude.ts';
3
+ import {
4
+ hasChangesToCommit,
5
+ stageAllAndCommit,
6
+ stageAllAndCommitWithTrailers,
7
+ stageAndCommit,
8
+ stageDirectory,
9
+ } from '../utils/git.ts';
10
+ import { listOngoingChanges } from '../utils/openspec.ts';
11
+ import { error, info, success, warn } from '../utils/output.ts';
12
+ import { extractProposalTitle, getLatestChangeId } from '../utils/proposal.ts';
13
+
14
+ /**
15
+ * Builds command arguments for the agent, conditionally adding Claude-specific flags.
16
+ *
17
+ * @param slashCommand - The OpenSpec slash command to execute
18
+ * @returns Array of command arguments
19
+ */
20
+ function buildAgentArgs(slashCommand: string): string[] {
21
+ const agentCommand = getAgentCommand();
22
+
23
+ if (agentCommand === 'claude') {
24
+ return ['--permission-mode', 'acceptEdits', slashCommand];
25
+ }
26
+
27
+ if (agentCommand === 'copilot') {
28
+ return ['--allow-all-tools', '-p', slashCommand];
29
+ }
30
+
31
+ return [slashCommand];
32
+ }
33
+
34
+ /**
35
+ * Invoke Claude with the archive command for a specific spec-id.
36
+ *
37
+ * @param specId - The spec ID to archive
38
+ * @returns Exit code (0 for success, 1 for error)
39
+ */
40
+ function invokeArchive(specId: string): Promise<number> {
41
+ // Extract the title from the proposal before archiving
42
+ const specDir = `openspec/changes/${specId}`;
43
+ const proposalPath = `${specDir}/proposal.md`;
44
+ const title = extractProposalTitle(proposalPath);
45
+
46
+ if (!title) {
47
+ warn('Warning: Could not extract proposal title for auto-commit');
48
+ return Promise.resolve(1);
49
+ }
50
+
51
+ const slashCommand = `/openspec:archive ${specId}`;
52
+ const claudeArgs = buildAgentArgs(slashCommand);
53
+ const claudeProcess = spawn(getAgentCommand(), claudeArgs, {
54
+ stdio: 'inherit',
55
+ });
56
+
57
+ return new Promise(resolve => {
58
+ claudeProcess.on('close', code => {
59
+ // If Claude process failed, return the error code
60
+ if (code !== 0) {
61
+ resolve(code ?? 1);
62
+ return;
63
+ }
64
+
65
+ const commitMessage = `Archive: ${title}`;
66
+ stageDirectory(`openspec/specs`);
67
+ stageDirectory(`openspec/changes/archive`);
68
+ const result = stageAndCommit(specDir, commitMessage);
69
+
70
+ if (!result.success) {
71
+ warn(`Warning: Failed to auto-commit archive: ${result.error}`);
72
+ warn('Archive completed but not committed. Please commit manually.');
73
+ resolve(0);
74
+ return;
75
+ }
76
+
77
+ success(`Archive committed: ${commitMessage}`);
78
+ resolve(0);
79
+ });
80
+
81
+ claudeProcess.on('error', err => {
82
+ error(`Error executing claude command: ${err.message}`);
83
+ resolve(1);
84
+ });
85
+ });
86
+ }
87
+
88
+ /**
89
+ * Handle the 'spec archive [spec-id]' command.
90
+ * Archives a spec by invoking Claude Code with the openspec:archive command.
91
+ * After successful archival, automatically commits the archived spec files.
92
+ *
93
+ * When spec-id is omitted:
94
+ * - 0 changes: Show error message
95
+ * - 1 change: Auto-select it
96
+ * - Multiple changes: Show interactive selection menu
97
+ *
98
+ * @param specId - Optional spec ID to archive
99
+ * @returns Exit code (0 for success, 1 for error)
100
+ */
101
+ export async function handleSpecArchive(specId: string | undefined): Promise<number> {
102
+ // If specId is provided, invoke directly
103
+ if (specId) {
104
+ return invokeArchive(specId);
105
+ }
106
+
107
+ // No specId provided - check how many ongoing changes exist
108
+ const changes = listOngoingChanges();
109
+
110
+ if (changes.length === 0) {
111
+ error('No ongoing changes found');
112
+ return 1;
113
+ }
114
+
115
+ if (changes.length === 1) {
116
+ const selectedChange = changes[0];
117
+ info(`Auto-selected change: ${selectedChange.id}`);
118
+ return invokeArchive(selectedChange.id);
119
+ }
120
+
121
+ // Multiple changes - show interactive selection (uses dynamic import for .tsx)
122
+ const { renderChangeSelect } = await import('../utils/change-select-render.tsx');
123
+ const selectedId = await renderChangeSelect(changes, 'Select a change to archive:');
124
+
125
+ if (!selectedId) {
126
+ // User cancelled selection
127
+ return 0;
128
+ }
129
+
130
+ return invokeArchive(selectedId);
131
+ }
132
+
133
+ /**
134
+ * Invoke Claude with the apply command for a specific change-id.
135
+ *
136
+ * @param changeId - The change ID to apply
137
+ * @returns Exit code (0 for success, 1 for error)
138
+ */
139
+ function invokeApply(changeId: string): Promise<number> {
140
+ const slashCommand = `/openspec:apply ${changeId}`;
141
+ const claudeArgs = buildAgentArgs(slashCommand);
142
+ const claudeProcess = spawn(getAgentCommand(), claudeArgs, {
143
+ stdio: 'inherit',
144
+ });
145
+
146
+ return new Promise(resolve => {
147
+ claudeProcess.on('close', code => {
148
+ resolve(code ?? 1);
149
+ });
150
+
151
+ claudeProcess.on('error', err => {
152
+ error(`Error executing claude command: ${err.message}`);
153
+ resolve(1);
154
+ });
155
+ });
156
+ }
157
+
158
+ /**
159
+ * Handle the 'spec apply [change-id]' command.
160
+ * Applies an approved OpenSpec change by invoking Claude Code with the openspec:apply command.
161
+ *
162
+ * When change-id is omitted:
163
+ * - 0 changes: Show error message
164
+ * - 1 change: Auto-select it
165
+ * - Multiple changes: Show interactive selection menu
166
+ *
167
+ * @param changeId - Optional change ID to apply
168
+ * @returns Exit code (0 for success, 1 for error)
169
+ */
170
+ export async function handleSpecApply(changeId: string | undefined): Promise<number> {
171
+ // If changeId is provided, invoke directly
172
+ if (changeId) {
173
+ return invokeApply(changeId);
174
+ }
175
+
176
+ // No changeId provided - check how many ongoing changes exist
177
+ const changes = listOngoingChanges();
178
+
179
+ if (changes.length === 0) {
180
+ error('No ongoing changes found');
181
+ return 1;
182
+ }
183
+
184
+ if (changes.length === 1) {
185
+ const selectedChange = changes[0];
186
+ info(`Auto-selected change: ${selectedChange.id}`);
187
+ return invokeApply(selectedChange.id);
188
+ }
189
+
190
+ // Multiple changes - show interactive selection (uses dynamic import for .tsx)
191
+ const { renderChangeSelect } = await import('../utils/change-select-render.tsx');
192
+ const selectedId = await renderChangeSelect(changes, 'Select a change to apply:');
193
+
194
+ if (!selectedId) {
195
+ // User cancelled selection
196
+ return 0;
197
+ }
198
+
199
+ return invokeApply(selectedId);
200
+ }
201
+
202
+ /**
203
+ * Handle the 'spec propose <text>' command.
204
+ * Creates a new spec proposal by invoking Claude Code with the openspec:proposal command.
205
+ * After successful proposal creation, automatically commits the proposal files.
206
+ *
207
+ * @param proposalText - The proposal text
208
+ * @returns Exit code (0 for success, 1 for error)
209
+ */
210
+ export async function handleSpecPropose(proposalText: string): Promise<number> {
211
+ // Validate that proposalText is provided
212
+ if (!proposalText || proposalText.trim() === '') {
213
+ error('Error: spec propose requires proposal text');
214
+ console.error('Usage: af spec propose <proposal-text>');
215
+ return 1;
216
+ }
217
+
218
+ // Build and execute the claude command
219
+ const claudeArgs = buildAgentArgs(`/openspec:proposal ${proposalText}`);
220
+ const claudeProcess = spawn(getAgentCommand(), claudeArgs, {
221
+ stdio: 'inherit', // Pipe stdout, stderr, and stdin to parent process
222
+ });
223
+
224
+ // Wait for the process to complete and return its status code
225
+ return new Promise(resolve => {
226
+ claudeProcess.on('close', code => {
227
+ // If Claude process failed, return the error code
228
+ if (code !== 0) {
229
+ resolve(code ?? 1);
230
+ return;
231
+ }
232
+
233
+ // Proposal created successfully, now auto-commit
234
+ const changeId = getLatestChangeId();
235
+ if (!changeId) {
236
+ warn('Warning: Could not determine change ID for auto-commit');
237
+ warn('Proposal created but not committed. Please commit manually.');
238
+ resolve(0);
239
+ return;
240
+ }
241
+
242
+ const proposalPath = `openspec/changes/${changeId}/proposal.md`;
243
+ const title = extractProposalTitle(proposalPath);
244
+ if (!title) {
245
+ warn('Warning: Could not extract proposal title for auto-commit');
246
+ warn('Proposal created but not committed. Please commit manually.');
247
+ resolve(0);
248
+ return;
249
+ }
250
+
251
+ const commitMessage = `Propose: ${title}`;
252
+ const changeDir = `openspec/changes/${changeId}`;
253
+ const result = stageAndCommit(changeDir, commitMessage);
254
+
255
+ if (!result.success) {
256
+ warn(`Warning: Failed to auto-commit proposal: ${result.error}`);
257
+ warn('Proposal created but not committed. Please commit manually.');
258
+ resolve(0);
259
+ return;
260
+ }
261
+
262
+ success(`Proposal committed: ${commitMessage}`);
263
+ resolve(0);
264
+ });
265
+
266
+ claudeProcess.on('error', err => {
267
+ error(`Error executing claude command: ${err.message}`);
268
+ resolve(1);
269
+ });
270
+ });
271
+ }
272
+
273
+ /**
274
+ * Commit all staged/unstaged changes with a message referencing the change title.
275
+ *
276
+ * @param changeId - The change ID to reference
277
+ * @returns Exit code (0 for success, 1 for error)
278
+ */
279
+ function commitForChange(changeId: string): number {
280
+ const proposalPath = `openspec/changes/${changeId}/proposal.md`;
281
+ const title = extractProposalTitle(proposalPath);
282
+
283
+ if (!title) {
284
+ error('Error: Could not extract proposal title');
285
+ return 1;
286
+ }
287
+
288
+ const commitMessage = `Apply: ${title}`;
289
+ const result = stageAllAndCommit(commitMessage);
290
+
291
+ if (!result.success) {
292
+ error(`Error: ${result.error}`);
293
+ return 1;
294
+ }
295
+
296
+ success(`Committed: ${commitMessage}`);
297
+ return 0;
298
+ }
299
+
300
+ /**
301
+ * Handle the 'commit apply [change-id]' command.
302
+ * Commits all staged/unstaged changes with the message format: `Apply: <Change Title>`.
303
+ *
304
+ * When change-id is omitted:
305
+ * - 0 changes: Show error message
306
+ * - 1 change: Auto-select it
307
+ * - Multiple changes: Show interactive selection menu
308
+ *
309
+ * @param changeId - Optional change ID to commit for
310
+ * @returns Exit code (0 for success, 1 for error)
311
+ */
312
+ export async function handleCommitApply(changeId: string | undefined): Promise<number> {
313
+ // If changeId is provided, commit directly
314
+ if (changeId) {
315
+ return commitForChange(changeId);
316
+ }
317
+
318
+ // No changeId provided - check how many ongoing changes exist
319
+ const changes = listOngoingChanges();
320
+
321
+ if (changes.length === 0) {
322
+ error('No ongoing changes found');
323
+ return 1;
324
+ }
325
+
326
+ if (changes.length === 1) {
327
+ const selectedChange = changes[0];
328
+ info(`Auto-selected change: ${selectedChange.id}`);
329
+ return commitForChange(selectedChange.id);
330
+ }
331
+
332
+ // Multiple changes - show interactive selection (uses dynamic import for .tsx)
333
+ const { renderChangeSelect } = await import('../utils/change-select-render.tsx');
334
+ const selectedId = await renderChangeSelect(changes, 'Select a change to commit for:');
335
+
336
+ if (!selectedId) {
337
+ // User cancelled selection
338
+ return 0;
339
+ }
340
+
341
+ return commitForChange(selectedId);
342
+ }
343
+
344
+ /**
345
+ * Parse a Key=Value argument into a trailer object.
346
+ *
347
+ * @param arg - The argument string in Key=Value format
348
+ * @returns Trailer object or null if not a valid Key=Value format
349
+ */
350
+ function parseTrailer(arg: string): { key: string; value: string } | null {
351
+ const eqIndex = arg.indexOf('=');
352
+ if (eqIndex === -1) {
353
+ return null;
354
+ }
355
+ const key = arg.substring(0, eqIndex);
356
+ const value = arg.substring(eqIndex + 1);
357
+ if (!key || !value) {
358
+ return null;
359
+ }
360
+ return { key, value };
361
+ }
362
+
363
+ /**
364
+ * Handle the 'commit save "<message>" [Key=Value...]' command.
365
+ * Stages all changes and commits with the provided message.
366
+ * Additional arguments in Key=Value format are added as git trailers.
367
+ *
368
+ * @param message - The commit message
369
+ * @param trailerArgs - Additional arguments that may be Key=Value trailers
370
+ * @returns Exit code (0 for success, 1 for error)
371
+ */
372
+ export function handleCommitSave(message: string | undefined, trailerArgs: string[]): number {
373
+ // Validate message is provided
374
+ if (!message || message.trim() === '') {
375
+ error('Error: commit save requires a message');
376
+ console.error('Usage: af commit save "<message>" [Key=Value...]');
377
+ return 1;
378
+ }
379
+
380
+ // Check if there are changes to commit
381
+ if (!hasChangesToCommit()) {
382
+ info('Nothing to commit');
383
+ return 0;
384
+ }
385
+
386
+ // Parse trailers from remaining arguments
387
+ const trailers: Array<{ key: string; value: string }> = [];
388
+ for (const arg of trailerArgs) {
389
+ const trailer = parseTrailer(arg);
390
+ if (trailer) {
391
+ trailers.push(trailer);
392
+ }
393
+ }
394
+
395
+ // Commit with trailers
396
+ const result = stageAllAndCommitWithTrailers(message, trailers);
397
+
398
+ if (!result.success) {
399
+ error(`Error: ${result.error}`);
400
+ return 1;
401
+ }
402
+
403
+ success(`Committed: ${message}`);
404
+ return 0;
405
+ }