@jals1212/pi-spaces-cli 0.7.1 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/README.md +116 -87
  3. package/dist/cli.js +441 -311
  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,25 +747,27 @@ 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",
670
753
  " done",
754
+ // The spaces come first, as an unsorted group in pi-space's own (alphabetical) order; with the `sort false` style
755
+ // below, zsh and fzf-tab show every group in the order it is added here.
756
+ " (( $#spaces )) && _describe -t spaces 'space' spaces -V pi-space-spaces && ret=0",
671
757
  " (( $#cmds )) && _describe -t commands 'command' cmds && ret=0",
672
- " (( $#spaces )) && _describe -t spaces 'space' spaces && ret=0",
673
758
  " (( $#targets )) && _describe -t targets 'target' targets && ret=0",
674
- " (( $#profiles )) && _describe -t profiles 'profile' profiles && ret=0",
675
759
  " (( $#shells )) && _describe -t shells 'shell' shells && ret=0",
676
760
  " (( $#opts )) && _describe -t options 'option' opts && ret=0",
677
761
  " return ret",
678
762
  "}",
679
763
  "compdef _pi_space pi-space",
764
+ // pi-space already gives its candidates in order (spaces first, alphabetical); zsh and fzf-tab must not resort them.
765
+ "zstyle ':completion:*:pi-space:*' sort false",
680
766
  "",
681
767
  ].join("\n");
682
768
 
683
769
  /** The groups of a `__complete` line; `target` is --every, which stands in for a space name. */
684
-
770
+
685
771
 
686
772
  /** One `__complete` line; the description never holds a tab or a newline. */
687
773
  function candidate(group , value , text ) {
@@ -703,10 +789,11 @@ function complete(words , io ) {
703
789
  return io.exit(0);
704
790
  }
705
791
 
706
- /** The commands with their summary; in first place `completion` is left out (it still works typed in full), after --help it is not. */
792
+ /** The commands with their summary, in alphabetical order; in first place `completion` is left out (it still works typed in full), after --help it is not. */
707
793
  function commandCandidates(withCompletion ) {
708
794
  return Object.entries(COMMANDS)
709
795
  .filter(([name]) => withCompletion || name !== "completion")
796
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
710
797
  .map(([name, help]) => candidate("command", name, help.summary));
711
798
  }
712
799
 
@@ -719,26 +806,44 @@ function firstOptions(call , alone ) {
719
806
  return [...calls, ...general];
720
807
  }
721
808
 
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"))];
809
+ /** A profile and the names in its spaces folder, as `__complete` offers them. */
810
+
811
+
812
+
813
+
814
+
815
+
816
+
817
+ /** `<profile>/base`, if asked, then `<profile>/<name>`, for each profile in order. */
818
+ function spaceCandidates(profiles , withBase ) {
819
+ const spaces = profiles.flatMap((profile) => [
820
+ ...(withBase ? [{ value: `${profile.name}/${BASE}`, text: `base home of ${profile.name} (${profile.command})` }] : []),
821
+ ...profile.spaces.map((name) => ({ value: `${profile.name}/${name}`, text: `space of ${profile.name} (${profile.command})` })),
822
+ ]);
823
+ // Alphabetical by the whole name, whatever the order of the profiles in the file.
824
+ spaces.sort((a, b) => (a.value < b.value ? -1 : a.value > b.value ? 1 : 0));
825
+ return spaces.map(({ value, text }) => candidate("space", value, text));
826
+ }
827
+
828
+ /** The `--every` targets: one for every profile, then one `<profile>/--every` for each. */
829
+ function everyCandidates(profiles , every , each ) {
830
+ return [candidate("target", "--every", every), ...profiles.map((profile) => candidate("target", `${profile.name}/--every`, each(profile.name)))];
725
831
  }
726
832
 
727
833
  /** 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);
834
+ function commandArguments(command , profiles ) {
730
835
  const options = [...COMMANDS[command].options, HELP_OPTION].map((option) => candidate("option", option.flag, option.text));
731
836
  switch (command) {
732
837
  case "remove":
733
838
  case "rename":
734
- return [...spaceCandidates(spaces, false), ...options];
839
+ return [...spaceCandidates(profiles, false), ...options];
735
840
  case "setup":
736
- return [...spaceCandidates(spaces, false), every("every space"), ...options];
841
+ return [...spaceCandidates(profiles, false), ...everyCandidates(profiles, "every space of every profile with a setup command", (name) => `every space of ${name}`), ...options];
737
842
  case "list":
738
843
  case "update":
739
844
  case "install":
740
845
  case "uninstall":
741
- return [...spaceCandidates(spaces, true), every("every space (base first)"), ...options];
846
+ return [...spaceCandidates(profiles, true), ...everyCandidates(profiles, "every profile, base first", (name) => `base and every space of ${name}`), ...options];
742
847
  case "completion":
743
848
  return [...SHELLS.map((shell) => candidate("shell", shell, `the ${shell} completion script`)), ...options];
744
849
  default:
@@ -752,37 +857,29 @@ function candidates(words , io ) {
752
857
  while (Object.hasOwn(CALL_OPTIONS, words[index] ?? "")) {
753
858
  const option = words[index] ;
754
859
  // The option is the last word: the cursor is on its value.
755
- if (index + 1 >= words.length) return optionValues(option, call, io);
860
+ if (index + 1 >= words.length) return optionValues(option);
756
861
  call[CALL_OPTIONS[option]] = words[index + 1];
757
862
  index += 2;
758
863
  }
759
- const { dir } = resolveSettings(call, io);
864
+ const configFile = call.config !== undefined ? resolve(call.config) : configPath(io.env, io.home);
865
+ const config = loadConfig(configFile);
866
+ // A --dir typed before the cursor is read as the spaces folder of every profile.
867
+ const dirOverride = call.dir === undefined ? undefined : resolve(call.dir);
868
+ const profiles = profileNames(config).map((name) => {
869
+ const profile = profileByName(config, name, io.env, io.home);
870
+ return { name, command: profile.command ?? "pi", spaces: listSpaces(dirOverride ?? profile.dir) };
871
+ });
760
872
  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)];
873
+ if (rest.length === 0) return [...spaceCandidates(profiles, true), ...commandCandidates(false), ...firstOptions(call, index === 0)];
763
874
  if (rest.length > 1) return [];
764
875
  if (isHelpFlag(rest[0])) return commandCandidates(true);
765
876
  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 [];
877
+ return commandArguments(rest[0], profiles);
777
878
  }
778
879
 
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
- }
880
+ /** The candidates for the value of a call option: the shell's files for a path, nothing for a command. */
881
+ function optionValues(option ) {
882
+ return option === "--config" || option === "--dir" ? [FILES] : [];
786
883
  }
787
884
 
788
885
  /** Prints the reason and returns false when the setup command has an unclosed quote. */
@@ -797,34 +894,35 @@ function checkSetup(io , source ) {
797
894
  }
798
895
 
799
896
  /** 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 ) {
897
+ function spawnSetup(setup , name , home , io , profile ) {
801
898
  // 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 });
899
+ const spaceEnv = spaceEnvFor(profile, home, name);
900
+ 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
901
  }
805
902
 
806
- /** The setup command for one space, from PI_SPACES_SETUP after the call options; checkSetup has accepted it. */
903
+ /** The setup command for one space, from PI_SPACES_SETUP of this profile's io; checkSetup has accepted it. */
807
904
  function setupFor(name , home , io ) {
808
905
  return setupCommand(io.env.PI_SPACES_SETUP ?? "", home, name);
809
906
  }
810
907
 
811
- async function create(name , dir , io , setupSource , profile ) {
908
+ async function create(name , scope ) {
909
+ const { io, dir, profile } = scope;
812
910
  const withSetup = hasSetupCommand(io.env);
813
911
  // A bad setup command is reported before anything is created.
814
- if (withSetup && !checkSetup(io, setupSource)) return io.exit(2);
912
+ if (withSetup && !checkSetup(io, scope.setupSource)) return io.exit(2);
815
913
  try {
816
- const path = createSpace(dir, name, newSpaceDeclaration(io.env, io.home, profile?.home));
914
+ const path = createSpace(dir, name, newSpaceDeclaration(io.env, io.home, profile.home));
817
915
  io.stdout.write(`Created space "${name}" at ${path}.\n`);
818
916
  if (withSetup) {
819
917
  const setup = setupFor(name, path, io);
820
918
  io.stdout.write(`Setting it up: ${[setup.program, ...setup.args].join(" ")}\n`);
821
919
  const status = await spawnSetup(setup, name, path, io, profile);
822
920
  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`);
921
+ 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
922
  return io.exit(status);
825
923
  }
826
924
  }
827
- io.stdout.write(`Start Pi in it with: pi-space ${name}\n`);
925
+ io.stdout.write(`Start Pi in it with: pi-space ${profile.name}/${name}\n`);
828
926
  return io.exit(0);
829
927
  } catch (error) {
830
928
  // A SpaceError is a rule (a bad or taken name); anything else is the filesystem, such as no permission.
@@ -833,13 +931,14 @@ async function create(name , dir , io , setupSource ,
833
931
  }
834
932
  }
835
933
 
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));
934
+ /** The base homes a space must not be: the one Pi started with, and the profile's. */
935
+ function protectedHomes(io , profile ) {
936
+ return [io.env.PI_SPACES_BASE_HOME, profile.home].filter((home) => Boolean(home));
839
937
  }
840
938
 
841
939
  /** 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 ) {
940
+ function rename(from , to , scope ) {
941
+ const { io, dir, profile } = scope;
843
942
  try {
844
943
  const protect = protectedHomes(io, profile);
845
944
  renameSpace(dir, from, to, agentHome(io.env, io.home), protect, io.home);
@@ -855,7 +954,8 @@ function rename(from , to , dir , io , profile
855
954
  * Without --yes it asks to remove ([y/N]), then, without --no-backup, whether to keep a backup ([Y/n]).
856
955
  * With --yes it asks nothing and keeps a backup unless --no-backup.
857
956
  */
858
- async function remove(name , yes , backup , dir , io , profile ) {
957
+ async function remove(name , yes , backup , scope ) {
958
+ const { io, dir, profile } = scope;
859
959
  const current = agentHome(io.env, io.home);
860
960
  const protect = protectedHomes(io, profile);
861
961
  let reader ;
@@ -864,7 +964,7 @@ async function remove(name , yes , backup , dir ,
864
964
  const path = checkRemovable(dir, name, current, protect, io.home);
865
965
  let keep = backup;
866
966
  if (!yes) {
867
- if (!io.stdin.isTTY) throw new SpaceError(`Removing a space needs confirmation; run pi-space remove ${name} --yes.`);
967
+ if (!io.stdin.isTTY) throw new SpaceError(`Removing a space needs confirmation; run pi-space remove ${profile.name}/${name} --yes.`);
868
968
  // One reader for every question, so an answer typed ahead is not lost between them.
869
969
  reader = lineReader(io.stdin);
870
970
  io.stdout.write(`Remove space "${name}"? This deletes ${path} and everything in it: sign-ins, packages, settings and sessions. [y/N] `);
@@ -961,40 +1061,63 @@ async function spawnPi(program , programArgs , io , env
961
1061
  return result.status ?? 1;
962
1062
  }
963
1063
 
964
- async function launch(name , args , dir , io , commandSource ) {
965
- if (!checkSpace(name, dir, io)) return io.exit(1);
1064
+ async function launch(name , args , scope ) {
1065
+ const { io } = scope;
1066
+ if (!checkSpace(name, scope.dir, io)) return io.exit(1);
966
1067
  let launched ;
967
1068
  try {
968
1069
  launched = launchCommand(io.env, name, args);
969
1070
  } catch (error) {
970
- io.stderr.write(`pi-space: ${commandSource}: ${(error ).message}.\n`);
1071
+ io.stderr.write(`pi-space: ${scope.commandSource}: ${(error ).message}.\n`);
971
1072
  return io.exit(2);
972
1073
  }
973
1074
  io.exit(await spawnPi(launched.program, launched.args, io, io.childEnv));
974
1075
  }
975
1076
 
976
1077
  /**
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.
1078
+ * Runs one of Pi's package commands in a space, in base and then every space of a profile (`<profile>/--every`),
1079
+ * or in every profile (`--every`), through the same Pi (`--space <name> <verb> [args...]`): the extension relaunches
1080
+ * it in that home, so the home is never set here. With --every each run gets a `== <profile>/<name>` header, one
1081
+ * failure does not stop the rest, and the status is 1 if any failed.
980
1082
  */
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 ;
1083
+ async function runPackages(verb , target , args , call ) {
1084
+ const { io } = call;
1085
+ let scopes ;
1086
+ if ("every" in target) {
1087
+ scopes = allScopes(call);
1088
+ } else {
1089
+ const ref = qualify(target.name, call);
1090
+ if (!ref) return;
1091
+ const scope = scopeOf(call, ref.profile);
1092
+ if (ref.name !== EVERY) {
1093
+ if (!checkSpace(ref.name, scope.dir, io)) return io.exit(1);
1094
+ let launched ;
1095
+ try {
1096
+ launched = launchCommand(scope.io.env, ref.name, [verb, ...args]);
1097
+ } catch (error) {
1098
+ io.stderr.write(`pi-space: ${scope.commandSource}: ${(error ).message}.\n`);
1099
+ return io.exit(2);
1100
+ }
1101
+ return io.exit(await spawnPi(launched.program, launched.args, scope.io, scope.io.childEnv));
1102
+ }
1103
+ scopes = [scope];
1104
+ }
1105
+ // Every command is checked before the first one runs, so a bad one is reported once, before any header.
1106
+ for (const scope of scopes) {
987
1107
  try {
988
- launched = launchCommand(io.env, name, [verb, ...args]);
1108
+ launchCommand(scope.io.env, BASE, []);
989
1109
  } 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`);
1110
+ io.stderr.write(`pi-space: ${scope.commandSource}: ${(error ).message}.\n`);
992
1111
  return io.exit(2);
993
1112
  }
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);
1113
+ }
1114
+ const failed = [];
1115
+ for (const scope of scopes) {
1116
+ for (const name of [BASE, ...listSpaces(scope.dir)]) {
1117
+ const launched = launchCommand(scope.io.env, name, [verb, ...args]);
1118
+ io.stdout.write(`== ${scope.profile.name}/${name}\n`);
1119
+ if ((await spawnPi(launched.program, launched.args, scope.io, scope.io.childEnv)) !== 0) failed.push(`${scope.profile.name}/${name}`);
1120
+ }
998
1121
  }
999
1122
  if (failed.length > 0) {
1000
1123
  io.stderr.write(`pi-space: ${verb} failed in: ${failed.join(", ")}\n`);
@@ -1003,36 +1126,67 @@ async function runPackages(verb , target , args
1003
1126
  io.exit(0);
1004
1127
  }
1005
1128
 
1129
+ /** Runs the setup command of a profile in each of `names`, with a `== <profile>/<name>` header; the failures are added to `failed`. */
1130
+ async function setupEach(scope , names , failed ) {
1131
+ for (const name of names) {
1132
+ scope.io.stdout.write(`== ${scope.profile.name}/${name}\n`);
1133
+ const home = join(scope.dir, name);
1134
+ if ((await spawnSetup(setupFor(name, home, scope.io), name, home, scope.io, scope.profile)) !== 0) failed.push(`${scope.profile.name}/${name}`);
1135
+ }
1136
+ }
1137
+
1006
1138
  /**
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.
1139
+ * Runs the setup command in a space, in every space of a profile (`<profile>/--every`), or in every space of every
1140
+ * profile that has a setup command (`--every`); never in base. With --every each run gets a `== <profile>/<name>`
1141
+ * header, one failure does not stop the rest, and the status is 1 if any failed.
1009
1142
  */
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");
1143
+ async function runSetup(target , call ) {
1144
+ const { io } = call;
1145
+ const noSetup = (name ) => `pi-space: Profile "${name}" has no setup command; give it a "setup" in the config file, or give --setup <cmd>.\n`;
1146
+ if ("every" in target) {
1147
+ const scopes = allScopes(call).filter((scope) => hasSetupCommand(scope.io.env));
1148
+ if (scopes.length === 0) {
1149
+ io.stderr.write('pi-space: No profile has a setup command; give one a "setup" in the config file, or give --setup <cmd>.\n');
1150
+ return io.exit(2);
1151
+ }
1152
+ // Every setup command is checked before the first one runs.
1153
+ if (!scopes.every((scope) => checkSetup(scope.io, scope.setupSource))) return io.exit(2);
1154
+ if (scopes.every((scope) => listSpaces(scope.dir).length === 0)) {
1155
+ io.stdout.write("No spaces yet. Create one with pi-space new <profile>/<name>.\n");
1156
+ return io.exit(0);
1157
+ }
1158
+ const failed = [];
1159
+ for (const scope of scopes) await setupEach(scope, listSpaces(scope.dir), failed);
1160
+ return finishSetup(failed, io);
1161
+ }
1162
+ const ref = qualify(target.name, call);
1163
+ if (!ref) return;
1164
+ const scope = scopeOf(call, ref.profile);
1165
+ if (!hasSetupCommand(scope.io.env)) {
1166
+ io.stderr.write(noSetup(ref.profile));
1013
1167
  return io.exit(2);
1014
1168
  }
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);
1169
+ if (!checkSetup(scope.io, scope.setupSource)) return io.exit(2);
1170
+ if (ref.name === EVERY) {
1171
+ const names = listSpaces(scope.dir);
1172
+ if (names.length === 0) {
1173
+ io.stdout.write(`No spaces yet. Create one with pi-space new ${ref.profile}/<name>.\n`);
1174
+ return io.exit(0);
1020
1175
  }
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);
1176
+ const failed = [];
1177
+ await setupEach(scope, names, failed);
1178
+ return finishSetup(failed, io);
1029
1179
  }
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);
1180
+ if (ref.name === BASE) {
1181
+ io.stderr.write("pi-space: setup runs only in spaces, not in base (the home Pi starts with).\n");
1182
+ return io.exit(1);
1035
1183
  }
1184
+ if (!checkSpace(ref.name, scope.dir, io)) return io.exit(1);
1185
+ const home = join(scope.dir, ref.name);
1186
+ return io.exit(await spawnSetup(setupFor(ref.name, home, scope.io), ref.name, home, scope.io, scope.profile));
1187
+ }
1188
+
1189
+ function finishSetup(failed , io ) {
1036
1190
  if (failed.length > 0) {
1037
1191
  io.stderr.write(`pi-space: setup failed in: ${failed.join(", ")}\n`);
1038
1192
  return io.exit(1);
@@ -1040,71 +1194,47 @@ async function runSetup(target , dir , io , setupSour
1040
1194
  io.exit(0);
1041
1195
  }
1042
1196
 
1043
- /** A picker item and where it starts: a profile of the config file, or the settings of this call. */
1197
+ /** A picker item: `<profile>/<name>` shown, and the profile and space it starts. */
1044
1198
 
1045
-
1199
+
1200
+
1046
1201
 
1047
1202
 
1048
1203
  /**
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.
1204
+ * The choices of the picker: base and the spaces of every profile, as `<profile>/<name>`, in the order of
1205
+ * profileNames; under the profile's name as a header when there is more than one profile. A profile that shares a
1206
+ * 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
1207
  */
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);
1208
+ function pickerChoices(call ) {
1209
+ const activeHome = call.io.env.PI_SPACE_HOME;
1210
+ const scopes = allScopes(call);
1211
+ return scopes.flatMap(({ profile, dir }) =>
1212
+ [BASE, ...listSpaces(dir)].map((space) => {
1213
+ const home = space === BASE ? profile.home : join(dir, space);
1059
1214
  return {
1060
- name,
1061
- ...(groupName !== undefined ? { group: groupName } : {}),
1062
- ...(profileName !== undefined ? { profile: profileName } : {}),
1215
+ name: `${profile.name}/${space}`,
1216
+ ...(scopes.length > 1 ? { group: profile.name } : {}),
1217
+ profile: profile.name,
1218
+ space,
1063
1219
  ...(activeHome && samePath(home, activeHome) ? { active: true } : {}),
1064
1220
  };
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))];
1221
+ }),
1222
+ );
1076
1223
  }
1077
1224
 
1078
1225
  /**
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.
1226
+ * A bare `pi-space` on a terminal: the picker over every profile, then Pi in the chosen space with that profile's
1227
+ * settings (and the --setup and --config of this call). Esc or the end of input exit 0; Ctrl-C exits 130.
1081
1228
  */
1082
- async function pick(call , settings , callerIo ) {
1083
- const { dir, commandSource } = settings;
1084
- const io = settings.io;
1229
+ async function pick(call ) {
1230
+ const { io } = call;
1085
1231
  if (!io.stdin.isTTY) {
1086
1232
  io.stderr.write(USAGE);
1087
1233
  return io.exit(2);
1088
1234
  }
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);
1235
+ const { item, interrupted } = await runPicker(pickerChoices(call), io);
1097
1236
  if (item === undefined) return io.exit(interrupted ? 130 : 0);
1098
1237
  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
1238
  // Its command was not checked before the picker: launch reports an unclosed quote.
1109
- return launch(choice.name, [], chosen.dir, chosen.io, chosen.commandSource);
1239
+ return launch(choice.space, [], scopeOf(call, choice.profile));
1110
1240
  }