terminal-ai-helper 2.0.0 → 2.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.
package/VERSION CHANGED
@@ -1 +1 @@
1
- 2.0.0
1
+ 2.1.0
package/install.sh CHANGED
@@ -23,11 +23,19 @@ else
23
23
  exit 1
24
24
  }
25
25
  if [ -d "$TAI_DIR/.git" ]; then
26
- # The one-liner is also the update path: an existing checkout is pulled
27
- # forward and reinstalled, never cloned over. A pull that cannot happen
28
- # (diverged, offline) fails here, loudly, rather than installing old code.
26
+ # The one-liner is also the update path: an existing checkout is refetched
27
+ # and reinstalled, never cloned over. The checkout is this install's own —
28
+ # nobody develops in it — so the update is one commit deep every time:
29
+ # fetch --depth 1, reset to what origin named, prune what is left behind.
30
+ # A pull here would download every commit made since the checkout was cut,
31
+ # and a checkout that predates --depth 1 would carry its whole history
32
+ # forever. Offline, the fetch fails and set -e stops the install loudly —
33
+ # the old rule stands: never install old code on the strength of a guess.
29
34
  echo "→ updating the checkout at $TAI_DIR…"
30
- git -C "$TAI_DIR" pull --ff-only --quiet
35
+ _branch="$(git -C "$TAI_DIR" rev-parse --abbrev-ref HEAD)"
36
+ git -C "$TAI_DIR" fetch --depth 1 --quiet origin "$_branch"
37
+ git -C "$TAI_DIR" reset --hard --quiet FETCH_HEAD
38
+ git -C "$TAI_DIR" gc --prune=now --quiet 2>/dev/null || true
31
39
  elif [ -e "$TAI_DIR" ]; then
32
40
  echo "tai: $TAI_DIR exists and is not a tai checkout — remove it or set TAI_DIR" >&2
33
41
  exit 1
@@ -142,12 +150,12 @@ _tai_enable() {
142
150
  local rc="$1" plugin="$2" source_line="$3" plugin_name
143
151
  plugin_name="${plugin##*/}"
144
152
  mkdir -p "$(dirname "$rc")"
145
- python3 - "$rc" "$source_line" "$plugin_name" <<'PY'
153
+ python3 - "$rc" "$source_line" "$plugin_name" "$plugin" <<'PY'
146
154
  import os
147
155
  import sys
148
156
  from pathlib import Path
149
157
 
150
- rc, source_line, plugin_name = sys.argv[1:]
158
+ rc, source_line, plugin_name, ours = sys.argv[1:]
151
159
  path = Path(rc)
152
160
  # surrogateescape, not errors="replace": a dotfile in a legacy encoding has bytes
153
161
  # that are not valid UTF-8, and replacing them on read would silently rewrite the
@@ -156,18 +164,47 @@ text = path.read_text(encoding="utf-8", errors="surrogateescape") if path.exists
156
164
  marker = "# tai autocomplete (installed by ./install.sh)"
157
165
  lines = text.splitlines(keepends=True)
158
166
  cleaned = []
167
+ retargeted = []
159
168
  i = 0
160
169
  while i < len(lines):
161
170
  line = lines[i]
162
171
  if line.strip() == marker:
163
- # Replace the managed two-line block when the checkout path changes.
172
+ # The managed block: refresh its line in place rather than removing and
173
+ # re-appending, so the block keeps its place and a moved tree rewrites
174
+ # the one line instead of gaining a second one.
175
+ cleaned.append(line)
164
176
  i += 1
165
177
  if i < len(lines) and plugin_name in lines[i] and "source" in lines[i]:
166
- i += 1
178
+ lines[i] = source_line + "\n"
167
179
  continue
180
+ stripped = line.lstrip()
181
+ if (not stripped.startswith("#") and "source" in line
182
+ and "plugins/" in line and plugin_name in line
183
+ and ours not in line):
184
+ # A tai source line left by an older door: the same plugin name from
185
+ # another tree — the checkout the curl one-liner cloned, still in the
186
+ # rc after npm took over the wrapper. Matching by name alone said
187
+ # "already enabled" and kept the shell sourcing code npm's updates
188
+ # never touch: `tai version` reported one tree while the shell ran
189
+ # another. The line is this install's to own, so it is retargeted.
190
+ old = [t for t in line.split() if plugin_name in t]
191
+ retargeted.append(old[-1].strip("\"'") if old else line.strip())
192
+ line = source_line + "\n"
168
193
  cleaned.append(line)
169
194
  i += 1
170
195
 
196
+ # A retarget can meet a stale line twice (a managed block and a stray copy);
197
+ # the line is sourced once, first occurrence wins.
198
+ wanted = source_line + "\n"
199
+ deduped, seen = [], False
200
+ for line in cleaned:
201
+ if line == wanted:
202
+ if seen:
203
+ continue
204
+ seen = True
205
+ deduped.append(line)
206
+ cleaned = deduped
207
+
171
208
  # A manually configured integration is already enabled; do not add a second
172
209
  # copy when the installer is rerun.
173
210
  has_source = any(
@@ -180,6 +217,9 @@ if not has_source:
180
217
  cleaned.append(marker + "\n")
181
218
  cleaned.append(source_line + "\n")
182
219
 
220
+ for old in retargeted:
221
+ print(f"tai: retargeted the {plugin_name} source line ({old} — was another install's tree)")
222
+
183
223
  # Replaced atomically, the same rule the index builder follows. This file is the
184
224
  # user's shell: an interrupt between truncate and write would otherwise leave a
185
225
  # half-written .zshrc and a shell that cannot start.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "terminal-ai-helper",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
4
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": {
package/readme.md CHANGED
@@ -28,20 +28,6 @@ curl -fsSL https://raw.githubusercontent.com/mlibre/Terminal-AI-Helper/main/inst
28
28
  exec zsh
29
29
  ```
30
30
 
31
- Or using npm
32
-
33
- ```sh
34
- npm i -g terminal-ai-helper@latest --allow-scripts=terminal-ai-helper
35
- exec zsh
36
- ```
37
-
38
- The flag is npm's: newer npm blocks install scripts by default, and without
39
- it the package unpacks and nothing is wired — no `tai` command, no rc lines.
40
-
41
- The line downloads TAI to `~/.tai` and runs the installer from there; run it
42
- again later and it updates in place. It enables both shells, imports your
43
- history, and builds the first index:
44
-
45
31
  ```text
46
32
  → cloning tai to /home/you/.tai…
47
33
  → learning from your history, building the index…
@@ -57,7 +43,17 @@ history, and builds the first index:
57
43
  ```
58
44
 
59
45
  That last line matters: a terminal that is already open keeps the old
60
- version until you open a new one — same after `tai update`.
46
+ version until you open a new one — same after `tai update`. An install
47
+ that switched doors could leave the shell's `source` line naming the
48
+ older tree — code updates never reach it; `tai update` retargets that
49
+ line, and `tai version` says where it points.
50
+
51
+ Or using npm
52
+
53
+ ```sh
54
+ npm i -g terminal-ai-helper@latest --allow-scripts=terminal-ai-helper
55
+ exec zsh
56
+ ```
61
57
 
62
58
  ## Commands
63
59
 
@@ -69,7 +65,7 @@ tai doctor # what is indexed, and what is held back
69
65
  tai bench # build time, latency, memory, each shell's index read time
70
66
  tai web # a read-only localhost dashboard (alias: tai dashboard)
71
67
  tai eval # candidate coverage against your own history
72
- tai version # print the release this install is running
68
+ tai version # this install's release, and where the shell plugin is wired
73
69
  tai purge # drop unusable rows, then rebuild
74
70
  tai forget git sta # drop every stored row for a command, exactly as typed
75
71
  tai uninstall # remove everything tai added
package/tai/cli.py CHANGED
@@ -213,6 +213,15 @@ def _update_npm(repo: "Path") -> int:
213
213
  The allow-scripts flag rides along because npm's newer vintages block
214
214
  install scripts by default — the same gate the readme's install line
215
215
  opens — and a blocked hook would leave a replaced tree nobody wired.
216
+
217
+ The rerun is decided by the wiring, not by the version. An install that
218
+ switched doors could leave the rc line naming an older tree — the
219
+ checkout the curl one-liner cloned, still sourced after npm took over
220
+ the wrapper — and then every `tai update` reported a version the shell
221
+ was not running, "already up to date" being exactly when the old guard
222
+ (`after == before or configured`) skipped the repair. The installer is
223
+ idempotent and its configured check is the wiring's own test, so it runs
224
+ whenever that test fails, whichever the version says.
216
225
  """
217
226
  import shutil
218
227
  import subprocess
@@ -233,7 +242,7 @@ def _update_npm(repo: "Path") -> int:
233
242
  # progress, and this line is the verdict.
234
243
  print("✓ already up to date" if after == before else f"✓ updated to {after}",
235
244
  flush=True)
236
- if after == before or _npm_configured():
245
+ if _npm_configured():
237
246
  return 0
238
247
  installer = repo / "install.sh"
239
248
  if not installer.exists():
@@ -257,6 +266,7 @@ def cmd_update(a) -> int:
257
266
 
258
267
  import shutil
259
268
  import subprocess
269
+ from pathlib import Path
260
270
 
261
271
  git = shutil.which("git")
262
272
  if not git:
@@ -272,15 +282,49 @@ def cmd_update(a) -> int:
272
282
  return subprocess.run([git, "-C", str(repo), "rev-parse", "--short", "HEAD"],
273
283
  capture_output=True, text=True).stdout.strip()
274
284
 
285
+ def run_git(*argv: str) -> subprocess.CompletedProcess:
286
+ return subprocess.run([git, "-C", str(repo), *argv],
287
+ capture_output=True, text=True)
288
+
289
+ # The checkout the one-liner cloned is this install's own tree, not a
290
+ # development branch: nobody commits in it, so the update is a fetch one
291
+ # commit deep and a reset to what origin named — the same three commands
292
+ # install.sh's piped door runs. A pull here would download every commit
293
+ # made since the checkout was cut, and one that predates --depth 1 would
294
+ # carry its whole history forever. A checkout anywhere else is somebody's
295
+ # tree — maybe a fork they develop in — and it keeps the careful pull:
296
+ # fast-forward only, a divergence fails loudly rather than resetting
297
+ # anybody's work away.
298
+ managed = (repo.resolve()
299
+ == Path(os.environ.get("TAI_DIR")
300
+ or Path.home() / ".tai").resolve())
301
+
275
302
  before = head()
276
- pull = subprocess.run([git, "-C", str(repo), "pull", "--ff-only"],
277
- capture_output=True, text=True)
278
- if pull.returncode != 0:
279
- # A local edit in the checkout is the usual cause, and git's own message
280
- # says exactly what to do about it, so pass it through rather than guess.
281
- print("tai: git pull failed:")
282
- print((pull.stderr or pull.stdout).strip())
283
- return 1
303
+ if managed:
304
+ branch = run_git("rev-parse", "--abbrev-ref", "HEAD").stdout.strip()
305
+ failed = None
306
+ for argv in (("fetch", "--depth", "1", "--quiet", "origin", branch),
307
+ ("reset", "--hard", "--quiet", "FETCH_HEAD")):
308
+ done = run_git(*argv)
309
+ if done.returncode != 0:
310
+ failed = argv[0]
311
+ break
312
+ if failed:
313
+ print(f"tai: git {failed} failed:")
314
+ print((done.stderr or done.stdout).strip())
315
+ return 1
316
+ # Hygiene, not a step: what the old history weighed is already
317
+ # unreachable after the reset, and a failed prune costs nothing.
318
+ run_git("gc", "--prune=now", "--quiet")
319
+ else:
320
+ pull = run_git("pull", "--ff-only")
321
+ if pull.returncode != 0:
322
+ # A local edit in the checkout is the usual cause, and git's own
323
+ # message says exactly what to do about it, so pass it through
324
+ # rather than guess.
325
+ print("tai: git pull failed:")
326
+ print((pull.stderr or pull.stdout).strip())
327
+ return 1
284
328
  after = head()
285
329
  # Flushed, because the installer below writes to the same terminal from a
286
330
  # child process and would otherwise appear ahead of this line.
@@ -578,21 +622,29 @@ def cmd_uninstall(a) -> int:
578
622
  removed.append(str(rc))
579
623
  # The piped door clones the code itself to TAI_DIR (default ~/.tai), and
580
624
  # leaving it behind would leave the program installed in every way but the
581
- # wrapper — the same rule the rc blocks above are held to. What makes the
582
- # directory tai's is its layout: cli.py and the installer inside it. A
583
- # directory without that shape is not ours to delete, so it is named and
584
- # kept. Last on purpose: this command usually runs from inside the checkout
585
- # (the wrapper execs this very file), so removing it has to be the last
586
- # thing the run does.
625
+ # wrapper — the same rule the rc blocks above are held to. The name is the
626
+ # identity: the path is tai's namespace, created by tai's installer and
627
+ # claimed by nothing else, so whatever the checkout has grown into — a
628
+ # shape an older door left half-made, files the user dropped beside the
629
+ # code — goes with the uninstall, and the report says which kind it was.
630
+ # One floor stands: never a directory the home lives in or under, so a
631
+ # mistyped TAI_DIR cannot name more than tai owns. Last on purpose: this
632
+ # command usually runs from inside the checkout (the wrapper execs this
633
+ # very file), so removing it has to be the last thing the run does.
587
634
  checkout = Path(os.environ.get("TAI_DIR") or home / ".tai")
588
- left = None
635
+ held = ""
589
636
  if checkout.exists():
590
- if ((checkout / "tai" / "cli.py").exists()
591
- and (checkout / "install.sh").exists()):
592
- shutil.rmtree(checkout)
593
- removed.append(str(checkout))
637
+ if home.is_relative_to(checkout):
638
+ held = f"left {checkout} — it holds the home directory"
594
639
  else:
595
- left = checkout
640
+ ours = ((checkout / "tai" / "cli.py").exists()
641
+ and (checkout / "install.sh").exists())
642
+ if checkout.is_dir():
643
+ shutil.rmtree(checkout)
644
+ else:
645
+ checkout.unlink()
646
+ removed.append(str(checkout) + ("" if ours
647
+ else " (not a tai checkout — removed anyway)"))
596
648
  # The npm door's code is npm's to remove, and the running tree says the
597
649
  # same thing the wrapper did — this process may itself be executing from
598
650
  # the package npm owns. Last, for the same reason the checkout is: the
@@ -613,10 +665,10 @@ def cmd_uninstall(a) -> int:
613
665
  print("tai removed" if removed else "tai was not installed")
614
666
  for path in removed:
615
667
  print(f" removed {path}")
616
- if left:
617
- print(f" left {left} — not a tai checkout")
618
668
  if npm_note:
619
669
  print(f" {npm_note}")
670
+ if held:
671
+ print(f" {held}")
620
672
  return 0
621
673
 
622
674
 
@@ -694,9 +746,47 @@ def cmd_jev(a) -> int:
694
746
 
695
747
 
696
748
  def cmd_version(a) -> int:
697
- """Say which release this is."""
749
+ """Which release this is, and where the shell's plugin line points.
750
+
751
+ The second half exists because the two can disagree. An install that
752
+ switched doors — the curl one-liner's checkout replaced by the npm
753
+ package — left the rc line naming the older tree, and the shell went on
754
+ running code `tai version` no longer reported: the release number comes
755
+ from the tree the wrapper execs, the plugin comes from wherever the rc
756
+ says. Both facts are read here, so the mismatch is visible in the same
757
+ command a user runs to answer "which tai is this".
758
+ """
698
759
  version = _read_version()
699
760
  print(f"tai {version}")
761
+ from pathlib import Path
762
+ repo = _repo_dir()
763
+ home = Path.home()
764
+ for shell, rc_name, plugin in (("zsh", ".zshrc", "tai.zsh"),
765
+ ("bash", ".bashrc", "tai.bash")):
766
+ wired = ""
767
+ try:
768
+ for line in (home / rc_name).read_text(
769
+ encoding="utf-8", errors="surrogateescape").splitlines():
770
+ if line.lstrip().startswith("#") or "source" not in line \
771
+ or plugin not in line:
772
+ continue
773
+ tokens = [t for t in line.split() if plugin in t]
774
+ if tokens:
775
+ wired = tokens[-1].strip("\"'")
776
+ break
777
+ except OSError:
778
+ pass
779
+ if not wired:
780
+ print(f"{shell} plugin not wired")
781
+ continue
782
+ note = ""
783
+ try:
784
+ if Path(wired).expanduser().resolve() != \
785
+ (repo / "plugins" / plugin).resolve():
786
+ note = " — another install; 'tai update' rewires it here"
787
+ except OSError:
788
+ pass
789
+ print(f"{shell} plugin {wired}{note}")
700
790
  return 0
701
791
 
702
792
 
@@ -842,8 +932,10 @@ def main() -> int:
842
932
  if argv and argv[0] == "record" and "--help" not in argv and "-h" not in argv:
843
933
  return cmd_record(_fast_record(argv[1:]))
844
934
  if argv == ["version"]:
845
- print(f"tai {_read_version()}")
846
- return 0
935
+ # The report is two small rc reads beside the version file — the lean
936
+ # path can afford what the parser path would print, and both doors
937
+ # must say the same thing about the wiring.
938
+ return cmd_version(None)
847
939
  # Any other command is a tai process the user asked for, and a non-empty
848
940
  # spool is records waiting for one: drain it here, so `tai suggest`,
849
941
  # `tai refresh` or the dashboard never answer from a store that is a few