@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.
- package/LICENSE +21 -0
- package/README.md +98 -0
- package/dist/ai/index.d.ts +24 -0
- package/dist/ai/index.js +78 -0
- package/dist/ai/mock.d.ts +17 -0
- package/dist/ai/mock.js +49 -0
- package/dist/ai/parser.d.ts +14 -0
- package/dist/ai/parser.js +109 -0
- package/dist/ai/prompt.d.ts +12 -0
- package/dist/ai/prompt.js +76 -0
- package/dist/ai/providers/index.d.ts +1 -0
- package/dist/ai/providers/index.js +2 -0
- package/dist/ai/providers/openrouter.d.ts +31 -0
- package/dist/ai/providers/openrouter.js +142 -0
- package/dist/ai/types.d.ts +46 -0
- package/dist/ai/types.js +15 -0
- package/dist/cli/index.d.ts +19 -0
- package/dist/cli/index.js +71 -0
- package/dist/config/index.d.ts +12 -0
- package/dist/config/index.js +363 -0
- package/dist/config/types.d.ts +76 -0
- package/dist/config/types.js +40 -0
- package/dist/context/directory.d.ts +18 -0
- package/dist/context/directory.js +71 -0
- package/dist/context/history.d.ts +16 -0
- package/dist/context/history.js +89 -0
- package/dist/context/index.d.ts +24 -0
- package/dist/context/index.js +61 -0
- package/dist/context/redaction.d.ts +14 -0
- package/dist/context/redaction.js +57 -0
- package/dist/context/stdin.d.ts +13 -0
- package/dist/context/stdin.js +86 -0
- package/dist/context/system.d.ts +11 -0
- package/dist/context/system.js +56 -0
- package/dist/context/types.d.ts +31 -0
- package/dist/context/types.js +10 -0
- package/dist/error/index.d.ts +30 -0
- package/dist/error/index.js +50 -0
- package/dist/logging/file-logger.d.ts +12 -0
- package/dist/logging/file-logger.js +66 -0
- package/dist/logging/index.d.ts +15 -0
- package/dist/logging/index.js +33 -0
- package/dist/logging/logger.d.ts +15 -0
- package/dist/logging/logger.js +60 -0
- package/dist/main.d.ts +2 -0
- package/dist/main.js +192 -0
- package/dist/output/execute.d.ts +30 -0
- package/dist/output/execute.js +144 -0
- package/dist/output/index.d.ts +4 -0
- package/dist/output/index.js +7 -0
- package/dist/output/types.d.ts +48 -0
- package/dist/output/types.js +34 -0
- package/dist/output/validate.d.ts +23 -0
- package/dist/output/validate.js +42 -0
- package/dist/safety/index.d.ts +34 -0
- package/dist/safety/index.js +59 -0
- package/dist/safety/patterns.d.ts +23 -0
- package/dist/safety/patterns.js +96 -0
- package/dist/safety/types.d.ts +20 -0
- package/dist/safety/types.js +18 -0
- package/dist/signals/index.d.ts +4 -0
- package/dist/signals/index.js +35 -0
- package/dist/ui/App.d.ts +4 -0
- package/dist/ui/App.js +57 -0
- package/dist/ui/components/ActionPrompt.d.ts +8 -0
- package/dist/ui/components/ActionPrompt.js +9 -0
- package/dist/ui/components/CommandDisplay.d.ts +9 -0
- package/dist/ui/components/CommandDisplay.js +13 -0
- package/dist/ui/components/DangerousWarning.d.ts +6 -0
- package/dist/ui/components/DangerousWarning.js +6 -0
- package/dist/ui/components/Spinner.d.ts +15 -0
- package/dist/ui/components/Spinner.js +17 -0
- package/dist/ui/hooks/useAnimation.d.ts +27 -0
- package/dist/ui/hooks/useAnimation.js +85 -0
- package/dist/ui/hooks/useTerminalSize.d.ts +12 -0
- package/dist/ui/hooks/useTerminalSize.js +56 -0
- package/dist/ui/hooks/useTimeout.d.ts +9 -0
- package/dist/ui/hooks/useTimeout.js +31 -0
- package/dist/ui/index.d.ts +25 -0
- package/dist/ui/index.js +80 -0
- package/dist/ui/output.d.ts +20 -0
- package/dist/ui/output.js +56 -0
- package/dist/ui/spinner.d.ts +13 -0
- package/dist/ui/spinner.js +58 -0
- package/dist/ui/types.d.ts +105 -0
- package/dist/ui/types.js +60 -0
- package/dist/ui/utils/formatCommand.d.ts +50 -0
- package/dist/ui/utils/formatCommand.js +113 -0
- 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
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';
|