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 +1 -1
- package/install.sh +48 -8
- package/package.json +1 -1
- package/readme.md +12 -16
- package/tai/cli.py +118 -26
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
2.
|
|
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
|
|
27
|
-
#
|
|
28
|
-
#
|
|
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"
|
|
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
|
-
#
|
|
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
|
|
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.
|
|
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 #
|
|
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
|
|
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
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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.
|
|
582
|
-
#
|
|
583
|
-
#
|
|
584
|
-
#
|
|
585
|
-
#
|
|
586
|
-
#
|
|
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
|
-
|
|
635
|
+
held = ""
|
|
589
636
|
if checkout.exists():
|
|
590
|
-
if (
|
|
591
|
-
|
|
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
|
-
|
|
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
|
-
"""
|
|
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
|
-
|
|
846
|
-
|
|
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
|