@timurproko/a1 0.1.1-dev.9 → 0.1.4

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 (77) hide show
  1. package/README.md +76 -15
  2. package/bin/cli.js +14 -2
  3. package/bin/module-identity.js +132 -0
  4. package/bin/ui.js +10 -0
  5. package/dist/native/darwin-arm64/manifest.json +1 -1
  6. package/dist/native/linux-x64/manifest.json +1 -1
  7. package/dist/native/win32-x64/manifest.json +2 -2
  8. package/dist/native/win32-x64/process-guardian.exe +0 -0
  9. package/dist/src/cli/capabilities.d.ts +15 -0
  10. package/dist/src/cli/capabilities.js +7 -0
  11. package/dist/src/cli/dispatch.d.ts +9 -3
  12. package/dist/src/cli/dispatch.js +104 -16
  13. package/dist/src/cli/index.d.ts +2 -0
  14. package/dist/src/cli/index.js +2 -0
  15. package/dist/src/cli/packages.d.ts +23 -0
  16. package/dist/src/cli/packages.js +84 -0
  17. package/dist/src/foundation/agent-engine-contracts/index.d.ts +1 -0
  18. package/dist/src/foundation/agent-engine-contracts/index.js +1 -0
  19. package/dist/src/foundation/agent-engine-contracts/package-ports.d.ts +54 -0
  20. package/dist/src/foundation/agent-engine-contracts/package-ports.js +31 -0
  21. package/dist/src/foundation/pi-component-adapter/shell-editor-autocomplete.js +1 -1
  22. package/dist/src/foundation/pi-component-adapter/shell-extension-ui.js +1 -1
  23. package/dist/src/foundation/pi-component-adapter/shell-footer-status.js +1 -1
  24. package/dist/src/foundation/pi-component-adapter/shell-presenters-transcript.d.ts +15 -0
  25. package/dist/src/foundation/pi-component-adapter/shell-presenters-transcript.js +34 -1
  26. package/dist/src/foundation/pi-component-adapter/shell-selectors-dialogs.js +1 -1
  27. package/dist/src/foundation/pi-component-adapter/shell-shared-facade.d.ts +1 -1
  28. package/dist/src/foundation/pi-component-adapter/shell-shared-facade.js +1 -1
  29. package/dist/src/foundation/pi-component-adapter/upstream/adjacent/core/keybindings.d.ts +2 -2
  30. package/dist/src/foundation/pi-component-adapter/upstream/adjacent/core/keybindings.js +1 -1
  31. package/dist/src/foundation/pi-component-adapter/upstream/components/countdown-timer.d.ts +1 -1
  32. package/dist/src/foundation/pi-component-adapter/upstream/components/custom-entry.d.ts +1 -1
  33. package/dist/src/foundation/pi-component-adapter/upstream/components/custom-entry.js +1 -1
  34. package/dist/src/foundation/pi-component-adapter/upstream/components/daxnuts.d.ts +1 -1
  35. package/dist/src/foundation/pi-component-adapter/upstream/components/earendil-announcement.d.ts +1 -1
  36. package/dist/src/foundation/pi-component-adapter/upstream/components/earendil-announcement.js +1 -1
  37. package/dist/src/foundation/pi-component-adapter/upstream/components/extension-editor.d.ts +1 -1
  38. package/dist/src/foundation/pi-component-adapter/upstream/components/extension-editor.js +1 -1
  39. package/dist/src/foundation/pi-component-adapter/upstream/components/first-time-setup.d.ts +1 -1
  40. package/dist/src/foundation/pi-component-adapter/upstream/components/first-time-setup.js +1 -1
  41. package/dist/src/foundation/pi-component-adapter/upstream/components/mermaid.js +1 -1
  42. package/dist/src/foundation/pi-component-adapter/upstream/components/owned-editor.d.ts +1 -1
  43. package/dist/src/foundation/pi-component-adapter/upstream/components/owned-editor.js +1 -1
  44. package/dist/src/foundation/pi-component-adapter/upstream/components/scoped-models-selector.d.ts +1 -1
  45. package/dist/src/foundation/pi-component-adapter/upstream/components/scoped-models-selector.js +1 -1
  46. package/dist/src/foundation/pi-component-adapter/upstream/components/session-footer.d.ts +1 -1
  47. package/dist/src/foundation/pi-component-adapter/upstream/components/session-footer.js +1 -1
  48. package/dist/src/foundation/pi-component-adapter/upstream/components/session-selector-search.js +1 -1
  49. package/dist/src/foundation/pi-component-adapter/upstream/components/session-selector.d.ts +1 -1
  50. package/dist/src/foundation/pi-component-adapter/upstream/components/session-selector.js +1 -1
  51. package/dist/src/foundation/pi-component-adapter/upstream/components/skill-invocation-message.d.ts +1 -1
  52. package/dist/src/foundation/pi-component-adapter/upstream/components/skill-invocation-message.js +1 -1
  53. package/dist/src/foundation/pi-component-adapter/upstream/components/status-indicator.d.ts +1 -1
  54. package/dist/src/foundation/pi-component-adapter/upstream/components/status-indicator.js +1 -1
  55. package/dist/src/foundation/pi-component-adapter/upstream/components/tree-selector.d.ts +1 -1
  56. package/dist/src/foundation/pi-component-adapter/upstream/components/tree-selector.js +1 -1
  57. package/dist/src/foundation/pi-component-adapter/upstream/components/trust-selector.d.ts +1 -1
  58. package/dist/src/foundation/pi-component-adapter/upstream/components/trust-selector.js +1 -1
  59. package/dist/src/foundation/pi-component-adapter/upstream/theme/theme.d.ts +1 -1
  60. package/dist/src/foundation/pi-component-adapter/upstream/theme/theme.js +1 -1
  61. package/dist/src/foundation/pi-engine-adapter/adapter.d.ts +8 -1
  62. package/dist/src/foundation/pi-engine-adapter/adapter.js +28 -1
  63. package/dist/src/foundation/pi-engine-adapter/index.d.ts +1 -0
  64. package/dist/src/foundation/pi-engine-adapter/index.js +1 -0
  65. package/dist/src/foundation/pi-engine-adapter/package-integration.d.ts +13 -0
  66. package/dist/src/foundation/pi-engine-adapter/package-integration.js +112 -0
  67. package/dist/src/foundation/pi-engine-adapter/runtime-integration.d.ts +18 -1
  68. package/dist/src/foundation/pi-engine-adapter/runtime-integration.js +37 -2
  69. package/dist/src/foundation/pi-owned-ui-integration/session-shell.js +14 -2
  70. package/dist/src/foundation/pi-tui-runtime-adapter/adapter.js +1 -1
  71. package/dist/src/foundation/release/release.js +26 -2
  72. package/dist/src/foundation/release/update.d.ts +11 -1
  73. package/dist/src/foundation/release/update.js +106 -5
  74. package/docs/architecture/toolchain.md +3 -3
  75. package/docs/ci-release-runbook.md +80 -40
  76. package/docs/features/launch-profiles.md +2 -0
  77. package/package.json +8 -3
package/README.md CHANGED
@@ -1,11 +1,7 @@
1
1
  # A1
2
2
 
3
- A1 is a terminal-native agent launcher for Windows, Linux, and macOS. Bare `a1` runs the A1-owned Pi-compatible UI, while explicit fallback profiles can launch untouched Pi directly. A1 also manages release selection, process ownership, and updates.
4
-
5
3
  ## Install
6
4
 
7
- Requires Node.js 22.19 through 24.x and npm 11.
8
-
9
5
  ```sh
10
6
  npm install --global @timurproko/a1@latest
11
7
  ```
@@ -14,30 +10,95 @@ npm install --global @timurproko/a1@latest
14
10
 
15
11
  ```sh
16
12
  a1 # A1-owned UI and profile: ~/.a1/agent
17
- a1 pi # untouched vanilla Pi oracle: ~/.pi/agent
18
- a1 sandbox # unchanged isolated vanilla Pi profile: ~/.a1/sandbox
19
13
  a1 version # show Installed, Release (latest), and Next versions
20
14
  a1 update # update to npm latest
21
15
  a1 update:next # update to npm next
22
16
  ```
23
17
 
24
- The `a1 ui` subcommand was removed; run bare `a1` for the owned UI.
18
+ Prerelease builds — what `a1 update:next` installs add two development profiles
19
+ for comparing against pinned Pi and for experimenting against an isolated profile.
20
+ A release build does not carry them.
25
21
 
26
- Any number of `a1`, `a1 pi`, and `a1 sandbox` commands may run in separate terminals at the same time. Closing one command closes only its own UI/Pi process and everything it spawned.
22
+ ```sh
23
+ a1 pi # untouched vanilla Pi oracle: ~/.pi/agent
24
+ a1 sandbox # unchanged isolated vanilla Pi profile: ~/.a1/sandbox
25
+ ```
26
+
27
+ ## Extensions
28
+
29
+ Pi extension packages install into A1's own profile, so bare `a1` loads them and
30
+ `a1 pi` and `a1 sandbox` do not. Sources are Pi's: `npm:`, git, or a local path.
31
+
32
+ ```sh
33
+ a1 install npm:pi-mcp-adapter # install a package into ~/.a1/agent
34
+ a1 remove npm:pi-mcp-adapter # remove it again (alias: a1 uninstall)
35
+ a1 list # list packages installed for a1
36
+ a1 update --extensions # update every installed package
37
+ a1 update npm:pi-mcp-adapter # update one of them
38
+ a1 update --models # refresh model catalogs
39
+ ```
40
+
41
+ A running session loads a newly installed package after a restart. Pi's own
42
+ profile at `~/.pi/agent` is managed by Pi itself.
27
43
 
28
44
  ## Develop
29
45
 
30
46
  ```sh
31
- npm ci # install exact locked dependencies
32
- npm run build # compile TypeScript and the process guardian into dist
33
- npm start # build and launch an isolated development instance
34
- npm run test:fast # typecheck + fast suite, no build needed
35
- npm test # same as test:fast
36
- npm run test:full # complete non-physical suite
47
+ npm ci # install exact locked dependencies
48
+ npm run build # compile TypeScript and the process guardian into dist
49
+ npm start # build and launch an isolated development `a1`
50
+ npm run start:pi # build and launch an isolated development `a1 pi`
51
+ npm run start:sandbox # build and launch an isolated development `a1 sandbox`
52
+ npm run test:fast # typecheck + fast suite, no build needed
53
+ npm test # same as test:fast
54
+ npm run test:full # complete non-physical suite
37
55
  ```
38
56
 
39
57
  ## Release
40
58
 
59
+ Two channels, both published by CI from the exact bytes it validated. Nothing is
60
+ ever published from a workstation.
61
+
62
+ ### Previews — npm `next`
63
+
64
+ Automatic. Every push to `develop` publishes one, versioned
65
+ `<major.minor.patch>-dev.<run number>` from whatever `package.json` declares. The
66
+ preview version is stamped at publish time and never committed, so previews cost no
67
+ commits and need no command.
68
+
69
+ ```sh
70
+ a1 update:next # install the newest preview
71
+ ```
72
+
73
+ ### Stable — npm `latest`
74
+
75
+ One command, from a clean `develop` that matches its remote:
76
+
41
77
  ```sh
42
- npm run release:next # publish current develop tip to npm next via trusted CI workflows
78
+ npm run release -- patch # 0.1.1 -> 0.1.2
79
+ npm run release -- minor # 0.1.1 -> 0.2.0
80
+ npm run release -- major # 0.1.1 -> 1.0.0
81
+ npm run release -- 0.4.0 # an exact version
43
82
  ```
83
+
84
+ It lands the version on `develop` through a pull request that merges itself.
85
+ **Landing it is what publishes** — the workflow builds, validates the packed release
86
+ on Windows, Linux, and macOS, publishes to npm `latest` with provenance, and only
87
+ then writes the `v<version>` tag and records the GitHub Release. The command waits
88
+ for that to succeed, then lands the next `-dev.0` version so previews resume
89
+ immediately.
90
+
91
+ ```sh
92
+ a1 update # install the newest stable release
93
+ ```
94
+
95
+ After a stable publish, `master` fast-forwards to the released commit, so `master`
96
+ always points at what npm `latest` serves while `develop` carries the work.
97
+
98
+ A release that fails leaves nothing behind: no tag, no GitHub Release, no moved
99
+ branch. Every version you can see somewhere is a version the registry actually
100
+ serves. Release tags are protected from deletion and movement; a wrong tag is
101
+ superseded by the next version, never repointed.
102
+
103
+ `docs/ci-release-runbook.md` has the full picture, including what happens when a
104
+ publish fails partway.
package/bin/cli.js CHANGED
@@ -2,7 +2,12 @@
2
2
 
3
3
  const packageRoot = new URL("..", import.meta.url);
4
4
  const { fileURLToPath } = await import("node:url");
5
- const { dispatchCli } = await import("../dist/src/cli/index.js");
5
+ const { readFile } = await import("node:fs/promises");
6
+ const { cliCapabilities, dispatchCli } = await import("../dist/src/cli/index.js");
7
+
8
+ // Which commands this build exposes follows from the build's own version, so a
9
+ // released a1 cannot be argued into offering the development profiles.
10
+ const capabilities = cliCapabilities(JSON.parse(await readFile(new URL("package.json", packageRoot), "utf8")).version);
6
11
 
7
12
  process.exitCode = await dispatchCli(process.argv.slice(2), {
8
13
  launch: async intent => {
@@ -21,6 +26,13 @@ process.exitCode = await dispatchCli(process.argv.slice(2), {
21
26
  const { runSelfUpdate } = await import("../dist/src/foundation/release/index.js");
22
27
  return await runSelfUpdate({ packageRoot: fileURLToPath(packageRoot), channel });
23
28
  },
29
+ packages: async request => {
30
+ const [{ runPackageCommand }, { createPiPackagesPort }] = await Promise.all([
31
+ import("../dist/src/cli/index.js"),
32
+ import("../dist/src/foundation/pi-engine-adapter/index.js"),
33
+ ]);
34
+ return await runPackageCommand(request, { createPort: createPiPackagesPort });
35
+ },
24
36
  }, {
25
37
  stderr: message => process.stderr.write(message),
26
- });
38
+ }, capabilities);
@@ -0,0 +1,132 @@
1
+ /**
2
+ * One pi-tui module identity per process.
3
+ *
4
+ * npm materializes @earendil-works/pi-tui twice under A1's package root: once
5
+ * as A1's direct dependency at the node_modules root, and once nested inside
6
+ * @earendil-works/pi-coding-agent, whose published npm-shrinkwrap.json makes
7
+ * npm build its dependency tree exactly as shrinkwrapped instead of sharing a
8
+ * hoisted copy. Both are the same version; npm just cannot tell them apart.
9
+ *
10
+ * Two copies means two of every TUI class. Pinned Pi hands extensions the
11
+ * nested copy — its extension loader aliases the specifier to whatever it
12
+ * resolves from its own directory — so an extension's `instanceof` check and
13
+ * its prototype patches land on classes A1's renderer never uses. Extension
14
+ * chrome silently disappears and routed input dead-ends, with no error.
15
+ *
16
+ * A1 therefore does not import the specifier at all. Its package declares the
17
+ * subpath import `#pi-tui`, resolving to pinned Pi's copy first and to the
18
+ * hoisted one only when that is absent — which is exactly the case where there
19
+ * is one copy and both sides agree anyway. Every A1 module imports `#pi-tui`,
20
+ * so which copy A1 uses is stated in package.json and enforced by Node at
21
+ * resolution, rather than arranged by rewriting an installed tree.
22
+ *
23
+ * What remains here is the check that it worked. The alias names one path
24
+ * inside pinned Pi; if a future layout moved that copy, resolution would fall
25
+ * back to A1's own and the split would return — silently, which is what made
26
+ * this expensive the first time. So launch compares the two resolutions and
27
+ * says so when they differ, loudly and once, instead of leaving a user to
28
+ * discover it as missing extension UI.
29
+ *
30
+ * This lives in bin/ (shipped, plain JS) because it inspects dependency
31
+ * resolution, which the Pi API boundary policy rightly forbids ordinary
32
+ * production code from touching.
33
+ */
34
+ import { createRequire } from "node:module";
35
+ import { existsSync, readFileSync, realpathSync } from "node:fs";
36
+ import { dirname, join } from "node:path";
37
+ import { pathToFileURL } from "node:url";
38
+
39
+ /**
40
+ * What A1's own modules resolve `#pi-tui` to, as a real path.
41
+ *
42
+ * The alias is read from the manifest and its entries tried in order, which is
43
+ * what Node does for a subpath import: a relative target is a file within the
44
+ * package, and a bare one goes through ordinary resolution. Asking Node
45
+ * directly is not an option here — `import.meta.resolve` ignores the parent it
46
+ * is given unless an experimental flag is set, so it would always answer for
47
+ * the running process rather than for the installation being inspected.
48
+ */
49
+ function resolveOwnPiTui(packageRoot) {
50
+ const manifest = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8"));
51
+ const alias = manifest.imports?.["#pi-tui"];
52
+ const targets = Array.isArray(alias) ? alias : alias === undefined ? [] : [alias];
53
+ if (targets.length === 0) throw new Error("package.json declares no #pi-tui alias");
54
+ for (const target of targets) {
55
+ if (typeof target !== "string") continue;
56
+ if (target.startsWith("./") || target.startsWith("../")) {
57
+ const candidate = join(packageRoot, target);
58
+ if (existsSync(candidate)) return canonical(candidate);
59
+ continue;
60
+ }
61
+ try {
62
+ return canonical(createRequire(pathToFileURL(join(packageRoot, "package.json")).href).resolve(target));
63
+ } catch {
64
+ continue;
65
+ }
66
+ }
67
+ throw new Error(`no #pi-tui target resolves: ${targets.join(", ")}`);
68
+ }
69
+
70
+ /**
71
+ * What pinned Pi resolves the same specifier to, as a real path — asked from
72
+ * inside Pi's own directory, which is where Pi asks it.
73
+ *
74
+ * Pi's directory is located by walking node_modules outward, the way Node
75
+ * itself would, rather than by resolving Pi's entry: its `exports` map offers
76
+ * no CommonJS condition, so an ordinary require cannot name it. From there a
77
+ * require resolves pi-tui, which publishes no `exports` map at all.
78
+ */
79
+ function resolvePinnedPiTui(packageRoot) {
80
+ let directory = packageRoot;
81
+ while (true) {
82
+ const candidate = join(directory, "node_modules", "@earendil-works", "pi-coding-agent");
83
+ if (existsSync(join(candidate, "package.json"))) {
84
+ return canonical(createRequire(pathToFileURL(join(candidate, "package.json")).href).resolve("@earendil-works/pi-tui"));
85
+ }
86
+ const parent = dirname(directory);
87
+ if (parent === directory) throw new Error("pinned Pi is not installed beneath this package root");
88
+ directory = parent;
89
+ }
90
+ }
91
+
92
+ function canonical(path) {
93
+ try {
94
+ return realpathSync.native(path);
95
+ } catch {
96
+ return path;
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Report whether A1 and pinned Pi resolve pi-tui to the same file.
102
+ * Returns a discriminated outcome; never throws.
103
+ */
104
+ export function inspectPiTuiModuleIdentity(packageRoot) {
105
+ let own;
106
+ let pinned;
107
+ try {
108
+ own = resolveOwnPiTui(packageRoot);
109
+ } catch (error) {
110
+ return { kind: "unresolved", side: "a1", message: message(error) };
111
+ }
112
+ try {
113
+ pinned = resolvePinnedPiTui(packageRoot);
114
+ } catch (error) {
115
+ return { kind: "unresolved", side: "pi", message: message(error) };
116
+ }
117
+ return own === pinned ? { kind: "unified", path: own } : { kind: "split", own, pinned };
118
+ }
119
+
120
+ function message(error) {
121
+ return error instanceof Error ? error.message : String(error);
122
+ }
123
+
124
+ /** Launch-entry wrapper: warn on stderr when the two sides disagree. */
125
+ export function assertSinglePiTuiModuleAtLaunch(packageRoot, warn) {
126
+ const outcome = inspectPiTuiModuleIdentity(packageRoot);
127
+ if (outcome.kind === "split") {
128
+ warn(`a1: pi-tui resolves to two different copies (${outcome.own} for a1, ${outcome.pinned} for Pi); extension UI may not render. The #pi-tui alias in a1's package.json no longer names Pi's copy.\n`);
129
+ } else if (outcome.kind === "unresolved") {
130
+ warn(`a1: could not resolve pi-tui from ${outcome.side === "a1" ? "a1" : "pinned Pi"} (${outcome.message}); extension UI may not render.\n`);
131
+ }
132
+ }
package/bin/ui.js CHANGED
@@ -1,5 +1,15 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ const { assertSinglePiTuiModuleAtLaunch } = await import("./module-identity.js");
4
+ const { fileURLToPath } = await import("node:url");
5
+
6
+ // Before the composition loads pinned Pi's terminal stack: confirm A1 and Pi
7
+ // resolve it to the same copy, so extensions and the owned UI share one module
8
+ // identity (see bin/module-identity.js for the full story). Nothing is repaired
9
+ // here — A1's package manifest decides which copy wins, and this only reports
10
+ // when that stopped being true.
11
+ assertSinglePiTuiModuleAtLaunch(fileURLToPath(new URL("..", import.meta.url)), message => process.stderr.write(message));
12
+
3
13
  const { runSelectedInteractiveRuntime } = await import("../dist/src/features/launch/index.js");
4
14
 
5
15
  runSelectedInteractiveRuntime(process.env.A1_LAUNCH_PROFILE ?? "a1", {
@@ -5,7 +5,7 @@
5
5
  "platform": "darwin",
6
6
  "architecture": "arm64",
7
7
  "capability": "unsupported",
8
- "builtAt": "2026-08-23T08:07:45.115Z",
8
+ "builtAt": "2026-08-23T17:52:00.836Z",
9
9
  "artifact": {
10
10
  "filename": "process-guardian",
11
11
  "sha256": "7524bf568992517f50ed53196c861c5edeba0d7275633bb4735ab45bd5963a28",
@@ -5,7 +5,7 @@
5
5
  "platform": "linux",
6
6
  "architecture": "x64",
7
7
  "capability": "supported",
8
- "builtAt": "2026-08-23T08:07:37.399Z",
8
+ "builtAt": "2026-08-23T17:52:10.246Z",
9
9
  "artifact": {
10
10
  "filename": "process-guardian",
11
11
  "sha256": "29e22fe29de2828982bc67ef418c4adcaab1490281477b7bea592ec6fe621bcd",
@@ -5,10 +5,10 @@
5
5
  "platform": "win32",
6
6
  "architecture": "x64",
7
7
  "capability": "supported",
8
- "builtAt": "2026-08-23T08:08:17.265Z",
8
+ "builtAt": "2026-08-23T17:52:55.388Z",
9
9
  "artifact": {
10
10
  "filename": "process-guardian.exe",
11
- "sha256": "a803cf8cc8bb6143d2adb7378183d4366a6bb880e110786ebeb7e778b64b6c59",
11
+ "sha256": "b31ad029bb569cfcb1a33db24999a556b5e12c1ac63f792fc1c555e6850243e6",
12
12
  "size": 172544
13
13
  },
14
14
  "provenance": {
@@ -0,0 +1,15 @@
1
+ /**
2
+ * `pi` and `sandbox` exist to compare A1 against pinned Pi and to try resources
3
+ * against an isolated profile. Both are development instruments rather than
4
+ * product, so a stable release does not carry them: what a released `a1` exposes
5
+ * is the product plus its maintenance and package commands.
6
+ *
7
+ * Which build this is comes from its own version. A prerelease version is a
8
+ * `next`-channel build, which is where that work happens; a release version is
9
+ * not. Nothing to configure, and no way for a released build to be talked into it.
10
+ */
11
+ export interface CliCapabilities {
12
+ readonly developmentProfiles: boolean;
13
+ }
14
+ export declare function cliCapabilities(version: string): CliCapabilities;
15
+ export declare function isPrereleaseVersion(version: string): boolean;
@@ -0,0 +1,7 @@
1
+ import { prerelease } from "semver";
2
+ export function cliCapabilities(version) {
3
+ return Object.freeze({ developmentProfiles: isPrereleaseVersion(version) });
4
+ }
5
+ export function isPrereleaseVersion(version) {
6
+ return (prerelease(version)?.length ?? 0) > 0;
7
+ }
@@ -1,15 +1,18 @@
1
1
  import { type InteractiveLaunchIntent, type LaunchProfileId } from "../features/launch/index.js";
2
+ import type { CliCapabilities } from "./capabilities.js";
3
+ import type { PackageCommandRequest } from "./packages.js";
2
4
  export type UpdateChannel = "stable" | "next";
3
5
  export interface CliHandlers {
4
6
  readonly launch: (intent: InteractiveLaunchIntent) => Promise<number>;
5
7
  readonly version: () => Promise<number>;
6
8
  readonly update: (channel: UpdateChannel) => Promise<number>;
9
+ readonly packages: (request: PackageCommandRequest) => Promise<number>;
7
10
  }
8
11
  export interface CliOutput {
9
12
  readonly stderr: (message: string) => void;
10
13
  }
11
- export declare const CLI_USAGE: string;
12
- export declare function dispatchCli(arguments_: readonly string[], handlers: CliHandlers, output: CliOutput): Promise<number>;
14
+ export declare function cliUsage(capabilities: CliCapabilities): string;
15
+ export declare function dispatchCli(arguments_: readonly string[], handlers: CliHandlers, output: CliOutput, capabilities: CliCapabilities): Promise<number>;
13
16
  export type CliCommand = {
14
17
  readonly kind: "launch";
15
18
  readonly profileId: LaunchProfileId;
@@ -18,8 +21,11 @@ export type CliCommand = {
18
21
  } | {
19
22
  readonly kind: "update";
20
23
  readonly channel: UpdateChannel;
24
+ } | {
25
+ readonly kind: "packages";
26
+ readonly request: PackageCommandRequest;
21
27
  } | {
22
28
  readonly kind: "error";
23
29
  readonly message: string;
24
30
  };
25
- export declare function parseCliCommand(arguments_: readonly string[]): CliCommand;
31
+ export declare function parseCliCommand(arguments_: readonly string[], capabilities: CliCapabilities): CliCommand;
@@ -1,35 +1,123 @@
1
1
  import { interactiveLaunchIntent } from "../features/launch/index.js";
2
2
  import { PRODUCT_TEXT } from "../product-identity.js";
3
- export const CLI_USAGE = PRODUCT_TEXT.usage(["", "pi", "sandbox", "version", "update", "update:next"]);
4
- export async function dispatchCli(arguments_, handlers, output) {
5
- const command = parseCliCommand(arguments_);
3
+ export function cliUsage(capabilities) {
4
+ return PRODUCT_TEXT.usage([
5
+ "",
6
+ ...(capabilities.developmentProfiles ? ["pi", "sandbox"] : []),
7
+ "version",
8
+ "update [self|<source>|--extensions|--models]",
9
+ "update:next",
10
+ "install <source>",
11
+ "remove <source>",
12
+ "list",
13
+ ]);
14
+ }
15
+ const PROFILE_WORDS = new Set(["pi", "sandbox"]);
16
+ export async function dispatchCli(arguments_, handlers, output, capabilities) {
17
+ const command = parseCliCommand(arguments_, capabilities);
6
18
  if (command.kind === "error") {
7
- output.stderr(`${command.message}\n${CLI_USAGE}\n`);
19
+ output.stderr(`${command.message}\n${cliUsage(capabilities)}\n`);
8
20
  return 2;
9
21
  }
10
22
  if (command.kind === "launch")
11
23
  return await handlers.launch(interactiveLaunchIntent(command.profileId));
12
24
  if (command.kind === "version")
13
25
  return await handlers.version();
26
+ if (command.kind === "packages")
27
+ return await handlers.packages(command.request);
14
28
  return await handlers.update(command.channel);
15
29
  }
16
- export function parseCliCommand(arguments_) {
30
+ export function parseCliCommand(arguments_, capabilities) {
17
31
  if (arguments_.length === 0)
18
32
  return { kind: "launch", profileId: "a1" };
19
- if (arguments_.length > 1)
20
- return { kind: "error", message: PRODUCT_TEXT.diagnostic("commands do not accept additional arguments.") };
21
- const [command] = arguments_;
22
- if (command === "pi" || command === "sandbox")
23
- return { kind: "launch", profileId: command };
24
- if (command === "ui")
25
- return { kind: "error", message: `The ui subcommand was removed; run bare ${PRODUCT_TEXT.commandName} for the owned UI.` };
33
+ const [command, ...rest] = arguments_;
34
+ if (capabilities.developmentProfiles && (command === "pi" || command === "sandbox")) {
35
+ return withoutArguments(rest, { kind: "launch", profileId: command });
36
+ }
26
37
  if (command === "version")
27
- return { kind: "version" };
28
- if (command === "update")
29
- return { kind: "update", channel: "stable" };
38
+ return withoutArguments(rest, { kind: "version" });
30
39
  if (command === "update:next")
31
- return { kind: "update", channel: "next" };
40
+ return withoutArguments(rest, { kind: "update", channel: "next" });
41
+ if (command === "update")
42
+ return parseUpdate(rest);
43
+ if (command === "install" || command === "remove" || command === "uninstall") {
44
+ return parseSourceCommand(command === "install" ? "install" : "remove", rest);
45
+ }
46
+ if (command === "list")
47
+ return withoutArguments(rest, { kind: "packages", request: { verb: "list", source: null } });
48
+ if (command === "ui")
49
+ return { kind: "error", message: `The ui subcommand was removed; run bare ${PRODUCT_TEXT.commandName} for the owned UI.` };
32
50
  if (command === "agent")
33
51
  return { kind: "error", message: `Bare ${PRODUCT_TEXT.commandName} is the ${PRODUCT_TEXT.displayName} agent experience; there is no agent subcommand.` };
34
52
  return { kind: "error", message: PRODUCT_TEXT.diagnostic(`received an unknown command: ${command ?? ""}`) };
35
53
  }
54
+ /**
55
+ * `update` carries both meanings pinned Pi gives it: itself by default, and the
56
+ * profile's packages when a target says so. Pi is refused as a target because A1
57
+ * certifies each release against one pinned Pi, so moving Pi underneath it would
58
+ * invalidate what was certified.
59
+ */
60
+ function parseUpdate(rest) {
61
+ if (rest.length === 0)
62
+ return { kind: "update", channel: "stable" };
63
+ if (rest.length > 1)
64
+ return { kind: "error", message: PRODUCT_TEXT.diagnostic("update accepts one target.") };
65
+ const [target] = rest;
66
+ if (target === "self")
67
+ return { kind: "update", channel: "stable" };
68
+ if (target === "pi") {
69
+ return {
70
+ kind: "error",
71
+ message: PRODUCT_TEXT.diagnostic(`pins the Pi version it was certified against; run ${PRODUCT_TEXT.commandName} update to move ${PRODUCT_TEXT.displayName} itself.`),
72
+ };
73
+ }
74
+ // A release channel is spelled with the colon. Taking the bare word as a package
75
+ // source would turn a near miss into a confident search for a package nobody has.
76
+ if (target === "next" || target === "stable") {
77
+ const form = target === "next" ? `${PRODUCT_TEXT.commandName} update:next` : `${PRODUCT_TEXT.commandName} update`;
78
+ return { kind: "error", message: PRODUCT_TEXT.diagnostic(`selects a release channel with a colon; run ${form}.`) };
79
+ }
80
+ if (target === "--extensions")
81
+ return { kind: "packages", request: { verb: "update", source: null } };
82
+ if (target === "--models")
83
+ return { kind: "packages", request: { verb: "refresh-models", source: null } };
84
+ if (target === undefined || target.startsWith("-"))
85
+ return unknownOption(target ?? "", "update");
86
+ if (PROFILE_WORDS.has(target))
87
+ return profileRejection("update");
88
+ return { kind: "packages", request: { verb: "update", source: target } };
89
+ }
90
+ function parseSourceCommand(verb, rest) {
91
+ const flag = rest.find(argument => argument.startsWith("-"));
92
+ if (flag !== undefined)
93
+ return unknownOption(flag, verb);
94
+ if (rest.length === 0)
95
+ return { kind: "error", message: PRODUCT_TEXT.diagnostic(`${verb} requires a package source.`) };
96
+ if (rest.length > 1)
97
+ return { kind: "error", message: PRODUCT_TEXT.diagnostic(`${verb} accepts one package source.`) };
98
+ const [source] = rest;
99
+ if (source === undefined)
100
+ return { kind: "error", message: PRODUCT_TEXT.diagnostic(`${verb} requires a package source.`) };
101
+ if (PROFILE_WORDS.has(source))
102
+ return profileRejection(verb);
103
+ return { kind: "packages", request: { verb, source } };
104
+ }
105
+ function withoutArguments(rest, command) {
106
+ if (rest.length === 0)
107
+ return command;
108
+ const [argument] = rest;
109
+ if (argument !== undefined && (PROFILE_WORDS.has(argument) || argument.startsWith("--profile")))
110
+ return profileRejection("list");
111
+ return { kind: "error", message: PRODUCT_TEXT.diagnostic("commands do not accept additional arguments.") };
112
+ }
113
+ function profileRejection(verb) {
114
+ return {
115
+ kind: "error",
116
+ message: PRODUCT_TEXT.diagnostic(`manages packages in its own profile, so ${verb} takes no profile; Pi manages Pi's own profile.`),
117
+ };
118
+ }
119
+ function unknownOption(option, verb) {
120
+ if (option.startsWith("--profile"))
121
+ return profileRejection(verb);
122
+ return { kind: "error", message: PRODUCT_TEXT.diagnostic(`received an unknown option for ${verb}: ${option}`) };
123
+ }
@@ -1,3 +1,5 @@
1
+ export * from "./capabilities.js";
1
2
  export * from "./dispatch.js";
3
+ export * from "./packages.js";
2
4
  export * from "./version.js";
3
5
  export * from "./version-stats.js";
@@ -1,3 +1,5 @@
1
+ export * from "./capabilities.js";
1
2
  export * from "./dispatch.js";
3
+ export * from "./packages.js";
2
4
  export * from "./version.js";
3
5
  export * from "./version-stats.js";
@@ -0,0 +1,23 @@
1
+ import { initializeProductProfile } from "../features/launch/index.js";
2
+ import type { AgentPackageOutcome, AgentPackagesPort, AgentPackagesPortInput } from "../foundation/agent-engine-contracts/index.js";
3
+ /**
4
+ * Package commands manage A1's own profile and nothing else, so no request carries
5
+ * a profile: `update` with no source means every installed package, and refreshing
6
+ * model catalogs is its own verb rather than a flag the caller has to remember to
7
+ * check.
8
+ */
9
+ export type PackageCommandVerb = "install" | "remove" | "list" | "update" | "refresh-models";
10
+ export interface PackageCommandRequest {
11
+ readonly verb: PackageCommandVerb;
12
+ readonly source: string | null;
13
+ }
14
+ export interface PackageCommandEnvironment {
15
+ readonly createPort: (input: AgentPackagesPortInput) => AgentPackagesPort;
16
+ readonly cwd?: string;
17
+ readonly environment?: NodeJS.ProcessEnv;
18
+ readonly stdout?: (message: string) => void;
19
+ readonly stderr?: (message: string) => void;
20
+ readonly initializeProfile?: typeof initializeProductProfile;
21
+ }
22
+ export declare function runPackageCommand(request: PackageCommandRequest, environment: PackageCommandEnvironment): Promise<number>;
23
+ export declare function renderPackageOutcome(outcome: AgentPackageOutcome, profileRoot: string): string;
@@ -0,0 +1,84 @@
1
+ import { configurationRootForProfile, initializeProductProfile, resolveLaunchProfilePaths } from "../features/launch/index.js";
2
+ import { PRODUCT_TEXT } from "../product-identity.js";
3
+ export async function runPackageCommand(request, environment) {
4
+ const stdout = environment.stdout ?? (message => process.stdout.write(message));
5
+ const stderr = environment.stderr ?? (message => process.stderr.write(message));
6
+ const cwd = environment.cwd ?? process.cwd();
7
+ const processEnvironment = environment.environment ?? process.env;
8
+ const paths = resolveLaunchProfilePaths({ environment: processEnvironment });
9
+ const profileRoot = configurationRootForProfile("a1", paths);
10
+ if (profileRoot === null)
11
+ throw new Error(PRODUCT_TEXT.diagnostic("has no profile root for package commands"));
12
+ try {
13
+ await (environment.initializeProfile ?? initializeProductProfile)(profileRoot);
14
+ }
15
+ catch (error) {
16
+ stderr(`${PRODUCT_TEXT.diagnostic(`could not prepare its profile at ${profileRoot}: ${message(error)}`)}\n`);
17
+ return 1;
18
+ }
19
+ const port = environment.createPort({
20
+ profileRoot,
21
+ cwd,
22
+ onProgress: progress => stdout(`${progress.message}\n`),
23
+ });
24
+ const outcome = await runVerb(port, request);
25
+ const rendered = renderPackageOutcome(outcome, profileRoot);
26
+ (outcome.status === "completed" ? stdout : stderr)(rendered);
27
+ return outcome.status === "completed" ? 0 : 1;
28
+ }
29
+ async function runVerb(port, request) {
30
+ if (request.verb === "list")
31
+ return await port.list();
32
+ if (request.verb === "refresh-models")
33
+ return await port.refreshModels();
34
+ if (request.verb === "update")
35
+ return await port.update(request.source ?? undefined);
36
+ if (request.source === null)
37
+ throw new Error(PRODUCT_TEXT.diagnostic(`requires a source for ${request.verb}`));
38
+ return request.verb === "install" ? await port.install(request.source) : await port.remove(request.source);
39
+ }
40
+ export function renderPackageOutcome(outcome, profileRoot) {
41
+ const name = PRODUCT_TEXT.displayName;
42
+ if (outcome.status === "failed") {
43
+ return `${PRODUCT_TEXT.diagnostic(`could not ${describeOperation(outcome.operation)}: ${outcome.detail ?? "unknown failure"}`)}\n`;
44
+ }
45
+ if (outcome.status === "not-found") {
46
+ return `${PRODUCT_TEXT.diagnostic(`found no package matching ${outcome.source ?? "that source"} in ${profileRoot}.`)}\n`;
47
+ }
48
+ switch (outcome.operation) {
49
+ case "install":
50
+ return `${name} installed ${outcome.source} into ${profileRoot}.\n`
51
+ + `Restart ${PRODUCT_TEXT.commandName} for a running session to load it.\n`;
52
+ case "remove":
53
+ return `${name} removed ${outcome.source} from ${profileRoot}.\n`;
54
+ case "update":
55
+ return outcome.source === null
56
+ ? `${name} updated the packages in ${profileRoot}.\n`
57
+ : `${name} updated ${outcome.source}.\n`;
58
+ case "refresh-models":
59
+ return `${name} refreshed the model catalogs in ${profileRoot}.\n`;
60
+ case "list":
61
+ return renderPackageList(outcome, profileRoot);
62
+ }
63
+ }
64
+ function renderPackageList(outcome, profileRoot) {
65
+ if (outcome.packages.length === 0)
66
+ return `${PRODUCT_TEXT.displayName} has no packages installed in ${profileRoot}.\n`;
67
+ const lines = [`Packages installed for ${PRODUCT_TEXT.commandName} in ${profileRoot}:`];
68
+ for (const entry of outcome.packages) {
69
+ lines.push(` ${entry.source}${entry.filtered ? " (partly enabled)" : ""}`);
70
+ if (entry.installedPath !== null)
71
+ lines.push(` ${entry.installedPath}`);
72
+ }
73
+ return `${lines.join("\n")}\n`;
74
+ }
75
+ function describeOperation(operation) {
76
+ if (operation === "refresh-models")
77
+ return "refresh the model catalogs";
78
+ if (operation === "list")
79
+ return "list installed packages";
80
+ return `${operation} the package`;
81
+ }
82
+ function message(error) {
83
+ return (error instanceof Error ? error.message : String(error)).replace(/\s+/g, " ").trim();
84
+ }