speci 0.1.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 (70) hide show
  1. package/README.md +523 -0
  2. package/bin/speci.ts +228 -0
  3. package/lib/commands/init.ts +299 -0
  4. package/lib/commands/monitor.ts +579 -0
  5. package/lib/commands/plan.ts +112 -0
  6. package/lib/commands/refactor.ts +157 -0
  7. package/lib/commands/run.ts +531 -0
  8. package/lib/commands/status.ts +209 -0
  9. package/lib/commands/task.ts +133 -0
  10. package/lib/config.ts +644 -0
  11. package/lib/copilot.ts +229 -0
  12. package/lib/errors.ts +166 -0
  13. package/lib/state.ts +148 -0
  14. package/lib/ui/banner.ts +109 -0
  15. package/lib/ui/box.ts +161 -0
  16. package/lib/ui/colors.ts +110 -0
  17. package/lib/ui/glyphs.ts +91 -0
  18. package/lib/ui/palette.ts +118 -0
  19. package/lib/ui/terminal.ts +118 -0
  20. package/lib/utils/atomic-write.ts +147 -0
  21. package/lib/utils/gate.ts +197 -0
  22. package/lib/utils/i18n.ts +92 -0
  23. package/lib/utils/lock.ts +189 -0
  24. package/lib/utils/logger.ts +143 -0
  25. package/lib/utils/preflight.ts +236 -0
  26. package/lib/utils/process.ts +127 -0
  27. package/lib/utils/signals.ts +145 -0
  28. package/lib/utils/suggest.ts +71 -0
  29. package/package.json +38 -0
  30. package/templates/agents/speci-fix.md +107 -0
  31. package/templates/agents/speci-impl.md +152 -0
  32. package/templates/agents/speci-plan.md +771 -0
  33. package/templates/agents/speci-refactor.md +652 -0
  34. package/templates/agents/speci-review.md +169 -0
  35. package/templates/agents/speci-task.md +369 -0
  36. package/templates/agents/speci-tidy.md +84 -0
  37. package/templates/agents/subagents/final_reviewer.prompt.md +143 -0
  38. package/templates/agents/subagents/mvt_generator.prompt.md +171 -0
  39. package/templates/agents/subagents/plan_codebase_context.prompt.md +29 -0
  40. package/templates/agents/subagents/plan_initial_planner.prompt.md +31 -0
  41. package/templates/agents/subagents/plan_refine_architecture.prompt.md +21 -0
  42. package/templates/agents/subagents/plan_refine_dataflow.prompt.md +23 -0
  43. package/templates/agents/subagents/plan_refine_edgecases.prompt.md +23 -0
  44. package/templates/agents/subagents/plan_refine_errors.prompt.md +22 -0
  45. package/templates/agents/subagents/plan_refine_final.prompt.md +25 -0
  46. package/templates/agents/subagents/plan_refine_integration.prompt.md +22 -0
  47. package/templates/agents/subagents/plan_refine_performance.prompt.md +23 -0
  48. package/templates/agents/subagents/plan_refine_requirements.prompt.md +16 -0
  49. package/templates/agents/subagents/plan_refine_security.prompt.md +22 -0
  50. package/templates/agents/subagents/plan_refine_testing.prompt.md +21 -0
  51. package/templates/agents/subagents/plan_requirements_deep_dive.prompt.md +30 -0
  52. package/templates/agents/subagents/progress_generator.prompt.md +178 -0
  53. package/templates/agents/subagents/refactor_analyze_crosscutting.prompt.md +66 -0
  54. package/templates/agents/subagents/refactor_analyze_duplication.prompt.md +65 -0
  55. package/templates/agents/subagents/refactor_analyze_errors.prompt.md +65 -0
  56. package/templates/agents/subagents/refactor_analyze_functions.prompt.md +66 -0
  57. package/templates/agents/subagents/refactor_analyze_naming.prompt.md +65 -0
  58. package/templates/agents/subagents/refactor_analyze_performance.prompt.md +66 -0
  59. package/templates/agents/subagents/refactor_analyze_state.prompt.md +66 -0
  60. package/templates/agents/subagents/refactor_analyze_structure.prompt.md +64 -0
  61. package/templates/agents/subagents/refactor_analyze_testing.prompt.md +66 -0
  62. package/templates/agents/subagents/refactor_analyze_types.prompt.md +66 -0
  63. package/templates/agents/subagents/refactor_review_completeness.prompt.md +63 -0
  64. package/templates/agents/subagents/refactor_review_final.prompt.md +63 -0
  65. package/templates/agents/subagents/refactor_review_risks.prompt.md +63 -0
  66. package/templates/agents/subagents/refactor_review_roadmap.prompt.md +63 -0
  67. package/templates/agents/subagents/refactor_review_technical.prompt.md +63 -0
  68. package/templates/agents/subagents/task_generator.prompt.md +145 -0
  69. package/templates/agents/subagents/task_reviewer.prompt.md +85 -0
  70. package/templates/speci.config.json +36 -0
package/lib/copilot.ts ADDED
@@ -0,0 +1,229 @@
1
+ /**
2
+ * Copilot CLI Wrapper Module
3
+ *
4
+ * Provides a clean interface for invoking GitHub Copilot CLI with:
5
+ * - Interactive mode (-i flag) for full terminal passthrough
6
+ * - One-shot mode (-p flag) for non-interactive agent execution
7
+ * - Retry logic with exponential backoff for transient failures
8
+ * - Proper stdio handling and process spawning
9
+ */
10
+
11
+ import { spawn } from 'node:child_process';
12
+ import type { SpeciConfig } from './config.js';
13
+ import { resolveAgentPath } from './config.js';
14
+ import { log } from './utils/logger.js';
15
+
16
+ /**
17
+ * Options for building copilot CLI arguments
18
+ */
19
+ export interface CopilotArgsOptions {
20
+ interactive: boolean;
21
+ prompt?: string;
22
+ agent?: string;
23
+ allowAll?: boolean;
24
+ }
25
+
26
+ /**
27
+ * Result of running an agent
28
+ */
29
+ export interface AgentRunResult {
30
+ success: boolean;
31
+ exitCode: number;
32
+ error?: string;
33
+ }
34
+
35
+ /**
36
+ * Retry policy for transient failures
37
+ */
38
+ interface RetryPolicy {
39
+ maxRetries: number;
40
+ baseDelay: number;
41
+ maxDelay: number;
42
+ retryableExitCodes: number[];
43
+ }
44
+
45
+ /**
46
+ * Default retry policy
47
+ */
48
+ const DEFAULT_RETRY_POLICY: RetryPolicy = {
49
+ maxRetries: 3,
50
+ baseDelay: 1000,
51
+ maxDelay: 4000,
52
+ retryableExitCodes: [429], // Rate limit
53
+ };
54
+
55
+ /**
56
+ * Build copilot CLI arguments from config and options
57
+ *
58
+ * @param config - Speci configuration
59
+ * @param options - Argument building options
60
+ * @returns Array of CLI arguments
61
+ *
62
+ * @example
63
+ * ```typescript
64
+ * const args = buildCopilotArgs(config, { interactive: true });
65
+ * // Returns: ['-i', '--allow-all']
66
+ * ```
67
+ */
68
+ export function buildCopilotArgs(
69
+ config: SpeciConfig,
70
+ options: CopilotArgsOptions
71
+ ): string[] {
72
+ const args: string[] = [];
73
+
74
+ // Mode flag
75
+ if (options.interactive) {
76
+ args.push('-i');
77
+ } else if (options.prompt) {
78
+ args.push('-p', options.prompt);
79
+ }
80
+
81
+ // Agent flag
82
+ if (options.agent) {
83
+ args.push(`--agent=${options.agent}`);
84
+ }
85
+
86
+ // Permission flag
87
+ const { permissions } = config.copilot;
88
+ if (permissions === 'allow-all') {
89
+ args.push('--allow-all');
90
+ } else if (permissions === 'yolo') {
91
+ args.push('--yolo');
92
+ }
93
+
94
+ // Model flag
95
+ if (config.copilot.model) {
96
+ args.push('--model', config.copilot.model);
97
+ }
98
+
99
+ // Extra flags
100
+ args.push(...config.copilot.extraFlags);
101
+
102
+ return args;
103
+ }
104
+
105
+ /**
106
+ * Spawn copilot CLI process
107
+ *
108
+ * @param args - CLI arguments
109
+ * @param options - Spawn options
110
+ * @returns Promise that resolves with exit code
111
+ * @throws {Error} If copilot process fails to spawn
112
+ */
113
+ export async function spawnCopilot(
114
+ args: string[],
115
+ options: { inherit?: boolean; cwd?: string } = {}
116
+ ): Promise<number> {
117
+ const { inherit = true, cwd = process.cwd() } = options;
118
+
119
+ return new Promise((resolve, reject) => {
120
+ const child = spawn('copilot', args, {
121
+ stdio: inherit ? 'inherit' : 'pipe',
122
+ cwd,
123
+ env: process.env,
124
+ shell: false,
125
+ });
126
+
127
+ child.on('error', (err) => {
128
+ reject(err);
129
+ });
130
+
131
+ child.on('close', (code) => {
132
+ resolve(code ?? 1);
133
+ });
134
+ });
135
+ }
136
+
137
+ /**
138
+ * Sleep for specified milliseconds
139
+ *
140
+ * @param ms - Milliseconds to sleep
141
+ * @returns Promise that resolves after delay
142
+ */
143
+ async function sleep(ms: number): Promise<void> {
144
+ return new Promise((resolve) => setTimeout(resolve, ms));
145
+ }
146
+
147
+ /**
148
+ * Run an agent with retry logic for transient failures
149
+ *
150
+ * @param config - Speci configuration
151
+ * @param agentName - Name of agent to run
152
+ * @param label - Human-readable label for logging
153
+ * @param policy - Retry policy (uses default if not specified)
154
+ * @returns Promise that resolves with agent run result
155
+ * @throws {Error} If agent file not found or execution fails
156
+ */
157
+ export async function runAgent(
158
+ config: SpeciConfig,
159
+ agentName: string,
160
+ _label: string,
161
+ policy: RetryPolicy = DEFAULT_RETRY_POLICY
162
+ ): Promise<AgentRunResult> {
163
+ let lastError: Error | undefined;
164
+ let lastExitCode = 1;
165
+
166
+ for (let attempt = 0; attempt <= policy.maxRetries; attempt++) {
167
+ if (attempt > 0) {
168
+ const delay = Math.min(
169
+ policy.baseDelay * Math.pow(2, attempt - 1),
170
+ policy.maxDelay
171
+ );
172
+ log.warn(`Retry ${attempt}/${policy.maxRetries} after ${delay}ms...`);
173
+ await sleep(delay);
174
+ }
175
+
176
+ try {
177
+ const agentPath = resolveAgentPath(
178
+ config,
179
+ agentName as
180
+ | 'plan'
181
+ | 'task'
182
+ | 'refactor'
183
+ | 'impl'
184
+ | 'review'
185
+ | 'fix'
186
+ | 'tidy'
187
+ );
188
+ const args = buildCopilotArgs(config, {
189
+ interactive: true,
190
+ agent: agentPath,
191
+ });
192
+
193
+ log.debug(`Spawning copilot: copilot ${args.join(' ')}`);
194
+ const exitCode = await spawnCopilot(args);
195
+
196
+ if (exitCode === 0) {
197
+ return { success: true, exitCode: 0 };
198
+ }
199
+
200
+ lastExitCode = exitCode;
201
+
202
+ // Check if retryable
203
+ if (!policy.retryableExitCodes.includes(exitCode)) {
204
+ return {
205
+ success: false,
206
+ exitCode,
207
+ error: `Agent exited with code ${exitCode}`,
208
+ };
209
+ }
210
+ } catch (err) {
211
+ lastError = err as Error;
212
+
213
+ // ENOENT is not retryable
214
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
215
+ return {
216
+ success: false,
217
+ exitCode: 127,
218
+ error: 'Copilot CLI not found. Is it installed and in PATH?',
219
+ };
220
+ }
221
+ }
222
+ }
223
+
224
+ return {
225
+ success: false,
226
+ exitCode: lastExitCode,
227
+ error: lastError?.message ?? `Failed after ${policy.maxRetries} retries`,
228
+ };
229
+ }
package/lib/errors.ts ADDED
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Error Codes Module
3
+ *
4
+ * Defines structured error codes with messages, causes, and solutions.
5
+ * Used for consistent error reporting across the CLI.
6
+ */
7
+
8
+ /**
9
+ * Error code definition with diagnostic information
10
+ */
11
+ export interface ErrorDefinition {
12
+ message: string;
13
+ cause: string;
14
+ solution: string;
15
+ }
16
+
17
+ /**
18
+ * All error codes used by speci CLI
19
+ *
20
+ * Error codes are grouped by category:
21
+ * - ERR-PRE-* : Prerequisite errors (missing dependencies, environment issues)
22
+ * - ERR-INP-* : Input errors (invalid arguments, missing files)
23
+ * - ERR-STA-* : State errors (lock conflicts, state parsing)
24
+ * - ERR-EXE-* : Execution errors (command failures, timeouts)
25
+ */
26
+ export const ERROR_CODES: Record<string, ErrorDefinition> = {
27
+ // Prerequisite Errors (ERR-PRE-*)
28
+ 'ERR-PRE-01': {
29
+ message: 'Copilot CLI is not installed',
30
+ cause: 'The copilot command was not found in PATH',
31
+ solution: 'Run: npm install -g @github/copilot',
32
+ },
33
+ 'ERR-PRE-02': {
34
+ message: 'Copilot CLI is not authenticated',
35
+ cause: 'Copilot CLI requires authentication to function',
36
+ solution: 'Run /login in Copilot CLI or set GH_TOKEN environment variable',
37
+ },
38
+ 'ERR-PRE-03': {
39
+ message: 'Not a git repository',
40
+ cause: 'Current directory is not inside a git repository',
41
+ solution: 'Run git init in your project root',
42
+ },
43
+ 'ERR-PRE-04': {
44
+ message: 'Configuration file not found',
45
+ cause: 'speci.config.json does not exist in project',
46
+ solution: 'Run speci init to create configuration',
47
+ },
48
+ 'ERR-PRE-05': {
49
+ message: 'PROGRESS.md file not found',
50
+ cause: 'Progress tracking file does not exist',
51
+ solution: 'Run speci init or create docs/PROGRESS.md manually',
52
+ },
53
+
54
+ // Input Errors (ERR-INP-*)
55
+ 'ERR-INP-01': {
56
+ message: 'Required argument missing',
57
+ cause: 'A required command argument was not provided',
58
+ solution: 'Check command usage with --help',
59
+ },
60
+ 'ERR-INP-02': {
61
+ message: 'Agent file not found',
62
+ cause: 'Specified agent file does not exist',
63
+ solution: 'Verify agent path or use bundled agents (set to null in config)',
64
+ },
65
+ 'ERR-INP-03': {
66
+ message: 'Config file is malformed',
67
+ cause: 'speci.config.json contains invalid JSON syntax',
68
+ solution: 'Fix JSON syntax errors in speci.config.json',
69
+ },
70
+ 'ERR-INP-04': {
71
+ message: 'Config validation failed',
72
+ cause: 'Configuration does not match expected schema',
73
+ solution: 'Check config values against schema requirements',
74
+ },
75
+ 'ERR-INP-05': {
76
+ message: 'Plan file not found',
77
+ cause: 'Specified plan file does not exist',
78
+ solution: 'Provide valid path with --plan option',
79
+ },
80
+
81
+ // State Errors (ERR-STA-*)
82
+ 'ERR-STA-01': {
83
+ message: 'Lock file already exists',
84
+ cause: 'Another speci instance may be running',
85
+ solution: 'Wait for other instance to finish or use --force to override',
86
+ },
87
+ 'ERR-STA-02': {
88
+ message: 'Cannot parse PROGRESS.md',
89
+ cause: 'Progress file format is invalid or corrupted',
90
+ solution: 'Verify PROGRESS.md follows expected markdown table format',
91
+ },
92
+ 'ERR-STA-03': {
93
+ message: 'Invalid state transition',
94
+ cause: 'PROGRESS.md contains invalid or conflicting state markers',
95
+ solution: 'Check state markers (IN PROGRESS, IN REVIEW, COMPLETE, etc.)',
96
+ },
97
+
98
+ // Execution Errors (ERR-EXE-*)
99
+ 'ERR-EXE-01': {
100
+ message: 'Gate command failed',
101
+ cause: 'One or more gate validation commands failed',
102
+ solution: 'Fix lint/typecheck/test errors reported in output',
103
+ },
104
+ 'ERR-EXE-02': {
105
+ message: 'Copilot execution failed',
106
+ cause: 'Copilot CLI command exited with error',
107
+ solution: 'Check Copilot authentication and permissions',
108
+ },
109
+ 'ERR-EXE-03': {
110
+ message: 'Max iterations reached',
111
+ cause: 'Loop reached maximum iteration limit',
112
+ solution: 'Review progress and increase --max-iterations if needed',
113
+ },
114
+ 'ERR-EXE-04': {
115
+ message: 'Max fix attempts exceeded',
116
+ cause: 'Gate validation failed after maximum fix attempts',
117
+ solution: 'Review gate failures and fix issues manually',
118
+ },
119
+ };
120
+
121
+ /**
122
+ * Get error definition by code
123
+ *
124
+ * @param code - Error code (e.g., 'ERR-PRE-01')
125
+ * @returns Error definition or undefined if code not found
126
+ */
127
+ export function getErrorDefinition(code: string): ErrorDefinition | undefined {
128
+ return ERROR_CODES[code];
129
+ }
130
+
131
+ /**
132
+ * Format error message with code, cause, and solution
133
+ *
134
+ * @param code - Error code
135
+ * @param context - Optional additional context
136
+ * @returns Formatted error message
137
+ */
138
+ export function formatError(code: string, context?: string): string {
139
+ const def = getErrorDefinition(code);
140
+ if (!def) {
141
+ return `Unknown error code: ${code}`;
142
+ }
143
+
144
+ let message = `[${code}] ${def.message}`;
145
+ if (context) {
146
+ message += `\n Context: ${context}`;
147
+ }
148
+ message += `\n Cause: ${def.cause}`;
149
+ message += `\n Solution: ${def.solution}`;
150
+
151
+ return message;
152
+ }
153
+
154
+ /**
155
+ * Create error with structured code and message
156
+ *
157
+ * @param code - Error code
158
+ * @param context - Optional additional context
159
+ * @returns Error object with formatted message
160
+ */
161
+ export function createError(code: string, context?: string): Error {
162
+ const message = formatError(code, context);
163
+ const error = new Error(message);
164
+ error.name = code;
165
+ return error;
166
+ }
package/lib/state.ts ADDED
@@ -0,0 +1,148 @@
1
+ /**
2
+ * State Parser Module
3
+ *
4
+ * Reads and parses PROGRESS.md to determine the current loop state.
5
+ * Provides state detection and task statistics for orchestrator decision-making.
6
+ */
7
+
8
+ import { readFile } from 'node:fs/promises';
9
+ import { existsSync } from 'node:fs';
10
+ import type { SpeciConfig } from './config.js';
11
+
12
+ /**
13
+ * State enum representing the current orchestration state
14
+ */
15
+ export enum STATE {
16
+ WORK_LEFT = 'WORK_LEFT',
17
+ IN_REVIEW = 'IN_REVIEW',
18
+ BLOCKED = 'BLOCKED',
19
+ DONE = 'DONE',
20
+ NO_PROGRESS = 'NO_PROGRESS',
21
+ }
22
+
23
+ /**
24
+ * Task statistics interface
25
+ */
26
+ export interface TaskStats {
27
+ total: number;
28
+ completed: number;
29
+ remaining: number;
30
+ inReview: number;
31
+ blocked: number;
32
+ }
33
+
34
+ // Pre-compile regex patterns at module load for performance
35
+ const PATTERNS = {
36
+ BLOCKED: /TASK_\d+\s*\|.*BLOCKED/i,
37
+ IN_REVIEW: /TASK_\d+\s*\|.*IN.REVIEW/i,
38
+ WORK_LEFT: /TASK_\d+\s*\|.*(NOT STARTED|IN PROGRESS)/i,
39
+ TASK_ROW: /TASK_(\d+)\s*\|([^|]+)\|([^|]+)(?:\|([^|]+))?/i,
40
+ } as const;
41
+
42
+ // Priority order for state detection (highest to lowest)
43
+ const STATE_PRIORITY: Array<{ state: STATE; pattern: RegExp }> = [
44
+ { state: STATE.BLOCKED, pattern: PATTERNS.BLOCKED },
45
+ { state: STATE.IN_REVIEW, pattern: PATTERNS.IN_REVIEW },
46
+ { state: STATE.WORK_LEFT, pattern: PATTERNS.WORK_LEFT },
47
+ ];
48
+
49
+ /**
50
+ * Check if content matches a state pattern
51
+ * @param content - Content to check
52
+ * @param pattern - Regex pattern to test
53
+ * @returns true if pattern matches
54
+ */
55
+ export function hasStatePattern(content: string, pattern: RegExp): boolean {
56
+ return pattern.test(content);
57
+ }
58
+
59
+ /**
60
+ * Get current orchestration state by parsing PROGRESS.md
61
+ *
62
+ * @param config - Speci configuration
63
+ * @returns Current STATE enum value
64
+ * @throws {Error} ERR-STA-02 if PROGRESS.md cannot be parsed
65
+ *
66
+ * @example
67
+ * ```typescript
68
+ * const state = await getState(config);
69
+ * if (state === STATE.WORK_LEFT) {
70
+ * // Start implementation
71
+ * }
72
+ * ```
73
+ */
74
+ export async function getState(config: SpeciConfig): Promise<STATE> {
75
+ const progressPath = config.paths.progress;
76
+
77
+ // Check file existence
78
+ if (!existsSync(progressPath)) {
79
+ return STATE.NO_PROGRESS;
80
+ }
81
+
82
+ // Read file content
83
+ const content = await readFile(progressPath, 'utf8');
84
+
85
+ // Check patterns in priority order (early exit)
86
+ for (const { state, pattern } of STATE_PRIORITY) {
87
+ if (pattern.test(content)) {
88
+ return state;
89
+ }
90
+ }
91
+
92
+ // No incomplete tasks found
93
+ return STATE.DONE;
94
+ }
95
+
96
+ /**
97
+ * Get task statistics from PROGRESS.md
98
+ *
99
+ * @param config - Speci configuration
100
+ * @returns TaskStats object with counts
101
+ *
102
+ * @example
103
+ * ```typescript
104
+ * const stats = await getTaskStats(config);
105
+ * console.log(`${stats.completed}/${stats.total} tasks complete`);
106
+ * ```
107
+ */
108
+ export async function getTaskStats(config: SpeciConfig): Promise<TaskStats> {
109
+ const progressPath = config.paths.progress;
110
+
111
+ if (!existsSync(progressPath)) {
112
+ return { total: 0, completed: 0, remaining: 0, inReview: 0, blocked: 0 };
113
+ }
114
+
115
+ const content = await readFile(progressPath, 'utf8');
116
+ const lines = content.split('\n');
117
+
118
+ const stats: TaskStats = {
119
+ total: 0,
120
+ completed: 0,
121
+ remaining: 0,
122
+ inReview: 0,
123
+ blocked: 0,
124
+ };
125
+
126
+ for (const line of lines) {
127
+ // Match task rows: | TASK_001 | Description | Status | or | TASK_001 | Title | Priority | Status |
128
+ const match = line.match(PATTERNS.TASK_ROW);
129
+ if (!match) continue;
130
+
131
+ stats.total++;
132
+
133
+ // Status is in the last captured group (either match[3] for 3 columns or match[4] for 4 columns)
134
+ const status = (match[4] || match[3]).trim().toUpperCase();
135
+
136
+ if (status === 'COMPLETE' || status === 'COMPLETED' || status === 'DONE') {
137
+ stats.completed++;
138
+ } else if (status === 'BLOCKED') {
139
+ stats.blocked++;
140
+ } else if (status === 'IN_REVIEW' || status === 'IN REVIEW') {
141
+ stats.inReview++;
142
+ } else if (status === 'NOT STARTED' || status === 'IN PROGRESS') {
143
+ stats.remaining++;
144
+ }
145
+ }
146
+
147
+ return stats;
148
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * ASCII Banner Module
3
+ *
4
+ * Renders the SPECI CLI brand banner with horizontal gradient effect.
5
+ * Uses Ice Blue color palette (sky-200 → sky-400 → sky-500) for visual appeal.
6
+ *
7
+ * The banner provides immediate brand recognition on CLI startup.
8
+ * Respects NO_COLOR and FORCE_COLOR environment variables.
9
+ */
10
+
11
+ import { createRequire } from 'node:module';
12
+ import { ANSI } from './palette.js';
13
+ import { colorize, supportsColor } from './colors.js';
14
+
15
+ // Use createRequire for reliable JSON imports in ESM (works in both runtime and tests)
16
+ const require = createRequire(import.meta.url);
17
+ const pkg = require('../../package.json') as { version: string };
18
+
19
+ /** Package version from package.json */
20
+ export const VERSION = pkg.version;
21
+
22
+ /**
23
+ * ASCII art banner for SPECI CLI
24
+ * 6 lines tall, 40 characters wide
25
+ */
26
+ export const BANNER_ART = [
27
+ ' ███████╗██████╗ ███████╗ ██████╗██╗',
28
+ ' ██╔════╝██╔══██╗██╔════╝██╔════╝██║',
29
+ ' ███████╗██████╔╝█████╗ ██║ ██║',
30
+ ' ╚════██║██╔═══╝ ██╔══╝ ██║ ██║',
31
+ ' ███████║██║ ███████╗╚██████╗██║',
32
+ ' ╚══════╝╚═╝ ╚══════╝ ╚═════╝╚═╝',
33
+ ] as const;
34
+
35
+ /**
36
+ * Options for banner rendering
37
+ */
38
+ export interface BannerOptions {
39
+ /**
40
+ * Include version number below banner
41
+ * @default true
42
+ */
43
+ showVersion?: boolean;
44
+ }
45
+
46
+ /**
47
+ * Apply horizontal gradient to a line of text
48
+ * Divides line into thirds: sky200 | sky400 | sky500
49
+ *
50
+ * @param line - Text line to apply gradient to
51
+ * @returns Gradient-colored line (or plain text if colors disabled)
52
+ */
53
+ function applyGradient(line: string): string {
54
+ if (!supportsColor()) {
55
+ return line;
56
+ }
57
+
58
+ const len = line.length;
59
+ const third = Math.floor(len / 3);
60
+
61
+ const left = line.slice(0, third);
62
+ const middle = line.slice(third, third * 2);
63
+ const right = line.slice(third * 2);
64
+
65
+ return (
66
+ `${ANSI.sky200}${left}` +
67
+ `${ANSI.sky400}${middle}` +
68
+ `${ANSI.sky500}${right}${ANSI.reset}`
69
+ );
70
+ }
71
+
72
+ /**
73
+ * Render ASCII art banner with Ice Blue gradient
74
+ *
75
+ * Displays the SPECI logo with a horizontal gradient effect.
76
+ * Optionally includes version number centered below the banner.
77
+ *
78
+ * @param options - Render options
79
+ * @returns Formatted banner string ready for console output
80
+ *
81
+ * @example
82
+ * ```typescript
83
+ * // Render banner with version
84
+ * console.log(renderBanner());
85
+ *
86
+ * // Render banner without version
87
+ * console.log(renderBanner({ showVersion: false }));
88
+ * ```
89
+ */
90
+ export function renderBanner(options: BannerOptions = {}): string {
91
+ const { showVersion = true } = options;
92
+
93
+ // Apply gradient to each line
94
+ const bannerLines = BANNER_ART.map((line) => applyGradient(line));
95
+
96
+ // Join banner lines
97
+ let output = bannerLines.join('\n');
98
+
99
+ // Add version if requested
100
+ if (showVersion) {
101
+ const versionText = `v${pkg.version}`;
102
+ const bannerWidth = BANNER_ART[0].length;
103
+ const padding = Math.floor((bannerWidth - versionText.length) / 2);
104
+ const centeredVersion = ' '.repeat(padding) + colorize(versionText, 'dim');
105
+ output += '\n' + centeredVersion;
106
+ }
107
+
108
+ return output;
109
+ }