terminal-ai-helper 1.3.1 → 1.3.3

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 CHANGED
@@ -19,3 +19,5 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
19
  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
20
  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
21
  SOFTWARE.
22
+
23
+ Other parts like zsh/bash/vite/... have their own license
package/VERSION CHANGED
@@ -1 +1 @@
1
- 1.3.1
1
+ 1.3.2
@@ -0,0 +1,36 @@
1
+ #!/usr/bin/env bash
2
+ # npm's postinstall hook: a global install of the published package finishes
3
+ # the job itself, so `npm i -g terminal-ai-helper` is the whole install — the
4
+ # same steps, the same welcome, the same report as the other two doors.
5
+ #
6
+ # Two guards stand before the installer:
7
+ # * only a *global* install reaches it. npm hands every script the config as
8
+ # npm_config_* environment, so npm_config_global tells a `-g` install from
9
+ # a dev `npm i` in a checkout of this repo — which must never edit anyone's
10
+ # rc files. The node_modules check is the second answer, for a package
11
+ # manager that speaks the same layout but not the same variable.
12
+ # * TAI_SKIP_POSTINSTALL=1 skips it, for scripts and containers that want
13
+ # the files on disk only.
14
+ set -u
15
+ here="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")/.." && pwd)"
16
+
17
+ global=""
18
+ case "${npm_config_global:-}" in
19
+ true | 1 | yes) global=1 ;;
20
+ esac
21
+ if [ -z "$global" ]; then
22
+ case "$here" in */node_modules/*) global=1 ;; esac
23
+ fi
24
+ [ -n "$global" ] || exit 0
25
+ [ "${TAI_SKIP_POSTINSTALL:-0}" = "1" ] && exit 0
26
+
27
+ if TAI_INSTALL_METHOD=npm bash "$here/install.sh"; then
28
+ exit 0
29
+ fi
30
+ status=$?
31
+ # The package itself is on disk; an installer that could not finish (no
32
+ # writable rc file, an odd HOME) must not fail the npm install over it — that
33
+ # would roll the package back and leave nothing at all. The retry is the same
34
+ # installer, named by its path in the package npm just unpacked.
35
+ echo "tai: automatic setup did not finish (exit $status) — finish with: bash \"$here/install.sh\"" >&2
36
+ exit 0
package/install.sh CHANGED
@@ -1,13 +1,15 @@
1
1
  #!/usr/bin/env bash
2
2
  # tai installer — Linux, stdlib python only, <30 seconds.
3
3
  #
4
- # Two doors, one install: from a checkout (./install.sh), or the one line the
4
+ # Three doors, one install: from a checkout (./install.sh), the one line the
5
5
  # readme leads with —
6
6
  # curl -fsSL https://raw.githubusercontent.com/mlibre/Terminal-AI-Helper/main/install.sh | bash
7
7
  # — where curl's output reaches bash on stdin and the repository has to be
8
- # fetched before anything below can run. The piped door clones to ~/.tai
8
+ # fetched before anything below can run, and npm's postinstall hook, which
9
+ # runs this script from the package npm just unpacked (bin/npm-postinstall
10
+ # guards it). The piped door clones to ~/.tai
9
11
  # (TAI_DIR moves it; TAI_REMOTE points it at a fork) and then executes the
10
- # cloned copy of this very script, so both doors run the same steps from a
12
+ # cloned copy of this very script, so every door runs the same steps from a
11
13
  # real checkout and there is exactly one installer to read.
12
14
  set -euo pipefail
13
15
 
@@ -130,7 +132,7 @@ if add:
130
132
  kept.append("# asynchronously, so tai's own hints never reach the screen. Both work.\n")
131
133
  kept.append("# Removed by `tai uninstall`, and by TAI_KEEP_AUTOSUGGEST=1.\n")
132
134
  kept.append("add-zsh-hook -d precmd _zsh_autosuggest_start 2>/dev/null\n")
133
- tmp = path.with_name(path.name + ".tai-install")
135
+ tmp = path.with_name(path.name + ".tai-new")
134
136
  tmp.write_text("".join(kept), encoding="utf-8", errors="surrogateescape")
135
137
  os.replace(tmp, path)
136
138
  PY
@@ -181,7 +183,7 @@ if not has_source:
181
183
  # Replaced atomically, the same rule the index builder follows. This file is the
182
184
  # user's shell: an interrupt between truncate and write would otherwise leave a
183
185
  # half-written .zshrc and a shell that cannot start.
184
- tmp = path.with_name(path.name + ".tai-install")
186
+ tmp = path.with_name(path.name + ".tai-new")
185
187
  tmp.write_text("".join(cleaned), encoding="utf-8", errors="surrogateescape")
186
188
  os.replace(tmp, path)
187
189
  PY
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "terminal-ai-helper",
3
- "version": "1.3.1",
4
- "description": "The terminal that knows your next command \u2014 bash/zsh suggestions that learn from your own history. Offline, private, no account.",
3
+ "version": "1.3.3",
4
+ "description": "The terminal that knows your next command, Bash/Zsh suggestions that learn from your own history. Offline and private, with no account required",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
@@ -17,9 +17,6 @@
17
17
  "suggestions",
18
18
  "history"
19
19
  ],
20
- "bin": {
21
- "tai-install": "bin/tai-install"
22
- },
23
20
  "files": [
24
21
  "bin",
25
22
  "tai",
@@ -33,9 +30,10 @@
33
30
  "docs:dev": "vitepress dev docs",
34
31
  "docs:build": "vitepress build docs",
35
32
  "docs:preview": "vitepress preview docs",
33
+ "postinstall": "bash bin/npm-postinstall",
36
34
  "prepublishOnly": "find plugins -name '*.zwc' -delete && find . -name __pycache__ -type d -prune -exec rm -rf {} +"
37
35
  },
38
36
  "devDependencies": {
39
37
  "vitepress": "^1.6.3"
40
38
  }
41
- }
39
+ }
File without changes
File without changes
File without changes
File without changes
File without changes
package/plugins/tai.bash CHANGED
File without changes
package/plugins/tai.zsh CHANGED
File without changes
File without changes
File without changes
File without changes
@@ -30,6 +30,13 @@ typeset -ga _TAI_MENU
30
30
  # _tai_menu_open for why the flag travels with the entry.
31
31
  typeset -ga _TAI_MENU_Q
32
32
  typeset -gi _TAI_MENU_IDX=0 _TAI_MENU_FROM=0 _TAI_MENU_TO=0 _TAI_MENU_LOOSE=0
33
+ # 1 when the rows are whole learned lines that commit onto the word span as
34
+ # whole lines (the first-word menu): _tai_menu_prefix refuses those, because a
35
+ # shared head written into the line would then be written a second time by
36
+ # _tai_menu_commit, which lays the whole entry over the span. Only a per-word
37
+ # menu — every row one word, written over exactly the span the prefix now
38
+ # fills — can take the extension.
39
+ typeset -gi _TAI_MENU_WHOLE=0
33
40
  # How deep one first-word key is read, and how many learned whole lines the
34
41
  # first-word menu carries ahead of the installed names. Three per key is what
35
42
  # keeps a two-line `go mod …` pair beside each other — one per key is the
@@ -62,6 +69,7 @@ _tai_menu_close() {
62
69
  _TAI_MENU_IDX=0
63
70
  _TAI_MENU_ARMED=0
64
71
  _TAI_MENU_LOOSE=0
72
+ _TAI_MENU_WHOLE=0
65
73
  _TAI_MENU_STEM=""
66
74
  }
67
75
 
@@ -286,6 +294,7 @@ _tai_menu_open() {
286
294
  local -A isdir dup
287
295
  local word before c rest p real
288
296
  local -i len=${#BUFFER} i skip width term limit added=0 cap=0
297
+ _TAI_MENU_WHOLE=0
289
298
 
290
299
  # The width of the terminal, which the layout below is built to. Read once
291
300
  # here, because the command list is capped by it before the menu knows how
@@ -371,6 +380,7 @@ _tai_menu_open() {
371
380
  out+=( "${_TAI_MENU_LEARNED[@]}" )
372
381
  repeat ${#_TAI_MENU_LEARNED[@]}; do outq+=( 0 ); done
373
382
  learned_whole=1
383
+ _TAI_MENU_WHOLE=1
374
384
  fi
375
385
  # An installed command the history has never seen. Worth offering only once
376
386
  # the command name is complete, which is the same rule _tai_query keeps so
@@ -589,6 +599,7 @@ _tai_menu_open_loose() {
589
599
  _TAI_MENU_TO=${#BUFFER}
590
600
  _TAI_MENU_ARMED=0
591
601
  _TAI_MENU_LOOSE=1
602
+ _TAI_MENU_WHOLE=0
592
603
  _tai_menu_layout ""
593
604
  _TAI_MENU_ARMED=0
594
605
  }
@@ -666,6 +677,76 @@ _tai_menu_paint() {
666
677
  fi
667
678
  }
668
679
 
680
+ # Write into the line what every entry already shares, before the list is drawn.
681
+ #
682
+ # The report: `cat .zs` answered with `.zsh/` and `.zshrc` — two candidates that
683
+ # are the same word until the `h` — and the menu showed them under a line still
684
+ # reading `.zs`. Every shell completes the part that is certain first, and
685
+ # readline's was already doing it here (the bash listing test pins `tzz` becoming
686
+ # `tzz_`); the menu made the user type the shared tail themselves, or take a whole
687
+ # entry to get it. So the press that opens the list also advances the line to the
688
+ # longest head every candidate starts with — `.zs` becomes `.zsh`, the list stands
689
+ # under it, and the next press is the choosing press it always was.
690
+ #
691
+ # The head is computed over the forms that would be *written*, not the raw rows:
692
+ # an entry a shell would split — `wombat book.txt` — is written quoted, and a
693
+ # head cut from the quoted forms is the one string that is safe to leave in the
694
+ # line when the user keeps typing instead of choosing. `womb` advances to
695
+ # `wombat\ ` there, one shell word, every entry still extending it.
696
+ #
697
+ # Three refusals, each its own way of being wrong answered:
698
+ # * a word that is empty (the line ends in a space, or there is no line):
699
+ # nothing typed is nothing to extend, and the listing is the answer;
700
+ # * the loose list and the first-word menu, whose rows are whole lines laid
701
+ # over the whole span — a head written into the line would be written a
702
+ # second time by _tai_menu_commit, which replaces the span with the entire
703
+ # entry;
704
+ # * a head that does not reach past the word, or does not start with it: the
705
+ # first is no extension at all, and the second is a word the user did not
706
+ # type — `my doc` answered with the quoted `My\ Documents` head reads as
707
+ # two words where one was typed, so the list answers instead.
708
+ #
709
+ # The span moves with the text: _TAI_MENU_TO follows the head, the cursor lands
710
+ # after it, and the layout runs again so the stem is cut against what the line
711
+ # now shows and _TAI_MENU_LINE describes the line that is actually standing.
712
+ # Committing an entry afterwards lays the whole entry over the span — the head
713
+ # included — so nothing is written twice.
714
+ _tai_menu_prefix() {
715
+ (( ${#_TAI_MENU} > 1 )) || return 1
716
+ (( _TAI_MENU_LOOSE || _TAI_MENU_WHOLE )) && return 1
717
+ local -i from=$_TAI_MENU_FROM to=$_TAI_MENU_TO k
718
+ # An empty word — the line ends in a space — has no span to extend.
719
+ (( from <= to )) || return 1
720
+ local -a forms
721
+ local c rest typed
722
+ forms=()
723
+ for (( k = 1; k <= ${#_TAI_MENU}; k++ )); do
724
+ c="${_TAI_MENU[k]}"
725
+ (( ${_TAI_MENU_Q[k]} )) && { _tai_quote "$c"; c="$_TAI_QUOTED" }
726
+ forms+=( "$c" )
727
+ done
728
+ # The longest head every written form starts with, found by shrinking the
729
+ # first form until the rest still begin with it — the same walk the stem
730
+ # makes over the raw rows.
731
+ rest="${forms[1]}"
732
+ for c in "${forms[@]}"; do
733
+ while [[ -n "$rest" && "$c" != "$rest"* ]]; do rest="${rest[1,-2]}"; done
734
+ [[ -n "$rest" ]] || break
735
+ done
736
+ [[ -n "$rest" ]] || return 1
737
+ typed="${BUFFER[from,to]}"
738
+ (( ${#rest} > ${#typed} )) || return 1
739
+ # Folded, like every lookup: `down` advances to `Downloads` when the disk
740
+ # says that is the word, and the case the candidates carry is the case the
741
+ # line takes — the same rule the commit below this one keeps.
742
+ [[ "${rest:l}" == "${typed:l}"* ]] || return 1
743
+ BUFFER="${BUFFER[1,$(( from - 1 ))]}$rest${BUFFER[$(( to + 1 )),-1]}"
744
+ CURSOR=${#BUFFER}
745
+ _TAI_MENU_TO=$(( from + ${#rest} - 1 ))
746
+ _tai_menu_layout "$rest"
747
+ return 0
748
+ }
749
+
669
750
  # Put the selected entry where the word was, and close the menu. Nothing runs: the
670
751
  # line is now `cd Movies/` with the cursor after it, which is the point — a
671
752
  # completion is not a decision to execute.
File without changes
@@ -272,7 +272,13 @@ tai-tab() {
272
272
  zle -N tai-tab
273
273
 
274
274
  # Tab opens the menu, and every Tab after that moves the selection one entry
275
- # along. The line is not touched: a menu is a way of looking, not of choosing.
275
+ # along. The one thing the first press writes is the part every entry already
276
+ # shares: `cat .zs` answered by `.zsh/` and `.zshrc` advances to `cat .zsh`
277
+ # before the list draws, because two candidates that are the same word until
278
+ # the `h` are a prefix the user should not have to type (see _tai_menu_prefix,
279
+ # which refuses it where writing it would lie — an empty word, a whole-line
280
+ # list, a head the typed word does not start with). After that the line stands:
281
+ # a menu is a way of choosing, not of typing.
276
282
  #
277
283
  # When there is nothing to offer, the key goes back to zsh's own completion.
278
284
  # `docker` has nothing to complete *to*, because everything tai knows about it
@@ -319,6 +325,11 @@ tai-menu() {
319
325
  _tai_menu_commit
320
326
  return
321
327
  fi
328
+ # Several entries: the line takes what they all already say (the shared
329
+ # head — `.zsh` under `.zsh/` and `.zshrc`), and the list draws under it.
330
+ # _tai_menu_prefix refuses it where the line must not be written; the menu
331
+ # then behaves exactly as it did.
332
+ _tai_menu_prefix
322
333
  _tai_menu_paint
323
334
  }
324
335
  zle -N tai-menu
package/readme.md CHANGED
@@ -21,13 +21,29 @@ answered by the habit it shadows](assets/demo.gif)
21
21
 
22
22
  [Docs](docs/architecture.md) · [Development](docs/development.md) · [Docs site](https://mlibre.github.io/Terminal-AI-Helper/) · [فارسی](readme-fa.md)
23
23
 
24
- ## Install
24
+ ## Install Or Update
25
25
 
26
26
  ```sh
27
27
  curl -fsSL https://raw.githubusercontent.com/mlibre/Terminal-AI-Helper/main/install.sh | bash
28
+ # Or using npm
29
+ npm i -g terminal-ai-helper@latest
28
30
  ```
29
31
 
30
- npm installs the same files: `npm i -g terminal-ai-helper`, then `tai-install`.
32
+ ## Commands
33
+
34
+ ```sh
35
+ tai update # newest version — GitHub for a checkout, npm for an npm install (alias: tai upgrade)
36
+ tai refresh # import new history rows, then rebuild the indexes
37
+ tai discover # learn --help from installed tools (cached, ~5-35s)
38
+ tai doctor # what is indexed, and what is held back
39
+ tai bench # build time, latency, memory, each shell's index read time
40
+ tai web # a read-only localhost dashboard (alias: tai dashboard)
41
+ tai eval # candidate coverage against your own history
42
+ tai version # print the release this install is running
43
+ tai purge # drop unusable rows, then rebuild
44
+ tai forget git sta # drop every stored row for a command, exactly as typed
45
+ tai uninstall # remove everything tai added
46
+ ```
31
47
 
32
48
  The line downloads TAI to `~/.tai` and runs the installer from there; run it
33
49
  again later and it updates in place. It enables both shells, imports your
@@ -65,23 +81,24 @@ machine. New commands show up within seconds; no restart, ever.
65
81
 
66
82
  ## The keys
67
83
 
68
- | key | what it does |
69
- | ----------------------- | -------------------------------------------------------------------------------------------------------------- |
70
- | `→` | take the hint at the end of the line, else move right |
71
- | `Ctrl-F` | take the hint |
72
- | `Alt-F` / `Ctrl-Right` | take **one word** of the hint, leaving the rest to type (bash: `Ctrl-Right`) |
73
- | `Tab` | cycle files, folders and options; on a bare first word the learned lines come first, ranked like the dashboard |
74
- | `Ctrl-Space` / `Ctrl-T` | open the list, hint or no hint; press again to move the selection |
75
- | `Enter` | take the selected entry and stop; a second `Enter` runs the line |
76
- | `Alt-G` | the command that followed the last one (bash) |
84
+ | key | what it does |
85
+ | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
86
+ | `→` | take the hint at the end of the line, else move right |
87
+ | `Ctrl-F` | take the hint |
88
+ | `Alt-F` / `Ctrl-Right` | take **one word** of the hint, leaving the rest to type (bash: `Ctrl-Right`) |
89
+ | `Tab` | write what every candidate shares, then cycle files, folders and options; on a bare first word the learned lines come first, ranked like the dashboard |
90
+ | `Ctrl-Space` / `Ctrl-T` | open the list, hint or no hint; press again to move the selection (zsh) |
91
+ | `Down` | peek at what usually follows — opens the list when the typed line shows no hint; history otherwise (zsh) |
92
+ | `Enter` | take the selected entry and stop; a second `Enter` runs the line |
93
+ | `Alt-G` | the command that followed the last one (bash) |
77
94
 
78
95
  A paste is not a query — pasted text never opens a list, and one typed
79
96
  character brings it back. `Enter` fills a selection in and stops; running it
80
97
  is always a second, deliberate `Enter`.
81
98
 
82
- **bash** ranks identically and takes the same keys, but readline cannot draw
83
- a list below the line: `Tab` completes directly — after `tai` by default,
84
- after every command with `TAI_COMPLETE_ALL=1`.
99
+ **bash** ranks identically and takes the hint keys, but readline cannot draw
100
+ a list below the line and has no menu key: `Tab` completes directly — after
101
+ `tai` by default, after every command with `TAI_COMPLETE_ALL=1`.
85
102
 
86
103
  ## What it suggests, and what it will not
87
104
 
@@ -104,6 +121,9 @@ memory, an array hit in microseconds.
104
121
  - **A path is answered by the filesystem** — what is actually there, newest
105
122
  first. A `cd` is answered by directories that exist here, judged from where
106
123
  you stand, not offered two directories away.
124
+ - **`systemctl` is answered by the machine's own unit list.**
125
+ `systemctl restart her` cycles `nginx.service` and friends — read once,
126
+ held in the shell.
107
127
  - **A typo is answered by what it is a typo of.** When nothing extends the
108
128
  line, the learned lines that hold every word you typed appear; the one-off
109
129
  typo ranks below the habit it shadows, in the index itself.
@@ -126,33 +146,13 @@ memory, an array hit in microseconds.
126
146
  tai web # or: tai dashboard → http://127.0.0.1:8247/
127
147
  ```
128
148
 
129
- ![the tai web dashboard — the try box answering `git `, the state of the
149
+ ![the tai web dashboard — the try box answering `git`, the state of the
130
150
  store, and the ranked panels](assets/web.png)
131
151
 
132
152
  One server, read-only: it binds `127.0.0.1`, answers GETs only, and writes
133
153
  nothing. Clicking a suggestion copies it; `--port N` moves it; `--no-browser`
134
154
  skips the auto-open.
135
155
 
136
- ## Commands
137
-
138
- ```sh
139
- tai refresh # import new history rows, then rebuild the indexes
140
- tai discover # learn --help from installed tools (cached, ~5-35s)
141
- tai doctor # what is indexed, and what is held back
142
- tai bench # build time, latency, memory, each shell's index read time
143
- tai web # a read-only localhost dashboard (alias: tai dashboard)
144
- tai eval # candidate coverage against your own history
145
- tai update # newest version from GitHub, then reinstall (alias: tai upgrade)
146
- tai version # print the release this install is running
147
- tai purge # drop unusable rows, then rebuild
148
- tai forget git sta # drop every stored row for a command, exactly as typed
149
- tai uninstall # remove everything tai added
150
- ```
151
-
152
- The plugins call `flush` themselves, and every command drains what is pending
153
- first — no answer lags a few records behind what you just did, and no restart
154
- is needed: the index is re-read at the next prompt.
155
-
156
156
  ## Configuration
157
157
 
158
158
  All have working defaults; they are for when yours is wrong.
@@ -191,7 +191,7 @@ release; it puts `tai` on `PATH` and prints the two `source` lines for your
191
191
  rc file.
192
192
 
193
193
  ```sh
194
- tai update # newest version from GitHub, then reinstall; alias: tai upgrade
194
+ tai update # newest version — GitHub for a checkout, npm for an npm install; alias: tai upgrade
195
195
  ```
196
196
 
197
197
  It stops rather than overwrite a copy you have edited yourself.
@@ -201,8 +201,9 @@ tai uninstall
201
201
  ```
202
202
 
203
203
  Removes the shell integration, the wrapper, the indexes, the SQLite history
204
- and the cached knowledge. The `~/.tai` folder stays; restart the terminal
205
- afterwards.
204
+ and the cached knowledge. The `~/.tai` folder stays — on an npm install,
205
+ `npm rm -g terminal-ai-helper` removes the package itself. Restart the
206
+ terminal afterwards.
206
207
 
207
208
  ## Why it stays fast
208
209
 
package/tai/__init__.py CHANGED
File without changes
package/tai/bench.py CHANGED
File without changes
package/tai/cli.py CHANGED
@@ -10,7 +10,8 @@ updating, and diagnostics.
10
10
  tai record "<cmd>" [--cwd X --git Y --branch B --exit 0]
11
11
  tai flush ingest the records the plugins spooled, then maintain
12
12
  tai refresh [--quiet]
13
- tai update | upgrade pull the latest version from GitHub and reinstall
13
+ tai update | upgrade pull the latest version and reinstall — from GitHub
14
+ for a checkout install, from npm for an npm one
14
15
  tai version print the release this install is running
15
16
  tai discover [tool ...]
16
17
  tai purge [--stale] [--rebuild/--no-rebuild]
@@ -149,14 +150,107 @@ def cmd_refresh(a) -> int:
149
150
  return 0
150
151
 
151
152
 
153
+ def _install_method(repo: "Path") -> str:
154
+ """'npm', 'git', or 'none' — decided by where the running code lives.
155
+
156
+ The wrapper execs one cli.py, and that file's location is the copy an
157
+ update has to replace, so the method is a fact about the path, not about
158
+ the user's memory of how they installed: npm keeps every global package
159
+ under a node_modules directory, a checkout keeps its .git, and anything
160
+ else (the .deb, a hand-copied tree) is 'none' — nothing here can pull it
161
+ forward, and the answer says so instead of guessing.
162
+ """
163
+ if "node_modules" in repo.parts:
164
+ return "npm"
165
+ if (repo / ".git").exists():
166
+ return "git"
167
+ return "none"
168
+
169
+
170
+ def _npm_configured() -> bool:
171
+ """True when npm's postinstall hook already ran the installer from the tree.
172
+
173
+ The hook runs install.sh inside the npm install itself, and rerunning it
174
+ from here would print the welcome twice and rebuild an index that is
175
+ current. The check is the installer's own two products — the wrapper
176
+ naming this tree and one rc line per shell sourcing it — asked of the text
177
+ npm's layout guarantees is unquoted through the middle: a prefix path with
178
+ a space in it comes out of printf %q escaped, but everything from
179
+ node_modules down survives intact.
180
+ """
181
+ from pathlib import Path
182
+
183
+ tail = "node_modules/terminal-ai-helper"
184
+ wrapper = (Path(os.environ.get("BIN_DIR") or Path.home() / ".local" / "bin")
185
+ / "tai")
186
+ try:
187
+ text = wrapper.read_text(encoding="utf-8", errors="surrogateescape")
188
+ except OSError:
189
+ return False
190
+ if f"{tail}/tai/cli.py" not in text:
191
+ return False
192
+ home = Path.home()
193
+ for rc, plugin in ((".zshrc", "tai.zsh"), (".bashrc", "tai.bash")):
194
+ try:
195
+ lines = (home / rc).read_text(
196
+ encoding="utf-8", errors="surrogateescape").splitlines()
197
+ except OSError:
198
+ return False
199
+ if not any("source" in line and f"{tail}/plugins/{plugin}" in line
200
+ for line in lines):
201
+ return False
202
+ return True
203
+
204
+
205
+ def _update_npm(repo: "Path") -> int:
206
+ """The npm door of `tai update`: let the registry replace the package.
207
+
208
+ npm's own output streams through — the install can take several seconds
209
+ and its progress is the only thing the user sees while it runs — and the
210
+ postinstall hook finishes the reinstall from the new tree unless scripts
211
+ were skipped (--ignore-scripts) or it failed, in which case the installer
212
+ runs here instead: idempotent, and the same report as every other door.
213
+ """
214
+ import shutil
215
+ import subprocess
216
+
217
+ npm = shutil.which("npm")
218
+ if not npm:
219
+ print("tai: npm is not installed, so there is nothing to update from")
220
+ print(" reinstall with: npm i -g terminal-ai-helper@latest")
221
+ return 1
222
+ before = _read_version()
223
+ pulled = subprocess.run([npm, "install", "-g", "terminal-ai-helper@latest"])
224
+ if pulled.returncode != 0:
225
+ print("tai: npm install failed — the previous version is still installed")
226
+ return 1
227
+ after = _read_version()
228
+ # Flushed before anything else prints: npm's own output above is the
229
+ # progress, and this line is the verdict.
230
+ print("✓ already up to date" if after == before else f"✓ updated to {after}",
231
+ flush=True)
232
+ if after == before or _npm_configured():
233
+ return 0
234
+ installer = repo / "install.sh"
235
+ if not installer.exists():
236
+ print(f"tai: {installer} is missing, so the new code was not installed")
237
+ return 1
238
+ # A subprocess, not a re-entrant call: the install just replaced the files
239
+ # this process was loaded from, so anything it calls now is the old code.
240
+ return subprocess.run(["bash", str(installer)]).returncode
241
+
242
+
152
243
  def cmd_update(a) -> int:
153
- """Pull the latest version of the checkout and reinstall it.
244
+ """Pull the latest version and reinstall it, from wherever tai came from.
154
245
 
155
- The user should never have to remember which folder the project lives in to
156
- get a new version, so this is driven from where tai already is rather than
157
- from a path they have to type.
246
+ The user should never have to remember which folder the project lives in —
247
+ or which package manager put it there — so this is driven from where tai
248
+ already is rather than from a path they have to type.
158
249
  """
159
250
  repo = _repo_dir()
251
+ if _install_method(repo) == "npm":
252
+ return _update_npm(repo)
253
+
160
254
  import shutil
161
255
  import subprocess
162
256
 
package/tai/decisions.py CHANGED
File without changes
package/tai/engcache.py CHANGED
File without changes
package/tai/engine.py CHANGED
File without changes
package/tai/evaluate.py CHANGED
File without changes
File without changes
package/tai/fresh.py CHANGED
File without changes
package/tai/helptext.py CHANGED
File without changes
package/tai/index.py CHANGED
File without changes
package/tai/jev.py CHANGED
File without changes
package/tai/knowledge.py CHANGED
File without changes
File without changes
package/tai/paths.py CHANGED
File without changes
package/tai/predictor.py CHANGED
File without changes
package/tai/seed.py CHANGED
File without changes
package/tai/spool.py CHANGED
File without changes
package/tai/store.py CHANGED
File without changes
package/tai/tune.py CHANGED
File without changes
package/tai/typo.py CHANGED
@@ -212,10 +212,18 @@ def shadow_map(eng, lines=None) -> dict[str, str]:
212
212
  * the two share their first word and are within two edits whole-line,
213
213
  which is the shape a mistyped word takes (`unsintall`/`uninstall` is
214
214
  two adjacent swaps, invisible to a one-edit rule);
215
+ * the comparison folds case — every lookup in the product folds case,
216
+ and the pair loop must agree, or `LS -la` stands beside `ls -la` as
217
+ its own row (the first words land in different groups) and a case
218
+ diff longer than two letters clears the edit cap. A capital twin is
219
+ a typo of the lowercase habit no less than a misspelling is; the
220
+ map's keys keep the store's own spelling either way;
215
221
  * the target is the *stronger* spelling: more successes, then more
216
222
  runs, then more recent — and when the evidence ties outright, the
217
- lexically smallest of the near-duplicates stands, which is the one
218
- honest way to break a tie the store carries no other evidence for;
223
+ spelling that is all-lowercase stands (commands are lowercase; the
224
+ capitalised twin is the accident), byte order otherwise, which is
225
+ the one honest way to break a tie the store carries no other
226
+ evidence for;
219
227
  * the population both sides come from is `lines`: the index passes the
220
228
  lines the user actually ran (a corpus convention is not a typo and
221
229
  not a habit one shadows), the engine passes the candidates it is
@@ -229,6 +237,9 @@ def shadow_map(eng, lines=None) -> dict[str, str]:
229
237
  """
230
238
  names = list(eng.cmds) if lines is None else \
231
239
  [c for c in lines if c in eng.cmds]
240
+ # The comparison runs on lowered copies — see the case bullet above —
241
+ # while every key this map returns is the store's own spelling.
242
+ low = {c: c.lower() for c in names}
232
243
  # Per-line letter sets and counts: the two cheap gates before the edit
233
244
  # distance. Two lines within two edits can differ by at most two distinct
234
245
  # letters per side (the set gate) and by at most two in character surplus
@@ -238,20 +249,23 @@ def shadow_map(eng, lines=None) -> dict[str, str]:
238
249
  # is a pair the distance would reject too. The counts also answer the
239
250
  # deficit for free — surplus minus the length difference is deficit, by
240
251
  # conservation of characters — so one walk per pair gates both sides.
241
- sets = {c: set(c) for c in names}
252
+ sets = {c: set(low[c]) for c in names}
242
253
  counts = {c: {} for c in names}
243
254
  for c, bag in counts.items():
244
- for ch in c:
255
+ for ch in low[c]:
245
256
  bag[ch] = bag.get(ch, 0) + 1
246
257
  groups: dict[str, dict[int, list[str]]] = {}
247
258
  for c in names:
248
- groups.setdefault(c.split(" ", 1)[0], {}).setdefault(len(c), []).append(c)
259
+ groups.setdefault(low[c].split(" ", 1)[0], {}).setdefault(len(c), []).append(c)
260
+ # The full-tie spelling to stand: all-lowercase first (commands are
261
+ # lowercase; the capitalised twin is the accident), byte order otherwise.
262
+ tkey = {c: (0, c) if low[c] == c else (1, c) for c in names}
249
263
  out: dict[str, str] = {}
250
264
  for cmd in names:
251
265
  st = eng.cmds[cmd]
252
266
  if st.freq > TYPO_FREQ_MAX:
253
267
  continue
254
- buckets = groups.get(cmd.split(" ", 1)[0])
268
+ buckets = groups.get(low[cmd].split(" ", 1)[0])
255
269
  if not buckets:
256
270
  continue
257
271
  s_set = sets[cmd]
@@ -281,14 +295,14 @@ def shadow_map(eng, lines=None) -> dict[str, str]:
281
295
  ost = eng.cmds[other]
282
296
  oev = (ost.success, ost.freq, ost.last_ts)
283
297
  # The target must be the stronger spelling — or, on a full
284
- # tie, the smaller one. Anything weaker is somebody else's
285
- # shadow, not this line's habit.
286
- if oev < ev or (oev == ev and other > cmd):
298
+ # tie, the likelier spelling (the tie bullet above). Anything
299
+ # weaker is somebody else's shadow, not this line's habit.
300
+ if oev < ev or (oev == ev and tkey[other] > tkey[cmd]):
287
301
  continue
288
- if lev1(cmd, other, 2) > 2:
302
+ if lev1(low[cmd], low[other], 2) > 2:
289
303
  continue
290
304
  if best is None or oev > best[0] or \
291
- (oev == best[0] and other < best[1]):
305
+ (oev == best[0] and tkey[other] < tkey[best[1]]):
292
306
  best = (oev, other)
293
307
  if best is not None:
294
308
  out[cmd] = best[1]
package/tai/web.py CHANGED
File without changes
package/bin/tai-install DELETED
@@ -1,13 +0,0 @@
1
- #!/usr/bin/env bash
2
- # npm's bin shim is a symlink into the package directory, and install.sh reads
3
- # its own location to decide what to install from — so resolve the symlink
4
- # chain to the real file first, then hand over to the installer at the
5
- # package root, one level above bin/.
6
- set -e
7
- src="${BASH_SOURCE[0]:-$0}"
8
- while [ -L "$src" ]; do
9
- dir="$(cd "$(dirname "$src")" && pwd)"
10
- src="$(readlink "$src")"
11
- [ "${src:0:1}" = "/" ] || src="$dir/$src"
12
- done
13
- exec bash "$(cd "$(dirname "$src")/.." && pwd)/install.sh" "$@"