@explyt/ai-agent 0.2.0 → 0.3.0-dogfood.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +3 -3
  2. package/THIRD-PARTY-NOTICES.md +34 -5
  3. package/agent-docs/README.md +6 -3
  4. package/agent-docs/updates.md +56 -0
  5. package/dist/cli/engine.js +102 -23
  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 +141 -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 +9 -73
  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 -0
  44. package/dist/install/installer.js +3652 -0
  45. package/dist/mcp-adapter.js +4 -4
  46. package/dist/rpiv-ask-user-question/{chunk-GAM3ZAHN.js → chunk-JNDW3P3A.js} +2 -2
  47. package/dist/rpiv-ask-user-question/index.js +3 -3
  48. package/dist/vendor/model-probe/rules.json +102 -0
  49. package/dist/vendor/model-probe/src/index.js +983 -0
  50. package/dist/vendor/model-probe/src/opencode-models-snapshot.js +8 -0
  51. package/dist/vendor/model-probe/src/opencode-models.js +6 -0
  52. package/dist/vendor/pi-ai-compat.js +2 -0
  53. package/dist/vendor/pi-coding-agent.js +159 -0
  54. package/dist/vendor/pi-web-access-deps/modelcontextprotocol__sdk__client__index.js.js +9 -0
  55. package/dist/vendor/pi-web-access-deps/modelcontextprotocol__sdk__client__streamableHttp.js.js +9 -0
  56. package/dist/vendor/pi-web-access.js +24958 -0
  57. package/dist/vendor/typebox/chunk-6XHASRAZ.js +4819 -0
  58. package/dist/vendor/typebox/index.js +703 -0
  59. package/dist/vendor/typebox/value.js +3736 -0
  60. package/package.json +16 -5
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.0" # start the terminal UI
21
- npx -y "@explyt/ai-agent@0.2.0" 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.0 --help` for the available options.
31
+ Run `npx -y @explyt/ai-agent@0.3.0 --help` for the available options.
@@ -20,7 +20,8 @@ License or other written license agreements with the Company.
20
20
  > depends on or redistributes. The release pipeline additionally generates a
21
21
  > machine-collected list of license metadata for every runtime npm dependency of the
22
22
  > artifact, marking which of them are bundled inside it. The public npm package bundles
23
- > none of them: npm installs them beside it. The managed backend archive redistributes its
23
+ > none of them as packages: npm installs them beside it; it does carry compiled copies of
24
+ > `model-probe` and `typebox` (section 5) and of `pi-web-access` (section 3) under `dist/vendor/`. The managed backend archive redistributes its
24
25
  > whole installed tree. The generated list is not a substitute for reviewing the licenses
25
26
  > and NOTICE files of all transitive dependencies before a commercial release.
26
27
 
@@ -88,6 +89,12 @@ License or other written license agreements with the Company.
88
89
  the unmodified `pi-mcp-adapter` entrypoint (`index.ts`) together with the package's
89
90
  small runtime helper files, so that Node can load it outside the engine's TypeScript
90
91
  loader.
92
+ - The public npm package contains `dist/vendor/pi-web-access.js`, an unobfuscated
93
+ compilation of the unmodified `pi-web-access` entrypoint (`dist/index.js`) carrying the
94
+ package's name, version and licence in a banner, so that its imports of the engine and
95
+ `typebox` resolve to the product's files under `dist/vendor/`; and small generated files
96
+ under `dist/vendor/pi-web-access-deps/` that import the package's own installed copy of
97
+ `@modelcontextprotocol/sdk`. The package's own files are not altered.
91
98
  - In the managed backend archive only, `pi-mcp-adapter` is redistributed with one
92
99
  textual modification made at build time: in its `index.ts`, the slash-command alias
93
100
  registered as `pi-mcp` (a second name for the package's `mcp` status command) is
@@ -97,6 +104,8 @@ License or other written license agreements with the Company.
97
104
  here as required by the MIT License; the original copyright and license notice above
98
105
  apply to the modified package unchanged. The public npm package does not carry the
99
106
  adapter and therefore carries no modification of it.
107
+ - The public npm package contains `dist/vendor/pi-coding-agent.js`, a generated file that
108
+ re-exports the engine's own bundled entry point by name; it contains no engine code.
100
109
  - The formerly bundled `pi-subagents` package was removed by spec 008; references to it in
101
110
  older release history are historical and do not describe the current artifact.
102
111
  - Seven units of `@earendil-works/pi-coding-agent` 0.84.4 are **copied** (from the TypeScript in
@@ -133,7 +142,7 @@ License or other written license agreements with the Company.
133
142
  - local Explyt extension with current `@earendil-works` imports
134
143
  - shell API-key execution removed; credential files use locked atomic writes and private permissions
135
144
 
136
- ### 5. model-probe, @opencode-ai/models, yaml, minimatch and semver
145
+ ### 5. model-probe, @opencode-ai/models, yaml, minimatch, semver and typebox
137
146
 
138
147
  - `model-probe` 0.6.0, MIT, https://www.npmjs.com/package/model-probe
139
148
  - Copyright (c) 2025 ratatulieoi
@@ -146,9 +155,18 @@ License or other written license agreements with the Company.
146
155
  - Copyright Isaac Z. Schlueter <i@izs.me> and minimatch contributors
147
156
  - `semver` 7.8.5, ISC, https://www.npmjs.com/package/semver
148
157
  - Copyright (c) Isaac Z. Schlueter and Contributors
149
-
150
- The MIT text below applies to the derivative, `model-probe` and `@opencode-ai/models`
151
- with the copyright notices above. The ISC text immediately below applies to `yaml` and
158
+ - `typebox` (the version pinned in the development manifest, which the build compiles; the
159
+ build-time metadata below and each compiled file's banner name it), MIT, https://www.npmjs.com/package/typebox
160
+ - Copyright (c) 2017-2026 Haydn Paterson
161
+
162
+ `model-probe` and `typebox` are also redistributed compiled, unobfuscated and unmodified:
163
+ the public npm package contains `dist/vendor/model-probe/` (the installed `model-probe`
164
+ compiled from its TypeScript, with its `rules.json`) and `dist/vendor/typebox/` (the pinned
165
+ `typebox` in one ESM build), each file carrying the package's name, version and
166
+ licence in a banner that names the exact version compiled.
167
+
168
+ The MIT text below applies to the derivative, `model-probe`, `@opencode-ai/models` and
169
+ `typebox` with the copyright notices above. The ISC text immediately below applies to `yaml` and
152
170
  `semver`, each with its own copyright holder listed above (the ISC terms are identical);
153
171
  `minimatch` is covered by the separate Blue Oak Model License text further down, and its
154
172
  copyright holder is the one listed for it above rather than the one named in the ISC text:
@@ -511,17 +529,28 @@ Consult each package for its complete license text.
511
529
  - @modelcontextprotocol/client@2.0.0: license metadata = MIT (installed by npm beside this package)
512
530
  - @modelcontextprotocol/core@2.0.0: license metadata = MIT (installed by npm beside this package)
513
531
  - @modelcontextprotocol/ext-apps@1.7.5: license metadata = MIT (installed by npm beside this package)
532
+ - @mozilla/readability@0.6.0: license metadata = Apache-2.0 (installed by npm beside this package)
514
533
  - @napi-rs/keyring@1.3.0: license metadata = MIT (installed by npm beside this package)
515
534
  - ajv@8.20.0: license metadata = MIT (installed by npm beside this package)
516
535
  - ajv-formats@3.0.1: license metadata = MIT (installed by npm beside this package)
517
536
  - cross-spawn@7.0.6: license metadata = MIT (installed by npm beside this package)
537
+ - defuddle@0.19.3: license metadata = MIT (installed by npm beside this package)
538
+ - linkedom@0.16.11: license metadata = ISC (installed by npm beside this package)
518
539
  - minimatch@10.2.6: license metadata = BlueOak-1.0.0 (installed by npm beside this package)
519
540
  - model-probe@0.6.0: license metadata = MIT (installed by npm beside this package)
520
541
  - open@10.2.0: license metadata = MIT (installed by npm beside this package)
542
+ - p-limit@6.2.0: license metadata = MIT (installed by npm beside this package)
521
543
  - pi-mcp-adapter@2.31.0: license metadata = MIT (installed by npm beside this package)
522
544
  - pi-web-access@0.31.0: license metadata = MIT (installed by npm beside this package)
545
+ - promise.try@2.0.2: license metadata = MIT (installed by npm beside this package)
523
546
  - recheck@4.5.0: license metadata = MIT (installed by npm beside this package)
524
547
  - semver@7.8.5: license metadata = ISC (installed by npm beside this package)
525
548
  - smol-toml@1.9.0: license metadata = BSD-3-Clause (installed by npm beside this package)
526
549
  - strip-json-comments@5.0.3: license metadata = MIT (installed by npm beside this package)
550
+ - turndown@7.2.4: license metadata = MIT (installed by npm beside this package)
551
+ - undici@8.11.2: license metadata = MIT (installed by npm beside this package)
552
+ - unpdf@1.8.1: license metadata = MIT (installed by npm beside this package)
527
553
  - yaml@2.9.0: license metadata = ISC (installed by npm beside this package)
554
+ - typebox@1.3.24: license metadata = MIT (compiled into dist/vendor/typebox)
555
+ - model-probe@0.6.0: license metadata = MIT (compiled into dist/vendor/model-probe)
556
+ - pi-web-access@0.31.0: license metadata = MIT (compiled into dist/vendor/pi-web-access.js)
@@ -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
 
@@ -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,19 +252,20 @@ export function isUsableShim(path, os = platform()) {
252
252
  }
253
253
 
254
254
  /**
255
- * PATH with `binDir` in front.
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.
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.
263
262
  */
264
- export function withEnginePath(env, binDir) {
265
- if (!binDir) return { ...env };
266
- const current = env.PATH ?? env.Path ?? "";
267
- return { ...env, PATH: current ? `${binDir}${delimiter}${current}` : 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);
268
269
  }
269
270
 
270
271
  /**
@@ -361,10 +362,36 @@ function explicitAgentDir(value, home) {
361
362
  * package would be walked as one. Next to it keeps it under the one parent this project
362
363
  * owns, and moves with an explicit `PI_CODING_AGENT_DIR` (the agent directory's parent
363
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
364
376
  */
365
- export function engineShimDir(env = process.env, home = homedir()) {
377
+ export function engineShimDir(env = process.env, home = homedir(), engineDir) {
366
378
  const agentDir = withAgentDir(env, home)[AGENT_DIR_ENV];
367
- 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);
368
395
  }
369
396
 
370
397
  /**
@@ -899,7 +926,7 @@ export function withIsolation(rest, packageRoot) {
899
926
  * debug artifacts, where the adapter ships next to the wrapper; the public terminal
900
927
  * package answers it with a migration error.
901
928
  */
902
- export const COMMANDS = new Set(["acp", "login", "logout", "tui", "update"]);
929
+ export const COMMANDS = new Set(["acp", "config", "install", "login", "logout", "tui", "update"]);
903
930
 
904
931
  /**
905
932
  * Engine flags that confine a terminal run to a single package: ours.
@@ -994,6 +1021,54 @@ export function terminalEngineArgs(entrypoint, packageRoot, extra = [], { login
994
1021
  */
995
1022
  export const ADAPTER_LOGIN_FLAG = "--terminal-login";
996
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
+
997
1072
  /**
998
1073
  * Flags belonging to other programs that consume the NEXT argument as their value.
999
1074
  *
@@ -1017,6 +1092,7 @@ export function parseArgs(argv) {
1017
1092
  let help = false;
1018
1093
  let version = false;
1019
1094
  let seenPositional = false;
1095
+ let terminalLogin = false;
1020
1096
  const rest = [];
1021
1097
 
1022
1098
  for (let i = 0; i < argv.length; i += 1) {
@@ -1037,7 +1113,7 @@ export function parseArgs(argv) {
1037
1113
  // to the `acp` invocation from its own config -- see ADAPTER_LOGIN_FLAG. It is also
1038
1114
  // consumed rather than forwarded: the entrypoint re-adds it for the adapter, so
1039
1115
  // passing it through as well would hand the adapter the same flag twice.
1040
- if (arg === ADAPTER_LOGIN_FLAG) { command = "login"; continue; }
1116
+ if (arg === ADAPTER_LOGIN_FLAG) { command = "login"; terminalLogin = true; continue; }
1041
1117
 
1042
1118
  // A foreign flag plus its value: forward both, and never read the value as a
1043
1119
  // subcommand.
@@ -1058,29 +1134,32 @@ export function parseArgs(argv) {
1058
1134
  rest.push(arg);
1059
1135
  }
1060
1136
 
1061
- return { command, help, version, rest };
1137
+ return { command, help, version, rest, terminalLogin };
1062
1138
  }
1063
1139
 
1064
1140
  export function helpText() {
1065
1141
  return [
1066
1142
  "Explyt AI Agent",
1067
1143
  "",
1068
- "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",
1069
1146
  "Or run a pinned version without installing:",
1070
1147
  " npx -y @explyt/ai-agent@<version> start the terminal UI",
1071
1148
  " npx -y @explyt/ai-agent@<version> tui start the terminal UI",
1072
1149
  " npx -y @explyt/ai-agent@<version> login store a provider credential",
1073
1150
  " npx -y @explyt/ai-agent@<version> logout remove stored credentials",
1074
1151
  "",
1075
- "Global commands: explyt tui | login | logout | update",
1076
- " 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",
1077
1155
  " -h, --help show this message",
1078
1156
  " -v, --version print the version",
1079
1157
  "",
1080
1158
  "Credentials and sessions are separate from other agents: logging in to a pi you run",
1081
1159
  "by hand does not log in here, so run login once for this agent.",
1082
1160
  "The terminal UI notes a newer version at startup; PI_OFFLINE=1 turns the check off.",
1083
- "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.",
1084
1163
  "",
1085
1164
  ].join("\n");
1086
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,
57
+ setProductPath,
55
58
  terminalEngineArgs,
56
59
  withAgentDir,
57
- withEnginePath,
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
- process.env.PATH = withEnginePath(process.env, engine.binDir).PATH;
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,