sequant 1.10.1 → 1.11.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 (41) hide show
  1. package/README.md +6 -1
  2. package/dist/bin/cli.js +55 -2
  3. package/dist/dashboard/server.d.ts +37 -0
  4. package/dist/dashboard/server.js +968 -0
  5. package/dist/src/commands/dashboard.d.ts +25 -0
  6. package/dist/src/commands/dashboard.js +44 -0
  7. package/dist/src/commands/doctor.d.ts +18 -1
  8. package/dist/src/commands/doctor.js +105 -2
  9. package/dist/src/commands/init.d.ts +1 -0
  10. package/dist/src/commands/init.js +26 -2
  11. package/dist/src/commands/run.d.ts +20 -0
  12. package/dist/src/commands/run.js +151 -3
  13. package/dist/src/commands/state.d.ts +60 -0
  14. package/dist/src/commands/state.js +267 -0
  15. package/dist/src/commands/stats.d.ts +3 -2
  16. package/dist/src/commands/stats.js +246 -38
  17. package/dist/src/commands/status.d.ts +2 -0
  18. package/dist/src/commands/status.js +28 -3
  19. package/dist/src/lib/ac-parser.d.ts +61 -0
  20. package/dist/src/lib/ac-parser.js +156 -0
  21. package/dist/src/lib/fs.d.ts +19 -0
  22. package/dist/src/lib/fs.js +58 -1
  23. package/dist/src/lib/settings.d.ts +7 -0
  24. package/dist/src/lib/settings.js +1 -0
  25. package/dist/src/lib/system.d.ts +19 -0
  26. package/dist/src/lib/system.js +26 -0
  27. package/dist/src/lib/templates.d.ts +34 -1
  28. package/dist/src/lib/templates.js +109 -5
  29. package/dist/src/lib/workflow/metrics-schema.d.ts +153 -0
  30. package/dist/src/lib/workflow/metrics-schema.js +138 -0
  31. package/dist/src/lib/workflow/metrics-writer.d.ts +102 -0
  32. package/dist/src/lib/workflow/metrics-writer.js +189 -0
  33. package/dist/src/lib/workflow/state-manager.d.ts +18 -1
  34. package/dist/src/lib/workflow/state-manager.js +61 -1
  35. package/dist/src/lib/workflow/state-schema.d.ts +152 -1
  36. package/dist/src/lib/workflow/state-schema.js +99 -0
  37. package/dist/src/lib/workflow/state-utils.d.ts +67 -3
  38. package/dist/src/lib/workflow/state-utils.js +289 -8
  39. package/dist/src/lib/workflow/types.d.ts +2 -0
  40. package/dist/src/lib/workflow/types.js +1 -0
  41. package/package.json +5 -1
@@ -16,6 +16,8 @@ function colorStatus(status) {
16
16
  return chalk.gray(status);
17
17
  case "in_progress":
18
18
  return chalk.blue(status);
19
+ case "waiting_for_qa_gate":
20
+ return chalk.yellow(status);
19
21
  case "ready_for_merge":
20
22
  return chalk.green(status);
21
23
  case "merged":
@@ -124,6 +126,7 @@ function displayIssueSummary(issues) {
124
126
  // Group by status
125
127
  const byStatus = {
126
128
  in_progress: [],
129
+ waiting_for_qa_gate: [],
127
130
  ready_for_merge: [],
128
131
  blocked: [],
129
132
  not_started: [],
@@ -136,6 +139,7 @@ function displayIssueSummary(issues) {
136
139
  // Display in priority order
137
140
  const statusOrder = [
138
141
  "in_progress",
142
+ "waiting_for_qa_gate",
139
143
  "ready_for_merge",
140
144
  "blocked",
141
145
  "not_started",
@@ -157,6 +161,9 @@ function displayIssueSummary(issues) {
157
161
  byStatus.in_progress.length > 0
158
162
  ? chalk.blue(`In Progress: ${byStatus.in_progress.length}`)
159
163
  : null,
164
+ byStatus.waiting_for_qa_gate.length > 0
165
+ ? chalk.yellow(`QA Gate: ${byStatus.waiting_for_qa_gate.length}`)
166
+ : null,
160
167
  byStatus.ready_for_merge.length > 0
161
168
  ? chalk.green(`Ready: ${byStatus.ready_for_merge.length}`)
162
169
  : null,
@@ -316,10 +323,14 @@ async function handleRebuild(options) {
316
323
  */
317
324
  async function handleCleanup(options) {
318
325
  const dryRun = options.dryRun ?? false;
326
+ const removeAll = options.all ?? false;
319
327
  if (!options.json) {
320
328
  if (dryRun) {
321
329
  console.log(chalk.bold("\n🧹 Cleanup preview (dry run)...\n"));
322
330
  }
331
+ else if (removeAll) {
332
+ console.log(chalk.bold("\n🧹 Cleaning up all orphaned entries...\n"));
333
+ }
323
334
  else {
324
335
  console.log(chalk.bold("\n🧹 Cleaning up stale entries...\n"));
325
336
  }
@@ -327,6 +338,7 @@ async function handleCleanup(options) {
327
338
  const result = await cleanupStaleEntries({
328
339
  dryRun,
329
340
  maxAgeDays: options.maxAge,
341
+ removeAll,
330
342
  verbose: !options.json,
331
343
  });
332
344
  if (options.json) {
@@ -336,7 +348,8 @@ async function handleCleanup(options) {
336
348
  if (result.success) {
337
349
  const orphanedCount = result.orphaned.length;
338
350
  const removedCount = result.removed.length;
339
- if (orphanedCount === 0 && removedCount === 0) {
351
+ const mergedCount = result.merged.length;
352
+ if (orphanedCount === 0 && removedCount === 0 && mergedCount === 0) {
340
353
  console.log(chalk.green("āœ“ No stale entries found"));
341
354
  }
342
355
  else {
@@ -346,14 +359,26 @@ async function handleCleanup(options) {
346
359
  else {
347
360
  console.log(chalk.green("āœ“ Cleanup completed"));
348
361
  }
362
+ if (mergedCount > 0) {
363
+ console.log(chalk.green(` Merged PRs (auto-removed): ${result.merged.map((n) => `#${n}`).join(", ")}`));
364
+ }
349
365
  if (orphanedCount > 0) {
350
- console.log(chalk.gray(` Orphaned (worktree missing): ${result.orphaned.map((n) => `#${n}`).join(", ")}`));
366
+ const orphanedNotMerged = result.orphaned.filter((n) => !result.merged.includes(n));
367
+ if (orphanedNotMerged.length > 0) {
368
+ console.log(chalk.yellow(` Abandoned (no merge): ${orphanedNotMerged.map((n) => `#${n}`).join(", ")}`));
369
+ }
351
370
  }
352
371
  if (removedCount > 0) {
353
- console.log(chalk.gray(` Removed: ${result.removed.map((n) => `#${n}`).join(", ")}`));
372
+ const removedNotMerged = result.removed.filter((n) => !result.merged.includes(n));
373
+ if (removedNotMerged.length > 0) {
374
+ console.log(chalk.gray(` Removed: ${removedNotMerged.map((n) => `#${n}`).join(", ")}`));
375
+ }
354
376
  }
355
377
  if (dryRun) {
356
378
  console.log(chalk.gray("\nRun without --dry-run to apply these changes."));
379
+ if (!removeAll && orphanedCount > 0) {
380
+ console.log(chalk.gray("Use --all to remove both merged and abandoned entries."));
381
+ }
357
382
  }
358
383
  }
359
384
  }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Acceptance Criteria Parser
3
+ *
4
+ * Extracts acceptance criteria from GitHub issue markdown.
5
+ * Supports checkbox format: `- [ ] **AC-1:** Description`
6
+ * Also supports alternate formats: `- [ ] **B2:** Description`
7
+ *
8
+ * @example
9
+ * ```typescript
10
+ * import { parseAcceptanceCriteria } from './ac-parser';
11
+ *
12
+ * const issueBody = `
13
+ * ## Acceptance Criteria
14
+ * - [ ] **AC-1:** User can login
15
+ * - [ ] **AC-2:** Session persists
16
+ * `;
17
+ *
18
+ * const criteria = parseAcceptanceCriteria(issueBody);
19
+ * // Returns: [
20
+ * // { id: 'AC-1', description: 'User can login', verificationMethod: 'manual', status: 'pending' },
21
+ * // { id: 'AC-2', description: 'Session persists', verificationMethod: 'manual', status: 'pending' }
22
+ * // ]
23
+ * ```
24
+ */
25
+ import { type AcceptanceCriterion, type AcceptanceCriteria, type ACVerificationMethod } from "./workflow/state-schema.js";
26
+ /**
27
+ * Infer verification method from description text
28
+ *
29
+ * @param description - The AC description text
30
+ * @returns The inferred verification method (defaults to 'manual')
31
+ */
32
+ export declare function inferVerificationMethod(description: string): ACVerificationMethod;
33
+ /**
34
+ * Parse acceptance criteria from GitHub issue markdown
35
+ *
36
+ * Extracts AC items from checkbox format in the issue body.
37
+ * Supports multiple formats:
38
+ * - `- [ ] **AC-1:** Description`
39
+ * - `- [ ] **B2:** Description`
40
+ * - `- [ ] AC-1: Description`
41
+ *
42
+ * @param issueBody - The full GitHub issue body markdown
43
+ * @returns Array of parsed acceptance criteria
44
+ */
45
+ export declare function parseAcceptanceCriteria(issueBody: string): AcceptanceCriterion[];
46
+ /**
47
+ * Extract and create full AcceptanceCriteria object from issue body
48
+ *
49
+ * This is the main entry point for the /spec skill to use.
50
+ *
51
+ * @param issueBody - The full GitHub issue body markdown
52
+ * @returns Complete AcceptanceCriteria object with items and summary
53
+ */
54
+ export declare function extractAcceptanceCriteria(issueBody: string): AcceptanceCriteria;
55
+ /**
56
+ * Check if an issue body contains acceptance criteria
57
+ *
58
+ * @param issueBody - The full GitHub issue body markdown
59
+ * @returns True if AC items are found
60
+ */
61
+ export declare function hasAcceptanceCriteria(issueBody: string): boolean;
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Acceptance Criteria Parser
3
+ *
4
+ * Extracts acceptance criteria from GitHub issue markdown.
5
+ * Supports checkbox format: `- [ ] **AC-1:** Description`
6
+ * Also supports alternate formats: `- [ ] **B2:** Description`
7
+ *
8
+ * @example
9
+ * ```typescript
10
+ * import { parseAcceptanceCriteria } from './ac-parser';
11
+ *
12
+ * const issueBody = `
13
+ * ## Acceptance Criteria
14
+ * - [ ] **AC-1:** User can login
15
+ * - [ ] **AC-2:** Session persists
16
+ * `;
17
+ *
18
+ * const criteria = parseAcceptanceCriteria(issueBody);
19
+ * // Returns: [
20
+ * // { id: 'AC-1', description: 'User can login', verificationMethod: 'manual', status: 'pending' },
21
+ * // { id: 'AC-2', description: 'Session persists', verificationMethod: 'manual', status: 'pending' }
22
+ * // ]
23
+ * ```
24
+ */
25
+ import { createAcceptanceCriterion, createAcceptanceCriteria, } from "./workflow/state-schema.js";
26
+ /**
27
+ * Regex patterns for AC extraction
28
+ *
29
+ * Matches:
30
+ * - `- [ ] **AC-1:** Description`
31
+ * - `- [x] **AC-1:** Description`
32
+ * - `- [ ] **B2:** Description`
33
+ * - `- [ ] **AC1:** Description`
34
+ */
35
+ const AC_PATTERNS = [
36
+ // Pattern 1: `- [ ] **AC-1:** Description` or `- [x] **AC-1:** Description`
37
+ /^-\s*\[[x\s]\]\s*\*\*([A-Za-z]+-?\d+):\*\*\s*(.+)$/gim,
38
+ // Pattern 2: `- [ ] **B2:** Description` (letter + number without hyphen)
39
+ /^-\s*\[[x\s]\]\s*\*\*([A-Za-z]\d+):\*\*\s*(.+)$/gim,
40
+ // Pattern 3: `- [ ] AC-1: Description` (no bold)
41
+ /^-\s*\[[x\s]\]\s*([A-Za-z]+-?\d+):\s*(.+)$/gim,
42
+ ];
43
+ /**
44
+ * Keywords that suggest verification method
45
+ */
46
+ const VERIFICATION_KEYWORDS = {
47
+ // Unit test keywords
48
+ unit: "unit_test",
49
+ "unit test": "unit_test",
50
+ unittest: "unit_test",
51
+ // Integration test keywords
52
+ integration: "integration_test",
53
+ "integration test": "integration_test",
54
+ api: "integration_test",
55
+ endpoint: "integration_test",
56
+ // Browser test keywords
57
+ browser: "browser_test",
58
+ "browser test": "browser_test",
59
+ e2e: "browser_test",
60
+ "end-to-end": "browser_test",
61
+ ui: "browser_test",
62
+ click: "browser_test",
63
+ navigate: "browser_test",
64
+ display: "browser_test",
65
+ dashboard: "browser_test",
66
+ // Manual keywords (explicit)
67
+ manual: "manual",
68
+ "manual test": "manual",
69
+ verify: "manual",
70
+ };
71
+ /**
72
+ * Infer verification method from description text
73
+ *
74
+ * @param description - The AC description text
75
+ * @returns The inferred verification method (defaults to 'manual')
76
+ */
77
+ export function inferVerificationMethod(description) {
78
+ const lowerDesc = description.toLowerCase();
79
+ // Check for explicit keywords (longer phrases first)
80
+ const sortedKeywords = Object.keys(VERIFICATION_KEYWORDS).sort((a, b) => b.length - a.length);
81
+ for (const keyword of sortedKeywords) {
82
+ if (lowerDesc.includes(keyword)) {
83
+ return VERIFICATION_KEYWORDS[keyword];
84
+ }
85
+ }
86
+ return "manual";
87
+ }
88
+ /**
89
+ * Parse a single line and extract AC if present
90
+ *
91
+ * @param line - A single line from the issue body
92
+ * @returns Parsed AC or null if line doesn't match
93
+ */
94
+ function parseACLine(line) {
95
+ for (const pattern of AC_PATTERNS) {
96
+ // Reset regex lastIndex for global patterns
97
+ pattern.lastIndex = 0;
98
+ const match = pattern.exec(line);
99
+ if (match) {
100
+ return {
101
+ id: match[1].toUpperCase(),
102
+ description: match[2].trim(),
103
+ };
104
+ }
105
+ }
106
+ return null;
107
+ }
108
+ /**
109
+ * Parse acceptance criteria from GitHub issue markdown
110
+ *
111
+ * Extracts AC items from checkbox format in the issue body.
112
+ * Supports multiple formats:
113
+ * - `- [ ] **AC-1:** Description`
114
+ * - `- [ ] **B2:** Description`
115
+ * - `- [ ] AC-1: Description`
116
+ *
117
+ * @param issueBody - The full GitHub issue body markdown
118
+ * @returns Array of parsed acceptance criteria
119
+ */
120
+ export function parseAcceptanceCriteria(issueBody) {
121
+ const criteria = [];
122
+ const seenIds = new Set();
123
+ // Split into lines and process each
124
+ const lines = issueBody.split("\n");
125
+ for (const line of lines) {
126
+ const parsed = parseACLine(line);
127
+ if (parsed && !seenIds.has(parsed.id)) {
128
+ seenIds.add(parsed.id);
129
+ const verificationMethod = inferVerificationMethod(parsed.description);
130
+ criteria.push(createAcceptanceCriterion(parsed.id, parsed.description, verificationMethod));
131
+ }
132
+ }
133
+ return criteria;
134
+ }
135
+ /**
136
+ * Extract and create full AcceptanceCriteria object from issue body
137
+ *
138
+ * This is the main entry point for the /spec skill to use.
139
+ *
140
+ * @param issueBody - The full GitHub issue body markdown
141
+ * @returns Complete AcceptanceCriteria object with items and summary
142
+ */
143
+ export function extractAcceptanceCriteria(issueBody) {
144
+ const items = parseAcceptanceCriteria(issueBody);
145
+ return createAcceptanceCriteria(items);
146
+ }
147
+ /**
148
+ * Check if an issue body contains acceptance criteria
149
+ *
150
+ * @param issueBody - The full GitHub issue body markdown
151
+ * @returns True if AC items are found
152
+ */
153
+ export function hasAcceptanceCriteria(issueBody) {
154
+ const items = parseAcceptanceCriteria(issueBody);
155
+ return items.length > 0;
156
+ }
@@ -7,3 +7,22 @@ export declare function ensureDir(path: string): Promise<void>;
7
7
  export declare function readFile(path: string): Promise<string>;
8
8
  export declare function writeFile(path: string, content: string): Promise<void>;
9
9
  export declare function getFileStats(path: string): Promise<import("fs").Stats>;
10
+ /**
11
+ * Check if a path is a symbolic link
12
+ */
13
+ export declare function isSymlink(path: string): Promise<boolean>;
14
+ /**
15
+ * Get the target of a symbolic link
16
+ */
17
+ export declare function getSymlinkTarget(path: string): Promise<string | null>;
18
+ /**
19
+ * Remove a file or symbolic link safely
20
+ */
21
+ export declare function removeFileOrSymlink(path: string): Promise<boolean>;
22
+ /**
23
+ * Create a symbolic link with cross-platform handling
24
+ * @param target The path the symlink should point to
25
+ * @param path The path where the symlink will be created
26
+ * @returns true if symlink was created, false if fallback to copy is needed
27
+ */
28
+ export declare function createSymlink(target: string, path: string): Promise<boolean>;
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * File system utilities
3
3
  */
4
- import { access, constants, mkdir, readFile as fsReadFile, writeFile as fsWriteFile, stat, } from "fs/promises";
4
+ import { access, constants, mkdir, readFile as fsReadFile, writeFile as fsWriteFile, stat, lstat, symlink, unlink, readlink, } from "fs/promises";
5
5
  import { dirname } from "path";
6
6
  export async function fileExists(path) {
7
7
  try {
@@ -41,3 +41,60 @@ export async function writeFile(path, content) {
41
41
  export async function getFileStats(path) {
42
42
  return stat(path);
43
43
  }
44
+ /**
45
+ * Check if a path is a symbolic link
46
+ */
47
+ export async function isSymlink(path) {
48
+ try {
49
+ const stats = await lstat(path);
50
+ return stats.isSymbolicLink();
51
+ }
52
+ catch {
53
+ return false;
54
+ }
55
+ }
56
+ /**
57
+ * Get the target of a symbolic link
58
+ */
59
+ export async function getSymlinkTarget(path) {
60
+ try {
61
+ return await readlink(path);
62
+ }
63
+ catch {
64
+ return null;
65
+ }
66
+ }
67
+ /**
68
+ * Remove a file or symbolic link safely
69
+ */
70
+ export async function removeFileOrSymlink(path) {
71
+ try {
72
+ await unlink(path);
73
+ return true;
74
+ }
75
+ catch {
76
+ return false;
77
+ }
78
+ }
79
+ /**
80
+ * Create a symbolic link with cross-platform handling
81
+ * @param target The path the symlink should point to
82
+ * @param path The path where the symlink will be created
83
+ * @returns true if symlink was created, false if fallback to copy is needed
84
+ */
85
+ export async function createSymlink(target, path) {
86
+ try {
87
+ await symlink(target, path);
88
+ return true;
89
+ }
90
+ catch (error) {
91
+ const err = error;
92
+ // On Windows, symlinks may require admin privileges
93
+ // EPERM: Operation not permitted (Windows without privileges)
94
+ // Return false to signal that caller should fall back to copy
95
+ if (err.code === "EPERM" || err.code === "EACCES") {
96
+ return false;
97
+ }
98
+ throw error;
99
+ }
100
+ }
@@ -73,6 +73,13 @@ export interface RunSettings {
73
73
  * Example: "feature/dashboard" for feature integration branches
74
74
  */
75
75
  defaultBase?: string;
76
+ /**
77
+ * Enable MCP servers in headless mode.
78
+ * When true, reads MCP config from Claude Desktop and passes to SDK.
79
+ * When false or --no-mcp flag is used, MCPs are disabled.
80
+ * Default: true
81
+ */
82
+ mcp: boolean;
76
83
  }
77
84
  /**
78
85
  * Full settings schema
@@ -46,6 +46,7 @@ export const DEFAULT_SETTINGS = {
46
46
  maxIterations: 3,
47
47
  smartTests: true,
48
48
  rotation: DEFAULT_ROTATION_SETTINGS,
49
+ mcp: true, // Enable MCP servers by default in headless mode
49
50
  },
50
51
  agents: DEFAULT_AGENT_SETTINGS,
51
52
  };
@@ -42,6 +42,25 @@ export declare function getClaudeConfigPath(): string;
42
42
  * Read configured MCP servers from Claude Desktop config
43
43
  */
44
44
  export declare function getConfiguredMcpServers(): string[];
45
+ /**
46
+ * MCP server configuration type (matches Claude Desktop config format)
47
+ * This is the format expected by the Claude Agent SDK mcpServers option
48
+ */
49
+ export interface McpServerConfig {
50
+ command: string;
51
+ args?: string[];
52
+ env?: Record<string, string>;
53
+ }
54
+ /**
55
+ * Get full MCP server configurations from Claude Desktop config
56
+ *
57
+ * Returns the complete mcpServers object suitable for passing to the
58
+ * Claude Agent SDK query() options. Returns undefined if config doesn't
59
+ * exist or is invalid (graceful degradation for AC-3).
60
+ *
61
+ * @returns MCP server configurations or undefined
62
+ */
63
+ export declare function getMcpServersConfig(): Record<string, McpServerConfig> | undefined;
45
64
  /**
46
65
  * Check which optional MCP servers are configured
47
66
  * Returns an object with server names as keys and configured status as values
@@ -137,6 +137,32 @@ export function getConfiguredMcpServers() {
137
137
  return [];
138
138
  }
139
139
  }
140
+ /**
141
+ * Get full MCP server configurations from Claude Desktop config
142
+ *
143
+ * Returns the complete mcpServers object suitable for passing to the
144
+ * Claude Agent SDK query() options. Returns undefined if config doesn't
145
+ * exist or is invalid (graceful degradation for AC-3).
146
+ *
147
+ * @returns MCP server configurations or undefined
148
+ */
149
+ export function getMcpServersConfig() {
150
+ const configPath = getClaudeConfigPath();
151
+ try {
152
+ const content = fs.readFileSync(configPath, "utf8");
153
+ const config = JSON.parse(content);
154
+ const servers = config.mcpServers;
155
+ // Return undefined if no mcpServers section exists
156
+ if (!servers || typeof servers !== "object") {
157
+ return undefined;
158
+ }
159
+ return servers;
160
+ }
161
+ catch {
162
+ // Config file doesn't exist or is invalid - graceful degradation
163
+ return undefined;
164
+ }
165
+ }
140
166
  /**
141
167
  * Check which optional MCP servers are configured
142
168
  * Returns an object with server names as keys and configured status as values
@@ -13,7 +13,40 @@ export declare function listTemplateFiles(): Promise<string[]>;
13
13
  * Get content of a template file
14
14
  */
15
15
  export declare function getTemplateContent(templatePath: string): Promise<string>;
16
+ /**
17
+ * Result of symlink creation attempt
18
+ */
19
+ export interface SymlinkResult {
20
+ created: boolean;
21
+ path: string;
22
+ target: string;
23
+ fallbackToCopy: boolean;
24
+ skipped: boolean;
25
+ reason?: string;
26
+ }
27
+ /**
28
+ * Options for copyTemplates
29
+ */
30
+ export interface CopyTemplatesOptions {
31
+ /** Use copies instead of symlinks for scripts (Windows default or user preference) */
32
+ noSymlinks?: boolean;
33
+ /** Force replacement of existing files/symlinks */
34
+ force?: boolean;
35
+ }
36
+ /**
37
+ * Create symlinks for files in a directory, with fallback to copy
38
+ * @param srcDir Source directory containing template files
39
+ * @param destDir Destination directory for symlinks
40
+ * @param options Options controlling symlink behavior
41
+ * @returns Array of results for each file
42
+ */
43
+ export declare function symlinkDir(srcDir: string, destDir: string, options?: {
44
+ force?: boolean;
45
+ }): Promise<SymlinkResult[]>;
16
46
  /**
17
47
  * Copy all templates to .claude/ directory
18
48
  */
19
- export declare function copyTemplates(stack: string, tokens?: Record<string, string>): Promise<void>;
49
+ export declare function copyTemplates(stack: string, tokens?: Record<string, string>, options?: CopyTemplatesOptions): Promise<{
50
+ scriptsSymlinked: boolean;
51
+ symlinkResults?: SymlinkResult[];
52
+ }>;
@@ -2,10 +2,11 @@
2
2
  * Template management - copy and process templates
3
3
  */
4
4
  import { readdir, chmod } from "fs/promises";
5
- import { join, dirname, basename } from "path";
5
+ import { join, dirname, basename, relative, isAbsolute } from "path";
6
6
  import { fileURLToPath } from "url";
7
- import { readFile, writeFile, ensureDir, fileExists } from "./fs.js";
7
+ import { readFile, writeFile, ensureDir, fileExists, isSymlink, createSymlink, removeFileOrSymlink, } from "./fs.js";
8
8
  import { getStackConfig } from "./stacks.js";
9
+ import { isNativeWindows } from "./system.js";
9
10
  // Get the package templates directory
10
11
  function getTemplatesDir() {
11
12
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -60,10 +61,100 @@ export async function getTemplateContent(templatePath) {
60
61
  const fullPath = join(templatesDir, relativePath);
61
62
  return readFile(fullPath);
62
63
  }
64
+ /**
65
+ * Create symlinks for files in a directory, with fallback to copy
66
+ * @param srcDir Source directory containing template files
67
+ * @param destDir Destination directory for symlinks
68
+ * @param options Options controlling symlink behavior
69
+ * @returns Array of results for each file
70
+ */
71
+ export async function symlinkDir(srcDir, destDir, options = {}) {
72
+ const results = [];
73
+ try {
74
+ const entries = await readdir(srcDir, { withFileTypes: true });
75
+ await ensureDir(destDir);
76
+ for (const entry of entries) {
77
+ if (entry.isDirectory()) {
78
+ // Recursively handle subdirectories
79
+ const subResults = await symlinkDir(join(srcDir, entry.name), join(destDir, entry.name), options);
80
+ results.push(...subResults);
81
+ continue;
82
+ }
83
+ const srcPath = join(srcDir, entry.name);
84
+ const destPath = join(destDir, entry.name);
85
+ // Calculate relative path from destDir to srcPath for portable symlinks
86
+ // Note: srcPath may already be absolute (when srcDir is absolute), so check first
87
+ const absoluteDest = isAbsolute(destPath)
88
+ ? destPath
89
+ : join(process.cwd(), destPath);
90
+ const absoluteSrc = isAbsolute(srcPath)
91
+ ? srcPath
92
+ : join(process.cwd(), srcPath);
93
+ const relativeTarget = relative(dirname(absoluteDest), absoluteSrc);
94
+ // Check if destination already exists
95
+ // Note: isSymlink uses lstat and works on broken symlinks,
96
+ // while fileExists uses access which fails on broken symlinks
97
+ const destIsSymlink = await isSymlink(destPath);
98
+ const destExists = destIsSymlink || (await fileExists(destPath));
99
+ if (destExists && !destIsSymlink && !options.force) {
100
+ // Regular file exists and force not specified - skip
101
+ results.push({
102
+ created: false,
103
+ path: destPath,
104
+ target: relativeTarget,
105
+ fallbackToCopy: false,
106
+ skipped: true,
107
+ reason: "existing file (use --force to replace)",
108
+ });
109
+ continue;
110
+ }
111
+ // Remove existing file/symlink if force or if it's already a symlink
112
+ // (symlinks are always replaced to ensure they point to correct target)
113
+ if (destExists && (options.force || destIsSymlink)) {
114
+ await removeFileOrSymlink(destPath);
115
+ }
116
+ // Try to create symlink
117
+ const symlinkCreated = await createSymlink(relativeTarget, destPath);
118
+ if (symlinkCreated) {
119
+ results.push({
120
+ created: true,
121
+ path: destPath,
122
+ target: relativeTarget,
123
+ fallbackToCopy: false,
124
+ skipped: false,
125
+ });
126
+ }
127
+ else {
128
+ // Symlink failed (likely Windows without privileges) - fall back to copy
129
+ const content = await readFile(srcPath);
130
+ await writeFile(destPath, content);
131
+ // Make shell scripts executable
132
+ if (entry.name.endsWith(".sh")) {
133
+ await chmod(destPath, 0o755);
134
+ }
135
+ results.push({
136
+ created: true,
137
+ path: destPath,
138
+ target: relativeTarget,
139
+ fallbackToCopy: true,
140
+ skipped: false,
141
+ reason: "symlink not supported, copied instead",
142
+ });
143
+ }
144
+ }
145
+ }
146
+ catch (error) {
147
+ // Skip if source doesn't exist
148
+ if (error.code !== "ENOENT") {
149
+ throw error;
150
+ }
151
+ }
152
+ return results;
153
+ }
63
154
  /**
64
155
  * Copy all templates to .claude/ directory
65
156
  */
66
- export async function copyTemplates(stack, tokens) {
157
+ export async function copyTemplates(stack, tokens, options = {}) {
67
158
  const templatesDir = getTemplatesDir();
68
159
  const stackConfig = getStackConfig(stack);
69
160
  const variables = {
@@ -107,12 +198,25 @@ export async function copyTemplates(stack, tokens) {
107
198
  await copyDir(join(templatesDir, "hooks"), ".claude/hooks");
108
199
  // Copy memory (constitution, etc.)
109
200
  await copyDir(join(templatesDir, "memory"), ".claude/memory");
110
- // Copy scripts (worktree helpers, etc.)
111
- await copyDir(join(templatesDir, "scripts"), "scripts/dev");
201
+ // Handle scripts directory - use symlinks unless disabled
202
+ const useSymlinks = !options.noSymlinks && !isNativeWindows();
203
+ let scriptsSymlinked = false;
204
+ let symlinkResults;
205
+ if (useSymlinks) {
206
+ // Use symlinks for scripts - they don't need template variable processing
207
+ symlinkResults = await symlinkDir(join(templatesDir, "scripts"), "scripts/dev", { force: options.force });
208
+ // Check if any symlinks were actually created (not all fell back to copy)
209
+ scriptsSymlinked = symlinkResults.some((r) => r.created && !r.fallbackToCopy);
210
+ }
211
+ else {
212
+ // Fall back to copies (Windows or --no-symlinks flag)
213
+ await copyDir(join(templatesDir, "scripts"), "scripts/dev");
214
+ }
112
215
  // Copy settings.json
113
216
  const settingsPath = join(templatesDir, "settings.json");
114
217
  if (await fileExists(settingsPath)) {
115
218
  const content = await readFile(settingsPath);
116
219
  await writeFile(".claude/settings.json", processTemplate(content, variables));
117
220
  }
221
+ return { scriptsSymlinked, symlinkResults };
118
222
  }