@vdntio/clai 0.1.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +98 -0
  3. package/dist/ai/index.d.ts +24 -0
  4. package/dist/ai/index.js +78 -0
  5. package/dist/ai/mock.d.ts +17 -0
  6. package/dist/ai/mock.js +49 -0
  7. package/dist/ai/parser.d.ts +14 -0
  8. package/dist/ai/parser.js +109 -0
  9. package/dist/ai/prompt.d.ts +12 -0
  10. package/dist/ai/prompt.js +76 -0
  11. package/dist/ai/providers/index.d.ts +1 -0
  12. package/dist/ai/providers/index.js +2 -0
  13. package/dist/ai/providers/openrouter.d.ts +31 -0
  14. package/dist/ai/providers/openrouter.js +142 -0
  15. package/dist/ai/types.d.ts +46 -0
  16. package/dist/ai/types.js +15 -0
  17. package/dist/cli/index.d.ts +19 -0
  18. package/dist/cli/index.js +71 -0
  19. package/dist/config/index.d.ts +12 -0
  20. package/dist/config/index.js +363 -0
  21. package/dist/config/types.d.ts +76 -0
  22. package/dist/config/types.js +40 -0
  23. package/dist/context/directory.d.ts +18 -0
  24. package/dist/context/directory.js +71 -0
  25. package/dist/context/history.d.ts +16 -0
  26. package/dist/context/history.js +89 -0
  27. package/dist/context/index.d.ts +24 -0
  28. package/dist/context/index.js +61 -0
  29. package/dist/context/redaction.d.ts +14 -0
  30. package/dist/context/redaction.js +57 -0
  31. package/dist/context/stdin.d.ts +13 -0
  32. package/dist/context/stdin.js +86 -0
  33. package/dist/context/system.d.ts +11 -0
  34. package/dist/context/system.js +56 -0
  35. package/dist/context/types.d.ts +31 -0
  36. package/dist/context/types.js +10 -0
  37. package/dist/error/index.d.ts +30 -0
  38. package/dist/error/index.js +50 -0
  39. package/dist/logging/file-logger.d.ts +12 -0
  40. package/dist/logging/file-logger.js +66 -0
  41. package/dist/logging/index.d.ts +15 -0
  42. package/dist/logging/index.js +33 -0
  43. package/dist/logging/logger.d.ts +15 -0
  44. package/dist/logging/logger.js +60 -0
  45. package/dist/main.d.ts +2 -0
  46. package/dist/main.js +192 -0
  47. package/dist/output/execute.d.ts +30 -0
  48. package/dist/output/execute.js +144 -0
  49. package/dist/output/index.d.ts +4 -0
  50. package/dist/output/index.js +7 -0
  51. package/dist/output/types.d.ts +48 -0
  52. package/dist/output/types.js +34 -0
  53. package/dist/output/validate.d.ts +23 -0
  54. package/dist/output/validate.js +42 -0
  55. package/dist/safety/index.d.ts +34 -0
  56. package/dist/safety/index.js +59 -0
  57. package/dist/safety/patterns.d.ts +23 -0
  58. package/dist/safety/patterns.js +96 -0
  59. package/dist/safety/types.d.ts +20 -0
  60. package/dist/safety/types.js +18 -0
  61. package/dist/signals/index.d.ts +4 -0
  62. package/dist/signals/index.js +35 -0
  63. package/dist/ui/App.d.ts +4 -0
  64. package/dist/ui/App.js +57 -0
  65. package/dist/ui/components/ActionPrompt.d.ts +8 -0
  66. package/dist/ui/components/ActionPrompt.js +9 -0
  67. package/dist/ui/components/CommandDisplay.d.ts +9 -0
  68. package/dist/ui/components/CommandDisplay.js +13 -0
  69. package/dist/ui/components/DangerousWarning.d.ts +6 -0
  70. package/dist/ui/components/DangerousWarning.js +6 -0
  71. package/dist/ui/components/Spinner.d.ts +15 -0
  72. package/dist/ui/components/Spinner.js +17 -0
  73. package/dist/ui/hooks/useAnimation.d.ts +27 -0
  74. package/dist/ui/hooks/useAnimation.js +85 -0
  75. package/dist/ui/hooks/useTerminalSize.d.ts +12 -0
  76. package/dist/ui/hooks/useTerminalSize.js +56 -0
  77. package/dist/ui/hooks/useTimeout.d.ts +9 -0
  78. package/dist/ui/hooks/useTimeout.js +31 -0
  79. package/dist/ui/index.d.ts +25 -0
  80. package/dist/ui/index.js +80 -0
  81. package/dist/ui/output.d.ts +20 -0
  82. package/dist/ui/output.js +56 -0
  83. package/dist/ui/spinner.d.ts +13 -0
  84. package/dist/ui/spinner.js +58 -0
  85. package/dist/ui/types.d.ts +105 -0
  86. package/dist/ui/types.js +60 -0
  87. package/dist/ui/utils/formatCommand.d.ts +50 -0
  88. package/dist/ui/utils/formatCommand.js +113 -0
  89. package/package.json +68 -0
@@ -0,0 +1,10 @@
1
+ // Context Data Types for the clai CLI
2
+ import { ClaiError } from '../error/index.js';
3
+ /** Error class for context gathering failures */
4
+ export class ContextError extends ClaiError {
5
+ constructor(message, code = 1) {
6
+ super(message, code);
7
+ this.name = 'ContextError';
8
+ Object.setPrototypeOf(this, ContextError.prototype);
9
+ }
10
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Base error class for all clai errors with exit code semantics
3
+ *
4
+ * Exit codes:
5
+ * 0: Success (help/version)
6
+ * 1: General/unhandled errors
7
+ * 2: Usage errors (invalid CLI arguments)
8
+ * 3: Config errors (parse failures, permissions)
9
+ * 4: API errors (auth, rate limit, timeout)
10
+ * 5: Safety errors (user abort)
11
+ * 130: Interrupted (SIGINT/SIGTERM)
12
+ */
13
+ export declare class ClaiError extends Error {
14
+ readonly code: number;
15
+ constructor(message: string, code?: number, cause?: Error);
16
+ }
17
+ /**
18
+ * Usage error: invalid CLI arguments or options
19
+ * Exit code: 2
20
+ */
21
+ export declare class UsageError extends ClaiError {
22
+ constructor(message: string, cause?: Error);
23
+ }
24
+ /**
25
+ * Interrupt error: SIGINT/SIGTERM received
26
+ * Exit code: 130
27
+ */
28
+ export declare class InterruptError extends ClaiError {
29
+ constructor(message?: string, cause?: Error);
30
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Base error class for all clai errors with exit code semantics
3
+ *
4
+ * Exit codes:
5
+ * 0: Success (help/version)
6
+ * 1: General/unhandled errors
7
+ * 2: Usage errors (invalid CLI arguments)
8
+ * 3: Config errors (parse failures, permissions)
9
+ * 4: API errors (auth, rate limit, timeout)
10
+ * 5: Safety errors (user abort)
11
+ * 130: Interrupted (SIGINT/SIGTERM)
12
+ */
13
+ export class ClaiError extends Error {
14
+ code;
15
+ constructor(message, code = 1, cause) {
16
+ super(message, { cause });
17
+ this.name = 'ClaiError';
18
+ // Define code as non-writable property
19
+ Object.defineProperty(this, 'code', {
20
+ value: code,
21
+ writable: false,
22
+ enumerable: true,
23
+ configurable: false,
24
+ });
25
+ // Maintain proper prototype chain for instanceof checks
26
+ Object.setPrototypeOf(this, ClaiError.prototype);
27
+ }
28
+ }
29
+ /**
30
+ * Usage error: invalid CLI arguments or options
31
+ * Exit code: 2
32
+ */
33
+ export class UsageError extends ClaiError {
34
+ constructor(message, cause) {
35
+ super(message, 2, cause);
36
+ this.name = 'UsageError';
37
+ Object.setPrototypeOf(this, UsageError.prototype);
38
+ }
39
+ }
40
+ /**
41
+ * Interrupt error: SIGINT/SIGTERM received
42
+ * Exit code: 130
43
+ */
44
+ export class InterruptError extends ClaiError {
45
+ constructor(message = 'Interrupted', cause) {
46
+ super(message, 130, cause);
47
+ this.name = 'InterruptError';
48
+ Object.setPrototypeOf(this, InterruptError.prototype);
49
+ }
50
+ }
@@ -0,0 +1,12 @@
1
+ import { Logger, LogLevel } from './logger.js';
2
+ export declare class FileLogger extends Logger {
3
+ private filePath;
4
+ constructor(filePath: string, level?: LogLevel);
5
+ private ensureDirectory;
6
+ private checkAndTruncate;
7
+ private writeToFile;
8
+ error(msg: string): void;
9
+ warn(msg: string): void;
10
+ info(msg: string): void;
11
+ debug(msg: string): void;
12
+ }
@@ -0,0 +1,66 @@
1
+ import { appendFileSync, statSync, truncateSync, mkdirSync } from 'fs';
2
+ import { dirname } from 'path';
3
+ import { Logger } from './logger.js';
4
+ const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
5
+ export class FileLogger extends Logger {
6
+ filePath;
7
+ constructor(filePath, level = 'verbose') {
8
+ super(level, 'never'); // File logging never uses color
9
+ this.filePath = filePath;
10
+ this.ensureDirectory();
11
+ }
12
+ ensureDirectory() {
13
+ try {
14
+ mkdirSync(dirname(this.filePath), { recursive: true });
15
+ }
16
+ catch (error) {
17
+ // Directory may already exist
18
+ }
19
+ }
20
+ checkAndTruncate() {
21
+ try {
22
+ const stats = statSync(this.filePath);
23
+ if (stats.size > MAX_FILE_SIZE) {
24
+ truncateSync(this.filePath, 0);
25
+ }
26
+ }
27
+ catch (error) {
28
+ // File doesn't exist yet, will be created on first write
29
+ }
30
+ }
31
+ writeToFile(level, msg) {
32
+ this.checkAndTruncate();
33
+ const entry = {
34
+ ts: new Date().toISOString(),
35
+ level,
36
+ msg,
37
+ };
38
+ const line = JSON.stringify(entry) + '\n';
39
+ try {
40
+ appendFileSync(this.filePath, line, 'utf-8');
41
+ }
42
+ catch (error) {
43
+ // Silent fail - don't crash app if file logging fails
44
+ }
45
+ }
46
+ error(msg) {
47
+ if (this.shouldLog('normal')) {
48
+ this.writeToFile('error', msg);
49
+ }
50
+ }
51
+ warn(msg) {
52
+ if (this.shouldLog('normal')) {
53
+ this.writeToFile('warn', msg);
54
+ }
55
+ }
56
+ info(msg) {
57
+ if (this.shouldLog('verbose')) {
58
+ this.writeToFile('info', msg);
59
+ }
60
+ }
61
+ debug(msg) {
62
+ if (this.shouldLog('verbose')) {
63
+ this.writeToFile('debug', msg);
64
+ }
65
+ }
66
+ }
@@ -0,0 +1,15 @@
1
+ import { Logger } from './logger.js';
2
+ import type { LogLevel } from './logger.js';
3
+ import { FileLogger } from './file-logger.js';
4
+ export { Logger, FileLogger };
5
+ export type { LogLevel };
6
+ export declare class CombinedLogger {
7
+ private stderrLogger;
8
+ private fileLogger?;
9
+ constructor(level: LogLevel, colorMode: 'auto' | 'always' | 'never', filePath?: string);
10
+ error(msg: string): void;
11
+ warn(msg: string): void;
12
+ info(msg: string): void;
13
+ debug(msg: string): void;
14
+ log(level: 'error' | 'warn' | 'info' | 'debug', msg: string): void;
15
+ }
@@ -0,0 +1,33 @@
1
+ import { Logger } from './logger.js';
2
+ import { FileLogger } from './file-logger.js';
3
+ export { Logger, FileLogger };
4
+ // Multi-target logger that writes to both stderr and file
5
+ export class CombinedLogger {
6
+ stderrLogger;
7
+ fileLogger;
8
+ constructor(level, colorMode, filePath) {
9
+ this.stderrLogger = new Logger(level, colorMode);
10
+ if (filePath) {
11
+ this.fileLogger = new FileLogger(filePath, level);
12
+ }
13
+ }
14
+ error(msg) {
15
+ this.stderrLogger.error(msg);
16
+ this.fileLogger?.error(msg);
17
+ }
18
+ warn(msg) {
19
+ this.stderrLogger.warn(msg);
20
+ this.fileLogger?.warn(msg);
21
+ }
22
+ info(msg) {
23
+ this.stderrLogger.info(msg);
24
+ this.fileLogger?.info(msg);
25
+ }
26
+ debug(msg) {
27
+ this.stderrLogger.debug(msg);
28
+ this.fileLogger?.debug(msg);
29
+ }
30
+ log(level, msg) {
31
+ this[level](msg);
32
+ }
33
+ }
@@ -0,0 +1,15 @@
1
+ export type LogLevel = 'quiet' | 'normal' | 'verbose';
2
+ export declare class Logger {
3
+ private level;
4
+ private colorEnabled;
5
+ private chalk;
6
+ constructor(level?: LogLevel, colorMode?: 'auto' | 'always' | 'never');
7
+ private resolveColorMode;
8
+ protected shouldLog(messageLevel: LogLevel): boolean;
9
+ private formatMessage;
10
+ error(msg: string): void;
11
+ warn(msg: string): void;
12
+ info(msg: string): void;
13
+ debug(msg: string): void;
14
+ log(level: 'error' | 'warn' | 'info' | 'debug', msg: string): void;
15
+ }
@@ -0,0 +1,60 @@
1
+ import { Chalk } from 'chalk';
2
+ export class Logger {
3
+ level;
4
+ colorEnabled;
5
+ chalk;
6
+ constructor(level = 'normal', colorMode = 'auto') {
7
+ this.level = level;
8
+ this.colorEnabled = this.resolveColorMode(colorMode);
9
+ // Create chalk instance with explicit level
10
+ this.chalk = this.colorEnabled ? new Chalk({ level: 3 }) : new Chalk({ level: 0 });
11
+ }
12
+ resolveColorMode(mode) {
13
+ if (mode === 'never' || process.env.NO_COLOR)
14
+ return false;
15
+ if (mode === 'always')
16
+ return true;
17
+ // auto: use TTY + TERM check
18
+ return process.stderr.isTTY && process.env.TERM !== 'dumb';
19
+ }
20
+ shouldLog(messageLevel) {
21
+ const levels = { quiet: 0, normal: 1, verbose: 2 };
22
+ return levels[messageLevel] <= levels[this.level];
23
+ }
24
+ formatMessage(level, msg) {
25
+ if (!this.colorEnabled)
26
+ return `[${level.toUpperCase()}] ${msg}`;
27
+ const prefixMap = {
28
+ error: this.chalk.red('[ERROR]'),
29
+ warn: this.chalk.yellow('[WARN]'),
30
+ info: this.chalk.cyan('[INFO]'),
31
+ debug: this.chalk.dim('[DEBUG]'),
32
+ };
33
+ const prefix = prefixMap[level] || `[${level.toUpperCase()}]`;
34
+ return `${prefix} ${msg}`;
35
+ }
36
+ error(msg) {
37
+ if (this.shouldLog('normal')) {
38
+ process.stderr.write(this.formatMessage('error', msg) + '\n');
39
+ }
40
+ }
41
+ warn(msg) {
42
+ if (this.shouldLog('normal')) {
43
+ process.stderr.write(this.formatMessage('warn', msg) + '\n');
44
+ }
45
+ }
46
+ info(msg) {
47
+ if (this.shouldLog('verbose')) {
48
+ process.stderr.write(this.formatMessage('info', msg) + '\n');
49
+ }
50
+ }
51
+ debug(msg) {
52
+ if (this.shouldLog('verbose')) {
53
+ process.stderr.write(this.formatMessage('debug', msg) + '\n');
54
+ }
55
+ }
56
+ // Generic log method
57
+ log(level, msg) {
58
+ this[level](msg);
59
+ }
60
+ }
package/dist/main.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/main.js ADDED
@@ -0,0 +1,192 @@
1
+ #!/usr/bin/env node
2
+ // clai - CLI for converting natural language to shell commands
3
+ import { parseCli } from './cli/index.js';
4
+ import { getConfig, ConfigError } from './config/index.js';
5
+ import { gatherContext, ContextError } from './context/index.js';
6
+ import { generateCommands, AIError, buildPrompt, formatPromptForDebug, } from './ai/index.js';
7
+ import { checkSafety, SafetyError } from './safety/index.js';
8
+ import { renderUI, UserAction, withSpinner, printCommand, printWarning, } from './ui/index.js';
9
+ import { executeCommand, ExecutionError } from './output/index.js';
10
+ import { UsageError, InterruptError } from './error/index.js';
11
+ import { registerSignalHandlers, checkInterrupt } from './signals/index.js';
12
+ import { CombinedLogger } from './logging/index.js';
13
+ async function main() {
14
+ try {
15
+ // Register signal handlers first
16
+ registerSignalHandlers();
17
+ // Parse CLI arguments
18
+ const cli = parseCli(process.argv);
19
+ // Load and merge config (file + env + CLI)
20
+ const config = getConfig(cli);
21
+ // Determine log level
22
+ const logLevel = config.quiet
23
+ ? 'quiet'
24
+ : config.debug || config.verbose > 0
25
+ ? 'verbose'
26
+ : 'normal';
27
+ // Create logger
28
+ const logger = new CombinedLogger(logLevel, config.ui.color, config.debugFile);
29
+ // Handle offline mode (not yet implemented)
30
+ if (config.offline) {
31
+ logger.error('Offline mode is not yet supported');
32
+ process.exit(1);
33
+ }
34
+ // Check for interrupts before context gathering
35
+ checkInterrupt();
36
+ // Gather context for AI prompt
37
+ const context = await gatherContext(config);
38
+ // Debug output
39
+ if (config.debug) {
40
+ logger.debug('=== Loaded Config ===');
41
+ logger.debug(`Provider: ${config.provider.default}`);
42
+ logger.debug(`Context maxFiles: ${config.context.maxFiles}`);
43
+ logger.debug(`Context maxHistory: ${config.context.maxHistory}`);
44
+ logger.debug(`Safety confirmDangerous: ${config.safety.confirmDangerous}`);
45
+ logger.debug(`UI color: ${config.ui.color}`);
46
+ logger.debug(`UI interactive: ${config.ui.interactive}`);
47
+ logger.debug(`UI numOptions: ${config.ui.numOptions}`);
48
+ logger.debug(`Model: ${config.model || '(default)'}`);
49
+ logger.debug(`Verbose: ${config.verbose}`);
50
+ logger.debug(`Dry run: ${config.dryRun}`);
51
+ logger.debug(`Force: ${config.force}`);
52
+ logger.debug('===========================');
53
+ logger.debug('=== Gathered Context ===');
54
+ logger.debug(`OS: ${context.system.osName} ${context.system.osVersion}`);
55
+ logger.debug(`Architecture: ${context.system.architecture}`);
56
+ logger.debug(`Shell: ${context.system.shell}`);
57
+ logger.debug(`User: ${context.system.user}`);
58
+ logger.debug(`Memory: ${context.system.totalMemoryMb} MB`);
59
+ logger.debug(`CWD: ${context.cwd}`);
60
+ logger.debug(`Files (${context.files.length}):`);
61
+ context.files
62
+ .slice(0, 5)
63
+ .forEach((f) => logger.debug(` - ${f}`));
64
+ if (context.files.length > 5) {
65
+ logger.debug(` ... and ${context.files.length - 5} more`);
66
+ }
67
+ logger.debug(`History (${context.history.length}):`);
68
+ context.history.forEach((h) => logger.debug(` - ${h}`));
69
+ if (context.stdin) {
70
+ logger.debug(`Stdin: ${context.stdin.substring(0, 100)}${context.stdin.length > 100 ? '...' : ''}`);
71
+ }
72
+ else {
73
+ logger.debug(`Stdin: (none - not piped)`);
74
+ }
75
+ logger.debug('===============================');
76
+ // Show the full prompt being sent to AI
77
+ const messages = buildPrompt(context, config.instruction, config.ui.numOptions);
78
+ logger.debug('=== AI Prompt ===');
79
+ logger.debug(formatPromptForDebug(messages));
80
+ logger.debug('========================');
81
+ }
82
+ // Check for interrupts before AI generation
83
+ checkInterrupt();
84
+ // Generate commands from AI (with spinner)
85
+ const commands = await withSpinner('Thinking...', () => generateCommands(context, config.instruction, config));
86
+ // Output the generated commands
87
+ if (config.dryRun) {
88
+ // Dry-run: show all commands with comments
89
+ process.stdout.write(`# Generated ${commands.length} command(s):\n`);
90
+ commands.forEach((cmd, i) => {
91
+ process.stdout.write(`# Option ${i + 1}:\n${cmd}\n\n`);
92
+ });
93
+ process.exit(0);
94
+ }
95
+ // Check safety of generated commands
96
+ const safety = checkSafety(commands, config);
97
+ if (config.debug) {
98
+ logger.debug('=== Safety Check ===');
99
+ logger.debug(`Is dangerous: ${safety.isDangerous}`);
100
+ logger.debug(`Should prompt: ${safety.shouldPrompt}`);
101
+ logger.debug('===========================');
102
+ }
103
+ // Determine if we should show interactive UI
104
+ // Always show in TTY mode (we're executing, not just copying)
105
+ // Skip only if: piped, force flag, or dry-run
106
+ const isTTY = process.stdin.isTTY && process.stdout.isTTY;
107
+ const showUI = isTTY && !config.force;
108
+ let selectedCommand;
109
+ if (showUI) {
110
+ // Show interactive UI for command selection
111
+ const result = await renderUI({
112
+ commands,
113
+ config,
114
+ isDangerous: safety.isDangerous,
115
+ });
116
+ if (result.action === UserAction.Abort) {
117
+ throw new SafetyError('Command execution aborted by user');
118
+ }
119
+ selectedCommand = result.command;
120
+ // Check for interrupts after UI interaction
121
+ checkInterrupt();
122
+ }
123
+ else {
124
+ // Non-interactive: use first command
125
+ selectedCommand = commands[0] ?? '';
126
+ // Show warning for dangerous commands in non-interactive mode
127
+ if (safety.isDangerous && !config.force) {
128
+ printWarning('This command may be dangerous. Use -f to skip this warning.');
129
+ }
130
+ }
131
+ // Execute or output the selected command
132
+ if (selectedCommand) {
133
+ // Check for interrupts before command execution
134
+ checkInterrupt();
135
+ if (showUI) {
136
+ // Interactive: execute the command
137
+ const result = await executeCommand(selectedCommand);
138
+ if (!result.success) {
139
+ logger.error(result.error.message);
140
+ process.exit(result.error.code);
141
+ }
142
+ process.exit(result.exitCode);
143
+ }
144
+ else {
145
+ // Non-interactive (piped): just output the command
146
+ printCommand(selectedCommand, safety.isDangerous);
147
+ process.exit(0);
148
+ }
149
+ }
150
+ else {
151
+ process.exit(0);
152
+ }
153
+ }
154
+ catch (error) {
155
+ // Note: logger may not be available if error occurs before config loading
156
+ if (error instanceof UsageError) {
157
+ process.stderr.write(`Error: ${error.message}\n`);
158
+ process.stderr.write("Try 'clai --help' for more information.\n");
159
+ process.exit(error.code);
160
+ }
161
+ if (error instanceof ConfigError) {
162
+ process.stderr.write(`Config error: ${error.message}\n`);
163
+ process.exit(error.code);
164
+ }
165
+ if (error instanceof ContextError) {
166
+ process.stderr.write(`Context error: ${error.message}\n`);
167
+ process.exit(error.code);
168
+ }
169
+ if (error instanceof AIError) {
170
+ process.stderr.write(`AI error: ${error.message}\n`);
171
+ process.exit(error.code);
172
+ }
173
+ if (error instanceof SafetyError) {
174
+ process.stderr.write(`Aborted: ${error.message}\n`);
175
+ process.exit(error.code);
176
+ }
177
+ if (error instanceof ExecutionError) {
178
+ process.stderr.write(`Execution error: ${error.message}\n`);
179
+ process.exit(error.code);
180
+ }
181
+ if (error instanceof InterruptError) {
182
+ process.stderr.write(`\n${error.message}\n`);
183
+ process.exit(error.code);
184
+ }
185
+ if (error instanceof Error) {
186
+ process.stderr.write(`Error: ${error.message}\n`);
187
+ process.exit(1);
188
+ }
189
+ process.exit(1);
190
+ }
191
+ }
192
+ main();
@@ -0,0 +1,30 @@
1
+ import { type ExecutionResult } from './types.js';
2
+ /**
3
+ * Get the user's shell from environment, falling back to /bin/sh
4
+ */
5
+ export declare function getShell(): string;
6
+ /**
7
+ * Options for command execution
8
+ */
9
+ export interface ExecuteOptions {
10
+ /** Shell to use (defaults to user's SHELL or /bin/sh) */
11
+ shell?: string;
12
+ /** Timeout in milliseconds (0 = no timeout) */
13
+ timeout?: number;
14
+ /** Whether to inherit stdio (default: true for interactive) */
15
+ inheritStdio?: boolean;
16
+ }
17
+ /**
18
+ * Execute a shell command and return the result
19
+ *
20
+ * Handles various edge cases:
21
+ * - Empty command → error with code 1
22
+ * - Recursive clai call → error with code 5 (safety)
23
+ * - Shell not found → error with code 127
24
+ * - Command not found → error with code 127
25
+ * - Permission denied → error with code 126
26
+ * - Signal termination → error with code 128
27
+ * - Timeout → error with code 124
28
+ * - Spawn failure → error with code 1
29
+ */
30
+ export declare function executeCommand(command: string, options?: ExecuteOptions): Promise<ExecutionResult>;
@@ -0,0 +1,144 @@
1
+ // src/output/execute.ts
2
+ // Command execution with proper error handling and exit code propagation
3
+ import { spawn } from 'child_process';
4
+ import { Errors } from './types.js';
5
+ import { validateCommand } from './validate.js';
6
+ /**
7
+ * Get the user's shell from environment, falling back to /bin/sh
8
+ */
9
+ export function getShell() {
10
+ return process.env.SHELL || '/bin/sh';
11
+ }
12
+ /**
13
+ * Execute a shell command and return the result
14
+ *
15
+ * Handles various edge cases:
16
+ * - Empty command → error with code 1
17
+ * - Recursive clai call → error with code 5 (safety)
18
+ * - Shell not found → error with code 127
19
+ * - Command not found → error with code 127
20
+ * - Permission denied → error with code 126
21
+ * - Signal termination → error with code 128
22
+ * - Timeout → error with code 124
23
+ * - Spawn failure → error with code 1
24
+ */
25
+ export function executeCommand(command, options = {}) {
26
+ return new Promise((resolve) => {
27
+ // Validate command first
28
+ const validation = validateCommand(command);
29
+ if (!validation.valid) {
30
+ resolve({ success: false, error: validation.error });
31
+ return;
32
+ }
33
+ const shell = options.shell ?? getShell();
34
+ const inheritStdio = options.inheritStdio ?? true;
35
+ let child;
36
+ let timeoutId;
37
+ let killed = false;
38
+ try {
39
+ child = spawn(shell, ['-c', command], {
40
+ stdio: inheritStdio ? 'inherit' : 'pipe',
41
+ env: process.env,
42
+ });
43
+ }
44
+ catch (err) {
45
+ // Spawn itself threw (rare, but possible)
46
+ resolve({
47
+ success: false,
48
+ error: Errors.spawnFailed(command, err instanceof Error ? err : new Error(String(err))),
49
+ });
50
+ return;
51
+ }
52
+ // Set up timeout if specified
53
+ if (options.timeout && options.timeout > 0) {
54
+ timeoutId = setTimeout(() => {
55
+ killed = true;
56
+ child.kill('SIGTERM');
57
+ // Give it a moment, then SIGKILL if still alive
58
+ setTimeout(() => {
59
+ if (!child.killed) {
60
+ child.kill('SIGKILL');
61
+ }
62
+ }, 1000);
63
+ }, options.timeout);
64
+ }
65
+ child.on('close', (code, signal) => {
66
+ if (timeoutId) {
67
+ clearTimeout(timeoutId);
68
+ }
69
+ // Handle timeout
70
+ if (killed) {
71
+ resolve({
72
+ success: false,
73
+ error: Errors.timeout(options.timeout),
74
+ });
75
+ return;
76
+ }
77
+ // Handle signal termination
78
+ if (signal) {
79
+ resolve({
80
+ success: false,
81
+ error: Errors.signalKilled(signal),
82
+ });
83
+ return;
84
+ }
85
+ // Handle exit codes
86
+ const exitCode = code ?? 0;
87
+ // Check for special exit codes that indicate errors
88
+ if (exitCode === 127) {
89
+ // Could be shell not found or command not found
90
+ // We can't easily distinguish, so use generic message
91
+ resolve({
92
+ success: false,
93
+ error: Errors.commandNotFound(command.split(/\s+/)[0] || command),
94
+ });
95
+ return;
96
+ }
97
+ if (exitCode === 126) {
98
+ resolve({
99
+ success: false,
100
+ error: Errors.permissionDenied(command.split(/\s+/)[0] || command),
101
+ });
102
+ return;
103
+ }
104
+ // All other exit codes: return as success with the exit code
105
+ // (The caller decides if non-zero is an error for their use case)
106
+ resolve({ success: true, exitCode });
107
+ });
108
+ child.on('error', (err) => {
109
+ if (timeoutId) {
110
+ clearTimeout(timeoutId);
111
+ }
112
+ // Check for specific error types
113
+ const errMsg = err.message.toLowerCase();
114
+ if (errMsg.includes('enoent') || errMsg.includes('not found')) {
115
+ // Shell or command not found
116
+ if (errMsg.includes(shell)) {
117
+ resolve({
118
+ success: false,
119
+ error: Errors.shellNotFound(shell),
120
+ });
121
+ }
122
+ else {
123
+ resolve({
124
+ success: false,
125
+ error: Errors.commandNotFound(command.split(/\s+/)[0] || command),
126
+ });
127
+ }
128
+ return;
129
+ }
130
+ if (errMsg.includes('eacces') || errMsg.includes('permission')) {
131
+ resolve({
132
+ success: false,
133
+ error: Errors.permissionDenied(command.split(/\s+/)[0] || command),
134
+ });
135
+ return;
136
+ }
137
+ // Generic spawn error
138
+ resolve({
139
+ success: false,
140
+ error: Errors.spawnFailed(command, err),
141
+ });
142
+ });
143
+ });
144
+ }
@@ -0,0 +1,4 @@
1
+ export { ExecutionError, Errors, type ExecutionResult, type ValidationResult, } from './types.js';
2
+ export { isRecursiveCall, validateCommand, } from './validate.js';
3
+ export { getShell, executeCommand, type ExecuteOptions, } from './execute.js';
4
+ export { printCommand, printWarning, printError, printSuccess, printInfo, } from '../ui/output.js';