aish-cli 1.0.0 → 1.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 (3) hide show
  1. package/aish.plugin.zsh +420 -0
  2. package/dist/index.js +251 -78
  3. package/package.json +14 -3
@@ -0,0 +1,420 @@
1
+ # aish - AI Shell Integration for Zsh
2
+ # Press Ctrl+G to convert natural language to shell commands
3
+
4
+ # Check if aish is installed
5
+ if ! command -v aish &>/dev/null; then
6
+ print -P "%F{yellow}[aish]%f aish command not found. Install with: npm install -g aish-cli"
7
+ return 1
8
+ fi
9
+
10
+ # History file
11
+ AISH_HISTFILE="${AISH_HISTFILE:-$HOME/.aish_history}"
12
+ AISH_HISTSIZE="${AISH_HISTSIZE:-100}"
13
+
14
+ # Shared state
15
+ typeset -g _aish_input_query=""
16
+
17
+ # Load history into array
18
+ typeset -ga _aish_history
19
+ _aish_load_history() {
20
+ _aish_history=()
21
+ [[ -f "$AISH_HISTFILE" ]] && _aish_history=("${(@f)$(< "$AISH_HISTFILE")}")
22
+ }
23
+
24
+ _aish_save_history() {
25
+ local entry="$1"
26
+ [[ -z "$entry" ]] && return
27
+ _aish_history=("${(@)_aish_history:#$entry}")
28
+ _aish_history+=("$entry")
29
+ while (( ${#_aish_history} > AISH_HISTSIZE )); do
30
+ shift _aish_history
31
+ done
32
+ printf '%s\n' "${_aish_history[@]}" > "$AISH_HISTFILE"
33
+ }
34
+
35
+ _aish_load_history
36
+
37
+ # Fill placeholders like <message>, <url>, etc.
38
+ # Sets _aish_input_query with result, returns 1 if cancelled
39
+ _aish_fill_placeholders() {
40
+ local cmd="$1"
41
+ local result="$cmd"
42
+
43
+ # Find all unique placeholders
44
+ local placeholders=()
45
+ local seen=()
46
+ while [[ "$result" =~ '<([^>]+)>' ]]; do
47
+ local match="${MATCH}"
48
+ local name="${match:1:-1}"
49
+
50
+ # Check if already seen
51
+ local found=0
52
+ for s in "${seen[@]}"; do
53
+ [[ "$s" == "$match" ]] && found=1 && break
54
+ done
55
+
56
+ if (( !found )); then
57
+ seen+=("$match")
58
+ placeholders+=("$match:$name")
59
+ fi
60
+
61
+ # Move past this match
62
+ result="${result#*$match}"
63
+ done
64
+
65
+ result="$cmd"
66
+
67
+ # If no placeholders, return as-is
68
+ if (( ${#placeholders[@]} == 0 )); then
69
+ _aish_input_query="$cmd"
70
+ return 0
71
+ fi
72
+
73
+ # Prompt for each placeholder
74
+ local total=${#placeholders[@]}
75
+ local current=0
76
+
77
+ for entry in "${placeholders[@]}"; do
78
+ (( current++ ))
79
+ local placeholder="${entry%%:*}"
80
+ local name="${entry#*:}"
81
+
82
+ # Read the value with inline display
83
+ local value=""
84
+ local vcursor=0
85
+ local char
86
+
87
+ # Helper to update display with current value
88
+ _aish_placeholder_display() {
89
+ # Replace first occurrence of placeholder with value for display
90
+ local before="${result%%$placeholder*}"
91
+ local after="${result#*$placeholder}"
92
+ local display_val="$value"
93
+ [[ -z "$display_val" ]] && display_val="$placeholder"
94
+
95
+ BUFFER="${before}${display_val}${after}"
96
+ CURSOR=$(( ${#before} + vcursor ))
97
+
98
+ POSTDISPLAY=$'\n '"$name ($current/$total)"' │ enter: continue │ esc: cancel'
99
+ region_highlight=()
100
+
101
+ # Highlight the value/placeholder area
102
+ local start=${#before}
103
+ local end=$(( start + ${#display_val} ))
104
+ if [[ -z "$value" ]]; then
105
+ region_highlight+=("${start} ${end} fg=yellow,bold")
106
+ else
107
+ region_highlight+=("${start} ${end} fg=green")
108
+ fi
109
+
110
+ # Dim the hint text
111
+ local hint_start=${#BUFFER}
112
+ local hint_end=$(( hint_start + ${#POSTDISPLAY} ))
113
+ region_highlight+=("${hint_start} ${hint_end} fg=8")
114
+
115
+ zle -R
116
+ }
117
+
118
+ _aish_placeholder_display
119
+
120
+ while read -k1 char; do
121
+ # Handle escape sequences (arrow keys)
122
+ if [[ "$char" == $'\e' ]]; then
123
+ read -k1 -t 0.1 char2
124
+ if [[ "$char2" == '[' ]]; then
125
+ read -k1 -t 0.1 char3
126
+ case "$char3" in
127
+ C) (( vcursor < ${#value} )) && (( vcursor++ )) ;; # Right
128
+ D) (( vcursor > 0 )) && (( vcursor-- )) ;; # Left
129
+ esac
130
+ _aish_placeholder_display
131
+ continue
132
+ else
133
+ # Escape key - cancel
134
+ _aish_input_query=""
135
+ POSTDISPLAY=""
136
+ return 1
137
+ fi
138
+ fi
139
+
140
+ case "$char" in
141
+ $'\n'|$'\r')
142
+ break ;;
143
+ $'\x7f'|$'\b')
144
+ if (( vcursor > 0 )); then
145
+ value="${value:0:$((vcursor-1))}${value:$vcursor}"
146
+ (( vcursor-- ))
147
+ fi ;;
148
+ $'\x03')
149
+ _aish_input_query=""
150
+ POSTDISPLAY=""
151
+ return 1 ;;
152
+ $'\x01') vcursor=0 ;; # Ctrl+A
153
+ $'\x05') vcursor=${#value} ;; # Ctrl+E
154
+ *)
155
+ value="${value:0:$vcursor}${char}${value:$vcursor}"
156
+ (( vcursor++ )) ;;
157
+ esac
158
+ _aish_placeholder_display
159
+ done
160
+
161
+ unset -f _aish_placeholder_display
162
+
163
+ # Cancel if empty value
164
+ if [[ -z "$value" ]]; then
165
+ _aish_input_query=""
166
+ POSTDISPLAY=""
167
+ return 1
168
+ fi
169
+
170
+ # Replace all occurrences of this placeholder
171
+ result="${result//$placeholder/$value}"
172
+ done
173
+
174
+ POSTDISPLAY=""
175
+ _aish_input_query="$result"
176
+ return 0
177
+ }
178
+
179
+ _aish_update_display() {
180
+ local prefix="$1"
181
+ local query="$2"
182
+ local qcursor="$3"
183
+
184
+ BUFFER="${prefix}${query}"
185
+ CURSOR=$(( ${#prefix} + qcursor ))
186
+ region_highlight=("0 ${#prefix} fg=magenta,bold")
187
+ zle -R
188
+ }
189
+
190
+ _aish_read_input() {
191
+ local prefix="$1"
192
+ local initial_query="${2:-}"
193
+
194
+ local query="$initial_query"
195
+ local qcursor=${#query}
196
+ local char
197
+ local hist_idx=$(( ${#_aish_history} + 1 ))
198
+ local saved_query=""
199
+
200
+ _aish_update_display "$prefix" "$query" "$qcursor"
201
+
202
+ while read -k1 char; do
203
+ if [[ "$char" == $'\e' ]]; then
204
+ read -k1 -t 0.1 char2
205
+ if [[ "$char2" == '[' ]]; then
206
+ read -k1 -t 0.1 char3
207
+ case "$char3" in
208
+ A) # Up
209
+ if (( hist_idx > 1 )); then
210
+ (( hist_idx == ${#_aish_history} + 1 )) && saved_query="$query"
211
+ (( hist_idx-- ))
212
+ query="${_aish_history[$hist_idx]}"
213
+ qcursor=${#query}
214
+ fi ;;
215
+ B) # Down
216
+ if (( hist_idx <= ${#_aish_history} )); then
217
+ (( hist_idx++ ))
218
+ if (( hist_idx > ${#_aish_history} )); then
219
+ query="$saved_query"
220
+ else
221
+ query="${_aish_history[$hist_idx]}"
222
+ fi
223
+ qcursor=${#query}
224
+ fi ;;
225
+ C) (( qcursor < ${#query} )) && (( qcursor++ )) ;;
226
+ D) (( qcursor > 0 )) && (( qcursor-- )) ;;
227
+ esac
228
+ _aish_update_display "$prefix" "$query" "$qcursor"
229
+ continue
230
+ else
231
+ _aish_input_query=""
232
+ return 1
233
+ fi
234
+ fi
235
+
236
+ case "$char" in
237
+ $'\n'|$'\r')
238
+ _aish_input_query="$query"
239
+ return 0 ;;
240
+ $'\x7f'|$'\b')
241
+ if (( qcursor > 0 )); then
242
+ query="${query:0:$((qcursor-1))}${query:$qcursor}"
243
+ (( qcursor-- ))
244
+ fi ;;
245
+ $'\x03')
246
+ _aish_input_query=""
247
+ return 1 ;;
248
+ $'\x01') qcursor=0 ;;
249
+ $'\x05') qcursor=${#query} ;;
250
+ $'\x15') query=""; qcursor=0 ;;
251
+ *)
252
+ query="${query:0:$qcursor}${char}${query:$qcursor}"
253
+ (( qcursor++ ))
254
+ hist_idx=$(( ${#_aish_history} + 1 )) ;;
255
+ esac
256
+ _aish_update_display "$prefix" "$query" "$qcursor"
257
+ done
258
+ }
259
+
260
+ _aish_widget() {
261
+ setopt LOCAL_OPTIONS NO_NOTIFY NO_MONITOR
262
+
263
+ local cmd
264
+ local saved_buffer="$BUFFER"
265
+ local saved_cursor="$CURSOR"
266
+ local saved_region_highlight=("${region_highlight[@]}")
267
+ local context=""
268
+
269
+ while true; do
270
+ local prefix="AI› "
271
+ [[ -n "$context" ]] && prefix="AI+ "
272
+
273
+ if ! _aish_read_input "$prefix" ""; then
274
+ region_highlight=("${saved_region_highlight[@]}")
275
+ BUFFER="$saved_buffer"
276
+ CURSOR="$saved_cursor"
277
+ zle -R
278
+ return
279
+ fi
280
+
281
+ local query="$_aish_input_query"
282
+
283
+ if [[ -z "$query" ]]; then
284
+ region_highlight=("${saved_region_highlight[@]}")
285
+ BUFFER="$saved_buffer"
286
+ CURSOR="$saved_cursor"
287
+ zle -R
288
+ return
289
+ fi
290
+
291
+ # Build query with context
292
+ local full_query="$query"
293
+ if [[ -n "$context" ]]; then
294
+ full_query="Previous conversation:
295
+ ${context}
296
+
297
+ User's new request: $query
298
+
299
+ Respond with only the updated command."
300
+ fi
301
+
302
+ # Show spinner in buffer while loading
303
+ local spinner='⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'
304
+ local spin_i=0
305
+ local tmpfile=$(mktemp)
306
+ local errfile=$(mktemp)
307
+
308
+ # Hide cursor
309
+ print -n $'\e[?25l'
310
+
311
+ aish --print "$full_query" > "$tmpfile" 2>"$errfile" &
312
+ local pid=$!
313
+
314
+ while kill -0 $pid 2>/dev/null; do
315
+ local spin_char="${spinner:$spin_i:1}"
316
+ BUFFER="${prefix}${query} ${spin_char}"
317
+ CURSOR=${#BUFFER}
318
+ region_highlight=("0 ${#prefix} fg=magenta,bold")
319
+ zle -R
320
+ spin_i=$(( (spin_i + 1) % 10 ))
321
+ sleep 0.08
322
+ done
323
+
324
+ # Show cursor
325
+ print -n $'\e[?25h'
326
+
327
+ wait $pid 2>/dev/null
328
+ local exit_code=$?
329
+ cmd=$(<"$tmpfile")
330
+ local errmsg=$(<"$errfile")
331
+ rm -f "$tmpfile" "$errfile"
332
+
333
+ if [[ $exit_code -ne 0 ]]; then
334
+ # Show error briefly
335
+ BUFFER=""
336
+ POSTDISPLAY=$'\n '"Error: ${errmsg:-aish command failed}"
337
+ region_highlight=()
338
+ zle -R
339
+ sleep 2
340
+ POSTDISPLAY=""
341
+ region_highlight=("${saved_region_highlight[@]}")
342
+ BUFFER="$saved_buffer"
343
+ CURSOR="$saved_cursor"
344
+ zle -R
345
+ return
346
+ fi
347
+
348
+ if [[ -n "$cmd" ]]; then
349
+ _aish_save_history "$query"
350
+
351
+ # Fill placeholders if any
352
+ if ! _aish_fill_placeholders "$cmd"; then
353
+ # Cancelled during placeholder fill
354
+ region_highlight=("${saved_region_highlight[@]}")
355
+ BUFFER="$saved_buffer"
356
+ CURSOR="$saved_cursor"
357
+ zle -R
358
+ return
359
+ fi
360
+ cmd="$_aish_input_query"
361
+
362
+ if [[ -n "$context" ]]; then
363
+ context="${context}
364
+
365
+ User refinement: ${query}
366
+ Command: ${cmd}"
367
+ else
368
+ context="User request: ${query}
369
+ Command: ${cmd}"
370
+ fi
371
+
372
+ # Show result with hints
373
+ BUFFER="$cmd"
374
+ CURSOR=${#BUFFER}
375
+ local hints=$'\n tab: refine │ enter: accept │ esc: cancel'
376
+ POSTDISPLAY="$hints"
377
+ # Highlight POSTDISPLAY area (starts after BUFFER)
378
+ local hint_start=${#BUFFER}
379
+ local hint_end=$(( hint_start + ${#hints} ))
380
+ region_highlight=("${saved_region_highlight[@]}" "${hint_start} ${hint_end} fg=8")
381
+ zle -R
382
+
383
+ # Wait for user choice - read into REPLY (no variable assignment shown)
384
+ zle -R
385
+ read -sk1
386
+
387
+ # Clear hints
388
+ POSTDISPLAY=""
389
+
390
+ # Handle key based on REPLY
391
+ if [[ "$REPLY" == $'\t' ]]; then
392
+ # Tab - refine
393
+ BUFFER=""
394
+ CURSOR=0
395
+ zle -R
396
+ continue
397
+ elif [[ "$REPLY" == $'\e' ]]; then
398
+ region_highlight=("${saved_region_highlight[@]}")
399
+ BUFFER="$saved_buffer"
400
+ CURSOR="$saved_cursor"
401
+ zle -R
402
+ return
403
+ elif [[ "$REPLY" == $'\n' || "$REPLY" == $'\r' ]]; then
404
+ return
405
+ else
406
+ [[ "$REPLY" == [[:print:]] ]] && BUFFER="${cmd}${REPLY}" && CURSOR=${#BUFFER}
407
+ return
408
+ fi
409
+ else
410
+ region_highlight=("${saved_region_highlight[@]}")
411
+ BUFFER="$saved_buffer"
412
+ CURSOR="$saved_cursor"
413
+ zle -R
414
+ return
415
+ fi
416
+ done
417
+ }
418
+
419
+ zle -N _aish_widget
420
+ bindkey '^G' _aish_widget
package/dist/index.js CHANGED
@@ -2,61 +2,156 @@
2
2
 
3
3
  // src/ai.ts
4
4
  import { execFile, spawn } from "child_process";
5
- import { readFile, unlink } from "fs/promises";
6
- import { tmpdir } from "os";
5
+ import { readFile, writeFile, unlink, access, mkdir } from "fs/promises";
6
+ import { createHash } from "crypto";
7
+ import { tmpdir, homedir } from "os";
7
8
  import { join } from "path";
8
9
  var SYSTEM_PROMPT = `You are a CLI assistant that converts natural language into the exact shell commands to run.
9
10
 
10
- RESEARCH STEPS (do all of these before responding):
11
- 1. Read the README.md (or README) file thoroughly \u2014 it often documents available commands, flags, and workflows.
12
- 2. Read the Makefile, package.json scripts, Justfile, Taskfile.yml, docker-compose.yml, Cargo.toml, or pyproject.toml \u2014 whichever exist \u2014 to find available targets/scripts.
13
- 3. When the user's request maps to a specific command or script, run it with --help to discover the exact flags and options available.
14
- 4. Cross-reference what you found: use the exact flag names and syntax from --help output and documentation, not guesses.
11
+ Some project files are provided below for reference so you don't need to read them yourself.
15
12
 
16
- RESPONSE FORMAT:
17
- Respond with ONLY a JSON object: {"commands": ["command1", "command2"]}
18
- No explanation, no markdown, no code fences. Just raw JSON.
19
- Prefer existing scripts/targets with correct flags over raw commands.`;
13
+ GUIDELINES:
14
+ 1. Review the project files provided below to understand available scripts and commands.
15
+ 2. If you need more context, use the Read tool to look at relevant files (scripts, configs, docs).
16
+ 3. Prefer existing scripts/targets from package.json, Makefile, etc. over raw commands.
17
+ 4. If unsure about exact flags, use common/standard ones or provide multiple options.
18
+
19
+ RESPONSE FORMAT \u2014 THIS IS CRITICAL:
20
+ Your final message MUST be ONLY a JSON object. No prose, no explanation, no "Based on...", no markdown.
21
+ Exactly this format: {"commands": ["command1"]}
22
+ If you are unsure which command the user wants, return multiple options and the user will pick: {"commands": ["option1", "option2"]}
23
+ Prefer existing scripts/targets with correct flags over raw commands.
24
+ For values the user hasn't specified, use <placeholders> like: git commit -m "<message>" or curl <url>. Use descriptive names inside the angle brackets.`;
25
+ var PROJECT_FILES = [
26
+ "Makefile",
27
+ "package.json",
28
+ "README.md",
29
+ "Justfile",
30
+ "Taskfile.yml",
31
+ "docker-compose.yml",
32
+ "Cargo.toml",
33
+ "pyproject.toml",
34
+ "Gemfile"
35
+ ];
36
+ var MAX_FILE_SIZE = 4e3;
37
+ async function gatherProjectContext(cwd) {
38
+ const sections = [];
39
+ const found = [];
40
+ for (const file of PROJECT_FILES) {
41
+ const filePath = join(cwd, file);
42
+ try {
43
+ await access(filePath);
44
+ let content = await readFile(filePath, "utf-8");
45
+ if (content.length > MAX_FILE_SIZE) {
46
+ content = content.slice(0, MAX_FILE_SIZE) + "\n...(truncated)";
47
+ }
48
+ sections.push(`--- ${file} ---
49
+ ${content}`);
50
+ found.push(file);
51
+ } catch {
52
+ }
53
+ }
54
+ if (found.length > 0) logVerbose(` context: ${found.join(", ")}`);
55
+ if (sections.length === 0) return "";
56
+ return `
57
+
58
+ Project files:
59
+
60
+ ${sections.join("\n\n")}`;
61
+ }
20
62
  var verbose = false;
21
63
  function setVerbose(v) {
22
64
  verbose = v;
23
65
  }
24
- function execPromise(cmd, args, cwd) {
25
- if (verbose) {
26
- console.error(`\x1B[2m$ ${cmd} ${args.join(" ")}\x1B[0m`);
27
- console.error(`\x1B[2m cwd: ${cwd}\x1B[0m`);
66
+ function tryParseJson(text) {
67
+ try {
68
+ const parsed = JSON.parse(text);
69
+ if (typeof parsed.command === "string") {
70
+ return [parsed.command];
71
+ }
72
+ if (Array.isArray(parsed.commands) && parsed.commands.every((c) => typeof c === "string")) {
73
+ return parsed.commands;
74
+ }
75
+ } catch {
28
76
  }
29
- return new Promise((resolve, reject) => {
30
- execFile(cmd, args, { cwd, maxBuffer: 1024 * 1024, timeout: 12e4 }, (err, stdout, stderr) => {
31
- if (verbose) {
32
- if (stderr) console.error(`\x1B[2mstderr: ${stderr}\x1B[0m`);
33
- if (stdout) console.error(`\x1B[2mstdout: ${stdout.slice(0, 500)}\x1B[0m`);
34
- if (err) console.error(`\x1B[2merror: ${err.message}\x1B[0m`);
35
- }
36
- if (err) reject(err);
37
- else resolve({ stdout, stderr });
38
- });
39
- });
77
+ return null;
40
78
  }
41
- function parseCommands(raw) {
42
- let cleaned = raw.trim();
43
- cleaned = cleaned.replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/i, "");
44
- cleaned = cleaned.trim();
45
- const parsed = JSON.parse(cleaned);
46
- if (!parsed.commands || !Array.isArray(parsed.commands)) {
47
- throw new Error("Invalid response: missing commands array");
79
+ function parseResponse(raw) {
80
+ let text = raw.trim();
81
+ let commands = tryParseJson(text);
82
+ if (commands) return { commands };
83
+ const fenceMatch = text.match(/```(?:json)?\s*([\s\S]*?)\s*```/);
84
+ if (fenceMatch) {
85
+ commands = tryParseJson(fenceMatch[1].trim());
86
+ if (commands) return { commands };
48
87
  }
49
- if (!parsed.commands.every((c) => typeof c === "string")) {
50
- throw new Error("Invalid response: commands must be strings");
88
+ const jsonMatch = text.match(/\{[\s\S]*"commands?"\s*:[\s\S]*\}/);
89
+ if (jsonMatch) {
90
+ commands = tryParseJson(jsonMatch[0]);
91
+ if (commands) return { commands };
51
92
  }
52
- return parsed.commands;
93
+ const backtickCmds = [...text.matchAll(/`([^`]+)`/g)].map((m) => m[1].trim()).filter((c) => c.length > 2 && !c.includes("{") && !c.startsWith("//"));
94
+ if (backtickCmds.length > 0) return { commands: backtickCmds };
95
+ throw new Error("Could not parse AI response. Run with -v to debug.");
53
96
  }
54
- function spawnWithStdin(cmd, args, input2, cwd) {
55
- if (verbose) {
56
- console.error(`\x1B[2m$ echo '...' | ${cmd} ${args.join(" ")}\x1B[0m`);
57
- console.error(`\x1B[2m cwd: ${cwd}\x1B[0m`);
58
- console.error(`\x1B[2m stdin: ${input2.slice(0, 200)}...\x1B[0m`);
97
+ var DIM = "\x1B[2m";
98
+ var R = "\x1B[0m";
99
+ function logVerbose(msg) {
100
+ if (verbose) console.error(`${DIM}${msg}${R}`);
101
+ }
102
+ function formatStats(wrapper) {
103
+ const lines = [];
104
+ const duration = wrapper.duration_ms;
105
+ const apiDuration = wrapper.duration_api_ms;
106
+ const turns = wrapper.num_turns;
107
+ const cost = wrapper.total_cost_usd;
108
+ const usage = wrapper.usage;
109
+ if (duration != null) {
110
+ const secs = (duration / 1e3).toFixed(1);
111
+ const apiSecs = apiDuration ? ` (api: ${(apiDuration / 1e3).toFixed(1)}s)` : "";
112
+ lines.push(` time: ${secs}s${apiSecs}`);
113
+ }
114
+ if (turns != null) lines.push(` turns: ${turns}`);
115
+ if (cost != null) lines.push(` cost: $${cost.toFixed(4)}`);
116
+ if (usage) {
117
+ const input2 = (usage.input_tokens || 0) + (usage.cache_creation_input_tokens || 0) + (usage.cache_read_input_tokens || 0);
118
+ const output = usage.output_tokens || 0;
119
+ lines.push(` tokens: ${input2} in / ${output} out`);
120
+ }
121
+ return lines.join("\n");
122
+ }
123
+ var CACHE_DIR = join(homedir(), ".cache", "aish");
124
+ var CACHE_TTL_MS = 60 * 60 * 1e3;
125
+ function cacheKey(query, context, model) {
126
+ return createHash("sha256").update(`${model}:${query}:${context}`).digest("hex").slice(0, 16);
127
+ }
128
+ async function cacheGet(key) {
129
+ try {
130
+ const filePath = join(CACHE_DIR, `${key}.json`);
131
+ const stat = await access(filePath).then(() => true).catch(() => false);
132
+ if (!stat) return null;
133
+ const raw = await readFile(filePath, "utf-8");
134
+ const entry = JSON.parse(raw);
135
+ if (Date.now() - entry.ts > CACHE_TTL_MS) {
136
+ await unlink(filePath).catch(() => {
137
+ });
138
+ return null;
139
+ }
140
+ return entry.result;
141
+ } catch {
142
+ return null;
143
+ }
144
+ }
145
+ async function cacheSet(key, result) {
146
+ try {
147
+ await mkdir(CACHE_DIR, { recursive: true });
148
+ await writeFile(join(CACHE_DIR, `${key}.json`), JSON.stringify({ ts: Date.now(), result }));
149
+ } catch {
59
150
  }
151
+ }
152
+ function spawnWithStdin(cmd, args, input2, cwd) {
153
+ logVerbose(`$ ${cmd} ${args.join(" ")}`);
154
+ logVerbose(` cwd: ${cwd}`);
60
155
  return new Promise((resolve, reject) => {
61
156
  const child = spawn(cmd, args, { cwd, stdio: ["pipe", "pipe", "pipe"] });
62
157
  let stdout = "";
@@ -64,10 +159,7 @@ function spawnWithStdin(cmd, args, input2, cwd) {
64
159
  child.stdout.on("data", (d) => stdout += d);
65
160
  child.stderr.on("data", (d) => stderr += d);
66
161
  child.on("close", (code) => {
67
- if (verbose) {
68
- if (stderr) console.error(`\x1B[2mstderr: ${stderr.slice(0, 500)}\x1B[0m`);
69
- if (stdout) console.error(`\x1B[2mstdout: ${stdout.slice(0, 500)}\x1B[0m`);
70
- }
162
+ if (verbose && stderr) logVerbose(`stderr: ${stderr.slice(0, 500)}`);
71
163
  if (code !== 0) reject(new Error(`${cmd} exited with code ${code}
72
164
  ${stderr}`));
73
165
  else resolve({ stdout, stderr });
@@ -78,48 +170,67 @@ ${stderr}`));
78
170
  });
79
171
  }
80
172
  async function queryClaude(query, cwd, model) {
81
- const prompt = `${SYSTEM_PROMPT}
173
+ const effectiveModel = model || "sonnet";
174
+ const context = await gatherProjectContext(cwd);
175
+ const key = cacheKey(query, context, effectiveModel);
176
+ const cached = await cacheGet(key);
177
+ if (cached) {
178
+ logVerbose(" cache: hit");
179
+ return cached;
180
+ }
181
+ const prompt = `${SYSTEM_PROMPT}${context}
82
182
 
83
183
  User request: ${query}`;
84
184
  const args = [
85
185
  "-p",
86
186
  "--output-format",
87
- "json"
187
+ "json",
188
+ "--model",
189
+ effectiveModel,
190
+ "--allowedTools",
191
+ "Read,Glob"
88
192
  ];
89
- if (model) {
90
- args.push("--model", model);
91
- } else {
92
- args.push("--model", "sonnet");
93
- }
94
193
  const { stdout } = await spawnWithStdin("claude", args, prompt, cwd);
95
194
  let text = stdout.trim();
96
195
  try {
97
196
  const wrapper = JSON.parse(text);
197
+ if (verbose) {
198
+ logVerbose(formatStats(wrapper));
199
+ if (wrapper.result) logVerbose(` result: ${wrapper.result}`);
200
+ }
98
201
  if (wrapper.result) {
99
202
  text = wrapper.result;
100
203
  }
101
204
  } catch {
205
+ if (verbose) logVerbose(` raw: ${text.slice(0, 500)}`);
102
206
  }
103
- return { commands: parseCommands(text) };
207
+ const result = parseResponse(text);
208
+ result.cacheKey = key;
209
+ return result;
104
210
  }
105
211
  async function queryCodex(query, cwd, model) {
212
+ const context = await gatherProjectContext(cwd);
106
213
  const resultFile = join(tmpdir(), `aish-codex-${Date.now()}.txt`);
214
+ const prompt = `${SYSTEM_PROMPT}${context}
215
+
216
+ User request: ${query}`;
107
217
  const args = [
108
218
  "exec",
219
+ "--sandbox",
220
+ "read-only",
109
221
  "-o",
110
222
  resultFile,
111
- `${SYSTEM_PROMPT}
112
-
113
- User request: ${query}`
223
+ "-"
224
+ // Read prompt from stdin
114
225
  ];
115
226
  if (model) {
116
227
  args.push("--model", model);
117
228
  }
118
- await execPromise("codex", args, cwd);
229
+ await spawnWithStdin("codex", args, prompt, cwd);
119
230
  const text = await readFile(resultFile, "utf-8");
120
231
  await unlink(resultFile).catch(() => {
121
232
  });
122
- return { commands: parseCommands(text) };
233
+ return parseResponse(text);
123
234
  }
124
235
  async function queryAi(provider, query, cwd, model) {
125
236
  if (provider === "codex") {
@@ -129,39 +240,75 @@ async function queryAi(provider, query, cwd, model) {
129
240
  }
130
241
 
131
242
  // src/ui.ts
243
+ import { createInterface } from "readline";
132
244
  import { select, input } from "@inquirer/prompts";
133
245
  var CYAN = "\x1B[36m";
246
+ var DIM2 = "\x1B[2m";
247
+ var YELLOW = "\x1B[33m";
134
248
  var RESET = "\x1B[0m";
135
- async function promptEdit(cmd) {
136
- const edited = await input({
137
- message: "Edit command:",
138
- default: cmd
249
+ var PLACEHOLDER_RE = /<([^>]+)>/g;
250
+ async function fillPlaceholders(cmd) {
251
+ const placeholders = [...cmd.matchAll(PLACEHOLDER_RE)];
252
+ if (placeholders.length === 0) return cmd;
253
+ console.log(`${DIM2} Fill in the placeholders:${RESET}`);
254
+ let result = cmd;
255
+ const seen = /* @__PURE__ */ new Set();
256
+ for (const match of placeholders) {
257
+ const full = match[0];
258
+ const name = match[1];
259
+ if (seen.has(full)) continue;
260
+ seen.add(full);
261
+ const value = await input({
262
+ message: `${YELLOW}${name}${RESET}`
263
+ });
264
+ if (!value.trim()) return null;
265
+ result = result.replaceAll(full, value);
266
+ }
267
+ return result;
268
+ }
269
+ function promptEdit(cmd) {
270
+ return new Promise((resolve) => {
271
+ const rl = createInterface({
272
+ input: process.stdin,
273
+ output: process.stdout,
274
+ terminal: true
275
+ });
276
+ rl.write(cmd);
277
+ rl.on("line", (line) => {
278
+ rl.close();
279
+ const trimmed = line.trim();
280
+ resolve(trimmed || null);
281
+ });
282
+ rl.on("close", () => resolve(null));
139
283
  });
140
- const trimmed = edited.trim();
141
- return trimmed || null;
142
284
  }
143
285
  async function promptAction(cmd) {
144
- console.log(`
145
- ${CYAN}${cmd}${RESET}
146
- `);
286
+ const hasPlaceholders = PLACEHOLDER_RE.test(cmd);
287
+ PLACEHOLDER_RE.lastIndex = 0;
147
288
  const action = await select({
148
289
  message: "Action:",
149
290
  choices: [
150
- { name: "Run", value: "run" },
151
- { name: "Edit", value: "edit" },
291
+ { name: hasPlaceholders ? "Run (fill placeholders)" : "Run", value: "run" },
292
+ { name: `Edit ${DIM2}(modify command before running)${RESET}`, value: "edit" },
152
293
  { name: "Cancel", value: "cancel" }
153
294
  ]
154
295
  });
155
- if (action === "run") return cmd;
156
- if (action === "edit") return promptEdit(cmd);
296
+ if (action === "run") return fillPlaceholders(cmd);
297
+ if (action === "edit") {
298
+ process.stdout.write(`${DIM2}> ${RESET}`);
299
+ return promptEdit(cmd);
300
+ }
157
301
  return null;
158
302
  }
159
303
  async function promptCommand(commands) {
160
304
  if (commands.length === 1) {
305
+ console.log(`
306
+ ${CYAN}${commands[0]}${RESET}
307
+ `);
161
308
  return promptAction(commands[0]);
162
309
  }
163
310
  const choice = await select({
164
- message: "Select a command to run:",
311
+ message: "Select a command:",
165
312
  choices: [
166
313
  ...commands.map((cmd) => ({
167
314
  name: `${CYAN}${cmd}${RESET}`,
@@ -171,6 +318,9 @@ async function promptCommand(commands) {
171
318
  ]
172
319
  });
173
320
  if (choice === "__cancel__") return null;
321
+ console.log(`
322
+ ${CYAN}${choice}${RESET}
323
+ `);
174
324
  return promptAction(choice);
175
325
  }
176
326
 
@@ -190,13 +340,13 @@ function execCommand(cmd, cwd) {
190
340
 
191
341
  // src/index.ts
192
342
  var BRAILLE = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
193
- var DIM = "\x1B[2m";
343
+ var DIM3 = "\x1B[2m";
194
344
  var RESET2 = "\x1B[0m";
195
345
  var RED = "\x1B[31m";
196
346
  function startSpinner(message) {
197
347
  let i = 0;
198
348
  const interval = setInterval(() => {
199
- process.stderr.write(`\r${DIM}${BRAILLE[i++ % BRAILLE.length]} ${message}${RESET2}`);
349
+ process.stderr.write(`\r${DIM3}${BRAILLE[i++ % BRAILLE.length]} ${message}${RESET2}`);
200
350
  }, 80);
201
351
  return () => {
202
352
  clearInterval(interval);
@@ -209,6 +359,7 @@ function parseArgs(argv) {
209
359
  let cwd = process.cwd();
210
360
  let model = process.env.AISH_MODEL || void 0;
211
361
  let verbose2 = false;
362
+ let print = false;
212
363
  const queryParts = [];
213
364
  for (let i = 0; i < args.length; i++) {
214
365
  const arg = args[i];
@@ -225,6 +376,8 @@ function parseArgs(argv) {
225
376
  model = args[++i];
226
377
  } else if (arg === "-v" || arg === "--verbose") {
227
378
  verbose2 = true;
379
+ } else if (arg === "--print") {
380
+ print = true;
228
381
  } else if (arg === "-h" || arg === "--help") {
229
382
  console.log(`Usage: aish [options] <query...>
230
383
 
@@ -232,38 +385,55 @@ Options:
232
385
  -p, --provider <claude|codex> AI provider (default: claude, env: AISH_PROVIDER)
233
386
  -m, --model <model> Model override (env: AISH_MODEL)
234
387
  --cwd <dir> Working directory
388
+ --print Output command only (for shell integration)
235
389
  -v, --verbose Show debug output
236
- -h, --help Show help`);
390
+ -h, --help Show help
391
+
392
+ Zsh Integration:
393
+ Add to .zshrc: source "$(npm root -g)/aish-cli/aish.plugin.zsh"
394
+ Then press Ctrl+G to activate AI mode`);
237
395
  process.exit(0);
238
396
  } else {
239
397
  queryParts.push(arg);
240
398
  }
241
399
  }
242
- return { query: queryParts.join(" "), provider, cwd, model, verbose: verbose2 };
400
+ return { query: queryParts.join(" "), provider, cwd, model, verbose: verbose2, print };
243
401
  }
244
402
  async function main() {
245
- const { query, provider, cwd, model, verbose: verbose2 } = parseArgs(process.argv);
403
+ const { query, provider, cwd, model, verbose: verbose2, print } = parseArgs(process.argv);
246
404
  setVerbose(verbose2);
247
405
  if (!query) {
248
406
  console.error(`${RED}Usage: aish <query>${RESET2}`);
249
407
  process.exit(1);
250
408
  }
251
- const stopSpinner = verbose2 ? () => {
409
+ const stopSpinner = verbose2 || print ? () => {
252
410
  } : startSpinner("Thinking...");
253
411
  let commands;
412
+ let resultCacheKey;
254
413
  try {
255
414
  const result = await queryAi(provider, query, cwd, model);
256
415
  commands = result.commands;
416
+ resultCacheKey = result.cacheKey;
257
417
  } catch (err) {
258
418
  stopSpinner();
419
+ if (print) {
420
+ process.exit(1);
421
+ }
259
422
  console.error(`${RED}Error: ${err.message}${RESET2}`);
260
423
  process.exit(1);
261
424
  }
262
425
  stopSpinner();
263
426
  if (commands.length === 0) {
427
+ if (print) {
428
+ process.exit(1);
429
+ }
264
430
  console.error(`${RED}No commands suggested.${RESET2}`);
265
431
  process.exit(1);
266
432
  }
433
+ if (print) {
434
+ console.log(commands[0]);
435
+ process.exit(0);
436
+ }
267
437
  let chosen;
268
438
  try {
269
439
  chosen = await promptCommand(commands);
@@ -274,6 +444,9 @@ async function main() {
274
444
  if (!chosen) {
275
445
  process.exit(0);
276
446
  }
447
+ if (resultCacheKey) {
448
+ await cacheSet(resultCacheKey, { commands });
449
+ }
277
450
  const exitCode = await execCommand(chosen, cwd);
278
451
  process.exit(exitCode);
279
452
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aish-cli",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "AI Shell - convert natural language to bash commands using Claude or Codex",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -8,11 +8,22 @@
8
8
  "type": "git",
9
9
  "url": "https://github.com/janicduplessis/aish.git"
10
10
  },
11
- "keywords": ["ai", "shell", "cli", "claude", "codex", "bash", "natural-language"],
11
+ "keywords": [
12
+ "ai",
13
+ "shell",
14
+ "cli",
15
+ "claude",
16
+ "codex",
17
+ "bash",
18
+ "natural-language"
19
+ ],
12
20
  "bin": {
13
21
  "aish": "./dist/index.js"
14
22
  },
15
- "files": ["dist"],
23
+ "files": [
24
+ "dist",
25
+ "aish.plugin.zsh"
26
+ ],
16
27
  "scripts": {
17
28
  "build": "tsup",
18
29
  "dev": "tsx src/index.ts",