@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,54 @@
|
|
|
1
|
+
import { execSync } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Represents an ongoing change from OpenSpec
|
|
5
|
+
*/
|
|
6
|
+
export interface OngoingChange {
|
|
7
|
+
/** The change ID (e.g., "add-user-auth") */
|
|
8
|
+
id: string;
|
|
9
|
+
/** The task progress status (e.g., "3/8 tasks" or "✓ Complete") */
|
|
10
|
+
status: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Parse the output of `openspec list --changes` to extract ongoing changes.
|
|
15
|
+
*
|
|
16
|
+
* @param output - The raw output from `openspec list --changes`
|
|
17
|
+
* @returns Array of ongoing changes with their IDs and statuses
|
|
18
|
+
*/
|
|
19
|
+
export function parseOpenspecListOutput(output: string): OngoingChange[] {
|
|
20
|
+
const changes: OngoingChange[] = [];
|
|
21
|
+
const lines = output.split('\n');
|
|
22
|
+
|
|
23
|
+
for (const line of lines) {
|
|
24
|
+
const trimmed = line.trim();
|
|
25
|
+
// Skip empty lines and the "Changes:" header
|
|
26
|
+
if (!trimmed || trimmed === 'Changes:') continue;
|
|
27
|
+
|
|
28
|
+
// Parse lines like " add-user-auth 3/8 tasks" or " my-change ✓ Complete"
|
|
29
|
+
// The ID is the first word, status is everything after
|
|
30
|
+
const match = trimmed.match(/^(\S+)\s+(.+)$/);
|
|
31
|
+
if (match) {
|
|
32
|
+
changes.push({
|
|
33
|
+
id: match[1],
|
|
34
|
+
status: match[2].trim(),
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return changes;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* List all ongoing changes from OpenSpec.
|
|
44
|
+
*
|
|
45
|
+
* @returns Array of ongoing changes, or empty array if command fails
|
|
46
|
+
*/
|
|
47
|
+
export function listOngoingChanges(): OngoingChange[] {
|
|
48
|
+
try {
|
|
49
|
+
const output = execSync('openspec list --changes', { encoding: 'utf-8' });
|
|
50
|
+
return parseOpenspecListOutput(output);
|
|
51
|
+
} catch {
|
|
52
|
+
return [];
|
|
53
|
+
}
|
|
54
|
+
}
|
package/utils/output.ts
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal output utilities with Ink-based components for consistent CLI formatting.
|
|
3
|
+
* Provides backward-compatible wrapper functions that use chalk (from Ink's dependency).
|
|
4
|
+
*
|
|
5
|
+
* For interactive UI, import and use Ink components directly via render().
|
|
6
|
+
* These functions are optimized for static, one-off messages.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import chalk from 'chalk';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* ANSI color codes for terminal output.
|
|
13
|
+
* Kept for backward compatibility but deprecated in favor of chalk.
|
|
14
|
+
* @deprecated Use chalk directly or Ink components for better control
|
|
15
|
+
*/
|
|
16
|
+
export const colors = {
|
|
17
|
+
reset: '\x1b[0m',
|
|
18
|
+
red: '\x1b[31m',
|
|
19
|
+
green: '\x1b[32m',
|
|
20
|
+
yellow: '\x1b[33m',
|
|
21
|
+
blue: '\x1b[34m',
|
|
22
|
+
cyan: '\x1b[36m',
|
|
23
|
+
gray: '\x1b[90m',
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Display a success message in green.
|
|
28
|
+
* @param message - The success message to display
|
|
29
|
+
*/
|
|
30
|
+
export function success(message: string): void {
|
|
31
|
+
console.log(chalk.green(message));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Display an error message in red.
|
|
36
|
+
* @param message - The error message to display
|
|
37
|
+
*/
|
|
38
|
+
export function error(message: string): void {
|
|
39
|
+
console.error(chalk.red(message));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Display an informational message in cyan.
|
|
44
|
+
* @param message - The info message to display
|
|
45
|
+
*/
|
|
46
|
+
export function info(message: string): void {
|
|
47
|
+
console.log(chalk.cyan(message));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Display a warning message in yellow.
|
|
52
|
+
* @param message - The warning message to display
|
|
53
|
+
*/
|
|
54
|
+
export function warn(message: string): void {
|
|
55
|
+
console.log(chalk.yellow(message));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Display a header message in blue with emphasis.
|
|
60
|
+
* Useful for section titles or major status updates.
|
|
61
|
+
* @param message - The header text to display
|
|
62
|
+
*/
|
|
63
|
+
export function header(message: string): void {
|
|
64
|
+
console.log();
|
|
65
|
+
console.log(chalk.blue(message));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Display a section message in bold (using color for emphasis).
|
|
70
|
+
* Useful for sub-sections or grouped content.
|
|
71
|
+
* @param message - The section text to display
|
|
72
|
+
*/
|
|
73
|
+
export function section(message: string): void {
|
|
74
|
+
console.log();
|
|
75
|
+
console.log(chalk.cyan(message));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Display a list item with an optional symbol prefix.
|
|
80
|
+
* @param message - The list item text to display
|
|
81
|
+
* @param symbol - Optional symbol to prefix (default: '•')
|
|
82
|
+
*/
|
|
83
|
+
export function listItem(message: string, symbol: string = '•'): void {
|
|
84
|
+
console.log(` ${chalk.gray(symbol)} ${message}`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Create a clickable hyperlink using OSC 8 escape sequences.
|
|
89
|
+
* In terminals that support OSC 8 (iTerm2, GNOME Terminal, Windows Terminal, etc.),
|
|
90
|
+
* the text will be displayed as a clickable link. In unsupported terminals,
|
|
91
|
+
* the text is displayed without the URL (graceful degradation).
|
|
92
|
+
*
|
|
93
|
+
* @param text - The visible text to display
|
|
94
|
+
* @param url - The URL to open when clicked
|
|
95
|
+
* @returns A string with OSC 8 escape sequences
|
|
96
|
+
*/
|
|
97
|
+
export function link(text: string, url: string): string {
|
|
98
|
+
// OSC 8 format: \x1b]8;;URL\x07TEXT\x1b]8;;\x07
|
|
99
|
+
// - \x1b]8;; starts the hyperlink with URL
|
|
100
|
+
// - \x07 (BEL) terminates the URL
|
|
101
|
+
// - TEXT is displayed
|
|
102
|
+
// - \x1b]8;;\x07 closes the hyperlink
|
|
103
|
+
return `\x1b]8;;${url}\x07${text}\x1b]8;;\x07`;
|
|
104
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { error as logError } from './output.ts';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Extract the proposal title from the first line of a proposal.md file.
|
|
7
|
+
* Strips leading '#', whitespace, and optional "Proposal: " prefix.
|
|
8
|
+
*
|
|
9
|
+
* @param proposalPath - Path to the proposal.md file
|
|
10
|
+
* @returns The extracted title, or null if extraction fails
|
|
11
|
+
*/
|
|
12
|
+
export function extractProposalTitle(proposalPath: string): string | null {
|
|
13
|
+
try {
|
|
14
|
+
const content = readFileSync(proposalPath, 'utf-8');
|
|
15
|
+
const firstLine = content.split('\n')[0];
|
|
16
|
+
|
|
17
|
+
if (!firstLine) {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Remove leading '#' and whitespace
|
|
22
|
+
let title = firstLine.replace(/^#+\s*/, '');
|
|
23
|
+
|
|
24
|
+
// Remove "Proposal: " prefix if present (case-insensitive)
|
|
25
|
+
title = title.replace(/^Proposal:\s*/i, '');
|
|
26
|
+
|
|
27
|
+
// Trim any remaining whitespace
|
|
28
|
+
title = title.trim();
|
|
29
|
+
|
|
30
|
+
return title || null;
|
|
31
|
+
} catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Get the most recently created change directory in openspec/changes.
|
|
38
|
+
* This is useful for determining which proposal was just created.
|
|
39
|
+
*
|
|
40
|
+
* @param changesDir - Optional path to changes directory (defaults to 'openspec/changes')
|
|
41
|
+
* @returns The change ID of the most recent change, or null if none found
|
|
42
|
+
*/
|
|
43
|
+
export function getLatestChangeId(changesDir: string = 'openspec/changes'): string | null {
|
|
44
|
+
try {
|
|
45
|
+
const entries = readdirSync(changesDir);
|
|
46
|
+
|
|
47
|
+
let latestTime = 0;
|
|
48
|
+
let latestId: string | null = null;
|
|
49
|
+
|
|
50
|
+
for (const entry of entries) {
|
|
51
|
+
if (entry === 'archive') {
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const fullPath = join(changesDir, entry);
|
|
56
|
+
const stats = statSync(fullPath);
|
|
57
|
+
|
|
58
|
+
if (stats.isDirectory() && stats.mtimeMs > latestTime) {
|
|
59
|
+
latestTime = stats.mtimeMs;
|
|
60
|
+
latestId = entry;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return latestId;
|
|
65
|
+
} catch (error) {
|
|
66
|
+
logError(`Error getting latest change ID: ${error}`);
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Represents an active change with its title and task progress.
|
|
73
|
+
*/
|
|
74
|
+
export interface ActiveChange {
|
|
75
|
+
id: string;
|
|
76
|
+
title: string | null;
|
|
77
|
+
completedTasks: number;
|
|
78
|
+
totalTasks: number;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Parse a tasks.md file and count completed and total tasks.
|
|
83
|
+
* Tasks are markdown checkbox lines: `- [ ]` (uncompleted) or `- [x]` (completed).
|
|
84
|
+
*
|
|
85
|
+
* @param tasksPath - Path to the tasks.md file
|
|
86
|
+
* @returns Object with completed and total task counts
|
|
87
|
+
*/
|
|
88
|
+
function countTasks(tasksPath: string): { completed: number; total: number } {
|
|
89
|
+
try {
|
|
90
|
+
const content = readFileSync(tasksPath, 'utf-8');
|
|
91
|
+
const lines = content.split('\n');
|
|
92
|
+
|
|
93
|
+
let completed = 0;
|
|
94
|
+
let total = 0;
|
|
95
|
+
|
|
96
|
+
for (const line of lines) {
|
|
97
|
+
const trimmed = line.trim();
|
|
98
|
+
if (trimmed.startsWith('- [x]') || trimmed.startsWith('- [X]')) {
|
|
99
|
+
completed++;
|
|
100
|
+
total++;
|
|
101
|
+
} else if (trimmed.startsWith('- [ ]')) {
|
|
102
|
+
total++;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return { completed, total };
|
|
107
|
+
} catch {
|
|
108
|
+
return { completed: 0, total: 0 };
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Get all active changes with their titles and task progress.
|
|
114
|
+
*
|
|
115
|
+
* @param changesDir - Optional path to changes directory (defaults to 'openspec/changes')
|
|
116
|
+
* @returns Array of active changes sorted alphabetically by ID
|
|
117
|
+
*/
|
|
118
|
+
export function getActiveChanges(changesDir: string = 'openspec/changes'): ActiveChange[] {
|
|
119
|
+
try {
|
|
120
|
+
if (!existsSync(changesDir)) {
|
|
121
|
+
return [];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const entries = readdirSync(changesDir);
|
|
125
|
+
const changes: ActiveChange[] = [];
|
|
126
|
+
|
|
127
|
+
for (const entry of entries) {
|
|
128
|
+
if (entry === 'archive') {
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const fullPath = join(changesDir, entry);
|
|
133
|
+
const stats = statSync(fullPath);
|
|
134
|
+
|
|
135
|
+
if (!stats.isDirectory()) {
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const proposalPath = join(fullPath, 'proposal.md');
|
|
140
|
+
const tasksPath = join(fullPath, 'tasks.md');
|
|
141
|
+
|
|
142
|
+
const title = extractProposalTitle(proposalPath);
|
|
143
|
+
const { completed, total } = countTasks(tasksPath);
|
|
144
|
+
|
|
145
|
+
changes.push({
|
|
146
|
+
id: entry,
|
|
147
|
+
title,
|
|
148
|
+
completedTasks: completed,
|
|
149
|
+
totalTasks: total,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Sort alphabetically by ID
|
|
154
|
+
changes.sort((a, b) => a.id.localeCompare(b.id));
|
|
155
|
+
|
|
156
|
+
return changes;
|
|
157
|
+
} catch {
|
|
158
|
+
return [];
|
|
159
|
+
}
|
|
160
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resource file utilities for extracting bundled resource files.
|
|
3
|
+
* Handles both development mode (files on disk) and compiled mode (embedded in binary).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { mkdirSync, writeFileSync, copyFileSync } from 'node:fs';
|
|
7
|
+
import { dirname, join } from 'node:path';
|
|
8
|
+
import { RESOURCE_FILES, isCompiled, type ResourceFile } from '../generated/setup-manifest.ts';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Get the project root directory where resources/ folder is located.
|
|
12
|
+
* In development mode, this is relative to this source file.
|
|
13
|
+
* In compiled mode, files are embedded and this isn't used.
|
|
14
|
+
*/
|
|
15
|
+
function getProjectRoot(): string {
|
|
16
|
+
// import.meta.dirname gives us the directory of this file (utils/)
|
|
17
|
+
// We need to go up one level to get to the project root
|
|
18
|
+
return dirname(import.meta.dirname);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Get a resource file entry by name.
|
|
23
|
+
*/
|
|
24
|
+
export function getResource(name: string): ResourceFile | undefined {
|
|
25
|
+
return RESOURCE_FILES.find(r => r.name === name);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Extract a resource file to a target path.
|
|
30
|
+
* Works in both development mode (copies from disk) and compiled mode (extracts from binary).
|
|
31
|
+
*
|
|
32
|
+
* @param name - The resource file name (e.g., "copy-prompt-reporter.ts")
|
|
33
|
+
* @param targetPath - The absolute path to write the file to
|
|
34
|
+
* @throws Error if the resource is not found
|
|
35
|
+
*/
|
|
36
|
+
export async function extractResource(name: string, targetPath: string): Promise<void> {
|
|
37
|
+
const resource = getResource(name);
|
|
38
|
+
if (!resource) {
|
|
39
|
+
throw new Error(`Resource not found: ${name}`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Ensure target directory exists
|
|
43
|
+
mkdirSync(dirname(targetPath), { recursive: true });
|
|
44
|
+
|
|
45
|
+
if (isCompiled()) {
|
|
46
|
+
// In compiled mode, use Bun.file() to read from $bunfs virtual filesystem
|
|
47
|
+
const content = await Bun.file(resource.embeddedPath).bytes();
|
|
48
|
+
writeFileSync(targetPath, content);
|
|
49
|
+
} else {
|
|
50
|
+
// In development mode, copy from source directory relative to project root
|
|
51
|
+
const sourcePath = join(getProjectRoot(), 'resources', resource.name);
|
|
52
|
+
copyFileSync(sourcePath, targetPath);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* List all available resource files.
|
|
58
|
+
*/
|
|
59
|
+
export function listResources(): string[] {
|
|
60
|
+
return RESOURCE_FILES.map(r => r.name);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Re-export for convenience
|
|
64
|
+
export { isCompiled };
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Setup file utilities for copying bundled configuration files.
|
|
3
|
+
* Handles both development mode (files on disk) and compiled mode (embedded in binary).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { existsSync, mkdirSync, writeFileSync, copyFileSync } from 'node:fs';
|
|
7
|
+
import { dirname, join } from 'node:path';
|
|
8
|
+
import { homedir } from 'node:os';
|
|
9
|
+
import { SETUP_FILES, isCompiled, type SetupFile } from '../generated/setup-manifest.ts';
|
|
10
|
+
|
|
11
|
+
export type ConflictResolution = 'overwrite' | 'skip' | 'overwrite-all' | 'skip-all';
|
|
12
|
+
|
|
13
|
+
export interface SetupResult {
|
|
14
|
+
copied: string[];
|
|
15
|
+
skipped: string[];
|
|
16
|
+
errors: Array<{ path: string; error: string }>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface FileInfo {
|
|
20
|
+
file: SetupFile;
|
|
21
|
+
targetPath: string;
|
|
22
|
+
exists: boolean;
|
|
23
|
+
/** OpenCode target path for command files (null for skills) */
|
|
24
|
+
openCodePath?: string;
|
|
25
|
+
openCodeExists?: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Get the Claude target directory (user's home directory).
|
|
30
|
+
*/
|
|
31
|
+
export function getTargetDir(): string {
|
|
32
|
+
return homedir();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Get the OpenCode config directory.
|
|
37
|
+
*/
|
|
38
|
+
export function getOpenCodeDir(): string {
|
|
39
|
+
return join(homedir(), '.config', 'opencode');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Check if a file is a command file (not a skill).
|
|
44
|
+
*/
|
|
45
|
+
function isCommandFile(relativePath: string): boolean {
|
|
46
|
+
return relativePath.includes('.claude/commands/');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Get OpenCode target path for a Claude command file.
|
|
51
|
+
* Returns null for non-command files (skills are shared via ~/.claude/skills/).
|
|
52
|
+
*/
|
|
53
|
+
export function getOpenCodeCommandPath(claudePath: string): string | null {
|
|
54
|
+
// Only transform command files
|
|
55
|
+
if (!claudePath.includes('/commands/')) return null;
|
|
56
|
+
|
|
57
|
+
// ~/.claude/commands/foo.md → ~/.config/opencode/command/foo.md
|
|
58
|
+
return claudePath
|
|
59
|
+
.replace(join(homedir(), '.claude'), join(homedir(), '.config', 'opencode'))
|
|
60
|
+
.replace('/commands/', '/command/');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* List all files that would be copied, with conflict info.
|
|
65
|
+
* Includes OpenCode target paths for command files.
|
|
66
|
+
*/
|
|
67
|
+
export function listSetupFiles(): FileInfo[] {
|
|
68
|
+
const targetDir = getTargetDir();
|
|
69
|
+
|
|
70
|
+
return SETUP_FILES.map(file => {
|
|
71
|
+
const targetPath = join(targetDir, file.relativePath);
|
|
72
|
+
const openCodePath = isCommandFile(file.relativePath)
|
|
73
|
+
? getOpenCodeCommandPath(targetPath)
|
|
74
|
+
: undefined;
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
file,
|
|
78
|
+
targetPath,
|
|
79
|
+
exists: existsSync(targetPath),
|
|
80
|
+
openCodePath: openCodePath ?? undefined,
|
|
81
|
+
openCodeExists: openCodePath ? existsSync(openCodePath) : undefined,
|
|
82
|
+
};
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Get the project root directory where setup/ folder is located.
|
|
88
|
+
* In development mode, this is relative to this source file.
|
|
89
|
+
* In compiled mode, files are embedded and this isn't used.
|
|
90
|
+
*/
|
|
91
|
+
function getProjectRoot(): string {
|
|
92
|
+
// import.meta.dirname gives us the directory of this file (utils/)
|
|
93
|
+
// We need to go up one level to get to the project root
|
|
94
|
+
return dirname(import.meta.dirname);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Copy a single file from embedded source to target.
|
|
99
|
+
*/
|
|
100
|
+
export async function copySetupFile(file: SetupFile, targetPath: string): Promise<void> {
|
|
101
|
+
// Ensure target directory exists
|
|
102
|
+
mkdirSync(dirname(targetPath), { recursive: true });
|
|
103
|
+
|
|
104
|
+
if (isCompiled()) {
|
|
105
|
+
// In compiled mode, use Bun.file() to read from $bunfs virtual filesystem
|
|
106
|
+
const content = await Bun.file(file.embeddedPath).bytes();
|
|
107
|
+
writeFileSync(targetPath, content);
|
|
108
|
+
} else {
|
|
109
|
+
// In development mode, copy from source directory relative to project root
|
|
110
|
+
const sourcePath = join(getProjectRoot(), 'setup', file.relativePath);
|
|
111
|
+
copyFileSync(sourcePath, targetPath);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Perform the full setup operation.
|
|
117
|
+
* Copies files to both Claude and OpenCode directories.
|
|
118
|
+
* @param resolveConflict - Callback for conflict resolution
|
|
119
|
+
*/
|
|
120
|
+
export async function performSetup(
|
|
121
|
+
resolveConflict: (targetPath: string) => Promise<ConflictResolution>,
|
|
122
|
+
): Promise<SetupResult> {
|
|
123
|
+
const result: SetupResult = {
|
|
124
|
+
copied: [],
|
|
125
|
+
skipped: [],
|
|
126
|
+
errors: [],
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
let skipAll = false;
|
|
130
|
+
let overwriteAll = false;
|
|
131
|
+
|
|
132
|
+
const files = listSetupFiles();
|
|
133
|
+
|
|
134
|
+
for (const { file, targetPath, exists, openCodePath, openCodeExists } of files) {
|
|
135
|
+
// Handle Claude target
|
|
136
|
+
try {
|
|
137
|
+
if (exists && !overwriteAll && !skipAll) {
|
|
138
|
+
const resolution = await resolveConflict(targetPath);
|
|
139
|
+
|
|
140
|
+
if (resolution === 'skip-all') {
|
|
141
|
+
skipAll = true;
|
|
142
|
+
result.skipped.push(targetPath);
|
|
143
|
+
// Also skip OpenCode path
|
|
144
|
+
if (openCodePath) {
|
|
145
|
+
result.skipped.push(openCodePath);
|
|
146
|
+
}
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (resolution === 'overwrite-all') {
|
|
150
|
+
overwriteAll = true;
|
|
151
|
+
}
|
|
152
|
+
if (resolution === 'skip') {
|
|
153
|
+
result.skipped.push(targetPath);
|
|
154
|
+
// Also skip OpenCode path
|
|
155
|
+
if (openCodePath) {
|
|
156
|
+
result.skipped.push(openCodePath);
|
|
157
|
+
}
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
} else if (exists && skipAll) {
|
|
161
|
+
result.skipped.push(targetPath);
|
|
162
|
+
if (openCodePath) {
|
|
163
|
+
result.skipped.push(openCodePath);
|
|
164
|
+
}
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
await copySetupFile(file, targetPath);
|
|
169
|
+
result.copied.push(targetPath);
|
|
170
|
+
} catch (err) {
|
|
171
|
+
result.errors.push({
|
|
172
|
+
path: targetPath,
|
|
173
|
+
error: err instanceof Error ? err.message : String(err),
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Handle OpenCode target (commands only)
|
|
178
|
+
if (openCodePath) {
|
|
179
|
+
try {
|
|
180
|
+
// Check for conflict on OpenCode path separately
|
|
181
|
+
if (openCodeExists && !overwriteAll && !skipAll) {
|
|
182
|
+
const resolution = await resolveConflict(openCodePath);
|
|
183
|
+
|
|
184
|
+
if (resolution === 'skip-all') {
|
|
185
|
+
skipAll = true;
|
|
186
|
+
result.skipped.push(openCodePath);
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
if (resolution === 'overwrite-all') {
|
|
190
|
+
overwriteAll = true;
|
|
191
|
+
}
|
|
192
|
+
if (resolution === 'skip') {
|
|
193
|
+
result.skipped.push(openCodePath);
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
} else if (openCodeExists && skipAll) {
|
|
197
|
+
result.skipped.push(openCodePath);
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
await copySetupFile(file, openCodePath);
|
|
202
|
+
result.copied.push(openCodePath);
|
|
203
|
+
} catch (err) {
|
|
204
|
+
result.errors.push({
|
|
205
|
+
path: openCodePath,
|
|
206
|
+
error: err instanceof Error ? err.message : String(err),
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return result;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Get count of setup files for display purposes.
|
|
217
|
+
*/
|
|
218
|
+
export function getSetupFileCount(): number {
|
|
219
|
+
return SETUP_FILES.length;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Re-export for convenience
|
|
223
|
+
export { isCompiled };
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Get the count of OpenCode command files for display purposes.
|
|
227
|
+
*/
|
|
228
|
+
export function getOpenCodeCommandCount(): number {
|
|
229
|
+
return SETUP_FILES.filter(f => isCommandFile(f.relativePath)).length;
|
|
230
|
+
}
|