@jals1212/pi-spaces 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/spaces.js CHANGED
@@ -17,28 +17,31 @@ export class SpaceError extends Error {
17
17
 
18
18
 
19
19
 
20
- function expandTilde(path , home ) {
20
+ /** Expands a leading `~` to the given home folder. */
21
+ export function expandTilde(path , home ) {
21
22
  if (path === "~") return home;
22
23
  if (path.startsWith("~/")) return join(home, path.slice(2));
23
24
  return path;
24
25
  }
25
26
 
26
27
  /**
27
- * The folder that holds every space: PI_SPACES_DIR, else a `spaces` folder next to the base home
28
+ * The folder that holds every space: PI_SPACES_DIR, else the dir of the profile that matches the base home
29
+ * (an absolute path, from the config file), else a `spaces` folder next to the base home
28
30
  * (~/.pi/agent gives ~/.pi/spaces), so every program that runs Pi with its own home keeps its own spaces.
29
31
  * The base home is the one Pi started with (PI_SPACES_BASE_HOME inside a space), else Pi's agent home.
30
32
  */
31
- export function spacesDir(env = process.env, home = osHomedir()) {
33
+ export function spacesDir(env = process.env, home = osHomedir(), profileDir ) {
32
34
  const fromEnv = env.PI_SPACES_DIR;
33
35
  // A relative value resolves against the user's home, never the working directory.
34
36
  if (fromEnv) return resolve(home, expandTilde(fromEnv, home));
37
+ if (profileDir) return profileDir;
35
38
  const agentHome = env.PI_CODING_AGENT_DIR ? expandTilde(env.PI_CODING_AGENT_DIR, home) : join(home, ".pi", "agent");
36
39
  const baseHome = env.PI_SPACES_BASE_HOME || agentHome;
37
40
  return join(dirname(resolve(baseHome)), "spaces");
38
41
  }
39
42
 
40
43
  // The words of the pi-space and /space commands, so a space name never reads as one.
41
- const COMMANDS = new Set(["list", "new", "remove", "rename", "switch", "update", "install", "uninstall", "setup"]);
44
+ const COMMANDS = new Set(["list", "new", "remove", "rename", "switch", "update", "install", "uninstall", "setup", "completion"]);
42
45
 
43
46
  /** True when PI_SPACES_SETUP holds a setup command, not just blanks. */
44
47
  export function hasSetupCommand(env ) {
@@ -235,7 +238,8 @@ function localPackageName(folder ) {
235
238
  }
236
239
  }
237
240
 
238
- function samePath(a , b ) {
241
+ /** True when both paths are the same place: real paths when they exist, resolved paths otherwise. */
242
+ export function samePath(a , b ) {
239
243
  return realOrResolved(a) === realOrResolved(b);
240
244
  }
241
245
 
@@ -1,9 +1,11 @@
1
1
  // pi-spaces: named Pi agent homes. Adds /space and shows the active space in the status line.
2
2
  import { readFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
3
4
  import { dirname, join } from "node:path";
4
5
  import { fileURLToPath } from "node:url";
5
6
  import { type ExtensionAPI, getAgentDir } from "@earendil-works/pi-coding-agent";
6
7
  import { completeSpaceArgs, runSpaceCommand, statusText } from "../src/command.ts";
8
+ import { activeProfile } from "../src/config.ts";
7
9
  import { addSpaceSection, exportSpaceInfo, spaceInfo } from "../src/context.ts";
8
10
  import { planStartup, relaunchCommand, runNow, scheduleRelaunch } from "../src/relaunch.ts";
9
11
  import { findOwnDeclaration, spacesDir } from "../src/spaces.ts";
@@ -12,9 +14,15 @@ const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
12
14
  const packageName: string = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8")).name;
13
15
 
14
16
  export default function (pi: ExtensionAPI) {
17
+ // The profile of the config file that describes this Pi's base home, if any. A file that cannot be used never
18
+ // stops Pi: it is reported once, at session start, and Pi behaves as with no file.
19
+ const { profile, warning } = activeProfile(process.env, homedir(), getAgentDir());
20
+ let warned = false;
21
+ const dir = spacesDir(process.env, homedir(), profile?.dir);
22
+
15
23
  // `pi --space <name>` is handled here, before the terminal UI starts and before any prompt runs:
16
24
  // Pi runs again in the space with every other argument, and this process exits with its code.
17
- const plan = planStartup({ proc: process, dir: spacesDir(), currentHome: getAgentDir(), env: process.env });
25
+ const plan = planStartup({ proc: process, dir, currentHome: getAgentDir(), env: process.env, spaceEnv: profile?.spaceEnv });
18
26
  if (plan.kind === "error") {
19
27
  process.stderr.write(`pi-spaces: ${plan.message}\n`);
20
28
  process.exit(1);
@@ -22,19 +30,24 @@ export default function (pi: ExtensionAPI) {
22
30
  if (plan.kind === "relaunch") process.exit(runNow(plan.command, plan.env));
23
31
 
24
32
  // Where this Pi runs: exported for tools and the programs they start, and told to the model before each run.
25
- const info = spaceInfo(spacesDir(), getAgentDir(), process.env);
33
+ const info = spaceInfo(dir, getAgentDir(), process.env);
26
34
  exportSpaceInfo(process.env, info);
27
35
  pi.on("before_agent_start", (event) => addSpaceSection(event.systemPromptOptions, info));
28
36
 
29
37
  pi.registerFlag("space", { description: "Start Pi in this space (see /space list)", type: "string" });
30
38
 
31
39
  pi.on("session_start", (_event, ctx) => {
32
- ctx.ui.setStatus("space", statusText(spacesDir(), getAgentDir()));
40
+ ctx.ui.setStatus("space", statusText(dir, getAgentDir()));
41
+ if (warning !== undefined && !warned) {
42
+ warned = true;
43
+ if (ctx.hasUI) ctx.ui.notify(`pi-spaces: ${warning}`, "warning");
44
+ else process.stderr.write(`pi-spaces: ${warning}\n`);
45
+ }
33
46
  });
34
47
 
35
48
  pi.registerCommand("space", {
36
49
  description: "Pick a Pi space to switch to, or list, create, remove and rename spaces (separate agent homes)",
37
- getArgumentCompletions: (prefix) => completeSpaceArgs(prefix, spacesDir()),
50
+ getArgumentCompletions: (prefix) => completeSpaceArgs(prefix, dir),
38
51
  handler: async (args, ctx) => {
39
52
  // Without a UI (print and JSON modes) notifications go nowhere, so they go to stderr;
40
53
  // stdout stays free for Pi's own output.
@@ -43,9 +56,10 @@ export default function (pi: ExtensionAPI) {
43
56
  : (message: string) => void process.stderr.write(`${message}\n`);
44
57
  await runSpaceCommand(args, {
45
58
  ctx: { mode: ctx.mode, hasUI: ctx.hasUI, shutdown: () => ctx.shutdown(), ui: { notify, confirm: (t, m) => ctx.ui.confirm(t, m), select: (t, o) => ctx.ui.select(t, o) } },
46
- dir: spacesDir(),
59
+ dir,
47
60
  currentHome: getAgentDir,
48
61
  env: process.env,
62
+ profile,
49
63
  declaration: () => findOwnDeclaration(getAgentDir(), packageName, packageRoot),
50
64
  command: () => relaunchCommand(),
51
65
  relaunch: scheduleRelaunch,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jals1212/pi-spaces",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "Named spaces for Pi: keep several agent homes (sign-ins, packages, settings) and pick one by name.",
5
5
  "license": "MIT",
6
6
  "author": "jals1212",
@@ -20,6 +20,7 @@
20
20
  "type": "module",
21
21
  "exports": {
22
22
  "./spaces": "./dist/spaces.js",
23
+ "./config": "./dist/config.js",
23
24
  "./package.json": "./package.json"
24
25
  },
25
26
  "pi": {
@@ -1,22 +1,26 @@
1
1
  ---
2
2
  name: pi-spaces
3
- description: "Trigger: pi-spaces, space, spaces, /space, pi-space, pi --space, PI_SPACE, PI_SPACE_HOME, PI_SPACES_COMMAND, PI_SPACES_SETUP, which space, agent home, base home, update, install, uninstall, setup. Know which Pi space this session runs in, its home and start command, and how spaces and their packages are listed, created, set up, updated, switched and removed."
3
+ description: "Trigger: pi-spaces, space, spaces, /space, pi-space, pi --space, PI_SPACE, PI_SPACE_HOME, PI_SPACES_COMMAND, PI_SPACES_SETUP, PI_SPACES_CONFIG, profile, which space, agent home, base home, update, install, uninstall, setup. Know which Pi space this session runs in, its home and start command, and how spaces and their packages are listed, created, set up, updated, switched and removed."
4
4
  license: MIT
5
5
  metadata:
6
6
  author: jals1212
7
- version: "1.0"
7
+ version: "1.3"
8
8
  ---
9
9
 
10
10
  ## Activation Contract
11
11
 
12
- Use when a task depends on which Pi agent home this session uses (settings, sign-ins, installed packages, sessions), or when the user asks about spaces. A space is a separate Pi agent home kept in a folder of the spaces folder; the base home is the one Pi started with. Pi reads its home once at startup, so one process stays in one space.
12
+ Use when a task depends on which Pi agent home this session uses (settings, sign-ins, installed packages, sessions), or when the user asks about spaces. A space is a separate Pi agent home kept in a folder of the spaces folder; the base home is the one Pi started with. A profile (an entry of the optional config file) describes a program that runs Pi: its base home, command, spaces folder, setup command and per-space variables. Pi reads its home once at startup, so one process stays in one space.
13
13
 
14
14
  ## Hard Rules
15
15
 
16
16
  - Read where you run from the `<pi_space>` system prompt section; in a shell, from `PI_SPACE` (unset in the base home), `PI_SPACE_HOME` (this Pi's agent home) and `PI_SPACES_COMMAND` (the program that starts Pi, default `pi`). Never guess from `~/.pi/agent`.
17
+ - The `pi-space` CLI names every space `<profile>/<name>` (`base` included, as `<profile>/base`); a bare name is refused. Nothing in the environment or the `<pi_space>` section names your own profile. To find it, take your base home (`PI_SPACES_BASE_HOME` when set, else `PI_SPACE_HOME`) and look in the config file for the profile whose `home` is that folder; no match means the built-in `pi`.
17
18
  - Files of this session's home (`settings.json`, `extensions/`, `sessions/`, installed packages) live under `PI_SPACE_HOME`; `pi install` and settings changes apply to this space only.
19
+ - The config's `default` is not used by `pi-space` (it warns and ignores it); the profile is always named. `pi-space` does not read `PI_SPACES_COMMAND`, `PI_SPACES_DIR` or `PI_SPACES_SETUP` from the shell: a profile or `--command`, `--dir`, `--setup` (for one named profile) gives them.
20
+ - Profiles live in `~/.pi-spaces/config.json`, or the file in `PI_SPACES_CONFIG`; its `schema` must be `pi-spaces.config/v1`. Read it to answer questions about profiles; edit it only when the user asks, and keep every key to the documented ones (unknown keys make the file invalid and ignored).
18
21
  - Never read `auth.json` or other credential files in any home.
19
- - You cannot move this session to another space. Ask the user to run `/space switch <name>` (interactive terminal only; Pi restarts), or start another Pi with `$PI_SPACES_COMMAND --space <name>`.
22
+ - You cannot move this session to another space. Ask the user to run `/space switch <name>` (interactive terminal only; Pi restarts), or start another Pi with `$PI_SPACES_COMMAND --space <name>` (the space name there is bare: `work`; `pi-space` takes `<profile>/work`).
23
+ - The Pi that `pi-space` starts runs as if started from a plain terminal for the named profile, even when you run it from inside another Pi or a space (it unwinds your space and sets `PI_CODING_AGENT_DIR` to that profile's home).
20
24
  - Never set `PI_CODING_AGENT_DIR` yourself, and never create, rename or delete space folders with file commands; use `/space` or `pi-space`.
21
25
  - Removing a space deletes everything in it: only on the user's explicit request, and keep the backup unless they say otherwise.
22
26
 
@@ -25,15 +29,18 @@ Use when a task depends on which Pi agent home this session uses (settings, sign
25
29
  | Need | Action |
26
30
  | --- | --- |
27
31
  | Which space, home, command | `<pi_space>` section, or `echo "$PI_SPACE|$PI_SPACE_HOME|$PI_SPACES_COMMAND"` |
28
- | List spaces from a shell | `pi-space list` (optional `@jals1212/pi-spaces-cli`) |
29
- | Spaces folder | `PI_SPACES_DIR`, else `spaces` next to the base home (`PI_SPACES_BASE_HOME`, else this home) |
30
- | Create a space | `pi-space new <name>`, or ask the user for `/space new <name>`; `pi-space new` also runs the setup command when there is one (`--setup <cmd>` or `PI_SPACES_SETUP`) |
31
- | Prepare a space for the program that runs Pi | `pi-space setup <name|--every>`: runs the setup command (`{home}` and `{name}` become the space's folder and name) with the space as `PI_CODING_AGENT_DIR`; never in base |
32
- | Work in another space | User runs `/space switch <name>`, or `$PI_SPACES_COMMAND --space <name> "<prompt>"` |
33
- | Remove a space | User request only: `pi-space remove <name> --yes` keeps a backup in `.backups` |
34
- | Packages of a space | `pi-space update <name|base|--every> --extensions`, `pi-space install <name> <source>`, `pi-space uninstall <name> <source>`, `pi-space list <name>`; without the CLI, `$PI_SPACES_COMMAND --space <name|base> update --extensions` |
35
-
36
- Names: lowercase letters, digits, `-` and `_`, starting with a letter or digit, at most 64 characters; `base` (the base home) and the command words (`list`, `new`, `remove`, `rename`, `switch`, `update`, `install`, `uninstall`, `setup`) are reserved.
32
+ | List spaces from a shell | `pi-space list` (optional `@jals1212/pi-spaces-cli`): every profile, each space as `<profile>/<name>` |
33
+ | Spaces folder | `PI_SPACES_DIR`, else the `dir` of the profile whose `home` is the base home, else `spaces` next to the base home (`PI_SPACES_BASE_HOME`, else this home) |
34
+ | Profiles, or why a variable differs per space | The config file (`PI_SPACES_CONFIG`, else `~/.pi-spaces/config.json`): a profile's `spaceEnv` variables are set only inside spaces (`{home}` and `{name}` filled in) and removed in base; without `spaceEnv` whatever a program keeps outside its agent home is shared with base |
35
+ | Pick a space from a shell | bare `pi-space` on a terminal opens a picker of every profile's spaces (`<profile>/<name>`, `base` included) and starts Pi in the one chosen |
36
+ | Rename a space | User request only: `pi-space rename <profile>/<old> <new>`; the new name alone or with the same profile (`<profile>/<new>`), a space never changes profile; refused for base and for the space you are in |
37
+ | Create a space | `pi-space new <profile>/<name>`, or ask the user for `/space new <name>` (this Pi's profile); `pi-space new` also runs the profile's setup command when there is one (or `--setup <cmd>` for one call) |
38
+ | Prepare a space for the program that runs Pi | `pi-space setup <profile>/<name>` (or `<profile>/--every`, or `--every` for every profile with a setup): runs the setup command (`{home}` and `{name}` become the space's folder and name) with the space as `PI_CODING_AGENT_DIR`; never in base |
39
+ | Work in another space | User runs `/space switch <name>`, or `$PI_SPACES_COMMAND --space <name> "<prompt>"`, or `pi-space <profile>/<name>` from a shell |
40
+ | Remove a space | User request only: `pi-space remove <profile>/<name> --yes` keeps a backup in `.backups` |
41
+ | Packages of a space | `pi-space update <profile>/<name|base|--every> --extensions` (`--every` alone: every profile), `pi-space install <profile>/<name> <source>`, `pi-space uninstall <profile>/<name> <source>`, `pi-space list <profile>/<name>`; without the CLI, `$PI_SPACES_COMMAND --space <name|base> update --extensions` |
42
+
43
+ Names (the part after `<profile>/`; a profile name follows the same rule): lowercase letters, digits, `-` and `_`, starting with a letter or digit, at most 64 characters; `base` (the base home) and the command words (`list`, `new`, `remove`, `rename`, `switch`, `update`, `install`, `uninstall`, `setup`, `completion`) are reserved.
37
44
 
38
45
  ## Execution Steps
39
46
 
@@ -47,4 +54,4 @@ State the space (or "base home"), the agent home path, and the command or `/spac
47
54
 
48
55
  ## References
49
56
 
50
- - `../../README.md` — commands, environment variables and how switching works.
57
+ - `../../README.md` — commands, environment variables, the profiles file (schema, `spaceEnv`) and how switching works.
package/src/command.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  // The /space command, kept apart from Pi's extension API so it can be tested directly.
2
2
  import { join } from "node:path";
3
+ import type { Profile } from "./config.ts";
3
4
  import { type Command, parseSpaceFlag, relaunchEnv, resolveSwitch } from "./relaunch.ts";
4
5
  import {
5
6
  activeSpace,
@@ -40,6 +41,8 @@ export interface SpaceCommandDeps {
40
41
  /** The agent home this Pi process runs with. */
41
42
  currentHome(): string;
42
43
  env: Env;
44
+ /** The profile that matches the base home, if any: its spaceEnv applies to switches and its setup to the `new` notice. */
45
+ profile?: Profile;
43
46
  /** This package's declaration for a new space's settings.json. */
44
47
  declaration(): PackageDeclaration;
45
48
  /** The command that starts this Pi process again. */
@@ -55,11 +58,13 @@ export async function runSpaceCommand(args: string, deps: SpaceCommandDeps): Pro
55
58
  try {
56
59
  if (sub === "list" && rest.length === 0) return list(deps);
57
60
  if (sub === "new" && rest.length === 1) {
58
- const path = createSpace(deps.dir, rest[0], deps.declaration());
59
- const name = rest[0];
61
+ const [name] = rest;
62
+ const path = createSpace(deps.dir, name, deps.declaration());
63
+ // pi-space names a space with its profile; without a matching profile this Pi is plain pi.
64
+ const qualified = `${deps.profile?.name ?? "pi"}/${name}`;
60
65
  ctx.ui.notify(
61
- hasSetupCommand(deps.env)
62
- ? `Created space "${name}" at ${path}. Prepare it from a shell with: pi-space setup ${name}. Then enter it with /space switch ${name}.`
66
+ hasSetupCommand(deps.env) || Boolean(deps.profile?.setup)
67
+ ? `Created space "${name}" at ${path}. Prepare it from a shell with: pi-space setup ${qualified}. Then enter it with /space switch ${name}.`
63
68
  : `Created space "${name}" at ${path}. Enter it with /space switch ${name}.`,
64
69
  "info",
65
70
  );
@@ -153,7 +158,7 @@ function switchTo(name: string, deps: SpaceCommandDeps): void {
153
158
  if (ctx.mode !== "tui") throw new SpaceError("/space switch needs the interactive terminal.");
154
159
  const currentHome = deps.currentHome();
155
160
  const target = resolveSwitch({ dir: deps.dir, name, currentHome, env: deps.env });
156
- deps.relaunch(deps.command(), relaunchEnv(deps.env, { ...target, currentHome }));
161
+ deps.relaunch(deps.command(), relaunchEnv(deps.env, { ...target, currentHome, spaceEnv: deps.profile?.spaceEnv }));
157
162
  ctx.ui.notify(
158
163
  target.space === undefined ? "Switching to the base home; Pi restarts in it." : `Switching to space "${target.space}"; Pi restarts in it.`,
159
164
  "info",
package/src/config.ts ADDED
@@ -0,0 +1,248 @@
1
+ // The config file: named profiles that describe a program that runs Pi (its agent home, the command
2
+ // that starts it, where its spaces live, how to set a space up, and the variables it needs per space).
3
+ // Pure and host-agnostic: the file system, the environment and the user's home are all inputs.
4
+ import { readFileSync } from "node:fs";
5
+ import { dirname, join, resolve } from "node:path";
6
+ import { type Env, expandTilde, samePath } from "./spaces.ts";
7
+
8
+ /** The schema this version of pi-spaces reads. */
9
+ export const SCHEMA = "pi-spaces.config/v1";
10
+ const NEWER_SCHEMA = /^pi-spaces\.config\/v([1-9][0-9]*)$/;
11
+ const PROFILE_NAME = /^[a-z0-9][a-z0-9_-]{0,63}$/;
12
+ const VARIABLE_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
13
+ // Set by pi-spaces itself on every switch; a profile that set them would break switching.
14
+ const RESERVED_VARIABLES = new Set(["PI_CODING_AGENT_DIR", "PI_SPACE", "PI_SPACE_HOME"]);
15
+ const isReservedVariable = (name: string) => RESERVED_VARIABLES.has(name) || name.startsWith("PI_SPACES_");
16
+ const TOP_KEYS = ["schema", "default", "profiles"];
17
+ const PROFILE_KEYS = ["home", "command", "dir", "setup", "spaceEnv"];
18
+ const BUILT_IN = "pi";
19
+
20
+ export class ConfigError extends Error {
21
+ constructor(message: string) {
22
+ super(message);
23
+ this.name = "ConfigError";
24
+ }
25
+ }
26
+
27
+ /** A profile as written in the file: validated and trimmed, paths not yet expanded. */
28
+ export interface ProfileSpec {
29
+ name: string;
30
+ home?: string;
31
+ command?: string;
32
+ dir?: string;
33
+ setup?: string;
34
+ spaceEnv?: Record<string, string>;
35
+ }
36
+
37
+ export interface Config {
38
+ /** The file it was read from. */
39
+ path: string;
40
+ /** The profile used when none is asked for; a profile of the file or `pi`. */
41
+ default?: string;
42
+ /** In file order. */
43
+ profiles: ProfileSpec[];
44
+ }
45
+
46
+ /** A profile with its paths resolved. `command`, `setup` and `spaceEnv` are set only when the profile has them. */
47
+ export interface Profile {
48
+ name: string;
49
+ /** The base agent home. */
50
+ home: string;
51
+ /** The spaces folder. */
52
+ dir: string;
53
+ command?: string;
54
+ setup?: string;
55
+ /** Variable templates; `{home}` and `{name}` are filled in by spaceEnvFor. */
56
+ spaceEnv?: Record<string, string>;
57
+ }
58
+
59
+ /** The config file: PI_SPACES_CONFIG (a relative value is taken from the user's home), else ~/.pi-spaces/config.json. */
60
+ export function configPath(env: Env, home: string): string {
61
+ const fromEnv = env.PI_SPACES_CONFIG?.trim();
62
+ if (fromEnv) return resolve(home, expandTilde(fromEnv, home));
63
+ return join(home, ".pi-spaces", "config.json");
64
+ }
65
+
66
+ function isObject(value: unknown): value is Record<string, unknown> {
67
+ return typeof value === "object" && value !== null && !Array.isArray(value);
68
+ }
69
+
70
+ function reason(error: unknown): string {
71
+ return error instanceof Error ? error.message : String(error);
72
+ }
73
+
74
+ type Fail = (problem: string) => never;
75
+
76
+ /**
77
+ * Reads and validates the config file. A missing file is no config (undefined). Anything else wrong
78
+ * throws a ConfigError that names the file and the problem; unknown keys are refused so typos fail loudly.
79
+ */
80
+ export function loadConfig(path: string, read: (path: string) => string = (file) => readFileSync(file, "utf8")): Config | undefined {
81
+ let text: string;
82
+ try {
83
+ text = read(path);
84
+ } catch (error) {
85
+ if ((error as NodeJS.ErrnoException)?.code === "ENOENT") return undefined;
86
+ throw new ConfigError(`${path}: cannot be read: ${reason(error)}`);
87
+ }
88
+ const fail: Fail = (problem) => {
89
+ throw new ConfigError(`${path}: ${problem}`);
90
+ };
91
+ let json: unknown;
92
+ try {
93
+ json = JSON.parse(text);
94
+ } catch (error) {
95
+ return fail(`not valid JSON: ${reason(error)}`);
96
+ }
97
+ if (!isObject(json)) return fail("the top level must be a JSON object.");
98
+ // The version first: a newer file may use keys this one does not know.
99
+ checkSchema(json.schema, "schema" in json, fail);
100
+ for (const key of Object.keys(json)) {
101
+ if (!TOP_KEYS.includes(key)) fail(`unknown key "${key}"; allowed keys are "schema", "default" and "profiles".`);
102
+ }
103
+ const config: Config = { path, profiles: parseProfiles(json.profiles, "profiles" in json, fail) };
104
+ if ("default" in json) {
105
+ const name = json.default;
106
+ if (typeof name !== "string" || name === "") return fail('"default" must be a string naming a profile.');
107
+ const known = profileNames(config);
108
+ if (!known.includes(name)) fail(`"default" names unknown profile "${name}"; known profiles: ${known.join(", ")}.`);
109
+ config.default = name;
110
+ }
111
+ return config;
112
+ }
113
+
114
+ function checkSchema(schema: unknown, present: boolean, fail: Fail): void {
115
+ if (!present) fail(`"schema" is required; use "${SCHEMA}".`);
116
+ if (schema === SCHEMA) return;
117
+ const version = typeof schema === "string" ? NEWER_SCHEMA.exec(schema)?.[1] : undefined;
118
+ // Only a canonical vN with N above 1 is a newer version; v0 and v01 are just invalid.
119
+ if (version !== undefined && Number(version) > 1) {
120
+ fail(`"schema" is "${schema}", which needs a newer pi-spaces; this one reads "${SCHEMA}".`);
121
+ }
122
+ fail(`invalid "schema" ${JSON.stringify(schema)}; use "${SCHEMA}".`);
123
+ }
124
+
125
+ function parseProfiles(value: unknown, present: boolean, fail: Fail): ProfileSpec[] {
126
+ if (!present) return [];
127
+ if (!isObject(value)) return fail('"profiles" must be an object of profiles.');
128
+ return Object.entries(value).map(([name, profile]) => parseProfile(name, profile, fail));
129
+ }
130
+
131
+ function parseProfile(name: string, value: unknown, fail: Fail): ProfileSpec {
132
+ if (!PROFILE_NAME.test(name)) {
133
+ fail(`invalid profile name "${name}": use lowercase letters, digits, "-" and "_", starting with a letter or digit (at most 64 characters).`);
134
+ }
135
+ if (!isObject(value)) return fail(`profile "${name}" must be an object.`);
136
+ for (const key of Object.keys(value)) {
137
+ if (!PROFILE_KEYS.includes(key)) {
138
+ fail(`unknown key "${key}" in profile "${name}"; allowed keys are "home", "command", "dir", "setup" and "spaceEnv".`);
139
+ }
140
+ }
141
+ const spec: ProfileSpec = { name };
142
+ for (const key of ["home", "command", "dir", "setup"] as const) {
143
+ if (!(key in value)) continue;
144
+ const text = value[key];
145
+ if (typeof text !== "string" || text.trim() === "") return fail(`"${key}" in profile "${name}" must be a non-empty string.`);
146
+ spec[key] = text.trim();
147
+ }
148
+ if ("spaceEnv" in value) spec.spaceEnv = parseSpaceEnv(name, value.spaceEnv, fail);
149
+ return spec;
150
+ }
151
+
152
+ function parseSpaceEnv(profile: string, value: unknown, fail: Fail): Record<string, string> {
153
+ if (!isObject(value)) return fail(`"spaceEnv" in profile "${profile}" must be an object of strings.`);
154
+ const entries: [string, string][] = [];
155
+ for (const [key, text] of Object.entries(value)) {
156
+ if (!VARIABLE_NAME.test(key)) {
157
+ fail(`invalid variable name "${key}" in "spaceEnv" of profile "${profile}"; use letters, digits and "_", not starting with a digit.`);
158
+ }
159
+ if (isReservedVariable(key)) fail(`"${key}" in "spaceEnv" of profile "${profile}" is set by pi-spaces itself; choose another variable.`);
160
+ if (typeof text !== "string") return fail(`"spaceEnv.${key}" in profile "${profile}" must be a string.`);
161
+ entries.push([key, text]);
162
+ }
163
+ // fromEntries defines own properties, so a name such as __proto__ stays plain data.
164
+ return Object.fromEntries(entries);
165
+ }
166
+
167
+ /** The profile names, in file order, with `pi` last unless the file defines it. */
168
+ export function profileNames(config: Config | undefined): string[] {
169
+ const names = (config?.profiles ?? []).map((profile) => profile.name);
170
+ return names.includes(BUILT_IN) ? names : [...names, BUILT_IN];
171
+ }
172
+
173
+ /**
174
+ * The built-in pi home: the base home inside a space (PI_SPACES_BASE_HOME), else PI_CODING_AGENT_DIR, else ~/.pi/agent.
175
+ * A home that a profile of the file names is that profile's, not pi's: inside the Pi of another program those
176
+ * variables hold that program's home, so pi keeps ~/.pi/agent.
177
+ */
178
+ function builtInHome(config: Config | undefined, env: Env, home: string): string {
179
+ const fromEnv = env.PI_SPACES_BASE_HOME || env.PI_CODING_AGENT_DIR;
180
+ const found = fromEnv ? resolve(home, expandTilde(fromEnv, home)) : undefined;
181
+ const claimed = (path: string) => config?.profiles.some((spec) => spec.home !== undefined && samePath(resolve(home, expandTilde(spec.home, home)), path));
182
+ return found !== undefined && !claimed(found) ? found : join(home, ".pi", "agent");
183
+ }
184
+
185
+ /** Resolves a profile; what a profile omits for `home` falls back to the built-in pi's, and `pi` also gets its `command`. */
186
+ function resolveProfile(config: Config | undefined, spec: ProfileSpec, env: Env, home: string): Profile {
187
+ const base = spec.home === undefined ? builtInHome(config, env, home) : resolve(home, expandTilde(spec.home, home));
188
+ const profile: Profile = {
189
+ name: spec.name,
190
+ home: base,
191
+ dir: spec.dir === undefined ? join(dirname(base), "spaces") : resolve(home, expandTilde(spec.dir, home)),
192
+ };
193
+ const command = spec.command ?? (spec.name === BUILT_IN ? "pi" : undefined);
194
+ if (command !== undefined) profile.command = command;
195
+ if (spec.setup !== undefined) profile.setup = spec.setup;
196
+ if (spec.spaceEnv !== undefined) profile.spaceEnv = spec.spaceEnv;
197
+ return profile;
198
+ }
199
+
200
+ /** The profile with this name, or a ConfigError that lists the known ones. `pi` always exists. */
201
+ export function profileByName(config: Config | undefined, name: string, env: Env, home: string): Profile {
202
+ const spec = config?.profiles.find((profile) => profile.name === name) ?? (name === BUILT_IN ? { name } : undefined);
203
+ if (spec === undefined) throw new ConfigError(`Unknown profile "${name}"; known profiles: ${profileNames(config).join(", ")}.`);
204
+ return resolveProfile(config, spec, env, home);
205
+ }
206
+
207
+ /** The profile whose home is the base home (the same place, real paths compared): the first in file order, then built-in `pi`. */
208
+ export function profileForHome(config: Config | undefined, baseHome: string, env: Env, home: string): Profile | undefined {
209
+ for (const name of profileNames(config)) {
210
+ const profile = profileByName(config, name, env, home);
211
+ if (samePath(profile.home, baseHome)) return profile;
212
+ }
213
+ return undefined;
214
+ }
215
+
216
+ /** The templates with every `{home}` and `{name}` replaced. Replacer functions keep `$` literal. */
217
+ export function expandSpaceEnv(templates: Record<string, string>, spaceHome: string, spaceName: string): Record<string, string> {
218
+ const fill = (template: string) => template.replace(/\{home\}/g, () => spaceHome).replace(/\{name\}/g, () => spaceName);
219
+ return Object.fromEntries(Object.entries(templates).map(([key, template]) => [key, fill(template)]));
220
+ }
221
+
222
+ /** The variables a space gets: the profile's spaceEnv for this space's folder and name (none without spaceEnv). */
223
+ export function spaceEnvFor(profile: Profile, spaceHome: string, spaceName: string): Record<string, string> {
224
+ return expandSpaceEnv(profile.spaceEnv ?? {}, spaceHome, spaceName);
225
+ }
226
+
227
+ /**
228
+ * The profile this Pi runs under, from the config file: the one whose home is the base home
229
+ * (PI_SPACES_BASE_HOME, else the agent home). An unusable file never stops Pi: it comes back as
230
+ * a warning and the result is the same as with no file.
231
+ */
232
+ export function activeProfile(
233
+ env: Env,
234
+ home: string,
235
+ agentHome: string,
236
+ read?: (path: string) => string,
237
+ ): { profile: Profile | undefined; warning: string | undefined } {
238
+ const baseHome = env.PI_SPACES_BASE_HOME || agentHome;
239
+ let config: Config | undefined;
240
+ let warning: string | undefined;
241
+ try {
242
+ config = loadConfig(configPath(env, home), read);
243
+ } catch (error) {
244
+ if (!(error instanceof ConfigError)) throw error;
245
+ warning = error.message;
246
+ }
247
+ return { profile: profileForHome(config, baseHome, env, home), warning };
248
+ }
package/src/context.ts CHANGED
@@ -20,7 +20,7 @@ export function spaceInfo(dir: string, agentHome: string, env: Env): SpaceInfo {
20
20
  return {
21
21
  space: activeSpace(dir, agentHome),
22
22
  home: resolve(agentHome),
23
- // A blank value counts as unset, so the default is pi, as in the pi-space command.
23
+ // A blank value counts as unset, so the default is pi (pi-space leaves it unset for a profile without a command).
24
24
  command: env.PI_SPACES_COMMAND?.trim() || "pi",
25
25
  };
26
26
  }
package/src/relaunch.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  // running the same Pi command again with PI_CODING_AGENT_DIR pointing at the space.
3
3
  import { spawnSync } from "node:child_process";
4
4
  import { delimiter, join, resolve, sep } from "node:path";
5
+ import { expandSpaceEnv } from "./config.ts";
5
6
  import { activeSpace, BASE, listSpaces, type Env, SpaceError, validateName } from "./spaces.ts";
6
7
 
7
8
  // Pi options that take a separate value, so the value is kept (or dropped) with them.
@@ -79,14 +80,18 @@ export function resolveSwitch(input: { dir: string; name: string; currentHome: s
79
80
  }
80
81
 
81
82
  // PWD and OLDPWD describe the working directory, which the relaunch keeps.
82
- const NOT_REWRITTEN = new Set(["PI_SPACES_DIR", "PI_SPACES_BASE_HOME", "PWD", "OLDPWD"]);
83
+ // PI_SPACES_BASE_ENV holds the base's own values, which must come back exactly as they were.
84
+ const NOT_REWRITTEN = new Set(["PI_SPACES_DIR", "PI_SPACES_BASE_HOME", "PI_SPACES_BASE_ENV", "PWD", "OLDPWD"]);
83
85
 
84
86
  /**
85
87
  * The environment for the relaunched Pi. Every variable whose value is the current home,
86
88
  * or a path inside it, moves to the same path in the target home, so variables a host
87
89
  * set from the home stay consistent without this package knowing the host.
90
+ * With the profile's spaceEnv templates, those variables are then set for a space with `{home}` and `{name}`
91
+ * filled in, recomputed for each space. The base's own values of them are kept in PI_SPACES_BASE_ENV on the way
92
+ * out and put back on the way back, so the base home runs exactly as it was started.
88
93
  */
89
- export function relaunchEnv(env: Env, target: SwitchTarget & { currentHome: string }): Env {
94
+ export function relaunchEnv(env: Env, target: SwitchTarget & { currentHome: string; spaceEnv?: Record<string, string> }): Env {
90
95
  const from = resolve(target.currentHome);
91
96
  const next: Env = {};
92
97
  for (const [key, value] of Object.entries(env)) {
@@ -99,9 +104,37 @@ export function relaunchEnv(env: Env, target: SwitchTarget & { currentHome: stri
99
104
  next.PI_SPACES_BASE_HOME = target.baseHome;
100
105
  if (target.space === undefined) delete next.PI_SPACE;
101
106
  else next.PI_SPACE = target.space;
107
+ if (target.spaceEnv && Object.keys(target.spaceEnv).length > 0) {
108
+ const saved = baseValues(env, target);
109
+ for (const key of Object.keys(target.spaceEnv)) delete next[key];
110
+ if (target.space !== undefined) {
111
+ Object.assign(next, expandSpaceEnv(target.spaceEnv, target.targetHome, target.space));
112
+ next.PI_SPACES_BASE_ENV = JSON.stringify(saved);
113
+ } else {
114
+ Object.assign(next, saved);
115
+ delete next.PI_SPACES_BASE_ENV;
116
+ }
117
+ }
102
118
  return next;
103
119
  }
104
120
 
121
+ /**
122
+ * The base home's own values of the spaceEnv variables: read from the environment when leaving the base home,
123
+ * else the ones kept in PI_SPACES_BASE_ENV (none when it is missing or unreadable).
124
+ */
125
+ function baseValues(env: Env, target: SwitchTarget & { currentHome: string; spaceEnv?: Record<string, string> }): Record<string, string> {
126
+ const keys = Object.keys(target.spaceEnv ?? {});
127
+ if (resolve(target.currentHome) === resolve(target.baseHome)) {
128
+ return Object.fromEntries(keys.flatMap((key) => (env[key] === undefined ? [] : [[key, env[key] as string]])));
129
+ }
130
+ try {
131
+ const kept = JSON.parse(env.PI_SPACES_BASE_ENV ?? "{}");
132
+ return Object.fromEntries(keys.flatMap((key) => (typeof kept?.[key] === "string" ? [[key, kept[key]]] : [])));
133
+ } catch {
134
+ return {};
135
+ }
136
+ }
137
+
105
138
  export interface Command {
106
139
  command: string;
107
140
  args: string[];
@@ -164,9 +197,10 @@ export type StartupPlan = { kind: "none" } | { kind: "error"; message: string }
164
197
  * space; otherwise run Pi again in the space with every other argument, since nothing has run yet.
165
198
  * `pi --space <name> <package command> ...` always runs Pi again, even in the current home, because
166
199
  * Pi runs a package command (install, remove, uninstall, update, list) only as its first argument.
200
+ * `spaceEnv` is the matched profile's variable templates, applied by relaunchEnv.
167
201
  */
168
- export function planStartup(input: { proc: Proc; dir: string; currentHome: string; env: Env }): StartupPlan {
169
- const { proc, dir, currentHome, env } = input;
202
+ export function planStartup(input: { proc: Proc; dir: string; currentHome: string; env: Env; spaceEnv?: Record<string, string> }): StartupPlan {
203
+ const { proc, dir, currentHome, env, spaceEnv } = input;
170
204
  const parsed = parseSpaceFlag(proc.argv.slice(2));
171
205
  if ("error" in parsed) return { kind: "error", message: parsed.error };
172
206
  if (parsed.name === undefined) return { kind: "none" };
@@ -183,7 +217,7 @@ export function planStartup(input: { proc: Proc; dir: string; currentHome: strin
183
217
  }
184
218
  return { kind: "error", message: error.message };
185
219
  }
186
- return { kind: "relaunch", command: relaunchCommand(proc, args), env: relaunchEnv(env, { ...target, currentHome }) };
220
+ return { kind: "relaunch", command: relaunchCommand(proc, args), env: relaunchEnv(env, { ...target, currentHome, spaceEnv }) };
187
221
  }
188
222
 
189
223
  /** Runs the command now with inherited stdio and returns its exit code (1 when it cannot start). */
package/src/spaces.ts CHANGED
@@ -17,28 +17,31 @@ export class SpaceError extends Error {
17
17
  export type Env = Record<string, string | undefined>;
18
18
  export type PackageDeclaration = string | ({ source: string } & Record<string, unknown>);
19
19
 
20
- function expandTilde(path: string, home: string): string {
20
+ /** Expands a leading `~` to the given home folder. */
21
+ export function expandTilde(path: string, home: string): string {
21
22
  if (path === "~") return home;
22
23
  if (path.startsWith("~/")) return join(home, path.slice(2));
23
24
  return path;
24
25
  }
25
26
 
26
27
  /**
27
- * The folder that holds every space: PI_SPACES_DIR, else a `spaces` folder next to the base home
28
+ * The folder that holds every space: PI_SPACES_DIR, else the dir of the profile that matches the base home
29
+ * (an absolute path, from the config file), else a `spaces` folder next to the base home
28
30
  * (~/.pi/agent gives ~/.pi/spaces), so every program that runs Pi with its own home keeps its own spaces.
29
31
  * The base home is the one Pi started with (PI_SPACES_BASE_HOME inside a space), else Pi's agent home.
30
32
  */
31
- export function spacesDir(env: Env = process.env, home: string = osHomedir()): string {
33
+ export function spacesDir(env: Env = process.env, home: string = osHomedir(), profileDir?: string): string {
32
34
  const fromEnv = env.PI_SPACES_DIR;
33
35
  // A relative value resolves against the user's home, never the working directory.
34
36
  if (fromEnv) return resolve(home, expandTilde(fromEnv, home));
37
+ if (profileDir) return profileDir;
35
38
  const agentHome = env.PI_CODING_AGENT_DIR ? expandTilde(env.PI_CODING_AGENT_DIR, home) : join(home, ".pi", "agent");
36
39
  const baseHome = env.PI_SPACES_BASE_HOME || agentHome;
37
40
  return join(dirname(resolve(baseHome)), "spaces");
38
41
  }
39
42
 
40
43
  // The words of the pi-space and /space commands, so a space name never reads as one.
41
- const COMMANDS = new Set(["list", "new", "remove", "rename", "switch", "update", "install", "uninstall", "setup"]);
44
+ const COMMANDS = new Set(["list", "new", "remove", "rename", "switch", "update", "install", "uninstall", "setup", "completion"]);
42
45
 
43
46
  /** True when PI_SPACES_SETUP holds a setup command, not just blanks. */
44
47
  export function hasSetupCommand(env: Env): boolean {
@@ -235,7 +238,8 @@ function localPackageName(folder: string): string | undefined {
235
238
  }
236
239
  }
237
240
 
238
- function samePath(a: string, b: string): boolean {
241
+ /** True when both paths are the same place: real paths when they exist, resolved paths otherwise. */
242
+ export function samePath(a: string, b: string): boolean {
239
243
  return realOrResolved(a) === realOrResolved(b);
240
244
  }
241
245