@jals1212/pi-spaces 0.5.0 → 0.6.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/CHANGELOG.md CHANGED
@@ -6,6 +6,40 @@ commands and options and a patch version fixes.
6
6
 
7
7
  ## Unreleased
8
8
 
9
+ ## 0.6.0
10
+
11
+ ### Added
12
+
13
+ - Profiles in a config file (`~/.pi-spaces/config.json`, or `PI_SPACES_CONFIG`):
14
+ named descriptions of a program that runs Pi, with its base `home`,
15
+ `command`, spaces `dir`, `setup` command and `spaceEnv`. The format carries a
16
+ version (`"schema": "pi-spaces.config/v1"`); a newer version is refused with
17
+ a message that it needs a newer pi-spaces, and unknown keys are refused. A
18
+ built-in `pi` profile always exists and can be replaced. See the README.
19
+ - `spaceEnv`: variables that exist only in spaces. Entering a space sets them
20
+ (`{home}` and `{name}` filled in) and moving between spaces recomputes
21
+ them. Going back to `base` puts back the values the base home had (kept in `PI_SPACES_BASE_ENV` meanwhile). Without
22
+ `spaceEnv`, whatever a program keeps outside its agent home stays shared with
23
+ the base home, as before. The variables pi-spaces sets itself are refused.
24
+ - The extension applies the profile whose `home` is the base home: its
25
+ `spaceEnv` on `/space switch` and `pi --space`, its `dir` as the spaces
26
+ folder, and `/space new` names `pi-space setup <name>` when it has a `setup`.
27
+ - `@jals1212/pi-spaces/config` exports the loader (`loadConfig`, `configPath`,
28
+ `profileByName`, `profileForHome`, `spaceEnvFor`, `activeProfile`,
29
+ `ConfigError`) for the `pi-space` command.
30
+
31
+ ### Changed
32
+
33
+ - `completion` is a reserved name, for `pi-space completion`. A space already
34
+ named `completion` is no longer listed; rename its folder by hand.
35
+ - The spaces folder is `PI_SPACES_DIR`, else the matching profile's `dir`, else
36
+ `spaces` next to the base home. `spacesDir` takes the profile's dir as an
37
+ optional third argument.
38
+ - `relaunchEnv` and `planStartup` take an optional `spaceEnv`; without it they
39
+ behave exactly as before.
40
+ - A config file that cannot be used never stops Pi: the extension warns once
41
+ when the session starts and behaves as with no file.
42
+
9
43
  ## 0.5.0
10
44
 
11
45
  ### Added
package/README.md CHANGED
@@ -71,17 +71,98 @@ The status line shows `◆ <name>` while a space is active. In the base home it
71
71
 
72
72
  Choosing an argument such as `switch` from the `/space ` suggestions only fills the line; press Enter again to run it. `/space` and Enter opens the picker in one step.
73
73
 
74
- **Names**: lowercase letters, digits, `-` and `_`, starting with a letter or digit, at most 64 characters. `base` is reserved, and so are the command words `list`, `new`, `remove`, `rename`, `switch`, `update`, `install`, `uninstall` and `setup`.
74
+ **Names**: lowercase letters, digits, `-` and `_`, starting with a letter or digit, at most 64 characters. `base` is reserved, and so are the command words `list`, `new`, `remove`, `rename`, `switch`, `update`, `install`, `uninstall`, `setup` and `completion`.
75
75
 
76
76
  ## The `pi-space` command
77
77
 
78
78
  The optional `@jals1212/pi-spaces-cli` package adds a `pi-space` command to your shell: `pi-space` picks a space from a numbered list and starts Pi in it, `pi-space <name> [pi args...]` starts Pi in a named space, `pi-space new <name>` creates one, `pi-space rename <old> <new>` renames one, `pi-space remove <name>` deletes one, and `pi-space list` lists them. `pi-space list|update|install|uninstall <name|--every> ...` runs Pi's package commands in a space, or in every space. `--command` and `--dir` (or `PI_SPACES_COMMAND` and `PI_SPACES_DIR`) make it run another program that runs Pi, with that program's spaces, and `--setup` (or `PI_SPACES_SETUP`) gives that program's command to prepare a new space: `pi-space new` runs it, and `pi-space setup <name|--every>` runs it again. It only runs `pi --space <name> [command]`, so this extension must be installed in the home it opens. See [`cli/README.md`](cli/README.md) for the commands, the options and the exit status.
79
79
 
80
+ ## Profiles
81
+
82
+ A **profile** describes one program that runs Pi (plain Pi is one too): where its agent home is, the command that starts it, where its spaces live, how to prepare a new space, and the variables it needs per space. Profiles live in an optional JSON file, so you do not need shell variables or functions to tell pi-spaces about a program. Without the file, nothing changes: this is exactly how pi-spaces behaves without any configuration.
83
+
84
+ The extension uses the profile whose `home` is the base home Pi started with (the one `base` goes back to); inside a space that is the home the space came from. The optional `pi-space` command ([`cli/README.md`](cli/README.md)) reads the same file and adds `--profile <name>` to pick a profile.
85
+
86
+ ### The file
87
+
88
+ | | |
89
+ | --- | --- |
90
+ | Location | `~/.pi-spaces/config.json` |
91
+ | Change it | `PI_SPACES_CONFIG=<path>` (a leading `~` is your home folder; a relative path is taken from it). The `pi-space` command also takes `--config <path>` (see [`cli/README.md`](cli/README.md)). |
92
+ | Missing | No profiles: Pi, spaces and `pi-space` behave as without this feature. |
93
+ | Invalid | See [When the file is wrong](#when-the-file-is-wrong). |
94
+
95
+ A complete example for a program called `my-launcher`, which keeps its own configuration outside its agent home:
96
+
97
+ ```json
98
+ {
99
+ "schema": "pi-spaces.config/v1",
100
+ "default": "ml",
101
+ "profiles": {
102
+ "ml": {
103
+ "home": "~/.my-launcher/agent",
104
+ "command": "my-launcher --",
105
+ "setup": "my-launcher --home {home} setup",
106
+ "spaceEnv": { "MY_LAUNCHER_CONFIG_HOME": "{home}/config" }
107
+ }
108
+ }
109
+ }
110
+ ```
111
+
112
+ With that file, a space of `my-launcher` lives in `~/.my-launcher/spaces/<name>`, `/space new <name>` points you at `pi-space setup <name>`, and entering the space `work` starts Pi with `MY_LAUNCHER_CONFIG_HOME=~/.my-launcher/spaces/work/config`, while the base home gets no such variable from pi-spaces. An abbreviation or alias is optional sugar on top, for example `abbr -a ml 'pi-space --profile ml'` in fish or `alias ml='pi-space --profile ml'` in a POSIX shell.
113
+
114
+ ### Schema `pi-spaces.config/v1`
115
+
116
+ Top level (every other key is refused):
117
+
118
+ | Key | Type | Required | Meaning |
119
+ | --- | --- | --- | --- |
120
+ | `schema` | string | yes | Exactly `"pi-spaces.config/v1"`. |
121
+ | `default` | string | no | The profile to use when none is asked for: a profile of the file, or `pi`. Without it, the built-in `pi` profile. |
122
+ | `profiles` | object | no | Profiles by name. Names: lowercase letters, digits, `-` and `_`, starting with a letter or digit, at most 64 characters. |
123
+
124
+ A profile (every other key is refused; every key is optional):
125
+
126
+ | Key | Type | Default | Meaning |
127
+ | --- | --- | --- | --- |
128
+ | `home` | string, not empty | `PI_CODING_AGENT_DIR`, else `~/.pi/agent` | The base agent home of the program: the home it starts Pi with, which `base` goes back to. `~` is your home folder; a relative path is taken from it. |
129
+ | `command` | string, not empty | `pi` for the profile `pi`; none for the others | The command that starts the program (`my-launcher --`), in front of `--space <name>`. |
130
+ | `dir` | string, not empty | `spaces` next to `home` (`~/.my-launcher/agent` gives `~/.my-launcher/spaces`) | The folder that holds the profile's spaces. `~` and relative paths as in `home`. |
131
+ | `setup` | string, not empty | none | The command that prepares a new space; `{home}` and `{name}` become the space's folder and name. With it, `/space new` names `pi-space setup <name>`. |
132
+ | `spaceEnv` | object of strings | none | Variables that exist only in spaces; see below. Names: letters, digits and `_`, not starting with a digit; not `PI_CODING_AGENT_DIR`, `PI_SPACE`, `PI_SPACE_HOME` or any `PI_SPACES_*`, which pi-spaces sets itself. |
133
+
134
+ All strings are trimmed. `{home}` and `{name}` are replaced everywhere they appear in a `spaceEnv` value (and `setup`), and `$` in a value stays literal.
135
+
136
+ ### `spaceEnv`: what is separate and what is shared
137
+
138
+ Pi keeps everything in its agent home, so a space separates it completely with no configuration. A program that runs Pi may also keep something **outside** that home (a configuration folder, say) and hand its location to Pi in an environment variable. When you switch, pi-spaces moves only the variables that point inside the old home (see [How switching works](#how-switching-works)); a variable that points elsewhere is not recognised as part of the home and stays as it was. So without `spaceEnv`, whatever the program keeps outside the agent home is **shared** between the base home and every space. That is the default on purpose: nothing is separated that you did not ask to separate.
139
+
140
+ To separate it, name the variable in `spaceEnv`, with a value built from the space:
141
+
142
+ - Entering a space sets the variable, from the template, with `{home}` the space's folder and `{name}` its name.
143
+ - Going back to `base` puts back the value the base home had when you left it, or removes the variable when it had none, so the base home runs exactly as it was started. The base's values travel in `PI_SPACES_BASE_ENV` while you are in spaces.
144
+ - Going from one space to another sets it again for the target; a value that pointed into the old space is replaced, not moved.
145
+ - `pi --space <name>` does the same at startup.
146
+
147
+ pi-spaces does not create what a variable points to; `setup` (or the program itself) does.
148
+
149
+ ### The built-in `pi` profile
150
+
151
+ Plain Pi is always a profile named `pi`: command `pi`, home `PI_CODING_AGENT_DIR` (else `~/.pi/agent`), no setup, no `spaceEnv`. Naming `pi` in `profiles` replaces it as a whole; what that entry leaves out still takes the defaults above for `home` and `command`, never anything from the old built-in's other fields.
152
+
153
+ ### Schema versions
154
+
155
+ The file says which format it uses in `schema`, so the format can grow without breaking files. This pi-spaces reads `pi-spaces.config/v1` only. A file with a newer `pi-spaces.config/vN` is refused with a message that it needs a newer pi-spaces (update the package); anything else in `schema`, or no `schema`, is refused as invalid. A newer format is never half-read.
156
+
157
+ ### When the file is wrong
158
+
159
+ Unknown keys, wrong types, bad names, a `default` that names no profile, an unreadable file and invalid JSON are all refused with a message that names the file and the problem, for example `~/.pi-spaces/config.json: unknown key "hmoe" in profile "ml"; allowed keys are "home", "command", "dir", "setup" and "spaceEnv".` Pi never stops for it: the extension shows the message once as a warning when the session starts (on stderr without a terminal UI) and then behaves as if there were no file. A missing file is not an error.
160
+
80
161
  ## Configuration reference
81
162
 
82
163
  ### Where spaces live
83
164
 
84
- In a `spaces` folder next to the home Pi starts with: `~/.pi/agent` keeps its spaces in `~/.pi/spaces/<name>/`. A program that runs Pi with its own home gets its own folder too, for example `~/.tool/agent` → `~/.tool/spaces`. The folder stays the same while you switch between spaces. `PI_SPACES_DIR` replaces it.
165
+ In a `spaces` folder next to the home Pi starts with: `~/.pi/agent` keeps its spaces in `~/.pi/spaces/<name>/`. A program that runs Pi with its own home gets its own folder too, for example `~/.tool/agent` → `~/.tool/spaces`. The folder stays the same while you switch between spaces. A [profile](#profiles)'s `dir` replaces it for that profile's home, and `PI_SPACES_DIR` replaces both.
85
166
 
86
167
  ### Environment variables
87
168
 
@@ -89,7 +170,8 @@ Read:
89
170
 
90
171
  | Variable | Meaning |
91
172
  | --- | --- |
92
- | `PI_SPACES_DIR` | The spaces folder. A relative value is taken from your home folder. Default: `spaces` next to the base home. |
173
+ | `PI_SPACES_DIR` | The spaces folder. A relative value is taken from your home folder. Default: the matching profile's `dir`, else `spaces` next to the base home. |
174
+ | `PI_SPACES_CONFIG` | The [config file](#profiles) with the profiles. A leading `~` is your home folder and a relative value is taken from it. Default: `~/.pi-spaces/config.json`. |
93
175
  | `PI_SPACES_COMMAND` | For the `pi-space` command ([`cli/README.md`](cli/README.md)): the program to run, before `--space <name>`. Default: `pi`. |
94
176
  | `PI_SPACES_SETUP` | For the `pi-space` command: the command that prepares a new space. `/space new` names `pi-space setup <name>` when it is set. |
95
177
  | `PI_CODING_AGENT_DIR` | Pi's agent home; its parent holds the default spaces folder. Default: `~/.pi/agent`. |
@@ -101,6 +183,7 @@ Set for the Pi a switch starts:
101
183
  | `PI_CODING_AGENT_DIR` | The space's folder, or the base home for `base`. |
102
184
  | `PI_SPACE` | The space's name. Unset in the base home. |
103
185
  | `PI_SPACES_BASE_HOME` | The home Pi started with, so `base` and the spaces folder survive switches. |
186
+ | `PI_SPACES_BASE_ENV` | Only with a profile's `spaceEnv`, and only in a space: the base home's own values of those variables (JSON), put back when you return to `base`. |
104
187
 
105
188
  Set in every Pi this extension runs in, even one started without a switch, for its tools and the programs they start:
106
189
 
@@ -133,6 +216,7 @@ Pi reads its agent home once, when it starts, so a running session cannot move t
133
216
 
134
217
  - `PI_CODING_AGENT_DIR`, `PI_SPACE` and `PI_SPACES_BASE_HOME` are set as in [Environment variables](#environment-variables).
135
218
  - Any other variable whose value is the old home, or a path inside it, moves to the same path in the new home; in a list such as `PATH`, each entry moves on its own. This keeps variables that a launcher derived from the home consistent. `PWD` and `OLDPWD` are left alone.
219
+ - With a [profile](#profiles) that has `spaceEnv`, those variables are then set, with `{home}` and `{name}` filled in, when the target is a space; when it is `base`, they get back the values the base home had (`PI_SPACES_BASE_ENV`).
136
220
  - The same options are kept, such as `--model`, `-e`, `--tools` and other extensions' options. The initial prompt, `@file` arguments, everything after `--`, and the session options (`-c`, `-r`, `--session`, `--session-id`, `--fork`, `--session-dir`, `--no-session`, `-n`) are dropped, because they belong to the home being left.
137
221
  - Switching works only in the interactive terminal.
138
222
 
@@ -164,7 +248,7 @@ npm test # links cli/ to this checkout, builds both, run
164
248
  pi -e ./extensions/spaces.ts # try the extension without installing it
165
249
  ```
166
250
 
167
- `src/` is TypeScript. Pi loads the extension's TypeScript itself. The command in `cli/` imports the core as `@jals1212/pi-spaces/spaces`: the plain JavaScript in `dist/` that `npm run build` makes (Node does not strip types from files under `node_modules`). `npm pack` and `npm publish` build it first. `scripts/link-self.mjs` makes `cli/node_modules/@jals1212/pi-spaces` a link to this checkout, so the command's tests use the core from the working tree instead of from npm.
251
+ `src/` is TypeScript. Pi loads the extension's TypeScript itself. The command in `cli/` imports the core as `@jals1212/pi-spaces/spaces` (and the profiles code as `@jals1212/pi-spaces/config`): the plain JavaScript in `dist/` that `npm run build` makes (Node does not strip types from files under `node_modules`). `npm pack` and `npm publish` build it first. `scripts/link-self.mjs` makes `cli/node_modules/@jals1212/pi-spaces` a link to this checkout, so the command's tests use the core from the working tree instead of from npm.
168
252
 
169
253
  Branches: work goes to `develop` through pull requests; `main` receives `develop` when a release closes.
170
254
 
package/dist/command.js 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
+
3
4
  import { parseSpaceFlag, relaunchEnv, resolveSwitch } from "./relaunch.js";
4
5
  import {
5
6
  activeSpace,
@@ -40,6 +41,8 @@ const SUBCOMMANDS = ["list", "new", "remove", "rename", "switch"];
40
41
 
41
42
 
42
43
 
44
+
45
+
43
46
 
44
47
 
45
48
 
@@ -58,7 +61,7 @@ export async function runSpaceCommand(args , deps )
58
61
  const path = createSpace(deps.dir, rest[0], deps.declaration());
59
62
  const name = rest[0];
60
63
  ctx.ui.notify(
61
- hasSetupCommand(deps.env)
64
+ hasSetupCommand(deps.env) || Boolean(deps.profile?.setup)
62
65
  ? `Created space "${name}" at ${path}. Prepare it from a shell with: pi-space setup ${name}. Then enter it with /space switch ${name}.`
63
66
  : `Created space "${name}" at ${path}. Enter it with /space switch ${name}.`,
64
67
  "info",
@@ -153,7 +156,7 @@ function switchTo(name , deps ) {
153
156
  if (ctx.mode !== "tui") throw new SpaceError("/space switch needs the interactive terminal.");
154
157
  const currentHome = deps.currentHome();
155
158
  const target = resolveSwitch({ dir: deps.dir, name, currentHome, env: deps.env });
156
- deps.relaunch(deps.command(), relaunchEnv(deps.env, { ...target, currentHome }));
159
+ deps.relaunch(deps.command(), relaunchEnv(deps.env, { ...target, currentHome, spaceEnv: deps.profile?.spaceEnv }));
157
160
  ctx.ui.notify(
158
161
  target.space === undefined ? "Switching to the base home; Pi restarts in it." : `Switching to space "${target.space}"; Pi restarts in it.`,
159
162
  "info",
package/dist/config.js ADDED
@@ -0,0 +1,242 @@
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 { expandTilde, samePath } from "./spaces.js";
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 ) => 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 ) {
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
+
29
+
30
+
31
+
32
+
33
+
34
+
35
+
36
+
37
+
38
+
39
+
40
+
41
+
42
+
43
+
44
+
45
+
46
+ /** A profile with its paths resolved. `command`, `setup` and `spaceEnv` are set only when the profile has them. */
47
+
48
+
49
+
50
+
51
+
52
+
53
+
54
+
55
+
56
+
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 , home ) {
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 ) {
67
+ return typeof value === "object" && value !== null && !Array.isArray(value);
68
+ }
69
+
70
+ function reason(error ) {
71
+ return error instanceof Error ? error.message : String(error);
72
+ }
73
+
74
+
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 , read = (file) => readFileSync(file, "utf8")) {
81
+ let text ;
82
+ try {
83
+ text = read(path);
84
+ } catch (error) {
85
+ if ((error )?.code === "ENOENT") return undefined;
86
+ throw new ConfigError(`${path}: cannot be read: ${reason(error)}`);
87
+ }
88
+ const fail = (problem) => {
89
+ throw new ConfigError(`${path}: ${problem}`);
90
+ };
91
+ let json ;
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 = { 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 , present , fail ) {
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 , present , fail ) {
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 , value , fail ) {
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 = { name };
142
+ for (const key of ["home", "command", "dir", "setup"] ) {
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 , value , fail ) {
153
+ if (!isObject(value)) return fail(`"spaceEnv" in profile "${profile}" must be an object of strings.`);
154
+ const entries = [];
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 ) {
169
+ const names = (config?.profiles ?? []).map((profile) => profile.name);
170
+ return names.includes(BUILT_IN) ? names : [...names, BUILT_IN];
171
+ }
172
+
173
+ /** The built-in pi home: the base home inside a space (PI_SPACES_BASE_HOME), else PI_CODING_AGENT_DIR, else ~/.pi/agent. */
174
+ function builtInHome(env , home ) {
175
+ const fromEnv = env.PI_SPACES_BASE_HOME || env.PI_CODING_AGENT_DIR;
176
+ return fromEnv ? resolve(home, expandTilde(fromEnv, home)) : join(home, ".pi", "agent");
177
+ }
178
+
179
+ /** Resolves a profile; what a profile omits for `home` falls back to the built-in pi's, and `pi` also gets its `command`. */
180
+ function resolveProfile(spec , env , home ) {
181
+ const base = spec.home === undefined ? builtInHome(env, home) : resolve(home, expandTilde(spec.home, home));
182
+ const profile = {
183
+ name: spec.name,
184
+ home: base,
185
+ dir: spec.dir === undefined ? join(dirname(base), "spaces") : resolve(home, expandTilde(spec.dir, home)),
186
+ };
187
+ const command = spec.command ?? (spec.name === BUILT_IN ? "pi" : undefined);
188
+ if (command !== undefined) profile.command = command;
189
+ if (spec.setup !== undefined) profile.setup = spec.setup;
190
+ if (spec.spaceEnv !== undefined) profile.spaceEnv = spec.spaceEnv;
191
+ return profile;
192
+ }
193
+
194
+ /** The profile with this name, or a ConfigError that lists the known ones. `pi` always exists. */
195
+ export function profileByName(config , name , env , home ) {
196
+ const spec = config?.profiles.find((profile) => profile.name === name) ?? (name === BUILT_IN ? { name } : undefined);
197
+ if (spec === undefined) throw new ConfigError(`Unknown profile "${name}"; known profiles: ${profileNames(config).join(", ")}.`);
198
+ return resolveProfile(spec, env, home);
199
+ }
200
+
201
+ /** The profile whose home is the base home (the same place, real paths compared): the first in file order, then built-in `pi`. */
202
+ export function profileForHome(config , baseHome , env , home ) {
203
+ for (const name of profileNames(config)) {
204
+ const profile = profileByName(config, name, env, home);
205
+ if (samePath(profile.home, baseHome)) return profile;
206
+ }
207
+ return undefined;
208
+ }
209
+
210
+ /** The templates with every `{home}` and `{name}` replaced. Replacer functions keep `$` literal. */
211
+ export function expandSpaceEnv(templates , spaceHome , spaceName ) {
212
+ const fill = (template ) => template.replace(/\{home\}/g, () => spaceHome).replace(/\{name\}/g, () => spaceName);
213
+ return Object.fromEntries(Object.entries(templates).map(([key, template]) => [key, fill(template)]));
214
+ }
215
+
216
+ /** The variables a space gets: the profile's spaceEnv for this space's folder and name (none without spaceEnv). */
217
+ export function spaceEnvFor(profile , spaceHome , spaceName ) {
218
+ return expandSpaceEnv(profile.spaceEnv ?? {}, spaceHome, spaceName);
219
+ }
220
+
221
+ /**
222
+ * The profile this Pi runs under, from the config file: the one whose home is the base home
223
+ * (PI_SPACES_BASE_HOME, else the agent home). An unusable file never stops Pi: it comes back as
224
+ * a warning and the result is the same as with no file.
225
+ */
226
+ export function activeProfile(
227
+ env ,
228
+ home ,
229
+ agentHome ,
230
+ read ,
231
+ ) {
232
+ const baseHome = env.PI_SPACES_BASE_HOME || agentHome;
233
+ let config ;
234
+ let warning ;
235
+ try {
236
+ config = loadConfig(configPath(env, home), read);
237
+ } catch (error) {
238
+ if (!(error instanceof ConfigError)) throw error;
239
+ warning = error.message;
240
+ }
241
+ return { profile: profileForHome(config, baseHome, env, home), warning };
242
+ }
package/dist/relaunch.js 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.js";
5
6
  import { activeSpace, BASE, listSpaces, SpaceError, validateName } from "./spaces.js";
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
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 , target ) {
94
+ export function relaunchEnv(env , target ) {
90
95
  const from = resolve(target.currentHome);
91
96
  const next = {};
92
97
  for (const [key, value] of Object.entries(env)) {
@@ -99,9 +104,37 @@ export function relaunchEnv(env , target
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 , target ) {
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] ]])));
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
 
106
139
 
107
140
 
@@ -164,9 +197,10 @@ const PACKAGE_COMMANDS = new Set(["install", "remove", "uninstall", "update", "l
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 ) {
169
- const { proc, dir, currentHome, env } = input;
202
+ export function planStartup(input ) {
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
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/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.6.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,20 +1,21 @@
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.1"
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
17
  - 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.
18
+ - 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
19
  - Never read `auth.json` or other credential files in any home.
19
20
  - 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>`.
20
21
  - Never set `PI_CODING_AGENT_DIR` yourself, and never create, rename or delete space folders with file commands; use `/space` or `pi-space`.
@@ -26,14 +27,15 @@ Use when a task depends on which Pi agent home this session uses (settings, sign
26
27
  | --- | --- |
27
28
  | Which space, home, command | `<pi_space>` section, or `echo "$PI_SPACE|$PI_SPACE_HOME|$PI_SPACES_COMMAND"` |
28
29
  | 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
+ | 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) |
31
+ | 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 |
30
32
  | 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
33
  | 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
34
  | Work in another space | User runs `/space switch <name>`, or `$PI_SPACES_COMMAND --space <name> "<prompt>"` |
33
35
  | Remove a space | User request only: `pi-space remove <name> --yes` keeps a backup in `.backups` |
34
36
  | 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
37
 
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.
38
+ 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`, `completion`) are reserved.
37
39
 
38
40
  ## Execution Steps
39
41
 
@@ -47,4 +49,4 @@ State the space (or "base home"), the agent home path, and the command or `/spac
47
49
 
48
50
  ## References
49
51
 
50
- - `../../README.md` — commands, environment variables and how switching works.
52
+ - `../../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. */
@@ -58,7 +61,7 @@ export async function runSpaceCommand(args: string, deps: SpaceCommandDeps): Pro
58
61
  const path = createSpace(deps.dir, rest[0], deps.declaration());
59
62
  const name = rest[0];
60
63
  ctx.ui.notify(
61
- hasSetupCommand(deps.env)
64
+ hasSetupCommand(deps.env) || Boolean(deps.profile?.setup)
62
65
  ? `Created space "${name}" at ${path}. Prepare it from a shell with: pi-space setup ${name}. Then enter it with /space switch ${name}.`
63
66
  : `Created space "${name}" at ${path}. Enter it with /space switch ${name}.`,
64
67
  "info",
@@ -153,7 +156,7 @@ function switchTo(name: string, deps: SpaceCommandDeps): void {
153
156
  if (ctx.mode !== "tui") throw new SpaceError("/space switch needs the interactive terminal.");
154
157
  const currentHome = deps.currentHome();
155
158
  const target = resolveSwitch({ dir: deps.dir, name, currentHome, env: deps.env });
156
- deps.relaunch(deps.command(), relaunchEnv(deps.env, { ...target, currentHome }));
159
+ deps.relaunch(deps.command(), relaunchEnv(deps.env, { ...target, currentHome, spaceEnv: deps.profile?.spaceEnv }));
157
160
  ctx.ui.notify(
158
161
  target.space === undefined ? "Switching to the base home; Pi restarts in it." : `Switching to space "${target.space}"; Pi restarts in it.`,
159
162
  "info",
package/src/config.ts ADDED
@@ -0,0 +1,242 @@
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
+ /** The built-in pi home: the base home inside a space (PI_SPACES_BASE_HOME), else PI_CODING_AGENT_DIR, else ~/.pi/agent. */
174
+ function builtInHome(env: Env, home: string): string {
175
+ const fromEnv = env.PI_SPACES_BASE_HOME || env.PI_CODING_AGENT_DIR;
176
+ return fromEnv ? resolve(home, expandTilde(fromEnv, home)) : join(home, ".pi", "agent");
177
+ }
178
+
179
+ /** Resolves a profile; what a profile omits for `home` falls back to the built-in pi's, and `pi` also gets its `command`. */
180
+ function resolveProfile(spec: ProfileSpec, env: Env, home: string): Profile {
181
+ const base = spec.home === undefined ? builtInHome(env, home) : resolve(home, expandTilde(spec.home, home));
182
+ const profile: Profile = {
183
+ name: spec.name,
184
+ home: base,
185
+ dir: spec.dir === undefined ? join(dirname(base), "spaces") : resolve(home, expandTilde(spec.dir, home)),
186
+ };
187
+ const command = spec.command ?? (spec.name === BUILT_IN ? "pi" : undefined);
188
+ if (command !== undefined) profile.command = command;
189
+ if (spec.setup !== undefined) profile.setup = spec.setup;
190
+ if (spec.spaceEnv !== undefined) profile.spaceEnv = spec.spaceEnv;
191
+ return profile;
192
+ }
193
+
194
+ /** The profile with this name, or a ConfigError that lists the known ones. `pi` always exists. */
195
+ export function profileByName(config: Config | undefined, name: string, env: Env, home: string): Profile {
196
+ const spec = config?.profiles.find((profile) => profile.name === name) ?? (name === BUILT_IN ? { name } : undefined);
197
+ if (spec === undefined) throw new ConfigError(`Unknown profile "${name}"; known profiles: ${profileNames(config).join(", ")}.`);
198
+ return resolveProfile(spec, env, home);
199
+ }
200
+
201
+ /** The profile whose home is the base home (the same place, real paths compared): the first in file order, then built-in `pi`. */
202
+ export function profileForHome(config: Config | undefined, baseHome: string, env: Env, home: string): Profile | undefined {
203
+ for (const name of profileNames(config)) {
204
+ const profile = profileByName(config, name, env, home);
205
+ if (samePath(profile.home, baseHome)) return profile;
206
+ }
207
+ return undefined;
208
+ }
209
+
210
+ /** The templates with every `{home}` and `{name}` replaced. Replacer functions keep `$` literal. */
211
+ export function expandSpaceEnv(templates: Record<string, string>, spaceHome: string, spaceName: string): Record<string, string> {
212
+ const fill = (template: string) => template.replace(/\{home\}/g, () => spaceHome).replace(/\{name\}/g, () => spaceName);
213
+ return Object.fromEntries(Object.entries(templates).map(([key, template]) => [key, fill(template)]));
214
+ }
215
+
216
+ /** The variables a space gets: the profile's spaceEnv for this space's folder and name (none without spaceEnv). */
217
+ export function spaceEnvFor(profile: Profile, spaceHome: string, spaceName: string): Record<string, string> {
218
+ return expandSpaceEnv(profile.spaceEnv ?? {}, spaceHome, spaceName);
219
+ }
220
+
221
+ /**
222
+ * The profile this Pi runs under, from the config file: the one whose home is the base home
223
+ * (PI_SPACES_BASE_HOME, else the agent home). An unusable file never stops Pi: it comes back as
224
+ * a warning and the result is the same as with no file.
225
+ */
226
+ export function activeProfile(
227
+ env: Env,
228
+ home: string,
229
+ agentHome: string,
230
+ read?: (path: string) => string,
231
+ ): { profile: Profile | undefined; warning: string | undefined } {
232
+ const baseHome = env.PI_SPACES_BASE_HOME || agentHome;
233
+ let config: Config | undefined;
234
+ let warning: string | undefined;
235
+ try {
236
+ config = loadConfig(configPath(env, home), read);
237
+ } catch (error) {
238
+ if (!(error instanceof ConfigError)) throw error;
239
+ warning = error.message;
240
+ }
241
+ return { profile: profileForHome(config, baseHome, env, home), warning };
242
+ }
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