Skip to content

Commit 83ce89b

Browse files
feat: ✨ enhance JSON mode to suppress output and capture final command execution details
1 parent 70f4c6a commit 83ce89b

File tree

2 files changed

+50
-97
lines changed

2 files changed

+50
-97
lines changed

README.md

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ aish c "docker ps" --model gpt-4o # Use specific model
108108
- `--provider <provider>` - Override default AI provider
109109
- `--model <model>` - Override provider's preferred model
110110
- `--max-tries <n>` - Abort after n failed executions (default 3)
111-
- `--json` - Output final structured JSON summary (combine with `--yes` for scripting)
111+
- `--json` - Output ONLY the final structured JSON summary (implies `--yes`; suppresses all intermediate and live output; captured stdout/stderr provided via `finalStdout` / `finalStderr`).
112112

113113
#### Non-Interactive & JSON Mode
114114
Use these flags together for scripting / automation:
@@ -142,10 +142,12 @@ echo "$summary" | jq .finalCommand
142142
"explanation": "Shows the current system date and time",
143143
"attempts": 0,
144144
"failures": [],
145-
"alternativesTried": 0
145+
"alternativesTried": 0,
146+
"finalStdout": "Sat Jan 04 12:34:56 UTC 2025\n",
147+
"finalStderr": ""
146148
}
147149
```
148-
All fields appear on a single line in actual output to simplify parsing (pretty-printed here for readability).
150+
All fields are emitted as a single compact one-line JSON object in real execution. `finalStdout` / `finalStderr` appear only after a command attempt (success or failure). They are omitted if no execution occurred (e.g. dangerous-command abort).
149151

150152
#### JSON Fields
151153
| Field | Description |
@@ -160,6 +162,8 @@ All fields appear on a single line in actual output to simplify parsing (pretty-
160162
| `attempts` | Number of failed command executions (non-zero exits) |
161163
| `failures[]` | Details per failed execution (stdout, stderr, explanation, solution) |
162164
| `alternativesTried` | Count of failures where an alternative command was executed |
165+
| `finalStdout` | Captured stdout of the last command attempt (success or failure) |
166+
| `finalStderr` | Captured stderr of the last command attempt (success or failure) |
163167

164168
#### Aborted Reasons
165169
| Reason | Meaning |
@@ -177,10 +181,10 @@ All fields appear on a single line in actual output to simplify parsing (pretty-
177181
| `unexpected-error` | Unhandled runtime exception occurred |
178182

179183
#### Tips
180-
- Combine `--yes --json` for CI pipelines.
184+
- Use `--json` for CI pipelines (auto-approves and suppresses live output).
181185
- Pipe to `jq` for assertions: `aish c "show date" -y --json | jq -r '.finalCommand'`.
182186
- Use `--max-tries 1` to fail fast for deterministic scripting.
183-
- In `--json` mode spinners and intermediate analysis/failure messages are suppressed (quiet output).
187+
- `--json` implies non-interactive mode and suppresses ALL non-JSON output (analysis, prompts, live stdout/stderr); final stdout/stderr are only available via `finalStdout` / `finalStderr` in the JSON line.
184188

185189
#### JSON Mode Test Guidance
186190
Previously a helper script existed for deterministic JSON checks. That script has been removed. Use direct invocations combining `--yes`, `--json`, and optional `--max-tries` and validate the final line with `jq`.

src/commands/command.ts

Lines changed: 41 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,12 @@ interface CommandContext {
103103
stdout: string;
104104
stderr: string;
105105
};
106+
// Successful execution output (used for jsonMode quiet capture)
107+
lastSuccess?: {
108+
command: string;
109+
stdout: string;
110+
stderr: string;
111+
};
106112
isSudoRetry?: boolean;
107113
sudoAttempts?: number;
108114
// Added for extended features
@@ -193,7 +199,10 @@ class CommandExecutor {
193199
abortedReason: context.abortedReason,
194200
originalQuery: context.originalQuery,
195201
finalQuery: context.query,
196-
finalCommand: context.currentAnalysis?.command || context.lastError?.command,
202+
finalCommand:
203+
context.currentAnalysis?.command ||
204+
context.lastError?.command ||
205+
context.lastSuccess?.command,
197206
explanation: context.currentAnalysis?.explanation,
198207
attempts: context.attemptCount,
199208
failures: context.failures.map((f) => ({
@@ -205,7 +214,12 @@ class CommandExecutor {
205214
stderr: f.stderr,
206215
stdout: f.stdout,
207216
})),
208-
alternativesTried: context.failures.filter((f) => f.alternativeCommand).length,
217+
alternativesTried: context.failures.filter((f) => f.alternativeCommand)
218+
.length,
219+
finalStdout:
220+
context.lastSuccess?.stdout || context.lastError?.stdout || undefined,
221+
finalStderr:
222+
context.lastSuccess?.stderr || context.lastError?.stderr || undefined,
209223
};
210224
// Ensure single line JSON for easier parsing
211225
console.log(JSON.stringify(summary));
@@ -266,8 +280,8 @@ class CommandExecutor {
266280
return;
267281
}
268282

269-
// Auto-approve path (non-interactive)
270-
if (context.autoApprove) {
283+
// Auto-approve path (non-interactive OR json mode)
284+
if (context.autoApprove || context.jsonMode) {
271285
context.state = CommandState.EXECUTING;
272286
return;
273287
}
@@ -317,6 +331,12 @@ class CommandExecutor {
317331
context.isSudoRetry = false;
318332

319333
if (result.exitCode === 0) {
334+
// Record success output for JSON summary (especially when suppressed)
335+
context.lastSuccess = {
336+
command: context.currentAnalysis.command,
337+
stdout: result.stdout,
338+
stderr: result.stderr,
339+
};
320340
// Reset sudo attempts on success
321341
context.sudoAttempts = 0;
322342
context.state = CommandState.SUCCESS;
@@ -499,51 +519,13 @@ class CommandExecutor {
499519
query: string,
500520
conversationHistory: ModelMessage[],
501521
): Promise<CommandAnalysis> {
502-
const normalized = query.toLowerCase();
503522
const systemPrompt =
504523
"You are a shell command expert. You MUST respond with valid JSON only, no other text or formatting.";
505524

506525
const userContent =
507526
conversationHistory.length > 0
508-
? `Based on our conversation, analyze this request and generate an appropriate shell command: "${query}"
509-
510-
Return a JSON object with these exact fields:
511-
{
512-
"command": "the shell command to execute",
513-
"explanation": "brief explanation of what the command does",
514-
"isDangerous": false,
515-
"requiresExternalPackages": false,
516-
"externalPackages": [],
517-
"needsInteractiveMode": false
518-
}
519-
520-
Set isDangerous to true ONLY for commands that could cause irreversible system damage or data loss (like rm -rf /, format, dd, etc.).
521-
Common development operations like removing lock files, node_modules, build artifacts, or temporary files are NOT dangerous.
522-
Set requiresExternalPackages to true and list packages if external tools are needed.
523-
Set needsInteractiveMode to true if the user's intent is clearly to open/run an interactive program (like "open vim", "start nano", "run python interactively", "launch htop", etc.) or if the program they're trying to run is interactive in your knowledge. If unsure, assume false.
524-
525-
For file searches, prefer searching in user directories (~) rather than system-wide (/) to avoid permission issues and long execution times.
526-
527-
JSON only:`
528-
: `Analyze this user query and generate an appropriate shell command: "${query}"
529-
530-
Return a JSON object with these exact fields:
531-
{
532-
"command": "the shell command to execute",
533-
"explanation": "brief explanation of what the command does",
534-
"isDangerous": false,
535-
"requiresExternalPackages": false,
536-
"externalPackages": [],
537-
"needsInteractiveMode": false
538-
}
539-
540-
Set isDangerous to true ONLY for commands that could cause irreversible system damage or data loss.
541-
Common development operations are NOT dangerous.
542-
Set needsInteractiveMode to true if the user's intent is clearly to open/run an interactive program (like "open vim", "start nano", "run python interactively", "launch htop", etc.).
543-
544-
For file searches, prefer searching in user directories (~) rather than system-wide (/).
545-
546-
JSON only:`;
527+
? `Based on our conversation, analyze this request and generate an appropriate shell command: "${query}"\n\nReturn a JSON object with these exact fields:\n{\n "command": "the shell command to execute",\n "explanation": "brief explanation of what the command does",\n "isDangerous": false,\n "requiresExternalPackages": false,\n "externalPackages": [],\n "needsInteractiveMode": false\n}\n\nSet isDangerous to true ONLY for commands that could cause irreversible system damage or data loss (like rm -rf /, format, dd, etc.).\nCommon development operations like removing lock files, node_modules, build artifacts, or temporary files are NOT dangerous.\nSet requiresExternalPackages to true and list packages if external tools are needed.\nSet needsInteractiveMode to true if the user's intent is clearly to open/run an interactive program (like "open vim", "start nano", "run python interactively", "launch htop", etc.) or if the program they're trying to run is interactive in your knowledge. If unsure, assume false.\n\nFor file searches, prefer searching in user directories (~) rather than system-wide (/) to avoid permission issues and long execution times.\n\nJSON only:`
528+
: `Analyze this user query and generate an appropriate shell command: "${query}"\n\nReturn a JSON object with these exact fields:\n{\n "command": "the shell command to execute",\n "explanation": "brief explanation of what the command does",\n "isDangerous": false,\n "requiresExternalPackages": false,\n "externalPackages": [],\n "needsInteractiveMode": false\n}\n\nSet isDangerous to true ONLY for commands that could cause irreversible system damage or data loss.\nCommon development operations are NOT dangerous.\nSet needsInteractiveMode to true if the user's intent is clearly to open/run an interactive program (like "open vim", "start nano", "run python interactively", "launch htop", etc.).\n\nFor file searches, prefer searching in user directories (~) rather than system-wide (/).\n\nJSON only:`;
547529

548530
const environmentInfo = `Environment Context:\nOS: ${os.type()} ${os.release()} (${os.platform()} ${os.arch()})\nDate: ${new Date().toISOString().split('T')[0]}\nCWD: ${process.cwd()}\n\nInstructions:\n- Only propose commands valid for this OS.\n- Avoid Linux-specific /proc paths on macOS (darwin).\n- Prefer portable POSIX utilities when possible.\n- If the user's request is impossible without additional tools or privileges, still produce a safe explanatory command (e.g., an echo) and keep isDangerous=false.`;
549531

@@ -582,43 +564,7 @@ JSON only:`;
582564
const systemPrompt =
583565
"You are a shell command expert. You MUST respond with valid JSON only, no other text or formatting.";
584566

585-
const userPrompt = `A command failed with the following details:
586-
587-
Command: ${error.command}
588-
Exit Code: ${error.exitCode}
589-
Standard Output: ${error.stdout || "(none)"}
590-
Standard Error: ${error.stderr || "(none)"}
591-
Original User Query: "${query}"
592-
593-
Based on our conversation history (if any) and these details, analyze the failure.
594-
595-
Return a JSON object with these exact fields:
596-
{
597-
"explanation": "brief explanation of why the command failed (1-2 sentences max)",
598-
"solution": "how to fix the issue or what the user should do (1-2 sentences max)",
599-
"alternativeCommand": "alternative command to try (or null ONLY if absolutely no alternative exists)",
600-
"needsInteractiveMode": false
601-
}
602-
603-
IMPORTANT: You should ALMOST ALWAYS provide an alternativeCommand that attempts to fulfill the user's original request. Look at the error message and suggest a command that will work. For example:
604-
- If a flag isn't recognized, suggest the command without that flag or with an equivalent
605-
- If permission denied, suggest with sudo or in a different directory
606-
- If a tool doesn't exist, suggest an alternative tool that achieves the same goal
607-
- If a file/directory doesn't exist, suggest creating it or using a different path
608-
609-
Only return null for alternativeCommand in cases where:
610-
- The user needs to install software first (but even then, try to suggest the install command)
611-
- The request is physically impossible (e.g., accessing hardware that doesn't exist)
612-
- The command requires user-specific information you don't have
613-
614-
IMPORTANT: Set needsInteractiveMode to true ONLY if ALL of these conditions are met:
615-
1. The failure was clearly caused by missing TTY/terminal (errors like "not a terminal", "no tty", input/output redirection issues)
616-
2. The alternative command you're suggesting is an interactive program (vim, nano, htop, etc.)
617-
3. Double-check: Does this alternative command actually need TTY to function properly?
618-
619-
If you're unsure about ANY of these conditions, set needsInteractiveMode to false. Be extremely conservative.
620-
621-
Be very concise and helpful. Keep explanations short. JSON only:`;
567+
const userPrompt = `A command failed with the following details:\n\nCommand: ${error.command}\nExit Code: ${error.exitCode}\nStandard Output: ${error.stdout || "(none)"}\nStandard Error: ${error.stderr || "(none)"}\nOriginal User Query: "${query}"\n\nBased on our conversation history (if any) and these details, analyze the failure.\n\nReturn a JSON object with these exact fields:\n{\n "explanation": "brief explanation of why the command failed (1-2 sentences max)",\n "solution": "how to fix the issue or what the user should do (1-2 sentences max)",\n "alternativeCommand": "alternative command to try (or null ONLY if absolutely no alternative exists)",\n "needsInteractiveMode": false\n}\n\nIMPORTANT: You should ALMOST ALWAYS provide an alternativeCommand that attempts to fulfill the user's original request. Look at the error message and suggest a command that will work. For example:\n- If a flag isn't recognized, suggest the command without that flag or with an equivalent\n- If permission denied, suggest with sudo or in a different directory\n- If a tool doesn't exist, suggest an alternative tool that achieves the same goal\n- If a file/directory doesn't exist, suggest creating it or using a different path\n\nOnly return null for alternativeCommand in cases where:\n- The user needs to install software first (but even then, try to suggest the install command)\n- The request is physically impossible (e.g., accessing hardware that doesn't exist)\n- The command requires user-specific information you don't have\n\nIMPORTANT: Set needsInteractiveMode to true ONLY if ALL of these conditions are met:\n1. The failure was clearly caused by missing TTY/terminal (errors like "not a terminal", "no tty", input/output redirection issues)\n2. The alternative command you're suggesting is an interactive program (vim, nano, htop, etc.)\n3. Double-check: Does this alternative command actually need TTY to function properly?\n\nIf you're unsure about ANY of these conditions, set needsInteractiveMode to false. Be extremely conservative.\n\nBe very concise and helpful. Keep explanations short. JSON only:`;
622568

623569
const failureEnvInfo = `Environment Context:\nOS: ${os.type()} ${os.release()} (${os.platform()} ${os.arch()})\nDate: ${new Date().toISOString().split('T')[0]}\nCWD: ${process.cwd()}\n\nValidation Rules:\n- Suggest only commands valid for this OS.\n- Avoid Linux-specific /proc paths on macOS.\n- If hardware metrics or privileged data are requested and unavailable without new tools, return alternativeCommand null with a concise explanation unless a standard built-in utility suffices.\n- Prefer safe existence checks (test -f, test -d) before operations.\n- Never hallucinate files or system paths.`;
624570

@@ -693,7 +639,7 @@ Be very concise and helpful. Keep explanations short. JSON only:`;
693639
child.on("close", (code) => {
694640
if (!resolved) {
695641
resolved = true;
696-
resolve({
642+
resolve({
697643
exitCode: code || 0,
698644
stdout: "",
699645
stderr: stderr,
@@ -758,6 +704,7 @@ Be very concise and helpful. Keep explanations short. JSON only:`;
758704
return new Promise((resolve) => {
759705
const wrappedCommand = `set -o pipefail; ${command}`;
760706
const useInteractive = this.forceTTY || needsInteractiveMode;
707+
const suppressOutput = this.jsonMode && this.autoApprove && !useInteractive;
761708

762709
// Only use piped stdin if we need to send a password
763710
const needsPipedStdin = sudoPassword && !command.includes("|");
@@ -801,15 +748,15 @@ Be very concise and helpful. Keep explanations short. JSON only:`;
801748
// For sudo commands, add newline before first real output
802749
if (sudoPassword && !firstOutputReceived && output.trim()) {
803750
firstOutputReceived = true;
804-
process.stdout.write("\n");
751+
if (!suppressOutput) process.stdout.write("\n");
805752
}
806753

807754
// Filter out password prompts from sudo
808755
const passwordPromptRegex =
809756
/^(Password|Mot de passe|Contraseña|Passwort|||Пароль):\s*$/m;
810757
const filteredOutput = output.replace(passwordPromptRegex, "");
811758
if (filteredOutput.trim()) {
812-
process.stdout.write(filteredOutput);
759+
if (!suppressOutput) process.stdout.write(filteredOutput);
813760
}
814761
stdout += output;
815762
});
@@ -829,9 +776,9 @@ Be very concise and helpful. Keep explanations short. JSON only:`;
829776
// For sudo commands, add newline before first real error output
830777
if (sudoPassword && !firstOutputReceived && output.trim()) {
831778
firstOutputReceived = true;
832-
process.stderr.write("\n");
779+
if (!suppressOutput) process.stderr.write("\n");
833780
}
834-
process.stderr.write(output);
781+
if (!suppressOutput) process.stderr.write(output);
835782
}
836783

837784
// If we see a password prompt and haven't sent password yet, send it
@@ -981,6 +928,7 @@ Be very concise and helpful. Keep explanations short. JSON only:`;
981928
* Display command analysis
982929
*/
983930
private displayCommandAnalysis(analysis: CommandAnalysis): void {
931+
// In jsonMode we suppress ALL non-JSON output
984932
if (!this.jsonMode) {
985933
console.log(chalk.cyan(analysis.command));
986934

@@ -1094,7 +1042,7 @@ export function setupCommandCommand(program: Command): void {
10941042
"--max-tries <n>",
10951043
"maximum failed attempts before aborting (default 3)",
10961044
)
1097-
.option("--json", "output final result summary as JSON")
1045+
.option("--json", "output final result summary as JSON (suppresses all non-JSON output and implies --yes)")
10981046
.allowUnknownOption()
10991047
.action(async (queryParts, options) => {
11001048
const query = queryParts.join(" ");
@@ -1103,11 +1051,12 @@ export function setupCommandCommand(program: Command): void {
11031051
: undefined;
11041052
const verbose = options.verbose || false;
11051053
const forceTTY = options.tty || false;
1106-
const autoApprove = options.yes || false;
1107-
const maxTries = options.maxTries
1108-
? parseInt(options.maxTries)
1109-
: 3;
1054+
let autoApprove = options.yes || false;
1055+
const maxTries = options.maxTries ? parseInt(options.maxTries) : 3;
11101056
const jsonMode = options.json || false;
1057+
if (jsonMode) {
1058+
autoApprove = true; // JSON mode implies non-interactive approval
1059+
}
11111060

11121061
try {
11131062
const config = loadConfig();
@@ -1120,7 +1069,7 @@ export function setupCommandCommand(program: Command): void {
11201069
await handleCommandGeneration(
11211070
model,
11221071
query,
1123-
timeoutSeconds,
1072+
timeoutSeconds,
11241073
verbose,
11251074
forceTTY,
11261075
autoApprove,

0 commit comments

Comments
 (0)