@jals1212/pi-spaces-cli 0.1.0 → 0.3.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,57 @@ commands and options and a patch version fixes.
6
6
 
7
7
  ## Unreleased
8
8
 
9
+ ## 0.3.0
10
+
11
+ ### Added
12
+
13
+ - `pi-space --new <name>` creates a space from the shell, with the same
14
+ `settings.json` as `/space new <name>`: the base home's declaration of
15
+ `@jals1212/pi-spaces`, or `npm:@jals1212/pi-spaces`. It does not start Pi.
16
+ A local path in the base home is copied when it is a copy of the extension.
17
+ - `pi-space --remove <name>` deletes a space after asking in
18
+ the terminal, with the same safety checks as `/space remove`, then asks
19
+ whether to keep a backup in `.backups` (`[Y/n]`: Enter keeps one). `--yes`
20
+ skips the questions and keeps a backup, and is required without a terminal;
21
+ `--no-backup` deletes without one.
22
+ - `--command <cmd>` and `--dir <path>`, before the rest of the arguments, set
23
+ the program to run and the spaces folder for one call, instead of
24
+ `PI_SPACES_COMMAND` and `PI_SPACES_DIR`. With `--dir`, the Pi it starts gets
25
+ that folder as `PI_SPACES_DIR`.
26
+ - Quotes in `--command` and `PI_SPACES_COMMAND`: double or single quotes keep a
27
+ path with spaces in one word, as in `--command '"/opt/my launcher/run" --'`.
28
+ A quote that is never closed is an error (exit status 2) and starts nothing.
29
+
30
+ ### Changed
31
+
32
+ - `pi-space list` with no spaces suggests `pi-space --new <name>`.
33
+ - A `"` or `'` in `PI_SPACES_COMMAND` is now a quote, no longer part of a
34
+ word: a path with an apostrophe, such as `/home/o'neil/bin/run`, needs double
35
+ quotes around it.
36
+ - Needs `@jals1212/pi-spaces` 0.2.0 or later (for `backupSpace` and the
37
+ local-path rule of `--new`).
38
+
39
+ ### Fixed
40
+
41
+ - `pi-space --new` copies the base home's local path to this checkout of the
42
+ extension instead of writing `npm:@jals1212/pi-spaces`.
43
+ - `pi-space --remove` explains a backup that cannot be written (for example a
44
+ read-only spaces folder): the space was not removed, and `--no-backup`
45
+ removes it without one.
46
+ - A program whose path has spaces now starts, including on Windows, where
47
+ cmd.exe runs it and each word is now quoted for it.
48
+
49
+ ## 0.2.0
50
+
51
+ ### Added
52
+
53
+ - `pi-space --version` (or `-v`) prints the version.
54
+
55
+ ### Fixed
56
+
57
+ - An unknown option such as `--bogus` in first place is reported as an unknown
58
+ option with the usage, exit code 2, instead of as an invalid space name.
59
+
9
60
  ## 0.1.0
10
61
 
11
62
  ### Added
package/README.md CHANGED
@@ -17,11 +17,28 @@ Node 20 or later. The Pi extension `@jals1212/pi-spaces` must be installed in th
17
17
  | `pi-space` | Shows a numbered list of spaces and starts Pi in the one you pick. |
18
18
  | `pi-space <name> [pi args...]` | Starts Pi in that space (`base` for the home Pi starts with). |
19
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. |
20
22
  | `pi-space --help` | Shows the usage. |
23
+ | `pi-space --version` | Prints the version. |
24
+
25
+ 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
+
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).
21
28
 
22
29
  ## Another program that runs Pi
23
30
 
24
- To start Pi through another program, set `PI_SPACES_COMMAND` in your shell profile; its words come before `--space`. Such a program usually has its own home, so point `PI_SPACES_DIR` at its spaces folder too, so `pi-space` lists the same spaces:
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:
32
+
33
+ ```sh
34
+ 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
36
+ pi-space --command '"/opt/my launcher/run" --' work # a path with spaces, in quotes
37
+ ```
38
+
39
+ Inside quotes a backslash is a plain character, so `"C:\Program Files\my-launcher\run.cmd"` works as written. A quote that is never closed is an error (exit status 2), and nothing is started.
40
+
41
+ An alias keeps it short: `alias my-space='pi-space --command "my-launcher --" --dir ~/.my-launcher/spaces'`. To make it the default instead, set the variables in your shell profile:
25
42
 
26
43
  ```sh
27
44
  export PI_SPACES_COMMAND="my-launcher --" # pi-space work → my-launcher -- --space work
@@ -30,8 +47,8 @@ export PI_SPACES_DIR=~/.my-launcher/spaces # the spaces next to that program's
30
47
 
31
48
  | Variable | Meaning |
32
49
  | --- | --- |
33
- | `PI_SPACES_COMMAND` | The program to run, split on spaces, before `--space <name>`. Default: `pi`. |
34
- | `PI_SPACES_DIR` | The spaces folder. A relative value is taken from your home folder. Default: `spaces` next to the base home. |
50
+ | `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
+ | `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. |
35
52
  | `PI_CODING_AGENT_DIR` | Pi's agent home; its parent holds the default spaces folder. Default: `~/.pi/agent`. |
36
53
 
37
54
  ## Exit status
@@ -39,8 +56,8 @@ export PI_SPACES_DIR=~/.my-launcher/spaces # the spaces next to that program's
39
56
  | Status | Meaning |
40
57
  | --- | --- |
41
58
  | 0 … | Pi's own exit status. |
42
- | 1 | An unknown or invalid space, an out-of-range pick, or the program could not start. |
43
- | 2 | No arguments and no terminal to pick in: the usage is printed. |
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. |
44
61
 
45
62
  ## More
46
63
 
package/dist/cli.js CHANGED
@@ -1,39 +1,182 @@
1
1
  // The optional `pi-space` command: pick a space and start Pi in it.
2
2
  // It only passes `--space <name>`; the extension does the switch, so it never sets PI_CODING_AGENT_DIR.
3
3
  import { spawn as nodeSpawn } from "node:child_process";
4
+ import { readFileSync } from "node:fs";
4
5
  import { homedir } from "node:os";
6
+ import { dirname, join, resolve } from "node:path";
5
7
  import { createInterface } from "node:readline";
6
- import { BASE, listSpaces, spacesDir, validateName } from "@jals1212/pi-spaces/spaces";
8
+ import { fileURLToPath } from "node:url";
9
+ import {
10
+ BACKUPS,
11
+ BASE,
12
+ backupSpace,
13
+ checkRemovable,
14
+ createSpace,
15
+
16
+ findOwnDeclaration,
17
+ listSpaces,
18
+
19
+ removeSpace,
20
+ SpaceError,
21
+ spacesDir,
22
+ validateName,
23
+ } from "@jals1212/pi-spaces/spaces";
7
24
 
8
- export const USAGE = `Usage: pi-space [<name> [pi args...] | list | --help]
9
- pi-space pick a space and start Pi in it
10
- pi-space <name> ... start Pi in a space ("base" for the home Pi starts with)
11
- pi-space list list the spaces
12
- PI_SPACES_COMMAND sets the program to run (default: pi), e.g. "my-launcher --".
25
+ export const USAGE = `Usage: pi-space [--command <cmd>] [--dir <path>] [<name> [pi args...] | list | --new <name> | --remove <name> [--yes] [--no-backup]]
26
+ 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
13
37
  `;
14
38
 
15
-
39
+ /** This package's version; package.json sits one folder above both src/ and dist/. */
40
+ export const VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
41
+
42
+ /** --command and --dir: for this call only, so nothing has to be exported into the shell. */
43
+
44
+
45
+
46
+
47
+
48
+
16
49
 
50
+
51
+
17
52
 
53
+
54
+
18
55
 
19
56
 
20
-
57
+
58
+
59
+
60
+
61
+ const CALL_OPTIONS = { "--command": "command", "--dir": "dir" } ;
21
62
 
22
- /** `list` is the subcommand, so a space named "list" can only be opened from the picker. */
63
+ /**
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.
67
+ * A space name never starts with "-", so any other option in first place is unknown, not a name.
68
+ */
23
69
  export function parseCli(args ) {
70
+ const options = {};
71
+ let index = 0;
72
+ while (Object.hasOwn(CALL_OPTIONS, args[index] ?? "")) {
73
+ const key = CALL_OPTIONS[args[index] ];
74
+ const value = args[index + 1];
75
+ if (!value || options[key] !== undefined) return { kind: "usage" };
76
+ options[key] = value;
77
+ index += 2;
78
+ }
79
+ const command = parseCommand(args.slice(index));
80
+ return command.kind === "usage" ? command : { ...command, ...options };
81
+ }
82
+
83
+ function parseCommand(args ) {
24
84
  const [first, ...rest] = args;
25
85
  if (first === undefined) return { kind: "picker" };
26
86
  if (first === "--help" || first === "-h") return { kind: "help" };
87
+ 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") {
90
+ const flags = rest.filter((arg) => arg.startsWith("-"));
91
+ const names = rest.filter((arg) => !arg.startsWith("-"));
92
+ const yes = flags.filter((flag) => flag === "--yes" || flag === "-y").length;
93
+ const noBackup = flags.filter((flag) => flag === "--no-backup").length;
94
+ if (names.length !== 1 || yes > 1 || noBackup > 1 || yes + noBackup !== flags.length) return { kind: "usage" };
95
+ return { kind: "remove", name: names[0], yes: yes === 1, backup: noBackup === 0 };
96
+ }
97
+ if (first.startsWith("-")) return { kind: "unknown-option", option: first };
27
98
  if (first === "list") return rest.length === 0 ? { kind: "list" } : { kind: "usage" };
28
99
  return { kind: "launch", name: first, args: rest };
29
100
  }
30
101
 
31
- /** The program and arguments that start Pi in a space: $PI_SPACES_COMMAND (default pi) plus --space <name>. */
102
+ /**
103
+ * The program and arguments that start Pi in a space: $PI_SPACES_COMMAND (default pi) plus --space <name>.
104
+ * Throws when the command has an unclosed quote.
105
+ */
32
106
  export function launchCommand(env , name , args ) {
33
- const [program = "pi", ...prefix] = (env.PI_SPACES_COMMAND ?? "").split(/\s+/).filter(Boolean);
107
+ const [program = "pi", ...prefix] = splitCommand(env.PI_SPACES_COMMAND ?? "");
34
108
  return { program, args: [...prefix, "--space", name, ...args] };
35
109
  }
36
110
 
111
+ /**
112
+ * Splits a command into words on whitespace. Double or single quotes keep whitespace inside a word and are
113
+ * removed. There are no backslash escapes, so a Windows path in quotes keeps its backslashes.
114
+ */
115
+ export function splitCommand(text ) {
116
+ const words = [];
117
+ let word = "";
118
+ // True from the first character or quote of a word, so "" alone is an empty word.
119
+ let started = false;
120
+ let quote ;
121
+ for (const char of text) {
122
+ if (quote) {
123
+ if (char === quote) quote = undefined;
124
+ else word += char;
125
+ } else if (char === '"' || char === "'") {
126
+ quote = char;
127
+ started = true;
128
+ } else if (/\s/.test(char)) {
129
+ if (started) words.push(word);
130
+ word = "";
131
+ started = false;
132
+ } else {
133
+ word += char;
134
+ started = true;
135
+ }
136
+ }
137
+ if (quote) throw new Error(`the command has an unclosed ${quote} quote`);
138
+ if (started) words.push(word);
139
+ return words;
140
+ }
141
+
142
+ /**
143
+ * One word for the single command line cmd.exe gets when the child runs with shell: true on Windows:
144
+ * wrapped in double quotes unless it is plain, with inner double quotes doubled.
145
+ */
146
+ export function quoteForCmd(word ) {
147
+ if (word !== "" && !/[\s"&|<>^()%!,;=]/.test(word)) return word;
148
+ return `"${word.replaceAll('"', '""')}"`;
149
+ }
150
+
151
+ const EXTENSION = "@jals1212/pi-spaces";
152
+
153
+ /**
154
+ * The extension's declaration for a new space's settings.json: the one in the base home's settings.json
155
+ * (PI_SPACES_BASE_HOME, else PI_CODING_AGENT_DIR, else ~/.pi/agent), else npm:@jals1212/pi-spaces.
156
+ */
157
+ export function newSpaceDeclaration(env , home ) {
158
+ const baseHome = env.PI_SPACES_BASE_HOME || agentHome(env, home);
159
+ let packageRoot ;
160
+ try {
161
+ packageRoot = dirname(fileURLToPath(import.meta.resolve(`${EXTENSION}/package.json`)));
162
+ } catch {
163
+ packageRoot = "";
164
+ }
165
+ return findOwnDeclaration(baseHome, EXTENSION, packageRoot, `npm:${EXTENSION}`);
166
+ }
167
+
168
+ /** The agent home Pi uses from this shell: PI_CODING_AGENT_DIR, else ~/.pi/agent. */
169
+ function agentHome(env , home ) {
170
+ return env.PI_CODING_AGENT_DIR ? env.PI_CODING_AGENT_DIR.replace(/^~(?=\/|$)/, home) : join(home, ".pi", "agent");
171
+ }
172
+
173
+
174
+
175
+
176
+
177
+
178
+
179
+
37
180
 
38
181
 
39
182
 
@@ -44,13 +187,17 @@ export function launchCommand(env , name , args )
44
187
 
45
188
 
46
189
 
190
+
191
+
47
192
 
48
-
193
+
194
+
195
+
49
196
 
50
197
 
51
198
 
52
199
  /** The real child process: stdio inherited, the environment unchanged, a shell on Windows so .cmd shims work. */
53
- export function spawnChild(program , args , options ) {
200
+ export function spawnChild(program , args , options ) {
54
201
  // Ctrl-C belongs to Pi while it runs; this process waits for it instead of dying first.
55
202
  const ignore = () => {};
56
203
  process.on("SIGINT", ignore);
@@ -69,36 +216,147 @@ export function processIo(proc = process) {
69
216
  stderr: proc.stderr,
70
217
  env: proc.env,
71
218
  home: homedir(),
219
+ platform: process.platform,
72
220
  spawn: spawnChild,
73
221
  exit: (code) => proc.exit(code),
74
222
  };
75
223
  }
76
224
 
77
225
  export async function main(argv , io ) {
78
- const dir = spacesDir(io.env, io.home);
79
226
  const command = parseCli(argv);
227
+ if (command.dir !== undefined || command.command !== undefined) {
228
+ // A relative --dir is relative to where the command runs; the child gets it absolute.
229
+ const env = { ...io.env };
230
+ if (command.dir !== undefined) env.PI_SPACES_DIR = resolve(command.dir);
231
+ if (command.command !== undefined) env.PI_SPACES_COMMAND = command.command;
232
+ io = { ...io, env, childEnv: command.dir !== undefined ? { ...io.env, PI_SPACES_DIR: env.PI_SPACES_DIR } : undefined };
233
+ }
234
+ const commandSource = command.command !== undefined ? "--command" : "PI_SPACES_COMMAND";
235
+ const dir = spacesDir(io.env, io.home);
80
236
  switch (command.kind) {
81
237
  case "help":
82
238
  io.stdout.write(USAGE);
83
239
  return io.exit(0);
240
+ case "version":
241
+ io.stdout.write(`${VERSION}\n`);
242
+ return io.exit(0);
243
+ case "unknown-option":
244
+ io.stderr.write(`pi-space: Unknown option "${command.option}".\n${USAGE}`);
245
+ return io.exit(2);
84
246
  case "usage":
85
247
  io.stderr.write(USAGE);
86
248
  return io.exit(2);
87
249
  case "list": {
88
250
  const spaces = listSpaces(dir);
89
251
  const lines = [`Spaces in ${dir}:`, ` ${BASE}`, ...spaces.map((name) => ` ${name}`)];
90
- if (spaces.length === 0) lines.push("No spaces yet. Create one with /space new <name> inside Pi.");
252
+ if (spaces.length === 0) lines.push("No spaces yet. Create one with pi-space --new <name>.");
91
253
  io.stdout.write(`${lines.join("\n")}\n`);
92
254
  return io.exit(0);
93
255
  }
256
+ case "new":
257
+ return create(command.name, dir, io);
258
+ case "remove":
259
+ return remove(command.name, command.yes, command.backup, dir, io);
94
260
  case "launch":
95
- return launch(command.name, command.args, dir, io);
261
+ return launch(command.name, command.args, dir, io, commandSource);
96
262
  case "picker":
97
- return pick(dir, io);
263
+ return pick(dir, io, commandSource);
264
+ }
265
+ }
266
+
267
+ function create(name , dir , io ) {
268
+ try {
269
+ const path = createSpace(dir, name, newSpaceDeclaration(io.env, io.home));
270
+ io.stdout.write(`Created space "${name}" at ${path}.\nStart Pi in it with: pi-space ${name}\n`);
271
+ return io.exit(0);
272
+ } catch (error) {
273
+ // A SpaceError is a rule (a bad or taken name); anything else is the filesystem, such as no permission.
274
+ io.stderr.write(`pi-space: ${error instanceof SpaceError ? error.message : `could not create space "${name}": ${(error ).message}`}\n`);
275
+ return io.exit(1);
276
+ }
277
+ }
278
+
279
+ /**
280
+ * Without --yes it asks to remove ([y/N]), then, without --no-backup, whether to keep a backup ([Y/n]).
281
+ * With --yes it asks nothing and keeps a backup unless --no-backup.
282
+ */
283
+ async function remove(name , yes , backup , dir , io ) {
284
+ const current = agentHome(io.env, io.home);
285
+ const protect = io.env.PI_SPACES_BASE_HOME ? [io.env.PI_SPACES_BASE_HOME] : [];
286
+ let reader ;
287
+ try {
288
+ // Fail before asking when the space cannot be removed anyway.
289
+ const path = checkRemovable(dir, name, current, protect, io.home);
290
+ let keep = backup;
291
+ if (!yes) {
292
+ if (!io.stdin.isTTY) throw new SpaceError(`Removing a space needs confirmation; run pi-space --remove ${name} --yes.`);
293
+ // One reader for every question, so an answer typed ahead is not lost between them.
294
+ reader = lineReader(io.stdin);
295
+ io.stdout.write(`Remove space "${name}"? This deletes ${path} and everything in it: sign-ins, packages, settings and sessions. [y/N] `);
296
+ if (!isYes(await reader.next())) {
297
+ io.stdout.write(`Kept space "${name}".\n`);
298
+ return io.exit(0);
299
+ }
300
+ if (backup) {
301
+ io.stdout.write(`Keep a backup of "${name}" in ${join(dir, BACKUPS)}? [Y/n] `);
302
+ // Only a clear no skips the backup: Enter or the end of input keep one.
303
+ const answer = (await reader.next())?.trim().toLowerCase();
304
+ keep = answer !== "n" && answer !== "no";
305
+ }
306
+ }
307
+ if (keep) {
308
+ const saved = backupSpace(dir, name, current, protect, io.home);
309
+ io.stdout.write(`Removed space "${name}"; its backup is ${saved}.\n`);
310
+ } else {
311
+ removeSpace(dir, name, current, protect, io.home);
312
+ io.stdout.write(`Removed space "${name}".\n`);
313
+ }
314
+ return io.exit(0);
315
+ } catch (error) {
316
+ io.stderr.write(`pi-space: ${error instanceof SpaceError ? error.message : `could not remove space "${name}": ${(error ).message}`}\n`);
317
+ return io.exit(1);
318
+ } finally {
319
+ reader?.close();
98
320
  }
99
321
  }
100
322
 
101
- async function launch(name , args , dir , io ) {
323
+ function isYes(answer ) {
324
+ const word = answer?.trim().toLowerCase();
325
+ return word === "y" || word === "yes";
326
+ }
327
+
328
+
329
+
330
+
331
+
332
+
333
+
334
+ /** Reads lines one at a time from one readline interface, keeping lines that arrive before they are asked for. */
335
+ function lineReader(input ) {
336
+ const rl = createInterface({ input });
337
+ const lines = [];
338
+ const waiting = [];
339
+ let ended = false;
340
+ rl.on("line", (line) => {
341
+ const resolve = waiting.shift();
342
+ if (resolve) resolve(line);
343
+ else lines.push(line);
344
+ });
345
+ rl.once("close", () => {
346
+ ended = true;
347
+ for (const resolve of waiting.splice(0)) resolve(undefined);
348
+ });
349
+ return {
350
+ next: () => {
351
+ if (lines.length > 0) return Promise.resolve(lines.shift());
352
+ if (ended) return Promise.resolve(undefined);
353
+ return new Promise((resolve) => waiting.push(resolve));
354
+ },
355
+ close: () => rl.close(),
356
+ };
357
+ }
358
+
359
+ async function launch(name , args , dir , io , commandSource ) {
102
360
  if (name !== BASE) {
103
361
  const invalid = validateName(name);
104
362
  if (invalid) {
@@ -110,8 +368,21 @@ async function launch(name , args , dir , io )
110
368
  return io.exit(1);
111
369
  }
112
370
  }
113
- const { program, args: programArgs } = launchCommand(io.env, name, args);
114
- const result = await io.spawn(program, programArgs, { stdio: "inherit", shell: process.platform === "win32" });
371
+ let launched ;
372
+ try {
373
+ launched = launchCommand(io.env, name, args);
374
+ } catch (error) {
375
+ io.stderr.write(`pi-space: ${commandSource}: ${(error ).message}.\n`);
376
+ return io.exit(2);
377
+ }
378
+ const { program, args: programArgs } = launched;
379
+ const windows = (io.platform ?? process.platform) === "win32";
380
+ const options = { stdio: "inherit", shell: windows };
381
+ if (io.childEnv) options.env = io.childEnv;
382
+ // With a shell, Node joins the program and arguments into one line without quoting them.
383
+ const result = windows
384
+ ? await io.spawn(quoteForCmd(program), programArgs.map(quoteForCmd), options)
385
+ : await io.spawn(program, programArgs, options);
115
386
  if (result.error) {
116
387
  io.stderr.write(`pi-space: could not start ${program}: ${result.error.message}\n`);
117
388
  return io.exit(1);
@@ -119,7 +390,7 @@ async function launch(name , args , dir , io )
119
390
  io.exit(result.status ?? 1);
120
391
  }
121
392
 
122
- async function pick(dir , io ) {
393
+ async function pick(dir , io , commandSource ) {
123
394
  if (!io.stdin.isTTY) {
124
395
  io.stderr.write(USAGE);
125
396
  return io.exit(2);
@@ -134,7 +405,7 @@ async function pick(dir , io ) {
134
405
  io.stderr.write(`pi-space: Choose a number from 1 to ${names.length}.\n`);
135
406
  return io.exit(1);
136
407
  }
137
- return launch(names[number - 1], [], dir, io);
408
+ return launch(names[number - 1], [], dir, io, commandSource);
138
409
  }
139
410
 
140
411
  /** One line from the stream, or undefined at end of input. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jals1212/pi-spaces-cli",
3
- "version": "0.1.0",
3
+ "version": "0.3.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",
@@ -38,7 +38,7 @@
38
38
  "test": "node ../scripts/link-self.mjs && npm run build --prefix .. && npm run build && node --test 'test/*.test.ts'"
39
39
  },
40
40
  "dependencies": {
41
- "@jals1212/pi-spaces": "^0.1.1"
41
+ "@jals1212/pi-spaces": "^0.2.0"
42
42
  },
43
43
  "publishConfig": {
44
44
  "access": "public",