@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.
- package/LICENSE +21 -0
- package/README.md +539 -0
- package/af +2 -0
- package/bun-upgrade.ts +130 -0
- package/commands/bun.ts +55 -0
- package/commands/changes.ts +35 -0
- package/commands/e2e.ts +12 -0
- package/commands/help.ts +236 -0
- package/commands/install-extension.ts +133 -0
- package/commands/jira.ts +577 -0
- package/commands/licenses.ts +32 -0
- package/commands/npm.ts +55 -0
- package/commands/scaffold.ts +105 -0
- package/commands/setup.tsx +156 -0
- package/commands/spec.ts +405 -0
- package/commands/stop-hook.ts +90 -0
- package/commands/todo.ts +208 -0
- package/commands/versions.ts +150 -0
- package/commands/watch.ts +344 -0
- package/commands/worktree.ts +424 -0
- package/components/change-select.tsx +71 -0
- package/components/confirm.tsx +41 -0
- package/components/file-conflict.tsx +52 -0
- package/components/input.tsx +53 -0
- package/components/layout.tsx +70 -0
- package/components/messages.tsx +48 -0
- package/components/progress.tsx +71 -0
- package/components/select.tsx +90 -0
- package/components/status-display.tsx +74 -0
- package/components/table.tsx +79 -0
- package/generated/setup-manifest.ts +67 -0
- package/git-worktree.ts +184 -0
- package/main.ts +12 -0
- package/npm-upgrade.ts +117 -0
- package/package.json +83 -0
- package/resources/copy-prompt-reporter.ts +443 -0
- package/router.ts +220 -0
- package/setup/.claude/commands/commit-work.md +47 -0
- package/setup/.claude/commands/complete-work.md +34 -0
- package/setup/.claude/commands/e2e.md +29 -0
- package/setup/.claude/commands/start-work.md +51 -0
- package/setup/.claude/skills/pm/SKILL.md +294 -0
- package/setup/.claude/skills/pm/templates/api-endpoint.md +69 -0
- package/setup/.claude/skills/pm/templates/bug-fix.md +77 -0
- package/setup/.claude/skills/pm/templates/feature.md +87 -0
- package/setup/.claude/skills/pm/templates/ui-component.md +78 -0
- package/utils/change-select-render.tsx +44 -0
- package/utils/claude.ts +9 -0
- package/utils/config.ts +58 -0
- package/utils/env.ts +53 -0
- package/utils/git.ts +120 -0
- package/utils/ink-render.tsx +50 -0
- package/utils/openspec.ts +54 -0
- package/utils/output.ts +104 -0
- package/utils/proposal.ts +160 -0
- package/utils/resources.ts +64 -0
- 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
|
+
}
|
package/commands/spec.ts
ADDED
|
@@ -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
|
+
}
|