@jals1212/pi-spaces-cli 0.3.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,64 @@ commands and options and a patch version fixes.
6
6
 
7
7
  ## Unreleased
8
8
 
9
+ ## 0.5.0
10
+
11
+ ### Added
12
+
13
+ - A setup command for new spaces: `--setup <cmd>` or `PI_SPACES_SETUP`. `pi-space
14
+ new` runs it in the new space, and `pi-space setup <name|--every>` runs it
15
+ again (never in `base`). `{home}` and `{name}` become the space's folder and
16
+ name, and it runs with `PI_CODING_AGENT_DIR`, `PI_SPACE_HOME` and `PI_SPACE`
17
+ set to the space, so a program that runs Pi can install what it needs there,
18
+ for example `pi-space --setup 'my-launcher --home {home} setup' new work`. A
19
+ failed setup keeps the space and names the command to run it again.
20
+ - `--setup` reaches the started Pi as `PI_SPACES_SETUP`.
21
+
22
+ ### Fixed
23
+
24
+ - A blank `--command` or `--dir` (only spaces) is a usage error, as an empty one
25
+ already was, instead of being taken as given.
26
+
27
+ ### Changed
28
+
29
+ - Needs `@jals1212/pi-spaces` 0.5.0, which reserves the name `setup`.
30
+
31
+ ## 0.4.0
32
+
33
+ ### Added
34
+
35
+ - `pi-space list <name|--every>` lists the packages of a space, and
36
+ `pi-space update`, `install` and `uninstall` take a space and run Pi's
37
+ command of that name in it, for example `pi-space update work --extensions`
38
+ or `pi-space install work npm:some-package`. What follows is Pi's own:
39
+ `install` and `uninstall` need a source, and `update` may have none. The
40
+ command runs through the extension (`pi --space <name> <command> ...`) and
41
+ its exit status is `pi-space`'s. Needs `@jals1212/pi-spaces` 0.4.0 or later
42
+ in the base home.
43
+ - `--every` takes the place of the space: the command runs in `base`, then in
44
+ every space in turn, each after a `== <name>` line. A failure does not stop
45
+ the rest; the exit status is 1 if any failed, and the last line on stderr
46
+ names them: `pi-space: update failed in: base, work`.
47
+ - `pi-space rename <old> <new>` renames a space, as `/space rename` does, with
48
+ the same refusals as `remove`.
49
+
50
+ ### Changed
51
+
52
+ - `pi-space new <name>` and `pi-space remove <name> [--yes] [--no-backup]` are
53
+ commands, like `list`, `rename` and Pi's own. They do what `--new` and
54
+ `--remove` did, with the same questions and backup.
55
+ - The command words `list`, `new`, `remove`, `rename`, `switch`, `update`,
56
+ `install` and `uninstall` can no longer name a space, so `pi-space <name>`
57
+ never reads as a command (see the extension's 0.4.0).
58
+ - Requires `@jals1212/pi-spaces` 0.4.0 or later.
59
+
60
+ ### Removed
61
+
62
+ - The options `--new` and `--remove`. They exit with status 2 and name the
63
+ command that replaced them: `pi-space --new work` →
64
+ `pi-space new <name>`, `pi-space --remove work` →
65
+ `pi-space remove <name> [--yes] [--no-backup]`.
66
+
9
67
  ## 0.3.1
10
68
 
11
69
  ### Fixed
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # pi-space
2
2
 
3
- The `pi-space` command: pick a [Pi](https://pi.dev) space and start Pi in it, from your shell. It is the optional companion of the Pi extension [`@jals1212/pi-spaces`](https://github.com/jals1212/pi-spaces#readme), which holds the spaces themselves.
3
+ The `pi-space` command: pick a [Pi](https://pi.dev) space and start Pi in it, create, set up, rename and remove spaces, and install, update and list the packages of a space, from your shell. It is the optional companion of the Pi extension [`@jals1212/pi-spaces`](https://github.com/jals1212/pi-spaces#readme), which holds the spaces themselves.
4
4
 
5
5
  ## Install
6
6
 
@@ -8,31 +8,54 @@ The `pi-space` command: pick a [Pi](https://pi.dev) space and start Pi in it, fr
8
8
  npm install -g @jals1212/pi-spaces-cli
9
9
  ```
10
10
 
11
- Node 20 or later. The Pi extension `@jals1212/pi-spaces` must be installed in the home the command opens: `pi-space` only runs `pi --space <name>`, and the extension does the switch.
11
+ Node 20 or later. The Pi extension `@jals1212/pi-spaces` must be installed in the home the command opens: `pi-space` only runs `pi --space <name> [command]`, and the extension does the switch. For the package commands (`list <name>`, `update`, `install`, `uninstall`) the extension in the base home must be **0.4.0 or later**: an older one starts Pi in the space instead of running the command.
12
12
 
13
13
  ## Commands
14
14
 
15
+ The command words are reserved: a space can never be named `list`, `new`, `remove`, `rename`, `switch`, `update`, `install`, `uninstall` or `setup`, so any other first word is a space name.
16
+
15
17
  | Command | What it does |
16
18
  | --- | --- |
17
19
  | `pi-space` | Shows a numbered list of spaces and starts Pi in the one you pick. |
18
20
  | `pi-space <name> [pi args...]` | Starts Pi in that space (`base` for the home Pi starts with). |
19
- | `pi-space list` | Lists the spaces. A space named `list` can still be opened from the picker. |
20
- | `pi-space --new <name>` | Creates an empty space, as `/space new <name>` does in Pi. It does not start Pi. |
21
- | `pi-space --remove <name> [--yes] [--no-backup]` | Deletes a space, as `/space remove <name>` does in Pi. It asks first (`[y/N]`), then whether to keep a backup in `<spaces folder>/.backups` (`[Y/n]`: Enter keeps one). `--yes` skips the questions and keeps a backup, and is required without a terminal; `--no-backup` deletes without one. |
21
+ | `pi-space list` | Lists the spaces. |
22
+ | `pi-space list <name\|--every>` | Lists the packages of a space (Pi's `list`). |
23
+ | `pi-space new <name>` | Creates an empty space, as `/space new <name>` does in Pi, then runs the setup command in it when there is one (see [Preparing a new space](#preparing-a-new-space)). It does not start Pi. |
24
+ | `pi-space setup <name\|--every>` | Runs the setup command in a space, or in every space. Never in `base`. |
25
+ | `pi-space rename <old> <new>` | Renames a space, as `/space rename` does in Pi. It refuses what `remove` refuses (see below). |
26
+ | `pi-space remove <name> [--yes] [--no-backup]` | Deletes a space, as `/space remove <name>` does in Pi. It asks first (`[y/N]`), then whether to keep a backup in `<spaces folder>/.backups` (`[Y/n]`: Enter keeps one). `--yes` skips the questions and keeps a backup, and is required without a terminal; `--no-backup` deletes without one. |
27
+ | `pi-space update <name\|--every> [pi update options...]` | Runs Pi's `update` in a space. What follows is Pi's: `--extensions`, `--all`, a source. With nothing after the name it is Pi's default. |
28
+ | `pi-space install <name\|--every> <source> [pi install options...]` | Runs Pi's `install` in a space. The source is required. |
29
+ | `pi-space uninstall <name\|--every> <source> [pi options...]` | Runs Pi's `uninstall` (its alias of `remove`) in a space. The source is required. |
22
30
  | `pi-space --help` | Shows the usage. |
23
31
  | `pi-space --version` | Prints the version. |
24
32
 
33
+ `--new` and `--remove`, the options of 0.3, were replaced by `new` and `remove`; they exit with status 2 and name the command to use.
34
+
35
+ ### Packages in a space, or in every space
36
+
37
+ ```sh
38
+ pi-space list work # the packages of "work"
39
+ pi-space install work npm:some-package # install in "work" only
40
+ pi-space update base --extensions # update the extensions of the base home
41
+ pi-space update --every # base, then every space
42
+ ```
43
+
44
+ A package command runs `pi --space <name> <command> [args...]` with Pi's stdio, through `$PI_SPACES_COMMAND` (or `--command`), and ends with the exit status of that Pi. The extension relaunches Pi in the space's home, so the command works on that home's `settings.json` and packages; `pi-space` never sets `PI_CODING_AGENT_DIR` itself. `<name>` is `base` or an existing space; anything else exits with status 1 before Pi starts.
45
+
46
+ `--every` takes the place of the name. The command runs in `base` first, then in every space in order, one after the other, each after a `== <name>` line on stdout. A failure does not stop the rest. The exit status is 0 if every run succeeded, otherwise 1, and the last line on stderr names the spaces that failed: `pi-space: update failed in: base, work`.
47
+
25
48
  A new space gets a `settings.json` that declares the extension, so `/space` works inside it: the same declaration as in the base home's `settings.json` (`PI_SPACES_BASE_HOME`, else `PI_CODING_AGENT_DIR`, else `~/.pi/agent`), or `npm:@jals1212/pi-spaces` when the base home does not declare it. A local path counts when it is a copy of the extension: a folder whose `package.json` is named `@jals1212/pi-spaces`.
26
49
 
27
- `--remove` refuses `base`, the space you are in, a space that holds the base home, and anything that is not a real folder inside the spaces folder, before it asks. A backup is the whole space folder, moved to `.backups/<name>-<UTC time>`; move it back to restore it. See [what it leaves on your machine](https://github.com/jals1212/pi-spaces#what-it-leaves-on-your-machine).
50
+ `remove` and `rename` refuse `base`, the space you are in, a space that holds the base home, and anything that is not a real folder inside the spaces folder, before it asks. A backup is the whole space folder, moved to `.backups/<name>-<UTC time>`; move it back to restore it. See [what it leaves on your machine](https://github.com/jals1212/pi-spaces#what-it-leaves-on-your-machine).
28
51
 
29
52
  ## Another program that runs Pi
30
53
 
31
- To start Pi through another program for one call, give `--command` and `--dir` before the rest; the words of `--command` come before `--space`. The words are split on spaces; double or single quotes keep a path with spaces in one word. Such a program usually has its own home, so `--dir` points at its spaces folder, and the Pi it starts gets that folder as `PI_SPACES_DIR` too. Nothing is exported, so a plain `pi` in the same shell is not affected:
54
+ To start Pi through another program for one call, give `--command` and `--dir` before the rest; the words of `--command` come before `--space`. The words are split on spaces; double or single quotes keep a path with spaces in one word. Such a program usually has its own home, so `--dir` points at its spaces folder, and the Pi it starts gets that folder as `PI_SPACES_DIR` too. Nothing is exported, so a plain `pi` in the same shell is not affected. Every command accepts these options and uses the ones it needs (`list` ignores `--command`, `rename` ignores `--setup`), so one alias can give them all:
32
55
 
33
56
  ```sh
34
57
  pi-space --command "my-launcher --" --dir ~/.my-launcher/spaces work # → my-launcher -- --space work
35
- pi-space --dir ~/.my-launcher/spaces --new work # create one in that folder
58
+ pi-space --dir ~/.my-launcher/spaces new work # create one in that folder
36
59
  pi-space --command '"/opt/my launcher/run" --' work # a path with spaces, in quotes
37
60
  ```
38
61
 
@@ -49,15 +72,30 @@ export PI_SPACES_DIR=~/.my-launcher/spaces # the spaces next to that program's
49
72
  | --- | --- |
50
73
  | `PI_SPACES_COMMAND` | The program to run, split on spaces (double or single quotes keep a path with spaces in one word, as in `"/opt/my launcher/run" --`), before `--space <name>`. Default: `pi`. `--command` wins for one call. |
51
74
  | `PI_SPACES_DIR` | The spaces folder. A relative value is taken from your home folder. Default: `spaces` next to the base home. `--dir` wins for one call; a relative `--dir` is taken from the current folder. |
75
+ | `PI_SPACES_SETUP` | The command that prepares a new space (see [Preparing a new space](#preparing-a-new-space)). Unset or blank: none. `--setup` wins for one call. |
52
76
  | `PI_CODING_AGENT_DIR` | Pi's agent home; its parent holds the default spaces folder. Default: `~/.pi/agent`. |
53
77
 
78
+ ## Preparing a new space
79
+
80
+ A new space starts clean: only a `settings.json` that declares the extension. A program that runs Pi may need its own packages in every home it opens. Give its setup command with `--setup` (or `PI_SPACES_SETUP`), and `pi-space new` runs it right after it creates the space. The words are split as for `--command`, and in each word `{home}` becomes the space's folder and `{name}` its name. The command runs with your stdio, and with `PI_CODING_AGENT_DIR` and `PI_SPACE_HOME` set to the space's folder and `PI_SPACE` to its name:
81
+
82
+ ```sh
83
+ pi-space --setup 'my-launcher --home {home} setup' new work # create "work", then: my-launcher --home ~/.pi/spaces/work setup
84
+ pi-space --setup 'pi install npm:some-package' new work # plain Pi: install a package in the new space
85
+ pi-space --setup 'my-launcher --home {home} setup' setup --every # run it again in every space, for example after an update
86
+ ```
87
+
88
+ When the setup fails, the space is kept and `pi-space` exits with the setup's status, naming `pi-space setup <name>` to run it again. `pi-space setup <name>` ends with the setup's status. With `--every` it runs in every space in order, never in `base`, each after a `== <name>` line, and exits as the package commands do. `base` is the home the program prepares itself.
89
+
90
+ Only those three variables change. A variable that points into the base home stays as it is, so a setup that needs one per space sets it itself, for example `env MY_CONFIG={home}/config my-launcher --home {home} setup`. `--setup` also reaches the Pi that `pi-space` starts as `PI_SPACES_SETUP`, so `/space new` there names `pi-space setup <name>`.
91
+
54
92
  ## Exit status
55
93
 
56
94
  | Status | Meaning |
57
95
  | --- | --- |
58
- | 0 … | Pi's own exit status. |
59
- | 1 | An unknown or invalid space, a name `--new` cannot use (taken, `base` or invalid), a space `--remove` cannot delete, `--remove` without `--yes` and no terminal, an out-of-range pick, or the program could not start. |
60
- | 2 | No arguments and no terminal to pick in, `--new` or `--remove` without exactly one name, or `--command` or `--dir` without a value or given twice: the usage is printed. A `--command` or `PI_SPACES_COMMAND` with an unclosed quote: the problem is printed. |
96
+ | 0 … | Pi's own exit status, for a space and for a package command in one space; the setup's exit status for `setup <name>` and for a `new` whose setup failed. |
97
+ | 1 | An unknown or invalid space, a name `new` or `rename` cannot use (taken, reserved or invalid), a space `remove` or `rename` cannot touch, `remove` without `--yes` and no terminal, an out-of-range pick, the program could not start, or `--every` and at least one run failed (the failed spaces are on the last line of stderr), or `setup base`, since setup runs only in spaces. |
98
+ | 2 | No arguments and no terminal to pick in, a command with the wrong number of names (`new`, `remove`, `rename`), `install` or `uninstall` without a source, a package command without a space or `--every`, or `--command`, `--dir` or `--setup` without a value (or a blank one) or given twice: the usage is printed. `setup` without a setup command: the problem is printed. The old `--new` and `--remove`: the replacing command is printed. A `--command`, `PI_SPACES_COMMAND`, `--setup` or `PI_SPACES_SETUP` with an unclosed quote: the problem is printed, and nothing is created or started. |
61
99
 
62
100
  ## More
63
101
 
package/dist/cli.js CHANGED
@@ -1,5 +1,6 @@
1
- // The optional `pi-space` command: pick a space and start Pi in it.
2
- // It only passes `--space <name>`; the extension does the switch, so it never sets PI_CODING_AGENT_DIR.
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.
3
4
  import { readFileSync } from "node:fs";
4
5
  import { homedir } from "node:os";
5
6
  import { dirname, join, resolve } from "node:path";
@@ -14,43 +15,73 @@ import {
14
15
  createSpace,
15
16
 
16
17
  findOwnDeclaration,
18
+ hasSetupCommand,
17
19
  listSpaces,
18
20
 
19
21
  removeSpace,
22
+ renameSpace,
20
23
  SpaceError,
21
24
  spacesDir,
22
25
  validateName,
23
26
  } from "@jals1212/pi-spaces/spaces";
24
27
 
25
- export const USAGE = `Usage: pi-space [--command <cmd>] [--dir <path>] [<name> [pi args...] | list | --new <name> | --remove <name> [--yes] [--no-backup]]
28
+ export const USAGE = `Usage: pi-space [--command <cmd>] [--dir <path>] [--setup <cmd>] [<name> [pi args...] | <command> ...]
26
29
  pi-space --help | --version
27
- pi-space pick a space and start Pi in it
28
- pi-space <name> ... start Pi in a space ("base" for the home Pi starts with)
29
- pi-space list list the spaces
30
- pi-space --new <name> create a space
31
- pi-space --remove <name> delete a space and everything in it, after asking, and keep a backup in .backups unless told not to
32
- (--yes skips the questions; --no-backup deletes without a backup)
33
- pi-space --version print the version
34
- Options for this call only:
35
- --command <cmd> the program to run instead of $PI_SPACES_COMMAND (default: pi), e.g. "my-launcher --"
36
- --dir <path> the spaces folder instead of $PI_SPACES_DIR; the Pi it starts gets it too
30
+ pi-space pick a space and start Pi in it
31
+ pi-space <name> [pi args...] start Pi in a space ("base" for the home Pi starts with)
32
+ pi-space list list the spaces
33
+ pi-space list <name|--every> list the packages of a space
34
+ pi-space new <name> create a space, then run the setup command in it when there is one
35
+ pi-space setup <name|--every> run the setup command in a space, or in every space (never in base)
36
+ pi-space rename <old> <new> rename a space
37
+ pi-space remove <name> delete a space and everything in it, after asking, and keep a backup in .backups unless told not to
38
+ (--yes skips the questions; --no-backup deletes without a backup)
39
+ pi-space update <name|--every> update the packages of a space (more words go to Pi: --extensions, --all, a source)
40
+ pi-space install <name|--every> <source> install a package in a space (more words go to Pi)
41
+ pi-space uninstall <name|--every> <source> remove a package from a space (more words go to Pi)
42
+ pi-space --help print this help
43
+ pi-space --version print the version
44
+ --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.
45
+ Options before the command, for this call only:
46
+ --command <cmd> the program to run instead of $PI_SPACES_COMMAND (default: pi), e.g. "my-launcher --"
47
+ --dir <path> the spaces folder instead of $PI_SPACES_DIR; the Pi it starts gets it too
48
+ --setup <cmd> the setup command instead of $PI_SPACES_SETUP; {home} and {name} become the space's folder and name
37
49
  `;
38
50
 
39
51
  /** This package's version; package.json sits one folder above both src/ and dist/. */
40
52
  export const VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
41
53
 
42
- /** --command and --dir: for this call only, so nothing has to be exported into the shell. */
54
+ /** --command, --dir and --setup: for this call only, so nothing has to be exported into the shell. */
43
55
 
44
56
 
45
57
 
58
+
46
59
 
47
60
 
61
+ /** The Pi commands that pi-space runs in one space or in every space. */
62
+
63
+
64
+ /** A space name (or `base`), or every space: base first, then the spaces in order. */
65
+
66
+
67
+ const PACKAGE_VERBS = ["list", "update", "install", "uninstall"];
68
+
69
+ /** The old options, and the command that replaced each. */
70
+ const REPLACED = {
71
+ "--new": "pi-space new <name>",
72
+ "--remove": "pi-space remove <name> [--yes] [--no-backup]",
73
+ } ;
74
+
48
75
 
49
76
 
50
77
 
51
78
 
79
+
52
80
 
81
+
53
82
 
83
+
84
+
54
85
 
55
86
 
56
87
 
@@ -58,12 +89,12 @@ export const VERSION = JSON.parse(readFileSync(new URL("../package.json"
58
89
 
59
90
 
60
91
 
61
- const CALL_OPTIONS = { "--command": "command", "--dir": "dir" } ;
92
+ const CALL_OPTIONS = { "--command": "command", "--dir": "dir", "--setup": "setup" } ;
62
93
 
63
94
  /**
64
- * --command and --dir come first; after a space name every argument belongs to Pi.
65
- * `list` is the subcommand, so a space named "list" can only be opened from the picker.
66
- * `--new` and `--remove` are options, not subcommands, so they never take a space name.
95
+ * --command, --dir and --setup come first; after a space name every argument belongs to Pi.
96
+ * The commands (list, new, setup, remove, rename, update, install, uninstall) are reserved words, so they are read
97
+ * before a name: the core never lets a space have one of them as its name.
67
98
  * A space name never starts with "-", so any other option in first place is unknown, not a name.
68
99
  */
69
100
  export function parseCli(args ) {
@@ -72,7 +103,8 @@ export function parseCli(args ) {
72
103
  while (Object.hasOwn(CALL_OPTIONS, args[index] ?? "")) {
73
104
  const key = CALL_OPTIONS[args[index] ];
74
105
  const value = args[index + 1];
75
- if (!value || options[key] !== undefined) return { kind: "usage" };
106
+ // A blank value is as good as none: it would name no program, folder or command.
107
+ if (!value?.trim() || options[key] !== undefined) return { kind: "usage" };
76
108
  options[key] = value;
77
109
  index += 2;
78
110
  }
@@ -85,8 +117,14 @@ function parseCommand(args ) {
85
117
  if (first === undefined) return { kind: "picker" };
86
118
  if (first === "--help" || first === "-h") return { kind: "help" };
87
119
  if (first === "--version" || first === "-v") return { kind: "version" };
88
- if (first === "--new") return rest.length === 1 ? { kind: "new", name: rest[0] } : { kind: "usage" };
89
- if (first === "--remove") {
120
+ if (Object.hasOwn(REPLACED, first)) return { kind: "replaced", option: first, instead: REPLACED[first ] };
121
+ if (first.startsWith("-")) return { kind: "unknown-option", option: first };
122
+ if (first === "list" && rest.length === 0) return { kind: "list" };
123
+ if (PACKAGE_VERBS.includes(first)) return parsePackages(first , rest);
124
+ if (first === "new") return rest.length === 1 ? { kind: "new", name: rest[0] } : { kind: "usage" };
125
+ if (first === "setup") return parseSetup(rest);
126
+ if (first === "rename") return rest.length === 2 ? { kind: "rename", from: rest[0], to: rest[1] } : { kind: "usage" };
127
+ if (first === "remove") {
90
128
  const flags = rest.filter((arg) => arg.startsWith("-"));
91
129
  const names = rest.filter((arg) => !arg.startsWith("-"));
92
130
  const yes = flags.filter((flag) => flag === "--yes" || flag === "-y").length;
@@ -94,11 +132,30 @@ function parseCommand(args ) {
94
132
  if (names.length !== 1 || yes > 1 || noBackup > 1 || yes + noBackup !== flags.length) return { kind: "usage" };
95
133
  return { kind: "remove", name: names[0], yes: yes === 1, backup: noBackup === 0 };
96
134
  }
97
- if (first.startsWith("-")) return { kind: "unknown-option", option: first };
98
- if (first === "list") return rest.length === 0 ? { kind: "list" } : { kind: "usage" };
99
135
  return { kind: "launch", name: first, args: rest };
100
136
  }
101
137
 
138
+ /** `setup <name>` or `setup --every`: exactly one argument. */
139
+ function parseSetup(rest ) {
140
+ if (rest.length !== 1) return { kind: "usage" };
141
+ if (rest[0] === "--every") return { kind: "setup", target: { every: true } };
142
+ if (rest[0].startsWith("-")) return { kind: "unknown-option", option: rest[0] };
143
+ return { kind: "setup", target: { name: rest[0] } };
144
+ }
145
+
146
+ /**
147
+ * `<verb> <name|--every> [args...]`. list takes nothing after the target; install and uninstall need a source
148
+ * (the first argument after it); update may have nothing, which is Pi's own default.
149
+ */
150
+ function parsePackages(verb , rest ) {
151
+ const [first, ...args] = rest;
152
+ if (first === undefined) return { kind: "usage" };
153
+ if (first !== "--every" && first.startsWith("-")) return { kind: "unknown-option", option: first };
154
+ if (verb === "list" && args.length > 0) return { kind: "usage" };
155
+ if ((verb === "install" || verb === "uninstall") && args.length === 0) return { kind: "usage" };
156
+ return { kind: "packages", verb, target: first === "--every" ? { every: true } : { name: first }, args };
157
+ }
158
+
102
159
  /**
103
160
  * The program and arguments that start Pi in a space: $PI_SPACES_COMMAND (default pi) plus --space <name>.
104
161
  * Throws when the command has an unclosed quote.
@@ -108,6 +165,17 @@ export function launchCommand(env , name , args )
108
165
  return { program, args: [...prefix, "--space", name, ...args] };
109
166
  }
110
167
 
168
+ /**
169
+ * The program and arguments of the setup command for one space: the template split like --command, with every
170
+ * {home} and {name} in each word replaced by the space's folder and name. Throws when it has an unclosed quote.
171
+ */
172
+ export function setupCommand(template , home , name ) {
173
+ // Replacer functions, so a "$" in the folder or name is put in as written, never read as a pattern.
174
+ const fill = (word ) => word.replaceAll("{home}", () => home).replaceAll("{name}", () => name);
175
+ const [program = "", ...args] = splitCommand(template).map(fill);
176
+ return { program, args };
177
+ }
178
+
111
179
  /**
112
180
  * Splits a command into words on whitespace. Double or single quotes keep whitespace inside a word and are
113
181
  * removed. There are no backslash escapes, so a Windows path in quotes keeps its backslashes.
@@ -163,7 +231,7 @@ function agentHome(env , home ) {
163
231
 
164
232
 
165
233
 
166
-
234
+
167
235
 
168
236
 
169
237
 
@@ -177,7 +245,7 @@ function agentHome(env , home ) {
177
245
 
178
246
 
179
247
 
180
-
248
+
181
249
 
182
250
 
183
251
 
@@ -214,14 +282,18 @@ export function processIo(proc = process) {
214
282
 
215
283
  export async function main(argv , io ) {
216
284
  const command = parseCli(argv);
217
- if (command.dir !== undefined || command.command !== undefined) {
218
- // A relative --dir is relative to where the command runs; the child gets it absolute.
219
- const env = { ...io.env };
220
- if (command.dir !== undefined) env.PI_SPACES_DIR = resolve(command.dir);
285
+ if (command.dir !== undefined || command.command !== undefined || command.setup !== undefined) {
286
+ // env is this call's view; the started Pi also gets --dir (absolute: a relative one is relative to where
287
+ // the command runs) and --setup, but not --command, which only says how to start it.
288
+ const forChild = {};
289
+ if (command.dir !== undefined) forChild.PI_SPACES_DIR = resolve(command.dir);
290
+ if (command.setup !== undefined) forChild.PI_SPACES_SETUP = command.setup;
291
+ const env = { ...io.env, ...forChild };
221
292
  if (command.command !== undefined) env.PI_SPACES_COMMAND = command.command;
222
- io = { ...io, env, childEnv: command.dir !== undefined ? { ...io.env, PI_SPACES_DIR: env.PI_SPACES_DIR } : undefined };
293
+ io = { ...io, env, childEnv: Object.keys(forChild).length > 0 ? { ...io.env, ...forChild } : undefined };
223
294
  }
224
295
  const commandSource = command.command !== undefined ? "--command" : "PI_SPACES_COMMAND";
296
+ const setupSource = command.setup !== undefined ? "--setup" : "PI_SPACES_SETUP";
225
297
  const dir = spacesDir(io.env, io.home);
226
298
  switch (command.kind) {
227
299
  case "help":
@@ -233,18 +305,27 @@ export async function main(argv , io ) {
233
305
  case "unknown-option":
234
306
  io.stderr.write(`pi-space: Unknown option "${command.option}".\n${USAGE}`);
235
307
  return io.exit(2);
308
+ case "replaced":
309
+ io.stderr.write(`pi-space: ${command.option} was replaced by: ${command.instead}\n`);
310
+ return io.exit(2);
236
311
  case "usage":
237
312
  io.stderr.write(USAGE);
238
313
  return io.exit(2);
239
314
  case "list": {
240
315
  const spaces = listSpaces(dir);
241
316
  const lines = [`Spaces in ${dir}:`, ` ${BASE}`, ...spaces.map((name) => ` ${name}`)];
242
- if (spaces.length === 0) lines.push("No spaces yet. Create one with pi-space --new <name>.");
317
+ if (spaces.length === 0) lines.push("No spaces yet. Create one with pi-space new <name>.");
243
318
  io.stdout.write(`${lines.join("\n")}\n`);
244
319
  return io.exit(0);
245
320
  }
321
+ case "packages":
322
+ return runPackages(command.verb, command.target, command.args, dir, io, commandSource);
246
323
  case "new":
247
- return create(command.name, dir, io);
324
+ return create(command.name, dir, io, setupSource);
325
+ case "setup":
326
+ return runSetup(command.target, dir, io, setupSource);
327
+ case "rename":
328
+ return rename(command.from, command.to, dir, io);
248
329
  case "remove":
249
330
  return remove(command.name, command.yes, command.backup, dir, io);
250
331
  case "launch":
@@ -254,10 +335,44 @@ export async function main(argv , io ) {
254
335
  }
255
336
  }
256
337
 
257
- function create(name , dir , io ) {
338
+ /** Prints the reason and returns false when the setup command has an unclosed quote. */
339
+ function checkSetup(io , source ) {
340
+ try {
341
+ splitCommand(io.env.PI_SPACES_SETUP ?? "");
342
+ return true;
343
+ } catch (error) {
344
+ io.stderr.write(`pi-space: ${source}: ${(error ).message}.\n`);
345
+ return false;
346
+ }
347
+ }
348
+
349
+ /** Runs a setup command in a space, as that space's agent home, and returns its exit status. */
350
+ function spawnSetup(setup , name , home , io ) {
351
+ return spawnPi(setup.program, setup.args, io, { ...io.env, PI_CODING_AGENT_DIR: home, PI_SPACE: name, PI_SPACE_HOME: home });
352
+ }
353
+
354
+ /** The setup command for one space, from PI_SPACES_SETUP after the call options; checkSetup has accepted it. */
355
+ function setupFor(name , home , io ) {
356
+ return setupCommand(io.env.PI_SPACES_SETUP ?? "", home, name);
357
+ }
358
+
359
+ async function create(name , dir , io , setupSource ) {
360
+ const withSetup = hasSetupCommand(io.env);
361
+ // A bad setup command is reported before anything is created.
362
+ if (withSetup && !checkSetup(io, setupSource)) return io.exit(2);
258
363
  try {
259
364
  const path = createSpace(dir, name, newSpaceDeclaration(io.env, io.home));
260
- io.stdout.write(`Created space "${name}" at ${path}.\nStart Pi in it with: pi-space ${name}\n`);
365
+ io.stdout.write(`Created space "${name}" at ${path}.\n`);
366
+ if (withSetup) {
367
+ const setup = setupFor(name, path, io);
368
+ io.stdout.write(`Setting it up: ${[setup.program, ...setup.args].join(" ")}\n`);
369
+ const status = await spawnSetup(setup, name, path, io);
370
+ if (status !== 0) {
371
+ 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`);
372
+ return io.exit(status);
373
+ }
374
+ }
375
+ io.stdout.write(`Start Pi in it with: pi-space ${name}\n`);
261
376
  return io.exit(0);
262
377
  } catch (error) {
263
378
  // A SpaceError is a rule (a bad or taken name); anything else is the filesystem, such as no permission.
@@ -266,6 +381,19 @@ function create(name , dir , io ) {
266
381
  }
267
382
  }
268
383
 
384
+ /** Renames a space after the same checks as remove: not base, not the active space, not one that holds the base home. */
385
+ function rename(from , to , dir , io ) {
386
+ try {
387
+ const protect = io.env.PI_SPACES_BASE_HOME ? [io.env.PI_SPACES_BASE_HOME] : [];
388
+ renameSpace(dir, from, to, agentHome(io.env, io.home), protect, io.home);
389
+ io.stdout.write(`Renamed space "${from}" to "${to}".\n`);
390
+ return io.exit(0);
391
+ } catch (error) {
392
+ io.stderr.write(`pi-space: ${error instanceof SpaceError ? error.message : `could not rename space "${from}": ${(error ).message}`}\n`);
393
+ return io.exit(1);
394
+ }
395
+ }
396
+
269
397
  /**
270
398
  * Without --yes it asks to remove ([y/N]), then, without --no-backup, whether to keep a backup ([Y/n]).
271
399
  * With --yes it asks nothing and keeps a backup unless --no-backup.
@@ -279,7 +407,7 @@ async function remove(name , yes , backup , dir ,
279
407
  const path = checkRemovable(dir, name, current, protect, io.home);
280
408
  let keep = backup;
281
409
  if (!yes) {
282
- if (!io.stdin.isTTY) throw new SpaceError(`Removing a space needs confirmation; run pi-space --remove ${name} --yes.`);
410
+ if (!io.stdin.isTTY) throw new SpaceError(`Removing a space needs confirmation; run pi-space remove ${name} --yes.`);
283
411
  // One reader for every question, so an answer typed ahead is not lost between them.
284
412
  reader = lineReader(io.stdin);
285
413
  io.stdout.write(`Remove space "${name}"? This deletes ${path} and everything in it: sign-ins, packages, settings and sessions. [y/N] `);
@@ -346,18 +474,38 @@ function lineReader(input ) {
346
474
  };
347
475
  }
348
476
 
349
- async function launch(name , args , dir , io , commandSource ) {
350
- if (name !== BASE) {
351
- const invalid = validateName(name);
352
- if (invalid) {
353
- io.stderr.write(`pi-space: ${invalid}\n`);
354
- return io.exit(1);
355
- }
356
- if (!listSpaces(dir).includes(name)) {
357
- io.stderr.write(`pi-space: Space "${name}" does not exist.\n`);
358
- return io.exit(1);
359
- }
477
+ /** Prints the reason and returns false when `name` is not base or an existing space. */
478
+ function checkSpace(name , dir , io ) {
479
+ if (name === BASE) return true;
480
+ const invalid = validateName(name);
481
+ if (invalid) {
482
+ io.stderr.write(`pi-space: ${invalid}\n`);
483
+ return false;
484
+ }
485
+ if (!listSpaces(dir).includes(name)) {
486
+ io.stderr.write(`pi-space: Space "${name}" does not exist.\n`);
487
+ return false;
488
+ }
489
+ return true;
490
+ }
491
+
492
+ /**
493
+ * Runs a program with inherited stdio and returns its exit status; 1, with the reason printed, when it cannot start.
494
+ * `env` is the child's whole environment; undefined lets it inherit this process's. Pi gets io.childEnv.
495
+ */
496
+ async function spawnPi(program , programArgs , io , env ) {
497
+ const options = { stdio: "inherit" };
498
+ if (env) options.env = env;
499
+ const result = await io.spawn(program, programArgs, options);
500
+ if (result.error) {
501
+ io.stderr.write(`pi-space: could not start ${program}: ${result.error.message}\n`);
502
+ return 1;
360
503
  }
504
+ return result.status ?? 1;
505
+ }
506
+
507
+ async function launch(name , args , dir , io , commandSource ) {
508
+ if (!checkSpace(name, dir, io)) return io.exit(1);
361
509
  let launched ;
362
510
  try {
363
511
  launched = launchCommand(io.env, name, args);
@@ -365,15 +513,74 @@ async function launch(name , args , dir , io , comm
365
513
  io.stderr.write(`pi-space: ${commandSource}: ${(error ).message}.\n`);
366
514
  return io.exit(2);
367
515
  }
368
- const { program, args: programArgs } = launched;
369
- const options = { stdio: "inherit" };
370
- if (io.childEnv) options.env = io.childEnv;
371
- const result = await io.spawn(program, programArgs, options);
372
- if (result.error) {
373
- io.stderr.write(`pi-space: could not start ${program}: ${result.error.message}\n`);
516
+ io.exit(await spawnPi(launched.program, launched.args, io, io.childEnv));
517
+ }
518
+
519
+ /**
520
+ * Runs one of Pi's package commands in a space, or in base and then every space with --every, through the same
521
+ * Pi (`--space <name> <verb> [args...]`): the extension relaunches it in that home, so the home is never set here.
522
+ * With --every each run gets a `== <name>` header, one failure does not stop the rest, and the status is 1 if any failed.
523
+ */
524
+ async function runPackages(verb , target , args , dir , io , commandSource ) {
525
+ const names = "every" in target ? [BASE, ...listSpaces(dir)] : [target.name];
526
+ if (!("every" in target) && !checkSpace(target.name, dir, io)) return io.exit(1);
527
+ const failed = [];
528
+ for (const name of names) {
529
+ let launched ;
530
+ try {
531
+ launched = launchCommand(io.env, name, [verb, ...args]);
532
+ } catch (error) {
533
+ // The same command for every space, so this is reported once, before any header.
534
+ io.stderr.write(`pi-space: ${commandSource}: ${(error ).message}.\n`);
535
+ return io.exit(2);
536
+ }
537
+ if ("every" in target) io.stdout.write(`== ${name}\n`);
538
+ const status = await spawnPi(launched.program, launched.args, io, io.childEnv);
539
+ if (!("every" in target)) return io.exit(status);
540
+ if (status !== 0) failed.push(name);
541
+ }
542
+ if (failed.length > 0) {
543
+ io.stderr.write(`pi-space: ${verb} failed in: ${failed.join(", ")}\n`);
544
+ return io.exit(1);
545
+ }
546
+ io.exit(0);
547
+ }
548
+
549
+ /**
550
+ * Runs the setup command in a space, or in every space with --every (never base), as `pi-space setup`.
551
+ * With --every each run gets a `== <name>` header, one failure does not stop the rest, and the status is 1 if any failed.
552
+ */
553
+ async function runSetup(target , dir , io , setupSource ) {
554
+ if (!hasSetupCommand(io.env)) {
555
+ io.stderr.write("pi-space: There is no setup command; set PI_SPACES_SETUP or give --setup <cmd>.\n");
556
+ return io.exit(2);
557
+ }
558
+ if (!checkSetup(io, setupSource)) return io.exit(2);
559
+ if (!("every" in target)) {
560
+ if (target.name === BASE) {
561
+ io.stderr.write("pi-space: setup runs only in spaces, not in base (the home Pi starts with).\n");
562
+ return io.exit(1);
563
+ }
564
+ if (!checkSpace(target.name, dir, io)) return io.exit(1);
565
+ const home = join(dir, target.name);
566
+ return io.exit(await spawnSetup(setupFor(target.name, home, io), target.name, home, io));
567
+ }
568
+ const names = listSpaces(dir);
569
+ if (names.length === 0) {
570
+ io.stdout.write("No spaces yet. Create one with pi-space new <name>.\n");
571
+ return io.exit(0);
572
+ }
573
+ const failed = [];
574
+ for (const name of names) {
575
+ io.stdout.write(`== ${name}\n`);
576
+ const home = join(dir, name);
577
+ if ((await spawnSetup(setupFor(name, home, io), name, home, io)) !== 0) failed.push(name);
578
+ }
579
+ if (failed.length > 0) {
580
+ io.stderr.write(`pi-space: setup failed in: ${failed.join(", ")}\n`);
374
581
  return io.exit(1);
375
582
  }
376
- io.exit(result.status ?? 1);
583
+ io.exit(0);
377
584
  }
378
585
 
379
586
  async function pick(dir , io , commandSource ) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jals1212/pi-spaces-cli",
3
- "version": "0.3.1",
3
+ "version": "0.5.0",
4
4
  "description": "The pi-space command: pick a space and start Pi in it. Companion to the Pi extension @jals1212/pi-spaces.",
5
5
  "license": "MIT",
6
6
  "author": "jals1212",
@@ -39,7 +39,7 @@
39
39
  },
40
40
  "dependencies": {
41
41
  "cross-spawn": "^7.0.6",
42
- "@jals1212/pi-spaces": "^0.2.0"
42
+ "@jals1212/pi-spaces": "^0.5.0"
43
43
  },
44
44
  "publishConfig": {
45
45
  "access": "public",