aish-cli 1.0.0 → 1.1.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 (3) hide show
  1. package/aish.plugin.zsh +427 -0
  2. package/dist/index.js +286 -78
  3. package/package.json +14 -3
@@ -0,0 +1,427 @@
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 stats=$(<"$errfile")
331
+ rm -f "$tmpfile" "$errfile"
332
+
333
+ # Strip ANSI codes from stats for clean display
334
+ stats="${stats//$'\e[2m'/}"
335
+ stats="${stats//$'\e[0m'/}"
336
+ stats="${stats## }" # trim leading space
337
+ stats="${stats%%$'\n'}" # trim trailing newline
338
+
339
+ if [[ $exit_code -ne 0 ]]; then
340
+ # Show error briefly
341
+ BUFFER=""
342
+ POSTDISPLAY=$'\n '"Error: ${stats:-aish command failed}"
343
+ region_highlight=()
344
+ zle -R
345
+ sleep 2
346
+ POSTDISPLAY=""
347
+ region_highlight=("${saved_region_highlight[@]}")
348
+ BUFFER="$saved_buffer"
349
+ CURSOR="$saved_cursor"
350
+ zle -R
351
+ return
352
+ fi
353
+
354
+ if [[ -n "$cmd" ]]; then
355
+ _aish_save_history "$query"
356
+
357
+ # Fill placeholders if any
358
+ if ! _aish_fill_placeholders "$cmd"; then
359
+ # Cancelled during placeholder fill
360
+ region_highlight=("${saved_region_highlight[@]}")
361
+ BUFFER="$saved_buffer"
362
+ CURSOR="$saved_cursor"
363
+ zle -R
364
+ return
365
+ fi
366
+ cmd="$_aish_input_query"
367
+
368
+ if [[ -n "$context" ]]; then
369
+ context="${context}
370
+
371
+ User refinement: ${query}
372
+ Command: ${cmd}"
373
+ else
374
+ context="User request: ${query}
375
+ Command: ${cmd}"
376
+ fi
377
+
378
+ # Show result with hints and stats
379
+ BUFFER="$cmd"
380
+ CURSOR=${#BUFFER}
381
+ local hints=$'\n tab: refine │ enter: accept │ esc: cancel'
382
+ [[ -n "$stats" ]] && hints+=" │ ${stats}"
383
+ POSTDISPLAY="$hints"
384
+ # Highlight POSTDISPLAY area (starts after BUFFER)
385
+ local hint_start=${#BUFFER}
386
+ local hint_end=$(( hint_start + ${#hints} ))
387
+ region_highlight=("${saved_region_highlight[@]}" "${hint_start} ${hint_end} fg=8")
388
+ zle -R
389
+
390
+ # Wait for user choice - read into REPLY (no variable assignment shown)
391
+ zle -R
392
+ read -sk1
393
+
394
+ # Clear hints
395
+ POSTDISPLAY=""
396
+
397
+ # Handle key based on REPLY
398
+ if [[ "$REPLY" == $'\t' ]]; then
399
+ # Tab - refine
400
+ BUFFER=""
401
+ CURSOR=0
402
+ zle -R
403
+ continue
404
+ elif [[ "$REPLY" == $'\e' ]]; then
405
+ region_highlight=("${saved_region_highlight[@]}")
406
+ BUFFER="$saved_buffer"
407
+ CURSOR="$saved_cursor"
408
+ zle -R
409
+ return
410
+ elif [[ "$REPLY" == $'\n' || "$REPLY" == $'\r' ]]; then
411
+ return
412
+ else
413
+ [[ "$REPLY" == [[:print:]] ]] && BUFFER="${cmd}${REPLY}" && CURSOR=${#BUFFER}
414
+ return
415
+ fi
416
+ else
417
+ region_highlight=("${saved_region_highlight[@]}")
418
+ BUFFER="$saved_buffer"
419
+ CURSOR="$saved_cursor"
420
+ zle -R
421
+ return
422
+ fi
423
+ done
424
+ }
425
+
426
+ zle -N _aish_widget
427
+ bindkey '^G' _aish_widget
package/dist/index.js CHANGED
@@ -2,61 +2,159 @@
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
+ GUIDELINES:
12
+ 1. For standard commands (git, ls, curl, etc.), respond immediately without reading files.
13
+ 2. For project-specific commands (build, test, run scripts, etc.), ALWAYS read README.md first (if it exists), then the relevant config file:
14
+ - Node.js: package.json
15
+ - Python: pyproject.toml, setup.py, or Pipfile
16
+ - Rust: Cargo.toml
17
+ - Go: go.mod
18
+ - Ruby: Gemfile
19
+ - Make: Makefile
20
+ - Other: Justfile, Taskfile.yml
21
+ 3. ALWAYS prefer project scripts over direct tool invocation:
22
+ - Use "npm run build" not "tsup" or "tsc"
23
+ - Use "make test" not the underlying test command
24
+ - Use "cargo build" not "rustc" directly
25
+ This ensures correct flags, environment, and project configuration.
26
+ 4. If unsure about exact flags, provide multiple options.
15
27
 
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.`;
28
+ RESPONSE FORMAT \u2014 THIS IS CRITICAL:
29
+ Your final message MUST be ONLY a JSON object. No prose, no explanation, no "Based on...", no markdown.
30
+ Exactly this format: {"commands": ["command1"]}
31
+ If you are unsure which command the user wants, return multiple options and the user will pick: {"commands": ["option1", "option2"]}
32
+ For values the user hasn't specified, use <placeholders> like: git commit -m "<message>" or curl <url>. Use descriptive names inside the angle brackets.`;
33
+ var TOOL_HINTS = [
34
+ { files: ["bun.lockb", "bun.lock"], hint: "Use bun (not npm/yarn/pnpm)" },
35
+ { files: ["pnpm-lock.yaml"], hint: "Use pnpm (not npm/yarn)" },
36
+ { files: ["yarn.lock"], hint: "Use yarn (not npm/pnpm)" },
37
+ { files: ["package-lock.json"], hint: "Use npm (not yarn/pnpm)" },
38
+ { files: ["Cargo.lock"], hint: "Use cargo for Rust commands" },
39
+ { files: ["poetry.lock"], hint: "Use poetry (not pip)" },
40
+ { files: ["Pipfile.lock"], hint: "Use pipenv (not pip)" },
41
+ { files: ["uv.lock"], hint: "Use uv (not pip/poetry)" },
42
+ { files: ["Gemfile.lock"], hint: "Use bundle exec for Ruby commands" },
43
+ { files: ["go.sum"], hint: "Use go modules" },
44
+ { files: ["flake.lock"], hint: "Nix flake project - use nix commands" }
45
+ ];
46
+ async function gatherProjectContext(cwd) {
47
+ const hints = [];
48
+ for (const { files, hint } of TOOL_HINTS) {
49
+ for (const file of files) {
50
+ try {
51
+ await access(join(cwd, file));
52
+ hints.push(hint);
53
+ break;
54
+ } catch {
55
+ }
56
+ }
57
+ }
58
+ if (hints.length > 0) logVerbose(` hints: ${hints.join(", ")}`);
59
+ if (hints.length === 0) return "";
60
+ return `
61
+
62
+ Tool hints:
63
+ - ${hints.join("\n- ")}`;
64
+ }
20
65
  var verbose = false;
21
66
  function setVerbose(v) {
22
67
  verbose = v;
23
68
  }
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`);
69
+ function tryParseJson(text) {
70
+ try {
71
+ const parsed = JSON.parse(text);
72
+ if (typeof parsed.command === "string") {
73
+ return [parsed.command];
74
+ }
75
+ if (Array.isArray(parsed.commands) && parsed.commands.every((c) => typeof c === "string")) {
76
+ return parsed.commands;
77
+ }
78
+ } catch {
28
79
  }
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
- });
80
+ return null;
40
81
  }
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");
82
+ function parseResponse(raw) {
83
+ let text = raw.trim();
84
+ let commands = tryParseJson(text);
85
+ if (commands) return { commands };
86
+ const fenceMatch = text.match(/```(?:json)?\s*([\s\S]*?)\s*```/);
87
+ if (fenceMatch) {
88
+ commands = tryParseJson(fenceMatch[1].trim());
89
+ if (commands) return { commands };
48
90
  }
49
- if (!parsed.commands.every((c) => typeof c === "string")) {
50
- throw new Error("Invalid response: commands must be strings");
91
+ const jsonMatch = text.match(/\{[\s\S]*"commands?"\s*:[\s\S]*\}/);
92
+ if (jsonMatch) {
93
+ commands = tryParseJson(jsonMatch[0]);
94
+ if (commands) return { commands };
51
95
  }
52
- return parsed.commands;
96
+ const backtickCmds = [...text.matchAll(/`([^`]+)`/g)].map((m) => m[1].trim()).filter((c) => c.length > 2 && !c.includes("{") && !c.startsWith("//"));
97
+ if (backtickCmds.length > 0) return { commands: backtickCmds };
98
+ throw new Error("Could not parse AI response. Run with -v to debug.");
53
99
  }
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`);
100
+ var DIM = "\x1B[2m";
101
+ var R = "\x1B[0m";
102
+ function logVerbose(msg) {
103
+ if (verbose) console.error(`${DIM}${msg}${R}`);
104
+ }
105
+ function formatStats(wrapper) {
106
+ const lines = [];
107
+ const duration = wrapper.duration_ms;
108
+ const apiDuration = wrapper.duration_api_ms;
109
+ const turns = wrapper.num_turns;
110
+ const cost = wrapper.total_cost_usd;
111
+ const usage = wrapper.usage;
112
+ if (duration != null) {
113
+ const secs = (duration / 1e3).toFixed(1);
114
+ const apiSecs = apiDuration ? ` (api: ${(apiDuration / 1e3).toFixed(1)}s)` : "";
115
+ lines.push(` time: ${secs}s${apiSecs}`);
116
+ }
117
+ if (turns != null) lines.push(` turns: ${turns}`);
118
+ if (cost != null) lines.push(` cost: $${cost.toFixed(4)}`);
119
+ if (usage) {
120
+ const input2 = (usage.input_tokens || 0) + (usage.cache_creation_input_tokens || 0) + (usage.cache_read_input_tokens || 0);
121
+ const output = usage.output_tokens || 0;
122
+ lines.push(` tokens: ${input2} in / ${output} out`);
123
+ }
124
+ return lines.join("\n");
125
+ }
126
+ var CACHE_DIR = join(homedir(), ".cache", "aish");
127
+ var CACHE_TTL_MS = 60 * 60 * 1e3;
128
+ function cacheKey(query, context, model) {
129
+ return createHash("sha256").update(`${model}:${query}:${context}`).digest("hex").slice(0, 16);
130
+ }
131
+ async function cacheGet(key) {
132
+ try {
133
+ const filePath = join(CACHE_DIR, `${key}.json`);
134
+ const stat = await access(filePath).then(() => true).catch(() => false);
135
+ if (!stat) return null;
136
+ const raw = await readFile(filePath, "utf-8");
137
+ const entry = JSON.parse(raw);
138
+ if (Date.now() - entry.ts > CACHE_TTL_MS) {
139
+ await unlink(filePath).catch(() => {
140
+ });
141
+ return null;
142
+ }
143
+ return entry.result;
144
+ } catch {
145
+ return null;
59
146
  }
147
+ }
148
+ async function cacheSet(key, result) {
149
+ try {
150
+ await mkdir(CACHE_DIR, { recursive: true });
151
+ await writeFile(join(CACHE_DIR, `${key}.json`), JSON.stringify({ ts: Date.now(), result }));
152
+ } catch {
153
+ }
154
+ }
155
+ function spawnWithStdin(cmd, args, input2, cwd) {
156
+ logVerbose(`$ ${cmd} ${args.join(" ")}`);
157
+ logVerbose(` cwd: ${cwd}`);
60
158
  return new Promise((resolve, reject) => {
61
159
  const child = spawn(cmd, args, { cwd, stdio: ["pipe", "pipe", "pipe"] });
62
160
  let stdout = "";
@@ -64,10 +162,7 @@ function spawnWithStdin(cmd, args, input2, cwd) {
64
162
  child.stdout.on("data", (d) => stdout += d);
65
163
  child.stderr.on("data", (d) => stderr += d);
66
164
  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
- }
165
+ if (verbose && stderr) logVerbose(`stderr: ${stderr.slice(0, 500)}`);
71
166
  if (code !== 0) reject(new Error(`${cmd} exited with code ${code}
72
167
  ${stderr}`));
73
168
  else resolve({ stdout, stderr });
@@ -78,48 +173,80 @@ ${stderr}`));
78
173
  });
79
174
  }
80
175
  async function queryClaude(query, cwd, model) {
81
- const prompt = `${SYSTEM_PROMPT}
176
+ const effectiveModel = model || "sonnet";
177
+ const context = await gatherProjectContext(cwd);
178
+ const key = cacheKey(query, context, effectiveModel);
179
+ const cached = await cacheGet(key);
180
+ if (cached) {
181
+ logVerbose(" cache: hit");
182
+ return { ...cached, stats: { cached: true } };
183
+ }
184
+ const prompt = `${SYSTEM_PROMPT}${context}
82
185
 
83
186
  User request: ${query}`;
84
187
  const args = [
85
188
  "-p",
86
189
  "--output-format",
87
- "json"
190
+ "json",
191
+ "--model",
192
+ effectiveModel,
193
+ "--allowedTools",
194
+ "Read,Glob"
88
195
  ];
89
- if (model) {
90
- args.push("--model", model);
91
- } else {
92
- args.push("--model", "sonnet");
93
- }
94
196
  const { stdout } = await spawnWithStdin("claude", args, prompt, cwd);
95
197
  let text = stdout.trim();
198
+ let stats = {};
96
199
  try {
97
200
  const wrapper = JSON.parse(text);
201
+ if (verbose) {
202
+ logVerbose(formatStats(wrapper));
203
+ if (wrapper.result) logVerbose(` result: ${wrapper.result}`);
204
+ }
205
+ stats.durationMs = wrapper.duration_ms;
206
+ stats.cost = wrapper.total_cost_usd;
207
+ if (wrapper.usage) {
208
+ stats.inputTokens = (wrapper.usage.input_tokens || 0) + (wrapper.usage.cache_creation_input_tokens || 0) + (wrapper.usage.cache_read_input_tokens || 0);
209
+ stats.outputTokens = wrapper.usage.output_tokens || 0;
210
+ }
98
211
  if (wrapper.result) {
99
212
  text = wrapper.result;
100
213
  }
101
214
  } catch {
215
+ if (verbose) logVerbose(` raw: ${text.slice(0, 500)}`);
102
216
  }
103
- return { commands: parseCommands(text) };
217
+ const result = parseResponse(text);
218
+ result.cacheKey = key;
219
+ result.stats = stats;
220
+ return result;
104
221
  }
105
222
  async function queryCodex(query, cwd, model) {
223
+ const startTime = Date.now();
224
+ const context = await gatherProjectContext(cwd);
106
225
  const resultFile = join(tmpdir(), `aish-codex-${Date.now()}.txt`);
226
+ const prompt = `${SYSTEM_PROMPT}${context}
227
+
228
+ User request: ${query}`;
107
229
  const args = [
108
230
  "exec",
231
+ "--sandbox",
232
+ "read-only",
233
+ "-c",
234
+ 'model_reasoning_effort="low"',
109
235
  "-o",
110
236
  resultFile,
111
- `${SYSTEM_PROMPT}
112
-
113
- User request: ${query}`
237
+ "-"
238
+ // Read prompt from stdin
114
239
  ];
115
240
  if (model) {
116
241
  args.push("--model", model);
117
242
  }
118
- await execPromise("codex", args, cwd);
243
+ await spawnWithStdin("codex", args, prompt, cwd);
119
244
  const text = await readFile(resultFile, "utf-8");
120
245
  await unlink(resultFile).catch(() => {
121
246
  });
122
- return { commands: parseCommands(text) };
247
+ const result = parseResponse(text);
248
+ result.stats = { durationMs: Date.now() - startTime };
249
+ return result;
123
250
  }
124
251
  async function queryAi(provider, query, cwd, model) {
125
252
  if (provider === "codex") {
@@ -129,39 +256,75 @@ async function queryAi(provider, query, cwd, model) {
129
256
  }
130
257
 
131
258
  // src/ui.ts
259
+ import { createInterface } from "readline";
132
260
  import { select, input } from "@inquirer/prompts";
133
261
  var CYAN = "\x1B[36m";
262
+ var DIM2 = "\x1B[2m";
263
+ var YELLOW = "\x1B[33m";
134
264
  var RESET = "\x1B[0m";
135
- async function promptEdit(cmd) {
136
- const edited = await input({
137
- message: "Edit command:",
138
- default: cmd
265
+ var PLACEHOLDER_RE = /<([^>]+)>/g;
266
+ async function fillPlaceholders(cmd) {
267
+ const placeholders = [...cmd.matchAll(PLACEHOLDER_RE)];
268
+ if (placeholders.length === 0) return cmd;
269
+ console.log(`${DIM2} Fill in the placeholders:${RESET}`);
270
+ let result = cmd;
271
+ const seen = /* @__PURE__ */ new Set();
272
+ for (const match of placeholders) {
273
+ const full = match[0];
274
+ const name = match[1];
275
+ if (seen.has(full)) continue;
276
+ seen.add(full);
277
+ const value = await input({
278
+ message: `${YELLOW}${name}${RESET}`
279
+ });
280
+ if (!value.trim()) return null;
281
+ result = result.replaceAll(full, value);
282
+ }
283
+ return result;
284
+ }
285
+ function promptEdit(cmd) {
286
+ return new Promise((resolve) => {
287
+ const rl = createInterface({
288
+ input: process.stdin,
289
+ output: process.stdout,
290
+ terminal: true
291
+ });
292
+ rl.write(cmd);
293
+ rl.on("line", (line) => {
294
+ rl.close();
295
+ const trimmed = line.trim();
296
+ resolve(trimmed || null);
297
+ });
298
+ rl.on("close", () => resolve(null));
139
299
  });
140
- const trimmed = edited.trim();
141
- return trimmed || null;
142
300
  }
143
301
  async function promptAction(cmd) {
144
- console.log(`
145
- ${CYAN}${cmd}${RESET}
146
- `);
302
+ const hasPlaceholders = PLACEHOLDER_RE.test(cmd);
303
+ PLACEHOLDER_RE.lastIndex = 0;
147
304
  const action = await select({
148
305
  message: "Action:",
149
306
  choices: [
150
- { name: "Run", value: "run" },
151
- { name: "Edit", value: "edit" },
307
+ { name: hasPlaceholders ? "Run (fill placeholders)" : "Run", value: "run" },
308
+ { name: `Edit ${DIM2}(modify command before running)${RESET}`, value: "edit" },
152
309
  { name: "Cancel", value: "cancel" }
153
310
  ]
154
311
  });
155
- if (action === "run") return cmd;
156
- if (action === "edit") return promptEdit(cmd);
312
+ if (action === "run") return fillPlaceholders(cmd);
313
+ if (action === "edit") {
314
+ process.stdout.write(`${DIM2}> ${RESET}`);
315
+ return promptEdit(cmd);
316
+ }
157
317
  return null;
158
318
  }
159
319
  async function promptCommand(commands) {
160
320
  if (commands.length === 1) {
321
+ console.log(`
322
+ ${CYAN}${commands[0]}${RESET}
323
+ `);
161
324
  return promptAction(commands[0]);
162
325
  }
163
326
  const choice = await select({
164
- message: "Select a command to run:",
327
+ message: "Select a command:",
165
328
  choices: [
166
329
  ...commands.map((cmd) => ({
167
330
  name: `${CYAN}${cmd}${RESET}`,
@@ -171,6 +334,9 @@ async function promptCommand(commands) {
171
334
  ]
172
335
  });
173
336
  if (choice === "__cancel__") return null;
337
+ console.log(`
338
+ ${CYAN}${choice}${RESET}
339
+ `);
174
340
  return promptAction(choice);
175
341
  }
176
342
 
@@ -190,13 +356,13 @@ function execCommand(cmd, cwd) {
190
356
 
191
357
  // src/index.ts
192
358
  var BRAILLE = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
193
- var DIM = "\x1B[2m";
359
+ var DIM3 = "\x1B[2m";
194
360
  var RESET2 = "\x1B[0m";
195
361
  var RED = "\x1B[31m";
196
362
  function startSpinner(message) {
197
363
  let i = 0;
198
364
  const interval = setInterval(() => {
199
- process.stderr.write(`\r${DIM}${BRAILLE[i++ % BRAILLE.length]} ${message}${RESET2}`);
365
+ process.stderr.write(`\r${DIM3}${BRAILLE[i++ % BRAILLE.length]} ${message}${RESET2}`);
200
366
  }, 80);
201
367
  return () => {
202
368
  clearInterval(interval);
@@ -209,6 +375,7 @@ function parseArgs(argv) {
209
375
  let cwd = process.cwd();
210
376
  let model = process.env.AISH_MODEL || void 0;
211
377
  let verbose2 = false;
378
+ let print = false;
212
379
  const queryParts = [];
213
380
  for (let i = 0; i < args.length; i++) {
214
381
  const arg = args[i];
@@ -225,6 +392,8 @@ function parseArgs(argv) {
225
392
  model = args[++i];
226
393
  } else if (arg === "-v" || arg === "--verbose") {
227
394
  verbose2 = true;
395
+ } else if (arg === "--print") {
396
+ print = true;
228
397
  } else if (arg === "-h" || arg === "--help") {
229
398
  console.log(`Usage: aish [options] <query...>
230
399
 
@@ -232,38 +401,74 @@ Options:
232
401
  -p, --provider <claude|codex> AI provider (default: claude, env: AISH_PROVIDER)
233
402
  -m, --model <model> Model override (env: AISH_MODEL)
234
403
  --cwd <dir> Working directory
404
+ --print Output command only (for shell integration)
235
405
  -v, --verbose Show debug output
236
- -h, --help Show help`);
406
+ -h, --help Show help
407
+
408
+ Zsh Integration:
409
+ Add to .zshrc: source "$(npm root -g)/aish-cli/aish.plugin.zsh"
410
+ Then press Ctrl+G to activate AI mode`);
237
411
  process.exit(0);
238
412
  } else {
239
413
  queryParts.push(arg);
240
414
  }
241
415
  }
242
- return { query: queryParts.join(" "), provider, cwd, model, verbose: verbose2 };
416
+ return { query: queryParts.join(" "), provider, cwd, model, verbose: verbose2, print };
243
417
  }
244
418
  async function main() {
245
- const { query, provider, cwd, model, verbose: verbose2 } = parseArgs(process.argv);
419
+ const { query, provider, cwd, model, verbose: verbose2, print } = parseArgs(process.argv);
246
420
  setVerbose(verbose2);
247
421
  if (!query) {
248
422
  console.error(`${RED}Usage: aish <query>${RESET2}`);
249
423
  process.exit(1);
250
424
  }
251
- const stopSpinner = verbose2 ? () => {
425
+ const stopSpinner = verbose2 || print ? () => {
252
426
  } : startSpinner("Thinking...");
253
427
  let commands;
428
+ let resultCacheKey;
429
+ let stats;
254
430
  try {
255
431
  const result = await queryAi(provider, query, cwd, model);
256
432
  commands = result.commands;
433
+ resultCacheKey = result.cacheKey;
434
+ stats = result.stats;
257
435
  } catch (err) {
258
436
  stopSpinner();
437
+ if (print) {
438
+ process.exit(1);
439
+ }
259
440
  console.error(`${RED}Error: ${err.message}${RESET2}`);
260
441
  process.exit(1);
261
442
  }
262
443
  stopSpinner();
444
+ if (stats) {
445
+ const parts = [];
446
+ if (stats.cached) {
447
+ parts.push("cached");
448
+ } else {
449
+ if (stats.durationMs) parts.push(`${(stats.durationMs / 1e3).toFixed(1)}s`);
450
+ if (stats.inputTokens || stats.outputTokens) {
451
+ parts.push(`${stats.inputTokens || 0}\u2192${stats.outputTokens || 0} tok`);
452
+ }
453
+ if (stats.cost) parts.push(`$${stats.cost.toFixed(4)}`);
454
+ }
455
+ if (parts.length > 0) {
456
+ const output = print ? process.stderr : process.stdout;
457
+ output.write(`${DIM3} ${parts.join(" \xB7 ")}${RESET2}
458
+ `);
459
+ }
460
+ }
263
461
  if (commands.length === 0) {
462
+ if (print) {
463
+ process.exit(1);
464
+ }
264
465
  console.error(`${RED}No commands suggested.${RESET2}`);
265
466
  process.exit(1);
266
467
  }
468
+ if (print) {
469
+ console.log(commands[0]);
470
+ process.exit(0);
471
+ }
267
472
  let chosen;
268
473
  try {
269
474
  chosen = await promptCommand(commands);
@@ -274,6 +479,9 @@ async function main() {
274
479
  if (!chosen) {
275
480
  process.exit(0);
276
481
  }
482
+ if (resultCacheKey) {
483
+ await cacheSet(resultCacheKey, { commands });
484
+ }
277
485
  const exitCode = await execCommand(chosen, cwd);
278
486
  process.exit(exitCode);
279
487
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aish-cli",
3
- "version": "1.0.0",
3
+ "version": "1.1.1",
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",