@explyt/ai-agent 0.2.1 → 0.3.0-dogfood.2

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 (45) hide show
  1. package/README.md +3 -3
  2. package/agent-docs/README.md +6 -3
  3. package/agent-docs/settings.md +43 -6
  4. package/agent-docs/updates.md +56 -0
  5. package/dist/cli/engine.js +102 -26
  6. package/dist/cli/index.js +112 -31
  7. package/dist/cli/install-auto.js +170 -0
  8. package/dist/cli/install-cleanup.js +187 -0
  9. package/dist/cli/install-command.js +135 -0
  10. package/dist/cli/install-config.js +33 -0
  11. package/dist/cli/install-file-retry.js +17 -0
  12. package/dist/cli/install-health.js +148 -0
  13. package/dist/cli/install-lock.js +203 -0
  14. package/dist/cli/install-migrate.js +60 -0
  15. package/dist/cli/install-node.js +73 -0
  16. package/dist/cli/install-npm.js +62 -0
  17. package/dist/cli/install-output.js +47 -0
  18. package/dist/cli/install-process.js +144 -0
  19. package/dist/cli/install-resolve.js +104 -0
  20. package/dist/cli/install-roots.js +34 -0
  21. package/dist/cli/install-run.js +341 -0
  22. package/dist/cli/install-settings.js +116 -0
  23. package/dist/cli/install-switch.js +233 -0
  24. package/dist/cli/install-winpath.js +52 -0
  25. package/dist/cli/installer-main.js +8 -0
  26. package/dist/cli/launch.js +161 -0
  27. package/dist/cli/update.js +2 -70
  28. package/dist/extensions/ask-user-question.js +1 -1
  29. package/dist/extensions/bash.js +1 -1
  30. package/dist/extensions/custom-provider.js +1 -1
  31. package/dist/extensions/hello.js +1 -1
  32. package/dist/extensions/identity.js +1 -1
  33. package/dist/extensions/mcp.js +1 -1
  34. package/dist/extensions/misc-hacks.js +1 -1
  35. package/dist/extensions/policy.js +1 -1
  36. package/dist/extensions/rules.js +1 -1
  37. package/dist/extensions/session-title.js +1 -1
  38. package/dist/extensions/skills.js +1 -1
  39. package/dist/extensions/subagents.js +1 -1
  40. package/dist/extensions/tui.js +1 -1
  41. package/dist/extensions/update-notice.js +1 -1
  42. package/dist/extensions/web-access.js +1 -1
  43. package/dist/extensions/write.js +1 -1
  44. package/dist/install/installer.js +3652 -0
  45. package/package.json +5 -2
package/README.md CHANGED
@@ -17,8 +17,8 @@ explyt update # install the newer latest, then restart open sessi
17
17
  For a one-off pinned run:
18
18
 
19
19
  ```bash
20
- npx -y "@explyt/ai-agent@0.2.1" # start the terminal UI
21
- npx -y "@explyt/ai-agent@0.2.1" login # store a provider credential, once
20
+ npx -y "@explyt/ai-agent@0.3.0" # start the terminal UI
21
+ npx -y "@explyt/ai-agent@0.3.0" login # store a provider credential, once
22
22
  ```
23
23
 
24
24
  The agent runs with exactly the extensions this build ships with and ignores any
@@ -28,4 +28,4 @@ you get.
28
28
  IDE (ACP) integration is not part of this package: it is provided by the Explyt
29
29
  managed backend, which the Neo plugin downloads and manages.
30
30
 
31
- Run `npx -y @explyt/ai-agent@0.2.1 --help` for the available options.
31
+ Run `npx -y @explyt/ai-agent@0.3.0 --help` for the available options.
@@ -9,6 +9,7 @@ it. Read the whole file before editing anything it describes.
9
9
  | File | Covers |
10
10
  |---|---|
11
11
  | `settings.md` | `settings.json`, its two layers, and every setting that exists |
12
+ | `updates.md` | the managed installation, automatic updates, `updateChannel` and `autoUpdate` |
12
13
  | `rules.md` | persistent instructions: where rule files live, their frontmatter, how to disable one |
13
14
  | `skills.md` | Skills: where they are found, the `SKILL.md` frontmatter, how to disable one |
14
15
  | `mcp.md` | MCP servers: the two `mcp.json` files, the entry shape, disabling a server or a tool |
@@ -39,9 +40,11 @@ not under `.explyt/`. `web-search.md` says where that directory is and what goes
39
40
 
40
41
  ## Updates of the agent itself
41
42
 
42
- The terminal UI shows one line when a newer version is published; nothing updates on its own.
43
- The user updates with `explyt update` (a global npm install) and silences the check with
44
- `PI_OFFLINE=1`. Neither is a `.explyt/` setting.
43
+ The agent updates itself: the terminal UI checks its channel at most once a day and installs a newer
44
+ version in the background, used from the next start. `explyt update` updates at once, and
45
+ `PI_OFFLINE=1` turns the check off. The update channel and the automatic update switch
46
+ (`updateChannel`, `autoUpdate`) are not `.explyt/` settings: they live in the engine's own
47
+ `settings.json` and are changed with `explyt config`. `updates.md` has the details.
45
48
 
46
49
  ## Do not create or edit `.pi/` files
47
50
 
@@ -46,7 +46,10 @@ rest of the file as it is.
46
46
  | `sessionTitle.enabled` | boolean | `true` | whether a new chat gets a short title generated by the model after its first turn succeeds |
47
47
  | `subagents.maxConcurrency` | positive integer | `20` | Maximum simultaneously running or waiting children |
48
48
  | `subagents.permission` | `inherit` \| `yolo` \| `ask-permissions` | `inherit` | Permission mode resolved for child tools |
49
- | `tui.mascot` | string | `"cat"` | the animated mascot in the terminal's working row: `cat`, `snake`, `pacman`, `tetris`, `fishing`, `robot`, `pong` or `crab` |
49
+ | `tui.mascot` | string | `"cat"` | the animated mascot in the terminal's working row: `cat`, `snake`, `pacman`, `tetris`, `fishing`, `robot`, `pong`, `crab`, or `custom` for the user's own |
50
+ | `tui.customMascotFrames` | array of strings | `[]` | the frames of the custom mascot, one string per frame, played in order |
51
+ | `tui.customMascotText` | string | `"Working…"` | the breathing word in front of the custom mascot |
52
+ | `tui.tips` | boolean | `true` | whether a usage tip is shown under the working row while the agent works |
50
53
 
51
54
  The rule and Skill list settings take exact, case-sensitive entries. Neither supports globs or
52
55
  prefixes: `"pdf-*"` matches nothing. An entry matching nothing is kept, not an error.
@@ -66,11 +69,45 @@ project's `.explyt/settings.json` is ignored, so write it to `~/.explyt/settings
66
69
  turns generation off everywhere; the chat then keeps the truncated first prompt as its title. A
67
70
  name the user sets with `/name` is never replaced either way.
68
71
 
69
- `tui.mascot` is also read from the **global** layer only; write it to `~/.explyt/settings.json`.
70
- The value is one of the eight ids above, lowercase and exact. Any other value, or a value that is
71
- not a string, shows the cat. The terminal command `/mascot` writes this key and switches the
72
- mascot at once; a hand edit applies from the next session. It has no effect in an IDE, which has no
73
- working row.
72
+ The four `tui.*` keys are also read from the **global** layer only; write them to
73
+ `~/.explyt/settings.json`. An edit applies from the user's next prompt; the terminal command
74
+ `/mascot` writes `tui.mascot` and switches the mascot at once. None of them has an effect in an IDE,
75
+ which has no working row.
76
+
77
+ `tui.mascot` is one of the nine ids above, lowercase and exact. Any other value, or a value that is
78
+ not a string, shows the cat.
79
+
80
+ ### Drawing a custom mascot
81
+
82
+ When the user asks for a mascot of their own, write all three keys: the frames, the word, and
83
+ `tui.mascot: "custom"`. The custom keys stay in the file when the user later picks another
84
+ mascot, and `/mascot custom` brings it back.
85
+
86
+ ```json
87
+ {
88
+ "tui": {
89
+ "mascot": "custom",
90
+ "customMascotText": "Quacking…",
91
+ "customMascotFrames": ["🦆 ", " 🦆 ", " 🦆 ", " 🦆 "]
92
+ }
93
+ }
94
+ ```
95
+
96
+ - Each string is one frame, shown for 120 ms, in order, then the list repeats. Draw a loop: the
97
+ last frame should lead back into the first.
98
+ - Keep every frame the same width, measured in terminal columns; an emoji or a CJK character is
99
+ two columns. Shorter frames are padded with spaces at their end.
100
+ - A frame or a text wider than **40 columns** is cut to 40.
101
+ - Plain characters only: escape sequences, colour codes, tabs and line breaks are removed. The
102
+ product paints the frames orange and the word in a breathing orange itself.
103
+ - Without `customMascotText` the word is `Working…`. Use one short word with `…` (U+2026), like
104
+ the built-in mascots.
105
+ - With no frames, an empty list, or only blank frames, `custom` shows the cat.
106
+
107
+ ### Tips
108
+
109
+ `tui.tips: false` hides the tip line under the working row. The tips themselves are part of the
110
+ product and cannot be edited.
74
111
 
75
112
  ## How the two layers combine
76
113
 
@@ -0,0 +1,56 @@
1
+ # Installing and updating the agent
2
+
3
+ The agent lives in one **managed installation** per user, shared by the terminal (`explyt`) and the
4
+ IDE plugin. The Explyt installer creates and updates it; the user never installs it with `npm`.
5
+
6
+ ## Where it is
7
+
8
+ ```
9
+ <root> = $XDG_DATA_HOME/explyt-ai-agent (default: ~/.local/share/explyt-ai-agent,
10
+ on Windows %USERPROFILE%\.local\share\explyt-ai-agent)
11
+ <root>/agent/ credentials, sessions, the engine's settings.json
12
+ <root>/node/<version>/ the Node the installed release runs with
13
+ <root>/install/current.json which installed release is current
14
+ <root>/install/launch.js the fixed entry the terminal command and the IDE start
15
+ ~/.local/bin/explyt the terminal command (explyt.cmd on Windows)
16
+ ```
17
+
18
+ Do not edit anything under `<root>/install/` or `<root>/node/` by hand; the installer owns it.
19
+ A shell and an IDE that see different `XDG_DATA_HOME` values use two different roots; the installer
20
+ and `explyt --version` warn when a second one exists.
21
+
22
+ ## Installing
23
+
24
+ - Linux, macOS: `curl -fsSL https://raw.githubusercontent.com/explyt/explyt-ai-agent-distribution/main/install.sh | sh`
25
+ - Windows (PowerShell): `irm https://raw.githubusercontent.com/explyt/explyt-ai-agent-distribution/main/install.ps1 | iex`
26
+ - From an existing installation: `explyt install`.
27
+
28
+ `npm i -g @explyt/ai-agent` is not an install path. A global npm copy found on `PATH` is reported as a
29
+ conflict, and the installer offers to remove it.
30
+
31
+ ## Updates
32
+
33
+ Updates are automatic: the terminal UI checks its channel at most once a day and installs a newer
34
+ version in the background. The new version is used from the next start; the terminal UI then says so,
35
+ and `explyt --continue` resumes the session on it. A running session keeps its version until it exits.
36
+ `explyt update` updates at once. `PI_OFFLINE=1` turns every check off.
37
+
38
+ ## The two update settings
39
+
40
+ They live in the engine's settings file, `<root>/agent/settings.json`, **not** in `.explyt/`. Change them
41
+ with `explyt config`, which keeps every other key of that file:
42
+
43
+ | Key | Values | Default | Meaning |
44
+ |---|---|---|---|
45
+ | `updateChannel` | `"stable"`, `"dogfood"` | `"stable"` | which versions the installer takes; `dogfood` gets prereleases first |
46
+ | `autoUpdate` | `true`, `false` | `true` | `false` stops the background check; `explyt update` still works |
47
+
48
+ ```
49
+ explyt config set updateChannel dogfood
50
+ explyt config set autoUpdate false
51
+ explyt config get updateChannel
52
+ ```
53
+
54
+ Any other value is refused by `explyt config` and read as the default, with a warning. Moving from
55
+ `dogfood` back to `stable` keeps the current (newer) version until stable catches up;
56
+ `explyt update --channel stable --allow-downgrade` installs the older stable version at once.
@@ -19,10 +19,10 @@ import {
19
19
  symlinkSync,
20
20
  writeFileSync,
21
21
  } from "node:fs";
22
- import { basename, delimiter, dirname, isAbsolute, join, resolve, sep } from "node:path";
22
+ import { basename, delimiter, dirname, isAbsolute, join, posix, resolve, sep, win32 } from "node:path";
23
23
  import { homedir, platform, tmpdir } from "node:os";
24
24
  import { fileURLToPath } from "node:url";
25
- import { randomBytes } from "node:crypto";
25
+ import { createHash, randomBytes } from "node:crypto";
26
26
 
27
27
  export const ENGINE_PACKAGE = "@earendil-works/pi-coding-agent";
28
28
 
@@ -252,22 +252,20 @@ export function isUsableShim(path, os = platform()) {
252
252
  }
253
253
 
254
254
  /**
255
- * Put `binDir` in front of PATH, in place.
256
- *
257
- * This is how the bundled engine is handed to the ACP adapter: the adapter spawns
258
- * the bare command `pi`, so putting our own shim first makes it pick the version
259
- * that ships with this package instead of whatever the user may have installed
260
- * globally. The alternative — the adapter's `PI_ACP_PI_COMMAND` override — cannot
261
- * be used here, because one of our IDE clients can only pass `command` and `args`
262
- * to a backend and never environment variables.
263
- *
264
- * In place, and nothing at all without a `binDir`: assigning a missing value to
265
- * `process.env` stores the string "undefined". `env.PATH` is read on the env itself,
266
- * which on Windows finds `Path` too; a copy of it would not.
255
+ * Leave `env` with exactly one PATH key: `dirs` (in order, empty ones skipped) in front
256
+ * of the inherited value (RQ-6). In place, and never a missing value: the inherited
257
+ * search path is read under any spelling (`PATH`, `Path`, ...), every spelling is then
258
+ * removed and one `PATH` is written. On Windows `process.env` is case-insensitive, so
259
+ * this rewrites `Path`; on POSIX a caller that hands us both spellings stops leaving it
260
+ * unspecified which one a child sees. Assigning a missing value to `process.env` would
261
+ * store the string "undefined" (#219), so the value written is always a string.
267
262
  */
268
- export function prependEnginePath(env, binDir) {
269
- if (!binDir) return;
270
- env.PATH = env.PATH ? `${binDir}${delimiter}${env.PATH}` : binDir;
263
+ export function setProductPath(env, dirs) {
264
+ const spellings = Object.keys(env).filter((key) => key.toUpperCase() === "PATH");
265
+ const inherited = env.PATH ?? (spellings.length > 0 ? env[spellings[0]] : undefined);
266
+ for (const key of spellings) delete env[key];
267
+ const parts = [...dirs.filter(Boolean), ...(inherited ? [inherited] : [])];
268
+ env.PATH = parts.join(delimiter);
271
269
  }
272
270
 
273
271
  /**
@@ -364,10 +362,36 @@ function explicitAgentDir(value, home) {
364
362
  * package would be walked as one. Next to it keeps it under the one parent this project
365
363
  * owns, and moves with an explicit `PI_CODING_AGENT_DIR` (the agent directory's parent
366
364
  * is what a developer relocating the state relocates).
365
+ *
366
+ * The name is KEYED by the engine root of the running install (spec 019, RQ-47): the
367
+ * terminal and the IDE share one state root, and with one unkeyed directory two installs
368
+ * with different engines rebuilt it on every alternate start -- and removed it while the
369
+ * other one's engine still read its docs and version through it. The key is a hash of
370
+ * the engine root's real path; on Windows the path is normalised and lower-cased first,
371
+ * because the same directory has many spellings there.
372
+ *
373
+ * @param {object} env the environment the agent directory is read from
374
+ * @param {string} [home] home directory, injectable for tests
375
+ * @param {string} engineDir the engine root of the running install
367
376
  */
368
- export function engineShimDir(env = process.env, home = homedir()) {
377
+ export function engineShimDir(env = process.env, home = homedir(), engineDir) {
369
378
  const agentDir = withAgentDir(env, home)[AGENT_DIR_ENV];
370
- return join(dirname(agentDir), "engine");
379
+ return join(dirname(agentDir), `engine-${engineShimKey(engineDir)}`);
380
+ }
381
+
382
+ /** The per-install part of the shim directory name: a short hash of the engine root. */
383
+ export function engineShimKey(engineDir, os = platform()) {
384
+ if (typeof engineDir !== "string" || engineDir === "") throw new Error("engineShimDir needs the engine root of the running install");
385
+ let real = engineDir;
386
+ try {
387
+ real = realpathSync(engineDir);
388
+ } catch {
389
+ // A root that cannot be resolved still gets a stable key from its spelling.
390
+ }
391
+ // The target platform's own path rules, not the host's: a win32 key computed on
392
+ // Linux (tests, cross-packed trees) must equal the one Windows computes.
393
+ const key = os === "win32" ? win32.resolve(real).toLowerCase() : posix.resolve(real);
394
+ return createHash("sha256").update(key).digest("hex").slice(0, 16);
371
395
  }
372
396
 
373
397
  /**
@@ -902,7 +926,7 @@ export function withIsolation(rest, packageRoot) {
902
926
  * debug artifacts, where the adapter ships next to the wrapper; the public terminal
903
927
  * package answers it with a migration error.
904
928
  */
905
- export const COMMANDS = new Set(["acp", "login", "logout", "tui", "update"]);
929
+ export const COMMANDS = new Set(["acp", "config", "install", "login", "logout", "tui", "update"]);
906
930
 
907
931
  /**
908
932
  * Engine flags that confine a terminal run to a single package: ours.
@@ -997,6 +1021,54 @@ export function terminalEngineArgs(entrypoint, packageRoot, extra = [], { login
997
1021
  */
998
1022
  export const ADAPTER_LOGIN_FLAG = "--terminal-login";
999
1023
 
1024
+ /**
1025
+ * The launch file of the installed release `packageRoot` belongs to, or null outside one.
1026
+ * An installed release is `<root>/install/releases/<cli>+<adapter>/`, and its launch file
1027
+ * is `<root>/install/launch.js` (spec 019, RQ-42, RQ-43): derived from the path, so no
1028
+ * variable has to carry it.
1029
+ */
1030
+ export function launchFileFor(packageRoot) {
1031
+ let dir = packageRoot;
1032
+ for (;;) {
1033
+ const parent = dirname(dir);
1034
+ if (parent === dir) return null;
1035
+ if (basename(parent) === "releases" && basename(dirname(parent)) === "install") {
1036
+ return join(dirname(parent), "launch.js");
1037
+ }
1038
+ dir = parent;
1039
+ }
1040
+ }
1041
+
1042
+ /**
1043
+ * The positionals a `--terminal-login` launch forwards to `login`, without the repeated
1044
+ * launch path (spec 019, RQ-28).
1045
+ *
1046
+ * A client that follows the protocol appends the terminal-auth method's `args` -- the
1047
+ * launch file and `--terminal-login` -- to its own launch command, so what runs is
1048
+ * `node <entry> acp <entry> --terminal-login`. `parseArgs` keeps the second `<entry>` as
1049
+ * a positional, and the engine would take it as the user's first prompt. A positional is
1050
+ * dropped when it names the same file as this process's `argv[1]` or as the launch file
1051
+ * of the installed release this CLI runs from (`launchFileFor`); every other argument is
1052
+ * kept.
1053
+ *
1054
+ * @param {string[]} rest the positionals `parseArgs` returned
1055
+ * @param {{ argv1?: string, launchFile?: string | null, cwd?: string }} self
1056
+ */
1057
+ export function withoutLaunchPath(rest, { argv1, launchFile, cwd = process.cwd() } = {}) {
1058
+ const same = (a, b) => {
1059
+ if (!a || !b) return false;
1060
+ const left = resolve(cwd, a);
1061
+ const right = resolve(cwd, b);
1062
+ if (left === right) return true;
1063
+ try {
1064
+ return realpathSync(left) === realpathSync(right);
1065
+ } catch {
1066
+ return false;
1067
+ }
1068
+ };
1069
+ return rest.filter((arg) => arg.startsWith("-") || !(same(arg, argv1) || same(arg, launchFile)));
1070
+ }
1071
+
1000
1072
  /**
1001
1073
  * Flags belonging to other programs that consume the NEXT argument as their value.
1002
1074
  *
@@ -1020,6 +1092,7 @@ export function parseArgs(argv) {
1020
1092
  let help = false;
1021
1093
  let version = false;
1022
1094
  let seenPositional = false;
1095
+ let terminalLogin = false;
1023
1096
  const rest = [];
1024
1097
 
1025
1098
  for (let i = 0; i < argv.length; i += 1) {
@@ -1040,7 +1113,7 @@ export function parseArgs(argv) {
1040
1113
  // to the `acp` invocation from its own config -- see ADAPTER_LOGIN_FLAG. It is also
1041
1114
  // consumed rather than forwarded: the entrypoint re-adds it for the adapter, so
1042
1115
  // passing it through as well would hand the adapter the same flag twice.
1043
- if (arg === ADAPTER_LOGIN_FLAG) { command = "login"; continue; }
1116
+ if (arg === ADAPTER_LOGIN_FLAG) { command = "login"; terminalLogin = true; continue; }
1044
1117
 
1045
1118
  // A foreign flag plus its value: forward both, and never read the value as a
1046
1119
  // subcommand.
@@ -1061,29 +1134,32 @@ export function parseArgs(argv) {
1061
1134
  rest.push(arg);
1062
1135
  }
1063
1136
 
1064
- return { command, help, version, rest };
1137
+ return { command, help, version, rest, terminalLogin };
1065
1138
  }
1066
1139
 
1067
1140
  export function helpText() {
1068
1141
  return [
1069
1142
  "Explyt AI Agent",
1070
1143
  "",
1071
- "Install globally: npm i -g @explyt/ai-agent, then run explyt",
1144
+ "Install with the Explyt installer, then run explyt:",
1145
+ " explyt install [--channel stable|dogfood] install or update the managed installation",
1072
1146
  "Or run a pinned version without installing:",
1073
1147
  " npx -y @explyt/ai-agent@<version> start the terminal UI",
1074
1148
  " npx -y @explyt/ai-agent@<version> tui start the terminal UI",
1075
1149
  " npx -y @explyt/ai-agent@<version> login store a provider credential",
1076
1150
  " npx -y @explyt/ai-agent@<version> logout remove stored credentials",
1077
1151
  "",
1078
- "Global commands: explyt tui | login | logout | update",
1079
- " explyt update check npm latest and update a global installation",
1152
+ "Commands: explyt tui | login | logout | install | update | config",
1153
+ " explyt update update the managed installation now (same as install)",
1154
+ " explyt config set updateChannel stable|dogfood | autoUpdate true|false",
1080
1155
  " -h, --help show this message",
1081
1156
  " -v, --version print the version",
1082
1157
  "",
1083
1158
  "Credentials and sessions are separate from other agents: logging in to a pi you run",
1084
1159
  "by hand does not log in here, so run login once for this agent.",
1085
1160
  "The terminal UI notes a newer version at startup; PI_OFFLINE=1 turns the check off.",
1086
- "IDE (ACP) integration ships in the Explyt managed backend, not this package.",
1161
+ "IDE (ACP) integration is set up by the Explyt installer, which places the adapter",
1162
+ "next to this package; it is not part of this package.",
1087
1163
  "",
1088
1164
  ].join("\n");
1089
1165
  }
package/dist/cli/index.js CHANGED
@@ -8,12 +8,11 @@
8
8
  // npx @explyt/ai-agent login a human, once: store a provider credential
9
9
  // npx @explyt/ai-agent logout a human: remove the stored credentials
10
10
  //
11
- // ACP is NOT part of the public package. The `acp` subcommand still parses so an old
12
- // IDE configuration fails with a migration message instead of "Unknown command", but
13
- // the public artifact ships no adapter: IDE integration lives in the Explyt managed
14
- // backend, which Neo downloads, verifies and launches locally. The SAME wrapper file
15
- // also ships inside that backend artifact, where the adapter IS present next to it --
16
- // there `acp` works exactly as before.
11
+ // ACP is NOT part of this package: the adapter is the separate package
12
+ // `@explyt/acp-adapter` (spec 019). `acp` loads it when the installer placed it next to
13
+ // this CLI in an installed release, or from the backend and debug trees, which carry it
14
+ // inside this package root; otherwise `acp` refuses and names the installer. The CLI
15
+ // never depends on the adapter (RQ-1).
17
16
  //
18
17
  // There is deliberately no `install`. The agent registers nothing and writes to no
19
18
  // user configuration: every run isolates the engine to the extensions WE ship, so
@@ -30,11 +29,14 @@
30
29
  // only. Any stray write corrupts the stream and the client drops the connection, so
31
30
  // all diagnostics in this file go to stderr.
32
31
 
33
- import { signalEnv, updateArgsError, updateCommand, UPDATE_ENV } from "./update.js";
32
+ import { installationRoot, secondRootWarning } from "./install-roots.js";
33
+ import { signalEnv, updateArgsError, UPDATE_ENV } from "./update.js";
34
+ import { runInstaller } from "./install-run.js";
35
+ import { configCommand } from "./install-config.js";
34
36
  import { spawnSync } from "node:child_process";
35
- import { closeSync, existsSync, openSync } from "node:fs";
37
+ import { closeSync, existsSync, openSync, readFileSync, realpathSync } from "node:fs";
36
38
  import { readFile, rm } from "node:fs/promises";
37
- import { dirname, join } from "node:path";
39
+ import { basename, dirname, join, sep } from "node:path";
38
40
  import { fileURLToPath, pathToFileURL } from "node:url";
39
41
  import {
40
42
  AGENT_DIR_ENV,
@@ -52,15 +54,22 @@ import {
52
54
  helpText,
53
55
  interactiveStdin,
54
56
  parseArgs,
55
- prependEnginePath,
57
+ setProductPath,
56
58
  terminalEngineArgs,
57
59
  withAgentDir,
58
60
  withIsolation,
61
+ withoutLaunchPath,
62
+ launchFileFor,
59
63
  } from "./engine.js";
60
64
 
61
65
  const here = dirname(fileURLToPath(import.meta.url));
62
66
  const packageRoot = findPackageRoot(here);
63
67
  const args = parseArgs(process.argv.slice(2));
68
+ // A terminal-auth launch repeats the launch path as a positional (spec 019, RQ-28);
69
+ // without this it would reach the engine as the user's first prompt.
70
+ if (args.terminalLogin) {
71
+ args.rest = withoutLaunchPath(args.rest, { argv1: process.argv[1], launchFile: packageRoot ? launchFileFor(packageRoot) : null });
72
+ }
64
73
 
65
74
  function fail(message, code = 1) {
66
75
  process.stderr.write(`explyt: ${message}\n`);
@@ -95,7 +104,7 @@ async function findEngine() {
95
104
  // The engine presents itself under our name from a manifest copy in a directory we
96
105
  // own, handed to it through PI_PACKAGE_DIR; see `ensureEngineShim` for why that and not
97
106
  // the installed manifest. A failed shim is reported and the engine runs unbranded.
98
- const shim = ensureEngineShim(engineDir, engineShimDir(process.env));
107
+ const shim = ensureEngineShim(engineDir, engineShimDir(process.env, undefined, engineDir));
99
108
  if (shim.status === "failed") {
100
109
  process.stderr.write(`explyt: engine keeps its own name: ${shim.detail}\n`);
101
110
  }
@@ -142,20 +151,28 @@ if (args.version) {
142
151
  const version = await selfVersion();
143
152
  if (!version) fail("cannot read the package version");
144
153
  process.stdout.write(`${version}\n`);
154
+ // RQ-46: a second installation root is named on stderr; stdout stays the version.
155
+ const secondRoot = secondRootWarning({ root: installationRoot() });
156
+ if (secondRoot) process.stderr.write(`explyt: warning: ${secondRoot}\n`);
145
157
  process.exit(0);
146
158
  }
147
159
 
148
- if (args.command === "update") {
149
- const refused = updateArgsError(args.rest);
150
- if (refused) fail(refused, 2);
151
- const version = await selfVersion();
152
- if (!version) fail("cannot read the package version");
153
- process.exit(await updateCommand({ root: packageRoot, current: version }));
160
+ // `install` and `update` run the installer of the managed installation (spec 019, RQ-18,
161
+ // RQ-33, RQ-36): `update` never runs `npm install -g`, also from a global npm install.
162
+ if (args.command === "install" || args.command === "update") {
163
+ // The engine's `update --extensions` is explained, not treated as an installer argument.
164
+ const engineUpdate = updateArgsError(args.rest);
165
+ if (engineUpdate && engineUpdate !== "update takes no arguments") fail(engineUpdate, 2);
166
+ process.exit(await runInstaller(args.rest));
167
+ }
168
+
169
+ if (args.command === "config") {
170
+ process.exit(await configCommand(args.rest, { agentDir: withAgentDir(process.env)[AGENT_DIR_ENV] }));
154
171
  }
155
172
 
156
173
  // A bare invocation is the terminal UI. It used to mean "ACP when stdin is a pipe";
157
- // that heuristic is gone with the public ACP surface -- an IDE that wants ACP runs
158
- // the managed backend, which passes `acp` explicitly.
174
+ // that heuristic is gone -- an IDE that wants ACP passes `acp` explicitly, through the
175
+ // installation's launch file or a backend/debug tree (spec 019, RQ-5).
159
176
  const wantsAcp = args.command === "acp";
160
177
  const wantsLogin = args.command === "login";
161
178
  const wantsLogout = args.command === "logout";
@@ -239,24 +256,76 @@ if (wantsTui || wantsLogin) {
239
256
  process.exit(child.status ?? 1);
240
257
  }
241
258
 
242
- // ACP MODE. Only reachable with an explicit `acp`, and only functional in the
243
- // managed-backend (or debug) artifact, where the adapter ships next to this file:
244
- // backend artifact: dist/cli/index.js -> dist/acp/index.js
245
- // dev source: cli/index.mjs -> vendor/pi-acp/dist/index.js
259
+ // ACP MODE (spec 019, RQ-5). The CLI prepares the engine environment and then loads
260
+ // the adapter, which starts the server on import. The adapter is accepted from exactly
261
+ // these places, in this order:
262
+ // 1. the candidate paths inside this package root -- the backend and debug trees:
263
+ // backend artifact: dist/cli/index.js -> dist/acp/index.js
264
+ // dev source: cli/index.mjs -> vendor/pi-acp/dist/index.js
265
+ // 2. an installed release npm produced from `@explyt/acp-adapter`:
266
+ // hoisted <modules>/@explyt/acp-adapter next to <modules>/@explyt/ai-agent
267
+ // nested the package whose node_modules holds this CLI
268
+ // Never NODE_PATH, the working directory, PATH, a workspace link or a global prefix:
269
+ // plain Node resolution would honour NODE_PATH and would follow this checkout's
270
+ // workspace link to the unobfuscated submodule.
271
+ const ADAPTER_PACKAGE = "@explyt/acp-adapter";
272
+
273
+ /** The adapter's `main` entry in `dir`, or null when `dir` is not the adapter package. */
274
+ function adapterMain(dir) {
275
+ try {
276
+ const manifest = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
277
+ if (manifest.name !== ADAPTER_PACKAGE || typeof manifest.main !== "string" || manifest.main === "") return null;
278
+ const main = join(dir, manifest.main);
279
+ return existsSync(main) ? main : null;
280
+ } catch {
281
+ return null;
282
+ }
283
+ }
284
+
285
+ /**
286
+ * The adapter of an installed release, found from this package's own real location. A
287
+ * directory that is a symlink (a workspace link) is not an installed release.
288
+ */
289
+ function installedReleaseAdapter(root) {
290
+ let real;
291
+ try {
292
+ real = realpathSync(root);
293
+ } catch {
294
+ return null;
295
+ }
296
+ const scope = dirname(real);
297
+ const modules = dirname(scope);
298
+ if (basename(scope) !== "@explyt" || basename(modules) !== "node_modules") return null;
299
+ const accept = (dir) => {
300
+ try {
301
+ // The package directory itself must be real: npm installs packages, a link is a workspace.
302
+ if (realpathSync(dir) !== dir) return null;
303
+ } catch {
304
+ return null;
305
+ }
306
+ return adapterMain(dir);
307
+ };
308
+ // hoisted: a sibling in the same node_modules
309
+ const hoisted = accept(join(modules, "@explyt", "acp-adapter"));
310
+ if (hoisted) return hoisted;
311
+ // nested: the package whose node_modules holds this CLI
312
+ const owner = dirname(modules);
313
+ if (owner.split(sep).includes("node_modules")) return accept(owner);
314
+ return null;
315
+ }
316
+
246
317
  const adapterCandidates = [
247
318
  join(here, "..", "acp", "index.js"),
248
319
  join(packageRoot, "dist", "acp", "index.js"),
249
320
  join(packageRoot, "vendor", "pi-acp", "dist", "index.js"),
250
321
  ];
251
- const adapter = adapterCandidates.find((candidate) => existsSync(candidate));
322
+ const adapter = adapterCandidates.find((candidate) => existsSync(candidate)) ?? installedReleaseAdapter(packageRoot);
252
323
  if (!adapter) {
253
- // The public terminal package: fail with a migration message rather than
254
- // "unknown command", because this is exactly what an old IDE entry runs.
324
+ // What an old IDE entry, or a terminal-only install, runs (RQ-27).
255
325
  fail(
256
- "ACP is not included in the public terminal package.\n" +
257
- " IDE integration is provided by the Explyt managed backend, which the Neo plugin\n" +
258
- " downloads and manages. Update the IDE configuration to use it, or pin the last\n" +
259
- " ACP-capable version of this package.",
326
+ "no ACP adapter is installed next to this package.\n" +
327
+ " IDE integration is set up by the Explyt installer, which installs the adapter\n" +
328
+ " together with this CLI; the Neo plugin runs it for you. See `explyt --help`.",
260
329
  );
261
330
  }
262
331
 
@@ -265,7 +334,19 @@ if (!adapter) {
265
334
  // one place the `.bin` shim is still needed, and the adapter handles the Windows
266
335
  // `.cmd` case itself.
267
336
  const engine = await requireEngine();
268
- prependEnginePath(process.env, engine.binDir);
337
+ // The adapter's own `PI_ACP_PI_COMMAND` names the engine command explicitly (the Neo
338
+ // plugin sets it for the managed backend, whose win32 tree is packed on Linux and has
339
+ // no `pi.cmd`, #219). A missing shim is excused only when that variable names a file
340
+ // that exists: subagents and bash tools pass the variable on to child processes, so a
341
+ // value inherited from an unrelated session must not mask a broken install (RQ-6, RQ-50).
342
+ const explicitEngineCommand = process.env.PI_ACP_PI_COMMAND;
343
+ if (!engine.binDir && !(explicitEngineCommand && existsSync(explicitEngineCommand))) {
344
+ fail(`the agent engine's pi bin is missing (looked for node_modules/.bin/pi next to ${engine.engineDir}); reinstall with the Explyt installer`);
345
+ }
346
+ // Exactly one PATH key (RQ-6): the engine's `.bin` first, then this Node's directory, so
347
+ // the engine's `#!/usr/bin/env node` finds the same Node even when the caller's PATH has
348
+ // none, then the inherited value. Never a missing value (#219).
349
+ setProductPath(process.env, [engine.binDir, dirname(process.execPath)]);
269
350
 
270
351
  // Point the engine at THIS agent's configuration directory rather than the
271
352
  // `~/.pi/agent` of a pi the user may also run by hand. Set here, in our own process,