@jals1212/pi-spaces-cli 0.7.1 → 0.8.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.
Files changed (4) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/README.md +116 -87
  3. package/dist/cli.js +431 -309
  4. package/package.json +2 -2
package/dist/cli.js CHANGED
@@ -1,10 +1,13 @@
1
1
  // The optional `pi-space` command: pick a space and start Pi in it, and manage the spaces and their packages.
2
- // It only passes `--space <name>` (and, for a package command, Pi's command after it); the extension does the
3
- // switch, so it sets PI_CODING_AGENT_DIR only for the setup command, which is not Pi and does no switch.
4
- // Profiles of the config file (--profile, "default") stand in for the command, the spaces folder and the setup.
2
+ // It passes `--space <name>` (and, for a package command, Pi's command after it) and the extension does the switch,
3
+ // but the Pi it starts runs as if started from a plain terminal for the named profile, whatever Pi the caller is in:
4
+ // the caller's space (PI_SPACES_BASE_HOME, PI_SPACE, PI_SPACE_HOME, PI_SPACES_BASE_ENV, its spaceEnv values and the
5
+ // paths inside its folder) is unwound back to its base, and PI_CODING_AGENT_DIR is the profile's home. The setup
6
+ // command, which is not Pi and does no switch, gets the space's own home and variables on top of that same base.
7
+ // Every space is named <profile>/<name>; the profile of the config file stands in for the command, the spaces folder and the setup.
5
8
  import { readFileSync } from "node:fs";
6
9
  import { homedir } from "node:os";
7
- import { dirname, join, resolve } from "node:path";
10
+ import { delimiter, dirname, join, resolve, sep } from "node:path";
8
11
  import { createInterface } from "node:readline";
9
12
  import { fileURLToPath } from "node:url";
10
13
  import crossSpawn from "cross-spawn";
@@ -24,7 +27,6 @@ import {
24
27
  renameSpace,
25
28
  SpaceError,
26
29
  samePath,
27
- spacesDir,
28
30
  validateName,
29
31
  } from "@jals1212/pi-spaces/spaces";
30
32
  import {
@@ -34,61 +36,63 @@ import {
34
36
  loadConfig,
35
37
 
36
38
  profileByName,
39
+ profileForHome,
37
40
  profileNames,
38
41
  spaceEnvFor,
39
42
  } from "@jals1212/pi-spaces/config";
40
43
 
41
- export const USAGE = `Usage: pi-space [--command <cmd>] [--dir <path>] [--setup <cmd>] [--profile <name>] [--config <path>] [<name> [pi args...] | <command> ...]
44
+ export const USAGE = `Usage: pi-space [--command <cmd>] [--dir <path>] [--setup <cmd>] [--config <path>] [<space> [pi args...] | <command> ...]
42
45
  pi-space --help | --version
43
- pi-space pick a space and start Pi in it
44
- pi-space <name> [pi args...] start Pi in a space ("base" for the home Pi starts with)
45
- pi-space list list the spaces
46
- pi-space list <name|--every> list the packages of a space
47
- pi-space new <name> create a space, then run the setup command in it when there is one
48
- pi-space setup <name|--every> run the setup command in a space, or in every space (never in base)
49
- pi-space rename <old> <new> rename a space
50
- pi-space remove <name> delete a space and everything in it, after asking, and keep a backup in .backups unless told not to
51
- (--yes skips the questions; --no-backup deletes without a backup)
52
- pi-space update <name|--every> update the packages of a space (more words go to Pi: --extensions, --all, a source)
53
- pi-space install <name|--every> <source> install a package in a space (more words go to Pi)
54
- pi-space uninstall <name|--every> <source> remove a package from a space (more words go to Pi)
55
- pi-space completion zsh print the zsh completion script (source <(pi-space completion zsh))
56
- pi-space <command> --help the help of one command
57
- pi-space --help print this help
58
- pi-space --version print the version
59
- --every takes the place of the name: base first (not for setup), then every space in turn; a failure does not stop the rest, and the exit status is 1 if any failed.
46
+ A <space> is <profile>/<name>: a profile of the config file (pi is always there), then the space; base is a space too, as <profile>/base.
47
+ pi-space pick a space and start Pi in it
48
+ pi-space <space> [pi args...] start Pi in a space (<profile>/base is the home that profile's Pi starts with)
49
+ pi-space list list the spaces of every profile
50
+ pi-space list <space|--every> list the packages of a space
51
+ pi-space new <space> create a space, then run the profile's setup command in it when there is one
52
+ pi-space setup <space|--every> run the setup command in a space, or in every space (never in base)
53
+ pi-space rename <space> <new> rename a space; the new name is bare or keeps the profile (<profile>/<new>)
54
+ pi-space remove <space> delete a space and everything in it, after asking, and keep a backup in .backups unless told not to
55
+ (--yes skips the questions; --no-backup deletes without a backup)
56
+ pi-space update <space|--every> update the packages of a space (more words go to Pi: --extensions, --all, a source)
57
+ pi-space install <space|--every> <source> install a package in a space (more words go to Pi)
58
+ pi-space uninstall <space|--every> <source> remove a package from a space (more words go to Pi)
59
+ pi-space completion zsh print the zsh completion script (source <(pi-space completion zsh))
60
+ pi-space <command> --help the help of one command
61
+ pi-space --help print this help
62
+ pi-space --version print the version
63
+ --every takes the place of the space: <profile>/--every runs in that profile's base (not for setup) and spaces, --every alone in every profile;
64
+ a failure does not stop the rest, and the exit status is 1 if any failed.
60
65
  Options before the command, for this call only:
61
- --command <cmd> the program to run instead of $PI_SPACES_COMMAND (default: pi), e.g. "my-launcher --"
62
- --dir <path> the spaces folder instead of $PI_SPACES_DIR; the Pi it starts gets it too
63
- --setup <cmd> the setup command instead of $PI_SPACES_SETUP; {home} and {name} become the space's folder and name
64
- --profile <name> the profile of the config file to use instead of its "default": its command, spaces folder, setup and spaceEnv
65
- --config <path> the config file instead of $PI_SPACES_CONFIG (default: ~/.pi-spaces/config.json); the Pi it starts gets it too
66
+ --command <cmd> the program to run instead of the profile's command, e.g. "my-launcher --" (for one profile)
67
+ --dir <path> the spaces folder instead of the profile's; the Pi it starts gets it too (for one profile)
68
+ --setup <cmd> the setup command instead of the profile's; {home} and {name} become the space's folder and name
69
+ --config <path> the config file instead of $PI_SPACES_CONFIG (default: ~/.pi-spaces/config.json); the Pi it starts gets it too
66
70
  `;
67
71
 
68
72
  /** This package's version; package.json sits one folder above both src/ and dist/. */
69
73
  export const VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
70
74
 
71
- /** --command, --dir, --setup, --profile and --config: for this call only, so nothing has to be exported into the shell. */
75
+ /** --command, --dir, --setup and --config: for this call only, so nothing has to be exported into the shell. */
72
76
 
73
77
 
74
78
 
75
79
 
76
-
77
80
 
78
81
 
79
82
 
80
83
  /** The Pi commands that pi-space runs in one space or in every space. */
81
84
 
82
85
 
83
- /** A space name (or `base`), or every space: base first, then the spaces in order. */
86
+ /** `<profile>/<name>` (or `<profile>/base`, or `<profile>/--every` for one profile), or every space of every profile: base first, then the spaces in order. */
84
87
 
85
88
 
86
89
  const PACKAGE_VERBS = ["list", "update", "install", "uninstall"];
87
90
 
88
91
  /** The old options, and the command that replaced each. */
89
92
  const REPLACED = {
90
- "--new": "pi-space new <name>",
91
- "--remove": "pi-space remove <name> [--yes] [--no-backup]",
93
+ "--new": "pi-space new <profile>/<name>",
94
+ "--remove": "pi-space remove <profile>/<name> [--yes] [--no-backup]",
95
+ "--profile": "pi-space <profile>/<name>",
92
96
  } ;
93
97
 
94
98
 
@@ -111,7 +115,7 @@ const REPLACED = {
111
115
 
112
116
 
113
117
 
114
- const CALL_OPTIONS = { "--command": "command", "--dir": "dir", "--setup": "setup", "--profile": "profile", "--config": "config" } ;
118
+ const CALL_OPTIONS = { "--command": "command", "--dir": "dir", "--setup": "setup", "--config": "config" } ;
115
119
 
116
120
  function isHelpFlag(word ) {
117
121
  return word === "--help" || word === "-h";
@@ -119,10 +123,9 @@ function isHelpFlag(word ) {
119
123
 
120
124
  /** What each call option is, for the help of the commands it applies to. */
121
125
  const CALL_HELP = {
122
- command: ["--command <cmd>", 'the program to run instead of $PI_SPACES_COMMAND (default: pi), e.g. "my-launcher --"'],
123
- dir: ["--dir <path>", "the spaces folder instead of $PI_SPACES_DIR; the Pi it starts gets it too"],
124
- setup: ["--setup <cmd>", "the setup command instead of $PI_SPACES_SETUP; {home} and {name} become the space's folder and name"],
125
- profile: ["--profile <name>", 'the profile of the config file instead of its "default"'],
126
+ command: ["--command <cmd>", 'the program to run instead of the profile\'s command, e.g. "my-launcher --"; for one profile'],
127
+ dir: ["--dir <path>", "the spaces folder instead of the profile's; the Pi it starts gets it too; for one profile"],
128
+ setup: ["--setup <cmd>", "the setup command instead of the profile's; {home} and {name} become the space's folder and name"],
126
129
  config: ["--config <path>", "the config file instead of $PI_SPACES_CONFIG (default: ~/.pi-spaces/config.json)"],
127
130
  };
128
131
 
@@ -143,92 +146,94 @@ const CALL_HELP = {
143
146
 
144
147
  const PI_ARGS = ["[pi args...]", "more words go to Pi, e.g. --extensions, --all or a source"];
145
148
  const NAME_OR_EVERY = [
146
- ["<name>", "base or a space"],
147
- ["--every", "base first, then every space in turn; a failure does not stop the rest, and the exit status is 1 if any failed"],
149
+ ["<space>", "<profile>/<name>: a space of a profile, or <profile>/base"],
150
+ ["<profile>/--every", "that profile's base, then its spaces in turn; a failure does not stop the rest, and the exit status is 1 if any failed"],
151
+ ["--every", "the same in every profile"],
148
152
  ];
149
153
 
150
154
  /** The commands, in the order completion lists them. They are reserved words: the core never lets a space be named one. */
151
155
  const COMMANDS = {
152
156
  list: {
153
157
  summary: "list the spaces, or the packages of one",
154
- usage: "list [<name|--every>]",
155
- about: ["With a name it runs Pi's list in that space; with --every, in base and then every space."],
158
+ usage: "list [<space|--every>]",
159
+ about: ["Alone it lists the spaces of every profile. With a space it runs Pi's list in it; with <profile>/--every, in that profile's base and then its spaces; with --every, in every profile's."],
156
160
  args: NAME_OR_EVERY,
157
161
  options: [],
158
- calls: ["command", "dir", "profile", "config"],
159
- examples: ["pi-space list", "pi-space list work", "pi-space list --every"],
162
+ calls: ["command", "dir", "config"],
163
+ examples: ["pi-space list", "pi-space list pi/work", "pi-space list pi/--every", "pi-space list --every"],
160
164
  },
161
165
  new: {
162
166
  summary: "create a space",
163
- usage: "new <name>",
164
- about: ["Its settings.json declares the extension. It runs the setup command in it when there is one, and does not start Pi."],
165
- args: [["<name>", "the name of the new space"]],
167
+ usage: "new <space>",
168
+ about: ["Its settings.json declares the extension. It runs the profile's setup command in it when there is one, and does not start Pi."],
169
+ args: [["<space>", "<profile>/<name>: the profile and the name of the new space"]],
166
170
  options: [],
167
- calls: ["dir", "setup", "profile", "config"],
168
- examples: ["pi-space new work", "pi-space --setup 'my-launcher --home {home} setup' new work"],
171
+ calls: ["dir", "setup", "config"],
172
+ examples: ["pi-space new pi/work", "pi-space --setup 'my-launcher --home {home} setup' new pi/work"],
169
173
  },
170
174
  setup: {
171
175
  summary: "run the setup command in a space",
172
- usage: "setup <name|--every>",
173
- about: ["It needs a setup command: --setup or $PI_SPACES_SETUP. It never runs in base."],
176
+ usage: "setup <space|--every>",
177
+ about: ["It needs a setup command: the profile's \"setup\" or --setup. It never runs in base."],
174
178
  args: [
175
- ["<name>", "a space"],
176
- ["--every", "every space, never base; a failure does not stop the rest, and the exit status is 1 if any failed"],
179
+ ["<space>", "<profile>/<name>: a space of a profile"],
180
+ ["<profile>/--every", "every space of that profile, never base; a failure does not stop the rest, and the exit status is 1 if any failed"],
181
+ ["--every", "every space of every profile that has a setup command (or of all, with --setup)"],
177
182
  ],
178
183
  options: [],
179
- calls: ["dir", "setup", "profile", "config"],
180
- examples: ["pi-space setup work", "pi-space setup --every", "pi-space --setup 'my-launcher --home {home} setup' setup --every"],
184
+ calls: ["dir", "setup", "config"],
185
+ examples: ["pi-space setup pi/work", "pi-space setup pi/--every", "pi-space setup --every", "pi-space --setup 'my-launcher --home {home} setup' setup --every"],
181
186
  },
182
187
  rename: {
183
188
  summary: "rename a space",
184
- usage: "rename <old> <new>",
185
- about: ["It refuses what remove refuses: base, the space you are in, and a space that holds the base home."],
189
+ usage: "rename <space> <new>",
190
+ about: ["It refuses what remove refuses: base, the space you are in, and a space that holds the base home. A space keeps its profile."],
186
191
  args: [
187
- ["<old>", "the space to rename"],
188
- ["<new>", "its new name"],
192
+ ["<space>", "<profile>/<name>: the space to rename"],
193
+ ["<new>", "its new name, bare or with the same profile"],
189
194
  ],
190
195
  options: [],
191
- calls: ["dir", "profile", "config"],
192
- examples: ["pi-space rename work client"],
196
+ calls: ["dir", "config"],
197
+ examples: ["pi-space rename pi/work client", "pi-space rename pi/work pi/client"],
193
198
  },
194
199
  remove: {
195
200
  summary: "delete a space, keeping a backup",
196
- usage: "remove <name> [--yes|-y] [--no-backup]",
201
+ usage: "remove <space> [--yes|-y] [--no-backup]",
197
202
  about: ["It deletes the space and everything in it, after asking, and keeps a backup in .backups unless told not to."],
198
- args: [["<name>", "the space to delete"]],
203
+ args: [["<space>", "<profile>/<name>: the space to delete"]],
199
204
  options: [
200
205
  { flag: "--yes", alias: "-y", text: "do not ask; keep a backup" },
201
206
  { flag: "--no-backup", text: "delete without a backup" },
202
207
  ],
203
- calls: ["dir", "profile", "config"],
204
- examples: ["pi-space remove work", "pi-space remove work --yes", "pi-space remove work --yes --no-backup"],
208
+ calls: ["dir", "config"],
209
+ examples: ["pi-space remove pi/work", "pi-space remove pi/work --yes", "pi-space remove pi/work --yes --no-backup"],
205
210
  },
206
211
  update: {
207
212
  summary: "update the packages of a space",
208
- usage: "update <name|--every> [pi args...]",
209
- about: ["It runs Pi's update in a space, or in base and then every space."],
213
+ usage: "update <space|--every> [pi args...]",
214
+ about: ["It runs Pi's update in a space, in a profile's base and then its spaces, or in every profile's."],
210
215
  args: [...NAME_OR_EVERY, PI_ARGS],
211
216
  options: [],
212
- calls: ["command", "dir", "profile", "config"],
213
- examples: ["pi-space update work", "pi-space update base --extensions", "pi-space --command 'my-launcher --' update --every"],
217
+ calls: ["command", "dir", "config"],
218
+ examples: ["pi-space update pi/work", "pi-space update pi/base --extensions", "pi-space --command 'my-launcher --' update pi/--every", "pi-space update --every"],
214
219
  },
215
220
  install: {
216
221
  summary: "install a package in a space",
217
- usage: "install <name|--every> <source> [pi args...]",
218
- about: ["It runs Pi's install in a space, or in base and then every space."],
222
+ usage: "install <space|--every> <source> [pi args...]",
223
+ about: ["It runs Pi's install in a space, in a profile's base and then its spaces, or in every profile's."],
219
224
  args: [...NAME_OR_EVERY, ["<source>", "the package to install, e.g. npm:some-package"], PI_ARGS],
220
225
  options: [],
221
- calls: ["command", "dir", "profile", "config"],
222
- examples: ["pi-space install work npm:some-package", "pi-space install --every npm:some-package"],
226
+ calls: ["command", "dir", "config"],
227
+ examples: ["pi-space install pi/work npm:some-package", "pi-space install pi/--every npm:some-package", "pi-space install --every npm:some-package"],
223
228
  },
224
229
  uninstall: {
225
230
  summary: "remove a package from a space",
226
- usage: "uninstall <name|--every> <source> [pi args...]",
227
- about: ["It runs Pi's uninstall (its alias of remove) in a space, or in base and then every space."],
231
+ usage: "uninstall <space|--every> <source> [pi args...]",
232
+ about: ["It runs Pi's uninstall (its alias of remove) in a space, in a profile's base and then its spaces, or in every profile's."],
228
233
  args: [...NAME_OR_EVERY, ["<source>", "the package to remove, e.g. npm:some-package"], PI_ARGS],
229
234
  options: [],
230
- calls: ["command", "dir", "profile", "config"],
231
- examples: ["pi-space uninstall work npm:some-package", "pi-space uninstall --every npm:some-package"],
235
+ calls: ["command", "dir", "config"],
236
+ examples: ["pi-space uninstall pi/work npm:some-package", "pi-space uninstall pi/--every npm:some-package", "pi-space uninstall --every npm:some-package"],
232
237
  },
233
238
  completion: {
234
239
  summary: "print the shell completion script",
@@ -269,7 +274,7 @@ function commandHelp(name ) {
269
274
  }
270
275
 
271
276
  /**
272
- * The call options (--command, --dir, --setup, --profile, --config) come first; after a space name every argument belongs to Pi.
277
+ * The call options (--command, --dir, --setup, --config) come first; after a space name every argument belongs to Pi.
273
278
  * The commands (list, new, setup, remove, rename, update, install, uninstall, completion) are reserved words, so they
274
279
  * are read before a name: the core never lets a space have one of them as its name. `__complete` is the hidden
275
280
  * command the completion script calls; it starts with "_", which no space name does.
@@ -346,7 +351,7 @@ function parsePackages(verb , rest ) {
346
351
  }
347
352
 
348
353
  /**
349
- * The program and arguments that start Pi in a space: $PI_SPACES_COMMAND (default pi) plus --space <name>.
354
+ * The program and arguments that start Pi in a space: PI_SPACES_COMMAND of this env (default pi) plus --space <name>.
350
355
  * Throws when the command has an unclosed quote.
351
356
  */
352
357
  export function launchCommand(env , name , args ) {
@@ -414,6 +419,46 @@ export function newSpaceDeclaration(env , home , profileHome
414
419
  return findOwnDeclaration(baseHome, EXTENSION, packageRoot, `npm:${EXTENSION}`);
415
420
  }
416
421
 
422
+ // The variables unwinding never rewrites: PWD and OLDPWD describe the working directory, the others are PI_SPACES_* state.
423
+ const KEPT_AS_IS = new Set(["PI_SPACES_DIR", "PI_SPACES_BASE_HOME", "PI_SPACES_BASE_ENV", "PWD", "OLDPWD"]);
424
+ const SPACE_VARIABLES = ["PI_SPACE", "PI_SPACE_HOME", "PI_SPACES_BASE_HOME", "PI_SPACES_BASE_ENV"];
425
+
426
+ /**
427
+ * The environment of a plain terminal for `profile`, from the caller's: inside a space (PI_SPACES_BASE_HOME is set and
428
+ * is not the current home) the space is unwound as the extension's relaunch does on the way back to base: every value
429
+ * that is the space's folder or a path inside it (each entry of a list on its own) moves to the base home, and the
430
+ * caller profile's spaceEnv variables get their base values from PI_SPACES_BASE_ENV (removed when it has none).
431
+ * Then the variables of a space are removed and PI_CODING_AGENT_DIR is the profile's home.
432
+ * This reimplements the small relaunchEnv step instead of exporting it: the core's exports are a public contract.
433
+ */
434
+ function plainEnv(call , profile ) {
435
+ const { config, io } = call;
436
+ const { env } = io;
437
+ const base = env.PI_SPACES_BASE_HOME;
438
+ const current = agentHome(env, io.home);
439
+ let next = { ...env };
440
+ if (base && !samePath(current, base)) {
441
+ const from = resolve(current);
442
+ const move = (part ) => (part === from || part.startsWith(from + sep) ? resolve(base) + part.slice(from.length) : part);
443
+ next = {};
444
+ for (const [key, value] of Object.entries(env)) {
445
+ if (value !== undefined) next[key] = KEPT_AS_IS.has(key) ? value : value.split(delimiter).map(move).join(delimiter);
446
+ }
447
+ let kept = {};
448
+ try {
449
+ kept = JSON.parse(env.PI_SPACES_BASE_ENV ?? "{}") ?? {};
450
+ } catch {}
451
+ for (const key of Object.keys(profileForHome(config, base, env, io.home)?.spaceEnv ?? {})) {
452
+ delete next[key];
453
+ const value = kept[key];
454
+ if (typeof value === "string") next[key] = value;
455
+ }
456
+ }
457
+ for (const key of SPACE_VARIABLES) delete next[key];
458
+ next.PI_CODING_AGENT_DIR = profile.home;
459
+ return next;
460
+ }
461
+
417
462
  /** The agent home Pi uses from this shell: PI_CODING_AGENT_DIR, else ~/.pi/agent. */
418
463
  function agentHome(env , home ) {
419
464
  return env.PI_CODING_AGENT_DIR ? env.PI_CODING_AGENT_DIR.replace(/^~(?=\/|$)/, home) : join(home, ".pi", "agent");
@@ -421,7 +466,7 @@ function agentHome(env , home ) {
421
466
 
422
467
 
423
468
 
424
-
469
+
425
470
 
426
471
 
427
472
 
@@ -435,7 +480,7 @@ function agentHome(env , home ) {
435
480
 
436
481
 
437
482
 
438
-
483
+
439
484
 
440
485
 
441
486
 
@@ -443,7 +488,7 @@ function agentHome(env , home ) {
443
488
 
444
489
 
445
490
  /**
446
- * The real child process: stdio inherited, the environment unchanged. cross-spawn is plain child_process.spawn
491
+ * The real child process: stdio inherited, the environment as the CLI gives it. cross-spawn is plain child_process.spawn
447
492
  * except on Windows, where it finds .cmd and .bat shims such as pi.cmd and escapes the arguments for them.
448
493
  */
449
494
  export function spawnChild(program , args , options ) {
@@ -470,82 +515,92 @@ export function processIo(proc = process) {
470
515
  };
471
516
  }
472
517
 
473
- /** Where a setting came from, which decides what the started Pi is told and how an error names it. */
474
-
518
+ /** What one call settled before it looks at any profile: the caller's io, its options and the config file. */
519
+
520
+
521
+
522
+
523
+
524
+
525
+
526
+
475
527
 
476
- /** What one call settled: its environment, the spaces folder, the profile it runs under and where each setting came from. */
477
-
478
-
528
+ /** One profile under this call: its folder, the io of the Pi it starts, and where its command and setup came from. */
529
+
530
+
531
+
479
532
 
533
+
480
534
 
481
-
482
-
483
-
484
535
 
485
536
 
486
537
 
487
538
 
488
- /** Whether the file itself gives the profile a `dir` (the resolved one always has one). */
489
- function hasOwnDir(config , profile ) {
490
- return config?.profiles.find((spec) => spec.name === profile.name)?.dir !== undefined;
491
- }
539
+ /** What stands for "every space of a profile" after the slash of `<profile>/--every`. */
540
+ const EVERY = "--every";
492
541
 
493
542
  /**
494
- * The command or setup of this call, with where it came from: the call option, then the --profile profile, then
495
- * the environment variable, then the default profile. A blank variable is none.
543
+ * The profile `name` under this call's options: the command, spaces folder and setup are the option's, else the
544
+ * profile's; the caller's PI_SPACES_COMMAND, PI_SPACES_DIR and PI_SPACES_SETUP are never read. The started Pi gets
545
+ * the caller's environment with those three set to what was settled (the command and the setup removed when there
546
+ * is none), and PI_SPACES_CONFIG when --config was given. io.env stays the caller's (the protections of remove and
547
+ * rename read the Pi the caller is in); io.childEnv, what Pi and the setup command start from, is the caller's
548
+ * environment unwound to a plain terminal of the profile (see plainEnv). Throws a ConfigError for an unknown profile.
496
549
  */
497
- function choose(field , call , asked , fallback , env ) {
498
- const variable = field === "command" ? "PI_SPACES_COMMAND" : "PI_SPACES_SETUP";
499
- const named = (profile ) => `profile "${profile?.name}" ${field}`;
500
- const candidates = [
501
- [call[field], "option", `--${field}`],
502
- [asked?.[field], "profile", named(asked)],
503
- [env[variable]?.trim() ? env[variable] : undefined, "env", variable],
504
- [fallback?.[field], "profile", named(fallback)],
505
- ];
506
- const found = candidates.find(([value]) => value !== undefined);
507
- return found && { value: found[0] , origin: found[1], source: found[2] };
550
+ function scopeOf(call , name ) {
551
+ const { options, config, configFile, io: caller } = call;
552
+ const profile = profileByName(config, name, caller.env, caller.home);
553
+ // Absolute: a relative one is relative to where the command runs.
554
+ const dir = options.dir !== undefined ? resolve(options.dir) : profile.dir;
555
+ const command = options.command ?? profile.command;
556
+ const setup = options.setup ?? profile.setup;
557
+ const handed = (from ) => {
558
+ const env = { ...from, PI_SPACES_DIR: dir };
559
+ if (command !== undefined) env.PI_SPACES_COMMAND = command;
560
+ else delete env.PI_SPACES_COMMAND;
561
+ if (setup !== undefined) env.PI_SPACES_SETUP = setup;
562
+ else delete env.PI_SPACES_SETUP;
563
+ if (options.config !== undefined) env.PI_SPACES_CONFIG = configFile;
564
+ return env;
565
+ };
566
+ return {
567
+ profile,
568
+ io: { ...caller, env: handed(caller.env), childEnv: handed(plainEnv(call, profile)) },
569
+ dir,
570
+ commandSource: options.command !== undefined ? "--command" : `profile "${name}" command`,
571
+ setupSource: options.setup !== undefined ? "--setup" : `profile "${name}" setup`,
572
+ };
573
+ }
574
+
575
+ /** The scopes of every profile, in the order of profileNames. */
576
+ function allScopes(call ) {
577
+ return profileNames(call.config).map((name) => scopeOf(call, name));
508
578
  }
509
579
 
510
580
  /**
511
- * Reads the config file, picks the profile and applies the precedence: call option > --profile > PI_SPACES_* >
512
- * the default profile > built-in. The call's own environment gets the winners (so the rest of the CLI reads
513
- * PI_SPACES_COMMAND, PI_SPACES_DIR and PI_SPACES_SETUP as ever); the started Pi gets --dir, --setup, --config and
514
- * what a profile supplies for the folder and the setup, but never --command. Throws a ConfigError.
581
+ * Splits `<profile>/<name>` at the first "/". A name without its profile exits 2 with the form to use, and gives
582
+ * undefined, so the caller returns.
515
583
  */
516
- function resolveSettings(call , io ) {
517
- const configFile = call.config !== undefined ? resolve(call.config) : configPath(io.env, io.home);
518
- const config = loadConfig(configFile);
519
- const asked = call.profile !== undefined ? profileByName(config, call.profile, io.env, io.home) : undefined;
520
- // With --profile the default is not consulted: a profile never borrows from another.
521
- const fallback = asked === undefined && config?.default !== undefined ? profileByName(config, config.default, io.env, io.home) : undefined;
522
- const env = { ...io.env };
523
- const forChild = {};
524
- if (call.config !== undefined) forChild.PI_SPACES_CONFIG = configFile;
525
- if (call.dir !== undefined) {
526
- // Absolute: a relative one is relative to where the command runs.
527
- forChild.PI_SPACES_DIR = resolve(call.dir);
528
- } else if (asked) {
529
- env.PI_SPACES_DIR = asked.dir;
530
- // The Pi would find the same folder by itself unless the file names it or PI_SPACES_DIR would win there.
531
- if (hasOwnDir(config, asked) || io.env.PI_SPACES_DIR) forChild.PI_SPACES_DIR = asked.dir;
532
- } else if (fallback && !io.env.PI_SPACES_DIR && hasOwnDir(config, fallback)) {
533
- forChild.PI_SPACES_DIR = fallback.dir;
584
+ function qualify(text , call ) {
585
+ const slash = text.indexOf("/");
586
+ if (slash > 0) return { profile: text.slice(0, slash), name: text.slice(slash + 1) };
587
+ call.io.stderr.write(`pi-space: Name the space with its profile, as <profile>/${slash === 0 ? text.slice(1) : text} (profiles: ${profileNames(call.config).join(", ")}).\n`);
588
+ call.io.exit(2);
589
+ return undefined;
590
+ }
591
+
592
+ /** Whether a command runs in more than one profile: the picker, `list` alone and --every alone. */
593
+ function spansProfiles(command ) {
594
+ switch (command.kind) {
595
+ case "picker":
596
+ case "list":
597
+ return true;
598
+ case "packages":
599
+ case "setup":
600
+ return "every" in command.target;
601
+ default:
602
+ return false;
534
603
  }
535
- const command = choose("command", call, asked, fallback, io.env);
536
- const setup = choose("setup", call, asked, fallback, io.env);
537
- if (command && command.origin !== "env") env.PI_SPACES_COMMAND = command.value;
538
- if (setup && setup.origin !== "env") forChild.PI_SPACES_SETUP = env.PI_SPACES_SETUP = setup.value;
539
- Object.assign(env, forChild);
540
- const dir = spacesDir(env, io.home, fallback?.dir);
541
- return {
542
- io: { ...io, env, childEnv: Object.keys(forChild).length > 0 ? { ...io.env, ...forChild } : undefined },
543
- dir,
544
- config,
545
- profile: asked ?? fallback,
546
- commandSource: command?.source ?? "PI_SPACES_COMMAND",
547
- setupSource: setup?.source ?? "PI_SPACES_SETUP",
548
- };
549
604
  }
550
605
 
551
606
  export async function main(argv , io ) {
@@ -578,40 +633,69 @@ export async function main(argv , io ) {
578
633
  case "complete":
579
634
  return complete(command.words, io);
580
635
  }
581
- const callerIo = io;
582
- let settings ;
636
+ const configFile = command.config !== undefined ? resolve(command.config) : configPath(io.env, io.home);
583
637
  try {
584
- settings = resolveSettings(command, io);
638
+ const config = loadConfig(configFile);
639
+ if (config?.default !== undefined) {
640
+ io.stderr.write(`pi-space: ${configFile}: "default" is no longer used; name each space with its profile (<profile>/<name>) and remove "default" from the file.\n`);
641
+ }
642
+ const call = { options: command, config, configFile, io };
643
+ if (spansProfiles(command) && (command.command !== undefined || command.dir !== undefined)) {
644
+ io.stderr.write("pi-space: --command and --dir apply to one profile; name it: <profile>/<name> or <profile>/--every.\n");
645
+ return io.exit(2);
646
+ }
647
+ switch (command.kind) {
648
+ case "list":
649
+ return listProfiles(call);
650
+ case "packages":
651
+ return await runPackages(command.verb, command.target, command.args, call);
652
+ case "new": {
653
+ const ref = qualify(command.name, call);
654
+ return ref && (await create(ref.name, scopeOf(call, ref.profile)));
655
+ }
656
+ case "setup":
657
+ return await runSetup(command.target, call);
658
+ case "rename": {
659
+ const ref = qualify(command.from, call);
660
+ if (!ref) return;
661
+ // The new name is bare or carries the same profile: a space never changes profile.
662
+ const slash = command.to.indexOf("/");
663
+ if (slash >= 0 && command.to.slice(0, slash) !== ref.profile) {
664
+ io.stderr.write(`pi-space: A space keeps its profile: rename ${ref.profile}/${ref.name} to ${ref.profile}/<name>.\n`);
665
+ return io.exit(2);
666
+ }
667
+ return rename(ref.name, slash >= 0 ? command.to.slice(slash + 1) : command.to, scopeOf(call, ref.profile));
668
+ }
669
+ case "remove": {
670
+ const ref = qualify(command.name, call);
671
+ return ref && (await remove(ref.name, command.yes, command.backup, scopeOf(call, ref.profile)));
672
+ }
673
+ case "launch": {
674
+ const ref = qualify(command.name, call);
675
+ return ref && (await launch(ref.name, command.args, scopeOf(call, ref.profile)));
676
+ }
677
+ case "picker":
678
+ return await pick(call);
679
+ }
585
680
  } catch (error) {
586
681
  if (!(error instanceof ConfigError)) throw error;
587
682
  io.stderr.write(`pi-space: ${error.message}\n`);
588
683
  return io.exit(2);
589
684
  }
590
- const { dir, profile, commandSource, setupSource } = settings;
591
- io = settings.io;
592
- switch (command.kind) {
593
- case "list": {
594
- const spaces = listSpaces(dir);
595
- const lines = [`Spaces in ${dir}:`, ` ${BASE}`, ...spaces.map((name) => ` ${name}`)];
596
- if (spaces.length === 0) lines.push("No spaces yet. Create one with pi-space new <name>.");
597
- io.stdout.write(`${lines.join("\n")}\n`);
598
- return io.exit(0);
599
- }
600
- case "packages":
601
- return runPackages(command.verb, command.target, command.args, dir, io, commandSource);
602
- case "new":
603
- return create(command.name, dir, io, setupSource, profile);
604
- case "setup":
605
- return runSetup(command.target, dir, io, setupSource, profile);
606
- case "rename":
607
- return rename(command.from, command.to, dir, io, profile);
608
- case "remove":
609
- return remove(command.name, command.yes, command.backup, dir, io, profile);
610
- case "launch":
611
- return launch(command.name, command.args, dir, io, commandSource);
612
- case "picker":
613
- return pick(command, settings, callerIo);
685
+ }
686
+
687
+ /** `pi-space list`: every profile, its folder, then its base and spaces as `<profile>/<name>`. */
688
+ function listProfiles(call ) {
689
+ const lines = [];
690
+ let any = false;
691
+ for (const { profile, dir } of allScopes(call)) {
692
+ const spaces = listSpaces(dir);
693
+ any ||= spaces.length > 0;
694
+ lines.push(`Spaces of ${profile.name} in ${dir}:`, ` ${profile.name}/${BASE}`, ...spaces.map((name) => ` ${profile.name}/${name}`));
614
695
  }
696
+ if (!any) lines.push("No spaces yet. Create one with pi-space new <profile>/<name>.");
697
+ call.io.stdout.write(`${lines.join("\n")}\n`);
698
+ return call.io.exit(0);
615
699
  }
616
700
 
617
701
  const SHELLS = ["zsh"];
@@ -644,7 +728,7 @@ const ZSH_COMPLETION = [
644
728
  "#compdef pi-space",
645
729
  "# zsh completion for pi-space. Load it from .zshrc with: source <(pi-space completion zsh)",
646
730
  "_pi_space() {",
647
- " local -a lines cmds spaces targets profiles shells opts",
731
+ " local -a lines cmds spaces targets shells opts",
648
732
  " local line group value text ret=1",
649
733
  ' lines=("${(@f)$(command "${words[1]}" __complete "${(@)words[2,CURRENT-1]}" 2>/dev/null)}")',
650
734
  ` if [[ "\${lines[1]}" == ${FILES} ]]; then`,
@@ -663,7 +747,6 @@ const ZSH_COMPLETION = [
663
747
  ' command) cmds+=("$value:$text") ;;',
664
748
  ' space) spaces+=("$value:$text") ;;',
665
749
  ' target) targets+=("$value:$text") ;;',
666
- ' profile) profiles+=("$value:$text") ;;',
667
750
  ' shell) shells+=("$value:$text") ;;',
668
751
  ' option) [[ "$PREFIX" == -* ]] && opts+=("$value:$text") ;;',
669
752
  " esac",
@@ -671,7 +754,6 @@ const ZSH_COMPLETION = [
671
754
  " (( $#cmds )) && _describe -t commands 'command' cmds && ret=0",
672
755
  " (( $#spaces )) && _describe -t spaces 'space' spaces && ret=0",
673
756
  " (( $#targets )) && _describe -t targets 'target' targets && ret=0",
674
- " (( $#profiles )) && _describe -t profiles 'profile' profiles && ret=0",
675
757
  " (( $#shells )) && _describe -t shells 'shell' shells && ret=0",
676
758
  " (( $#opts )) && _describe -t options 'option' opts && ret=0",
677
759
  " return ret",
@@ -681,7 +763,7 @@ const ZSH_COMPLETION = [
681
763
  ].join("\n");
682
764
 
683
765
  /** The groups of a `__complete` line; `target` is --every, which stands in for a space name. */
684
-
766
+
685
767
 
686
768
  /** One `__complete` line; the description never holds a tab or a newline. */
687
769
  function candidate(group , value , text ) {
@@ -719,26 +801,41 @@ function firstOptions(call , alone ) {
719
801
  return [...calls, ...general];
720
802
  }
721
803
 
722
- /** `base`, if asked, then the spaces. */
723
- function spaceCandidates(spaces , withBase ) {
724
- return [...(withBase ? [candidate("space", BASE, "the home Pi starts with")] : []), ...spaces.map((name) => candidate("space", name, "space"))];
804
+ /** A profile and the names in its spaces folder, as `__complete` offers them. */
805
+
806
+
807
+
808
+
809
+
810
+
811
+
812
+ /** `<profile>/base`, if asked, then `<profile>/<name>`, for each profile in order. */
813
+ function spaceCandidates(profiles , withBase ) {
814
+ return profiles.flatMap((profile) => [
815
+ ...(withBase ? [candidate("space", `${profile.name}/${BASE}`, `the home ${profile.name} starts with`)] : []),
816
+ ...profile.spaces.map((name) => candidate("space", `${profile.name}/${name}`, profile.command)),
817
+ ]);
818
+ }
819
+
820
+ /** The `--every` targets: one for every profile, then one `<profile>/--every` for each. */
821
+ function everyCandidates(profiles , every , each ) {
822
+ return [candidate("target", "--every", every), ...profiles.map((profile) => candidate("target", `${profile.name}/--every`, each(profile.name)))];
725
823
  }
726
824
 
727
825
  /** What follows a command word: its target, its own options and --help. */
728
- function commandArguments(command , spaces ) {
729
- const every = (text ) => candidate("target", "--every", text);
826
+ function commandArguments(command , profiles ) {
730
827
  const options = [...COMMANDS[command].options, HELP_OPTION].map((option) => candidate("option", option.flag, option.text));
731
828
  switch (command) {
732
829
  case "remove":
733
830
  case "rename":
734
- return [...spaceCandidates(spaces, false), ...options];
831
+ return [...spaceCandidates(profiles, false), ...options];
735
832
  case "setup":
736
- return [...spaceCandidates(spaces, false), every("every space"), ...options];
833
+ return [...spaceCandidates(profiles, false), ...everyCandidates(profiles, "every space of every profile with a setup command", (name) => `every space of ${name}`), ...options];
737
834
  case "list":
738
835
  case "update":
739
836
  case "install":
740
837
  case "uninstall":
741
- return [...spaceCandidates(spaces, true), every("every space (base first)"), ...options];
838
+ return [...spaceCandidates(profiles, true), ...everyCandidates(profiles, "every profile, base first", (name) => `base and every space of ${name}`), ...options];
742
839
  case "completion":
743
840
  return [...SHELLS.map((shell) => candidate("shell", shell, `the ${shell} completion script`)), ...options];
744
841
  default:
@@ -752,37 +849,29 @@ function candidates(words , io ) {
752
849
  while (Object.hasOwn(CALL_OPTIONS, words[index] ?? "")) {
753
850
  const option = words[index] ;
754
851
  // The option is the last word: the cursor is on its value.
755
- if (index + 1 >= words.length) return optionValues(option, call, io);
852
+ if (index + 1 >= words.length) return optionValues(option);
756
853
  call[CALL_OPTIONS[option]] = words[index + 1];
757
854
  index += 2;
758
855
  }
759
- const { dir } = resolveSettings(call, io);
856
+ const configFile = call.config !== undefined ? resolve(call.config) : configPath(io.env, io.home);
857
+ const config = loadConfig(configFile);
858
+ // A --dir typed before the cursor is read as the spaces folder of every profile.
859
+ const dirOverride = call.dir === undefined ? undefined : resolve(call.dir);
860
+ const profiles = profileNames(config).map((name) => {
861
+ const profile = profileByName(config, name, io.env, io.home);
862
+ return { name, command: profile.command ?? "pi", spaces: listSpaces(dirOverride ?? profile.dir) };
863
+ });
760
864
  const rest = words.slice(index);
761
- const spaces = listSpaces(dir);
762
- if (rest.length === 0) return [...commandCandidates(false), ...spaceCandidates(spaces, true), ...firstOptions(call, index === 0)];
865
+ if (rest.length === 0) return [...commandCandidates(false), ...spaceCandidates(profiles, true), ...firstOptions(call, index === 0)];
763
866
  if (rest.length > 1) return [];
764
867
  if (isHelpFlag(rest[0])) return commandCandidates(true);
765
868
  if (!Object.hasOwn(COMMANDS, rest[0])) return [];
766
- return commandArguments(rest[0], spaces);
767
- }
768
-
769
- /** The candidates for the value of a call option: profile names with their command, the shell's files for a path, nothing for a command. */
770
- function optionValues(option , call , io ) {
771
- if (option === "--profile") {
772
- const { config } = resolveSettings(call, io);
773
- return profileNames(config).map((name) => candidate("profile", name, profileText(config, name, io)));
774
- }
775
- if (option === "--config" || option === "--dir") return [FILES];
776
- return [];
869
+ return commandArguments(rest[0], profiles);
777
870
  }
778
871
 
779
- /** A profile's command, else "profile". */
780
- function profileText(config , name , io ) {
781
- try {
782
- return profileByName(config, name, io.env, io.home).command || "profile";
783
- } catch {
784
- return "profile";
785
- }
872
+ /** The candidates for the value of a call option: the shell's files for a path, nothing for a command. */
873
+ function optionValues(option ) {
874
+ return option === "--config" || option === "--dir" ? [FILES] : [];
786
875
  }
787
876
 
788
877
  /** Prints the reason and returns false when the setup command has an unclosed quote. */
@@ -797,34 +886,35 @@ function checkSetup(io , source ) {
797
886
  }
798
887
 
799
888
  /** Runs a setup command in a space, as that space's agent home (with the profile's spaceEnv), and returns its exit status. */
800
- function spawnSetup(setup , name , home , io , profile ) {
889
+ function spawnSetup(setup , name , home , io , profile ) {
801
890
  // The profile's spaceEnv, for this space, then the three variables of the space, which a profile cannot set.
802
- const spaceEnv = profile ? spaceEnvFor(profile, home, name) : {};
803
- return spawnPi(setup.program, setup.args, io, { ...io.env, ...spaceEnv, PI_CODING_AGENT_DIR: home, PI_SPACE: name, PI_SPACE_HOME: home });
891
+ const spaceEnv = spaceEnvFor(profile, home, name);
892
+ return spawnPi(setup.program, setup.args, io, { ...(io.childEnv ?? io.env), ...spaceEnv, PI_CODING_AGENT_DIR: home, PI_SPACE: name, PI_SPACE_HOME: home });
804
893
  }
805
894
 
806
- /** The setup command for one space, from PI_SPACES_SETUP after the call options; checkSetup has accepted it. */
895
+ /** The setup command for one space, from PI_SPACES_SETUP of this profile's io; checkSetup has accepted it. */
807
896
  function setupFor(name , home , io ) {
808
897
  return setupCommand(io.env.PI_SPACES_SETUP ?? "", home, name);
809
898
  }
810
899
 
811
- async function create(name , dir , io , setupSource , profile ) {
900
+ async function create(name , scope ) {
901
+ const { io, dir, profile } = scope;
812
902
  const withSetup = hasSetupCommand(io.env);
813
903
  // A bad setup command is reported before anything is created.
814
- if (withSetup && !checkSetup(io, setupSource)) return io.exit(2);
904
+ if (withSetup && !checkSetup(io, scope.setupSource)) return io.exit(2);
815
905
  try {
816
- const path = createSpace(dir, name, newSpaceDeclaration(io.env, io.home, profile?.home));
906
+ const path = createSpace(dir, name, newSpaceDeclaration(io.env, io.home, profile.home));
817
907
  io.stdout.write(`Created space "${name}" at ${path}.\n`);
818
908
  if (withSetup) {
819
909
  const setup = setupFor(name, path, io);
820
910
  io.stdout.write(`Setting it up: ${[setup.program, ...setup.args].join(" ")}\n`);
821
911
  const status = await spawnSetup(setup, name, path, io, profile);
822
912
  if (status !== 0) {
823
- io.stderr.write(`pi-space: Setup failed in space "${name}" (exit status ${status}); the space is kept. Run it again with: pi-space setup ${name}\n`);
913
+ io.stderr.write(`pi-space: Setup failed in space "${name}" (exit status ${status}); the space is kept. Run it again with: pi-space setup ${profile.name}/${name}\n`);
824
914
  return io.exit(status);
825
915
  }
826
916
  }
827
- io.stdout.write(`Start Pi in it with: pi-space ${name}\n`);
917
+ io.stdout.write(`Start Pi in it with: pi-space ${profile.name}/${name}\n`);
828
918
  return io.exit(0);
829
919
  } catch (error) {
830
920
  // A SpaceError is a rule (a bad or taken name); anything else is the filesystem, such as no permission.
@@ -833,13 +923,14 @@ async function create(name , dir , io , setupSource ,
833
923
  }
834
924
  }
835
925
 
836
- /** The base homes a space must not be: the one Pi started with, and the selected profile's. */
837
- function protectedHomes(io , profile ) {
838
- return [io.env.PI_SPACES_BASE_HOME, profile?.home].filter((home) => Boolean(home));
926
+ /** The base homes a space must not be: the one Pi started with, and the profile's. */
927
+ function protectedHomes(io , profile ) {
928
+ return [io.env.PI_SPACES_BASE_HOME, profile.home].filter((home) => Boolean(home));
839
929
  }
840
930
 
841
931
  /** Renames a space after the same checks as remove: not base, not the active space, not one that holds the base home. */
842
- function rename(from , to , dir , io , profile ) {
932
+ function rename(from , to , scope ) {
933
+ const { io, dir, profile } = scope;
843
934
  try {
844
935
  const protect = protectedHomes(io, profile);
845
936
  renameSpace(dir, from, to, agentHome(io.env, io.home), protect, io.home);
@@ -855,7 +946,8 @@ function rename(from , to , dir , io , profile
855
946
  * Without --yes it asks to remove ([y/N]), then, without --no-backup, whether to keep a backup ([Y/n]).
856
947
  * With --yes it asks nothing and keeps a backup unless --no-backup.
857
948
  */
858
- async function remove(name , yes , backup , dir , io , profile ) {
949
+ async function remove(name , yes , backup , scope ) {
950
+ const { io, dir, profile } = scope;
859
951
  const current = agentHome(io.env, io.home);
860
952
  const protect = protectedHomes(io, profile);
861
953
  let reader ;
@@ -864,7 +956,7 @@ async function remove(name , yes , backup , dir ,
864
956
  const path = checkRemovable(dir, name, current, protect, io.home);
865
957
  let keep = backup;
866
958
  if (!yes) {
867
- if (!io.stdin.isTTY) throw new SpaceError(`Removing a space needs confirmation; run pi-space remove ${name} --yes.`);
959
+ if (!io.stdin.isTTY) throw new SpaceError(`Removing a space needs confirmation; run pi-space remove ${profile.name}/${name} --yes.`);
868
960
  // One reader for every question, so an answer typed ahead is not lost between them.
869
961
  reader = lineReader(io.stdin);
870
962
  io.stdout.write(`Remove space "${name}"? This deletes ${path} and everything in it: sign-ins, packages, settings and sessions. [y/N] `);
@@ -961,40 +1053,63 @@ async function spawnPi(program , programArgs , io , env
961
1053
  return result.status ?? 1;
962
1054
  }
963
1055
 
964
- async function launch(name , args , dir , io , commandSource ) {
965
- if (!checkSpace(name, dir, io)) return io.exit(1);
1056
+ async function launch(name , args , scope ) {
1057
+ const { io } = scope;
1058
+ if (!checkSpace(name, scope.dir, io)) return io.exit(1);
966
1059
  let launched ;
967
1060
  try {
968
1061
  launched = launchCommand(io.env, name, args);
969
1062
  } catch (error) {
970
- io.stderr.write(`pi-space: ${commandSource}: ${(error ).message}.\n`);
1063
+ io.stderr.write(`pi-space: ${scope.commandSource}: ${(error ).message}.\n`);
971
1064
  return io.exit(2);
972
1065
  }
973
1066
  io.exit(await spawnPi(launched.program, launched.args, io, io.childEnv));
974
1067
  }
975
1068
 
976
1069
  /**
977
- * Runs one of Pi's package commands in a space, or in base and then every space with --every, through the same
978
- * Pi (`--space <name> <verb> [args...]`): the extension relaunches it in that home, so the home is never set here.
979
- * With --every each run gets a `== <name>` header, one failure does not stop the rest, and the status is 1 if any failed.
1070
+ * Runs one of Pi's package commands in a space, in base and then every space of a profile (`<profile>/--every`),
1071
+ * or in every profile (`--every`), through the same Pi (`--space <name> <verb> [args...]`): the extension relaunches
1072
+ * it in that home, so the home is never set here. With --every each run gets a `== <profile>/<name>` header, one
1073
+ * failure does not stop the rest, and the status is 1 if any failed.
980
1074
  */
981
- async function runPackages(verb , target , args , dir , io , commandSource ) {
982
- const names = "every" in target ? [BASE, ...listSpaces(dir)] : [target.name];
983
- if (!("every" in target) && !checkSpace(target.name, dir, io)) return io.exit(1);
984
- const failed = [];
985
- for (const name of names) {
986
- let launched ;
1075
+ async function runPackages(verb , target , args , call ) {
1076
+ const { io } = call;
1077
+ let scopes ;
1078
+ if ("every" in target) {
1079
+ scopes = allScopes(call);
1080
+ } else {
1081
+ const ref = qualify(target.name, call);
1082
+ if (!ref) return;
1083
+ const scope = scopeOf(call, ref.profile);
1084
+ if (ref.name !== EVERY) {
1085
+ if (!checkSpace(ref.name, scope.dir, io)) return io.exit(1);
1086
+ let launched ;
1087
+ try {
1088
+ launched = launchCommand(scope.io.env, ref.name, [verb, ...args]);
1089
+ } catch (error) {
1090
+ io.stderr.write(`pi-space: ${scope.commandSource}: ${(error ).message}.\n`);
1091
+ return io.exit(2);
1092
+ }
1093
+ return io.exit(await spawnPi(launched.program, launched.args, scope.io, scope.io.childEnv));
1094
+ }
1095
+ scopes = [scope];
1096
+ }
1097
+ // Every command is checked before the first one runs, so a bad one is reported once, before any header.
1098
+ for (const scope of scopes) {
987
1099
  try {
988
- launched = launchCommand(io.env, name, [verb, ...args]);
1100
+ launchCommand(scope.io.env, BASE, []);
989
1101
  } catch (error) {
990
- // The same command for every space, so this is reported once, before any header.
991
- io.stderr.write(`pi-space: ${commandSource}: ${(error ).message}.\n`);
1102
+ io.stderr.write(`pi-space: ${scope.commandSource}: ${(error ).message}.\n`);
992
1103
  return io.exit(2);
993
1104
  }
994
- if ("every" in target) io.stdout.write(`== ${name}\n`);
995
- const status = await spawnPi(launched.program, launched.args, io, io.childEnv);
996
- if (!("every" in target)) return io.exit(status);
997
- if (status !== 0) failed.push(name);
1105
+ }
1106
+ const failed = [];
1107
+ for (const scope of scopes) {
1108
+ for (const name of [BASE, ...listSpaces(scope.dir)]) {
1109
+ const launched = launchCommand(scope.io.env, name, [verb, ...args]);
1110
+ io.stdout.write(`== ${scope.profile.name}/${name}\n`);
1111
+ if ((await spawnPi(launched.program, launched.args, scope.io, scope.io.childEnv)) !== 0) failed.push(`${scope.profile.name}/${name}`);
1112
+ }
998
1113
  }
999
1114
  if (failed.length > 0) {
1000
1115
  io.stderr.write(`pi-space: ${verb} failed in: ${failed.join(", ")}\n`);
@@ -1003,36 +1118,67 @@ async function runPackages(verb , target , args
1003
1118
  io.exit(0);
1004
1119
  }
1005
1120
 
1121
+ /** Runs the setup command of a profile in each of `names`, with a `== <profile>/<name>` header; the failures are added to `failed`. */
1122
+ async function setupEach(scope , names , failed ) {
1123
+ for (const name of names) {
1124
+ scope.io.stdout.write(`== ${scope.profile.name}/${name}\n`);
1125
+ const home = join(scope.dir, name);
1126
+ if ((await spawnSetup(setupFor(name, home, scope.io), name, home, scope.io, scope.profile)) !== 0) failed.push(`${scope.profile.name}/${name}`);
1127
+ }
1128
+ }
1129
+
1006
1130
  /**
1007
- * Runs the setup command in a space, or in every space with --every (never base), as `pi-space setup`.
1008
- * With --every each run gets a `== <name>` header, one failure does not stop the rest, and the status is 1 if any failed.
1131
+ * Runs the setup command in a space, in every space of a profile (`<profile>/--every`), or in every space of every
1132
+ * profile that has a setup command (`--every`); never in base. With --every each run gets a `== <profile>/<name>`
1133
+ * header, one failure does not stop the rest, and the status is 1 if any failed.
1009
1134
  */
1010
- async function runSetup(target , dir , io , setupSource , profile ) {
1011
- if (!hasSetupCommand(io.env)) {
1012
- io.stderr.write("pi-space: There is no setup command; set PI_SPACES_SETUP or give --setup <cmd>.\n");
1135
+ async function runSetup(target , call ) {
1136
+ const { io } = call;
1137
+ const noSetup = (name ) => `pi-space: Profile "${name}" has no setup command; give it a "setup" in the config file, or give --setup <cmd>.\n`;
1138
+ if ("every" in target) {
1139
+ const scopes = allScopes(call).filter((scope) => hasSetupCommand(scope.io.env));
1140
+ if (scopes.length === 0) {
1141
+ io.stderr.write('pi-space: No profile has a setup command; give one a "setup" in the config file, or give --setup <cmd>.\n');
1142
+ return io.exit(2);
1143
+ }
1144
+ // Every setup command is checked before the first one runs.
1145
+ if (!scopes.every((scope) => checkSetup(scope.io, scope.setupSource))) return io.exit(2);
1146
+ if (scopes.every((scope) => listSpaces(scope.dir).length === 0)) {
1147
+ io.stdout.write("No spaces yet. Create one with pi-space new <profile>/<name>.\n");
1148
+ return io.exit(0);
1149
+ }
1150
+ const failed = [];
1151
+ for (const scope of scopes) await setupEach(scope, listSpaces(scope.dir), failed);
1152
+ return finishSetup(failed, io);
1153
+ }
1154
+ const ref = qualify(target.name, call);
1155
+ if (!ref) return;
1156
+ const scope = scopeOf(call, ref.profile);
1157
+ if (!hasSetupCommand(scope.io.env)) {
1158
+ io.stderr.write(noSetup(ref.profile));
1013
1159
  return io.exit(2);
1014
1160
  }
1015
- if (!checkSetup(io, setupSource)) return io.exit(2);
1016
- if (!("every" in target)) {
1017
- if (target.name === BASE) {
1018
- io.stderr.write("pi-space: setup runs only in spaces, not in base (the home Pi starts with).\n");
1019
- return io.exit(1);
1161
+ if (!checkSetup(scope.io, scope.setupSource)) return io.exit(2);
1162
+ if (ref.name === EVERY) {
1163
+ const names = listSpaces(scope.dir);
1164
+ if (names.length === 0) {
1165
+ io.stdout.write(`No spaces yet. Create one with pi-space new ${ref.profile}/<name>.\n`);
1166
+ return io.exit(0);
1020
1167
  }
1021
- if (!checkSpace(target.name, dir, io)) return io.exit(1);
1022
- const home = join(dir, target.name);
1023
- return io.exit(await spawnSetup(setupFor(target.name, home, io), target.name, home, io, profile));
1024
- }
1025
- const names = listSpaces(dir);
1026
- if (names.length === 0) {
1027
- io.stdout.write("No spaces yet. Create one with pi-space new <name>.\n");
1028
- return io.exit(0);
1168
+ const failed = [];
1169
+ await setupEach(scope, names, failed);
1170
+ return finishSetup(failed, io);
1029
1171
  }
1030
- const failed = [];
1031
- for (const name of names) {
1032
- io.stdout.write(`== ${name}\n`);
1033
- const home = join(dir, name);
1034
- if ((await spawnSetup(setupFor(name, home, io), name, home, io, profile)) !== 0) failed.push(name);
1172
+ if (ref.name === BASE) {
1173
+ io.stderr.write("pi-space: setup runs only in spaces, not in base (the home Pi starts with).\n");
1174
+ return io.exit(1);
1035
1175
  }
1176
+ if (!checkSpace(ref.name, scope.dir, io)) return io.exit(1);
1177
+ const home = join(scope.dir, ref.name);
1178
+ return io.exit(await spawnSetup(setupFor(ref.name, home, scope.io), ref.name, home, scope.io, scope.profile));
1179
+ }
1180
+
1181
+ function finishSetup(failed , io ) {
1036
1182
  if (failed.length > 0) {
1037
1183
  io.stderr.write(`pi-space: setup failed in: ${failed.join(", ")}\n`);
1038
1184
  return io.exit(1);
@@ -1040,71 +1186,47 @@ async function runSetup(target , dir , io , setupSour
1040
1186
  io.exit(0);
1041
1187
  }
1042
1188
 
1043
- /** A picker item and where it starts: a profile of the config file, or the settings of this call. */
1189
+ /** A picker item: `<profile>/<name>` shown, and the profile and space it starts. */
1044
1190
 
1045
-
1191
+
1192
+
1046
1193
 
1047
1194
 
1048
1195
  /**
1049
- * The choices of the picker: base and the spaces of this call, then, when the call names no profile, folder or
1050
- * command and there is a config file, those of every other profile, each under its name; a profile whose spaces
1051
- * folder is already listed is skipped. The space this Pi runs in (PI_SPACE_HOME) is the active one.
1196
+ * The choices of the picker: base and the spaces of every profile, as `<profile>/<name>`, in the order of
1197
+ * profileNames; under the profile's name as a header when there is more than one profile. A profile that shares a
1198
+ * folder with another is listed too (each runs its own command). The space this Pi runs in (PI_SPACE_HOME) is the active one.
1052
1199
  */
1053
- function pickerChoices(call , settings , callerIo ) {
1054
- const { dir, config, profile, io } = settings;
1055
- const activeHome = io.env.PI_SPACE_HOME;
1056
- const group = (groupName , profileName , spaces , baseHome ) =>
1057
- [BASE, ...listSpaces(spaces)].map((name) => {
1058
- const home = name === BASE ? baseHome : join(spaces, name);
1200
+ function pickerChoices(call ) {
1201
+ const activeHome = call.io.env.PI_SPACE_HOME;
1202
+ const scopes = allScopes(call);
1203
+ return scopes.flatMap(({ profile, dir }) =>
1204
+ [BASE, ...listSpaces(dir)].map((space) => {
1205
+ const home = space === BASE ? profile.home : join(dir, space);
1059
1206
  return {
1060
- name,
1061
- ...(groupName !== undefined ? { group: groupName } : {}),
1062
- ...(profileName !== undefined ? { profile: profileName } : {}),
1207
+ name: `${profile.name}/${space}`,
1208
+ ...(scopes.length > 1 ? { group: profile.name } : {}),
1209
+ profile: profile.name,
1210
+ space,
1063
1211
  ...(activeHome && samePath(home, activeHome) ? { active: true } : {}),
1064
1212
  };
1065
- });
1066
- const baseHome = profile?.home ?? (io.env.PI_SPACES_BASE_HOME || agentHome(io.env, io.home));
1067
- const others = config !== undefined && call.profile === undefined && call.dir === undefined && call.command === undefined
1068
- ? profileNames(config)
1069
- .map((name) => profileByName(config, name, callerIo.env, callerIo.home))
1070
- .filter((other, index, all) => !samePath(other.dir, dir) && all.findIndex((first) => samePath(first.dir, other.dir)) === index)
1071
- : [];
1072
- if (others.length === 0) return group(undefined, undefined, dir, baseHome);
1073
- // The current one keeps the settings of this call; its header is its profile's name.
1074
- const current = group(profile?.name ?? "current", undefined, dir, baseHome);
1075
- return [...current, ...others.flatMap((other) => group(other.name, other.name, other.dir, other.home))];
1213
+ }),
1214
+ );
1076
1215
  }
1077
1216
 
1078
1217
  /**
1079
- * A bare `pi-space` on a terminal: the picker, then Pi in the chosen space. A space of another profile starts
1080
- * with that profile's settings (and the --config and --setup of this call). Esc or the end of input exit 0; Ctrl-C exits 130.
1218
+ * A bare `pi-space` on a terminal: the picker over every profile, then Pi in the chosen space with that profile's
1219
+ * settings (and the --setup and --config of this call). Esc or the end of input exit 0; Ctrl-C exits 130.
1081
1220
  */
1082
- async function pick(call , settings , callerIo ) {
1083
- const { dir, commandSource } = settings;
1084
- const io = settings.io;
1221
+ async function pick(call ) {
1222
+ const { io } = call;
1085
1223
  if (!io.stdin.isTTY) {
1086
1224
  io.stderr.write(USAGE);
1087
1225
  return io.exit(2);
1088
1226
  }
1089
- // A bad command is reported before the list, not after the user has chosen.
1090
- try {
1091
- splitCommand(io.env.PI_SPACES_COMMAND ?? "");
1092
- } catch (error) {
1093
- io.stderr.write(`pi-space: ${commandSource}: ${(error ).message}.\n`);
1094
- return io.exit(2);
1095
- }
1096
- const { item, interrupted } = await runPicker(pickerChoices(call, settings, callerIo), io);
1227
+ const { item, interrupted } = await runPicker(pickerChoices(call), io);
1097
1228
  if (item === undefined) return io.exit(interrupted ? 130 : 0);
1098
1229
  const choice = item ;
1099
- if (choice.profile === undefined) return launch(choice.name, [], dir, io, commandSource);
1100
- let chosen ;
1101
- try {
1102
- chosen = resolveSettings({ ...(call.config !== undefined ? { config: call.config } : {}), ...(call.setup !== undefined ? { setup: call.setup } : {}), profile: choice.profile }, callerIo);
1103
- } catch (error) {
1104
- if (!(error instanceof ConfigError)) throw error;
1105
- io.stderr.write(`pi-space: ${error.message}\n`);
1106
- return io.exit(2);
1107
- }
1108
1230
  // Its command was not checked before the picker: launch reports an unclosed quote.
1109
- return launch(choice.name, [], chosen.dir, chosen.io, chosen.commandSource);
1231
+ return launch(choice.space, [], scopeOf(call, choice.profile));
1110
1232
  }