@jals1212/pi-spaces 0.1.1 → 0.2.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,33 @@ commands and options and a patch version fixes.
6
6
 
7
7
  ## Unreleased
8
8
 
9
+ ## 0.2.0
10
+
11
+ ### Added
12
+
13
+ - `/space remove` asks, after the confirmation, whether to keep a backup:
14
+ the space moves to `.backups/<name>-<UTC time>` in the spaces folder, and
15
+ keeping one is the first choice. `--yes` keeps a backup, and `--no-backup`
16
+ deletes without one. `backupSpace` and `BACKUPS` in the core do it, with the
17
+ same checks as `removeSpace`.
18
+
19
+ ### Changed
20
+
21
+ - `/space remove <name> --yes` now keeps a backup instead of deleting the
22
+ space; add `--no-backup` to delete it as before.
23
+
24
+ ### Fixed
25
+
26
+ - `/space new` copies a local path that loads another copy of this package
27
+ (a folder whose `package.json` is named `@jals1212/pi-spaces`) instead of
28
+ writing the folder of the copy that runs.
29
+ - `/space remove` and `backupSpace` report a backup that cannot be written
30
+ (for example a read-only spaces folder) as an error that says the space was
31
+ not removed and offers `--no-backup`, instead of a raw filesystem error.
32
+ - `findOwnDeclaration` takes an optional fallback, answered when nothing
33
+ matches, and an empty package folder matches a local path only by its
34
+ `package.json` name.
35
+
9
36
  ## 0.1.1
10
37
 
11
38
  ### Changed
package/README.md CHANGED
@@ -45,6 +45,8 @@ From your shell:
45
45
  ```sh
46
46
  pi --space work # start Pi in "work"
47
47
  pi --space work "review this branch" # with a prompt
48
+ pi-space --new work # create a space (optional CLI)
49
+ pi-space --remove work # delete it, after asking; keeps a backup unless told not to (optional CLI)
48
50
  pi-space # pick from a numbered list (optional CLI)
49
51
  ```
50
52
 
@@ -60,7 +62,7 @@ A new space starts empty except for a `settings.json` that declares this package
60
62
  | `/space switch <name>` | Restarts Pi in that space. `base` is the home Pi started with. |
61
63
  | `/space switch` | Same as `/space`. |
62
64
  | `/space rename <old> <new>` | Renames a space. The active space cannot be renamed. |
63
- | `/space remove <name>` | Deletes a space after asking. `--yes` skips the question; it is required without a dialog (print and JSON modes). |
65
+ | `/space remove <name> [--yes] [--no-backup]` | Deletes a space after asking, then asks whether to keep a backup in `<spaces folder>/.backups` (keeping one is first in the list; dismissing the question keeps the space). `--yes` skips the questions and keeps a backup; it is required without a dialog (print and JSON modes). `--no-backup` deletes without one. |
64
66
  | `pi --space <name>` | Starts Pi directly in a space, keeping the prompt and every other option. |
65
67
 
66
68
  The status line shows `space: <name>` while a space is active, and nothing in the base home.
@@ -71,7 +73,7 @@ Choosing an argument such as `switch` from the `/space ` suggestions only fills
71
73
 
72
74
  ## The `pi-space` command
73
75
 
74
- The optional `@jals1212/pi-spaces-cli` package adds a `pi-space` command to your shell: `pi-space` picks a space from a numbered list and starts Pi in it, `pi-space <name> [pi args...]` starts Pi in a named space, and `pi-space list` lists them. `PI_SPACES_COMMAND` makes it run another program that runs Pi. It only runs `pi --space <name>`, so this extension must be installed in the home it opens. See [`cli/README.md`](cli/README.md) for the commands, `PI_SPACES_COMMAND` and the exit status.
76
+ The optional `@jals1212/pi-spaces-cli` package adds a `pi-space` command to your shell: `pi-space` picks a space from a numbered list and starts Pi in it, `pi-space <name> [pi args...]` starts Pi in a named space, `pi-space --new <name>` creates one, `pi-space --remove <name>` deletes one, and `pi-space list` lists them. `--command` and `--dir` (or `PI_SPACES_COMMAND` and `PI_SPACES_DIR`) make it run another program that runs Pi, with that program's spaces. It only runs `pi --space <name>`, so this extension must be installed in the home it opens. See [`cli/README.md`](cli/README.md) for the commands, the options and the exit status.
75
77
 
76
78
  ## Configuration reference
77
79
 
@@ -119,17 +121,19 @@ Pi reads its agent home once, when it starts, so a running session cannot move t
119
121
 
120
122
  ## What it leaves on your machine
121
123
 
122
- Only the spaces you create, each a folder in the spaces folder, and its `settings.json`. Pi fills a space as it would any home. Removing a space deletes its folder; nothing else is written outside it.
124
+ Only the spaces you create, each a folder in the spaces folder, and its `settings.json`. Pi fills a space as it would any home. Removing a space moves its folder to `.backups/<name>-<UTC time>` in the spaces folder, or deletes it with `--no-backup` or when you choose so; nothing else is written outside it. A backup is a whole space: move it back to restore it (`mv ~/.pi/spaces/.backups/work-2026-10-28T14-30-05Z ~/.pi/spaces/work`), or delete it when you no longer need it. Backups are never removed for you, and `.backups` is never listed as a space.
123
125
 
124
126
  ## Safety
125
127
 
126
- `/space remove` and `/space rename` refuse:
128
+ `/space remove`, `/space rename` and `pi-space --remove` refuse, before they ask:
127
129
 
128
130
  - `base` and the active space;
129
131
  - symlinks, and anything that is not a real folder directly inside the spaces folder;
130
132
  - a space that holds the current or base home;
131
133
  - a spaces folder that is the filesystem root, your home folder, or the agent home (or inside it).
132
134
 
135
+ A backup also needs `.backups` to be a real folder, not a symlink or a file.
136
+
133
137
  ## Development
134
138
 
135
139
  Two packages live in this repository: the Pi extension and its shared core at the root (`@jals1212/pi-spaces`), and the `pi-space` command in `cli/` (`@jals1212/pi-spaces-cli`).
package/dist/command.js CHANGED
@@ -1,9 +1,24 @@
1
1
  // The /space command, kept apart from Pi's extension API so it can be tested directly.
2
2
  import { join } from "node:path";
3
3
  import { relaunchEnv, resolveSwitch } from "./relaunch.js";
4
- import { activeSpace, BASE, checkRemovable, createSpace, listSpaces, removeSpace, renameSpace, SpaceError } from "./spaces.js";
4
+ import {
5
+ activeSpace,
6
+ BACKUPS,
7
+ BASE,
8
+ backupSpace,
9
+ checkRemovable,
10
+ createSpace,
11
+
12
+ listSpaces,
13
+
14
+ removeSpace,
15
+ renameSpace,
16
+ SpaceError,
17
+ } from "./spaces.js";
5
18
 
6
- export const USAGE = "Usage: /space [list | new <name> | remove <name> | rename <old> <new> | switch [<name>]]";
19
+ export const USAGE = "Usage: /space [list | new <name> | remove <name> [--yes] [--no-backup] | rename <old> <new> | switch [<name>]]";
20
+ const KEEP_BACKUP = "Keep a backup";
21
+ const NO_BACKUP = "Delete without a backup";
7
22
  const SUBCOMMANDS = ["list", "new", "remove", "rename", "switch"];
8
23
 
9
24
 
@@ -43,9 +58,8 @@ export async function runSpaceCommand(args , deps )
43
58
  ctx.ui.notify(`Created space "${rest[0]}" at ${path}. Enter it with /space switch ${rest[0]}.`, "info");
44
59
  return;
45
60
  }
46
- if (sub === "remove" && (rest.length === 1 || (rest.length === 2 && rest[1] === "--yes"))) {
47
- return await remove(rest[0], rest[1] === "--yes", deps);
48
- }
61
+ const removal = sub === "remove" ? parseRemove(rest) : undefined;
62
+ if (removal) return await remove(removal, deps);
49
63
  if (sub === "rename" && rest.length === 2) {
50
64
  renameSpace(deps.dir, rest[0], rest[1], deps.currentHome(), protectedHomes(deps));
51
65
  ctx.ui.notify(`Renamed space "${rest[0]}" to "${rest[1]}".`, "info");
@@ -69,8 +83,24 @@ function list(deps ) {
69
83
  deps.ctx.ui.notify(lines.join("\n"), "info");
70
84
  }
71
85
 
72
- async function remove(name , yes , deps ) {
86
+
87
+
88
+
89
+
90
+
91
+
92
+ /** remove <name> with --yes and --no-backup after it, each at most once, in any order. */
93
+ function parseRemove(rest ) {
94
+ const [name, ...flags] = rest;
95
+ if (name === undefined || name.startsWith("-")) return undefined;
96
+ if (flags.some((flag) => flag !== "--yes" && flag !== "--no-backup") || new Set(flags).size !== flags.length) return undefined;
97
+ return { name, yes: flags.includes("--yes"), backup: !flags.includes("--no-backup") };
98
+ }
99
+
100
+ /** Without --yes it asks to remove, then (without --no-backup) whether to keep a backup, keeping one first in the list. */
101
+ async function remove({ name, yes, backup } , deps ) {
73
102
  const { ctx } = deps;
103
+ let keep = backup;
74
104
  if (!yes) {
75
105
  if (!ctx.hasUI) throw new SpaceError(`Removing a space needs confirmation; run /space remove ${name} --yes.`);
76
106
  // Fail before asking when the space cannot be removed anyway.
@@ -79,10 +109,18 @@ async function remove(name , yes , deps )
79
109
  `Remove space "${name}"?`,
80
110
  `This deletes ${join(deps.dir, name)} and everything in it: sign-ins, packages, settings and sessions.`,
81
111
  );
82
- if (!ok) {
112
+ const choice = ok && backup ? await ctx.ui.select(`Keep a backup of "${name}" in ${join(deps.dir, BACKUPS)}?`, [KEEP_BACKUP, NO_BACKUP]) : undefined;
113
+ // Dismissing the backup question is not an answer to it, so nothing is removed.
114
+ if (!ok || (backup && choice === undefined)) {
83
115
  ctx.ui.notify(`Kept space "${name}".`, "info");
84
116
  return;
85
117
  }
118
+ keep = backup && choice === KEEP_BACKUP;
119
+ }
120
+ if (keep) {
121
+ const path = backupSpace(deps.dir, name, deps.currentHome(), protectedHomes(deps));
122
+ ctx.ui.notify(`Removed space "${name}"; its backup is ${path}.`, "info");
123
+ return;
86
124
  }
87
125
  removeSpace(deps.dir, name, deps.currentHome(), protectedHomes(deps));
88
126
  ctx.ui.notify(`Removed space "${name}".`, "info");
package/dist/spaces.js CHANGED
@@ -149,6 +149,48 @@ export function removeSpace(dir , name , agentHome , protec
149
149
  rmSync(checkRemovable(dir, name, agentHome, protect, userHome), { recursive: true, force: true });
150
150
  }
151
151
 
152
+ /** The folder inside the spaces folder that holds removed spaces; its leading dot keeps it from being a space. */
153
+ export const BACKUPS = ".backups";
154
+
155
+ /**
156
+ * Removes a space by moving it to <spaces folder>/.backups/<name>-<UTC time>, after checkRemovable.
157
+ * Returns the backup's path. A second backup in the same second gets a number; nothing is overwritten.
158
+ */
159
+ export function backupSpace(
160
+ dir ,
161
+ name ,
162
+ agentHome ,
163
+ protect = [],
164
+ userHome = osHomedir(),
165
+ now = new Date(),
166
+ ) {
167
+ const from = checkRemovable(dir, name, agentHome, protect, userHome);
168
+ const folder = join(dir, BACKUPS);
169
+ if ((existsSync(folder) || isSymlink(folder)) && !isRealFolder(folder)) {
170
+ throw new SpaceError(`The backups folder ${folder} is not a folder; move it away and try again.`);
171
+ }
172
+ try {
173
+ mkdirSync(folder, { recursive: true });
174
+ } catch (error) {
175
+ throw backupFailed(name, error);
176
+ }
177
+ const stamp = `${now.toISOString().slice(0, 19).replaceAll(":", "-")}Z`;
178
+ let to = join(folder, `${name}-${stamp}`);
179
+ for (let n = 2; existsSync(to) || isSymlink(to); n++) to = join(folder, `${name}-${stamp}-${n}`);
180
+ // Inside the spaces folder, so the same filesystem: a rename, never a copy.
181
+ try {
182
+ renameSync(from, to);
183
+ } catch (error) {
184
+ throw backupFailed(name, error);
185
+ }
186
+ return to;
187
+ }
188
+
189
+ function backupFailed(name , error ) {
190
+ const reason = error instanceof Error ? error.message : String(error);
191
+ return new SpaceError(`Could not back up space "${name}": ${reason}. The space was not removed; fix that, or remove it without a backup with --no-backup.`);
192
+ }
193
+
152
194
  /** Renames a space after the same safety checks as removal. The new name must be free. */
153
195
  export function renameSpace(dir , oldName , newName , agentHome , protect = [], userHome = osHomedir()) {
154
196
  assertName(oldName);
@@ -174,22 +216,39 @@ function gitRepoName(source ) {
174
216
  return withoutRef.replace(/\.git$/, "").split(/[/:]/).pop() ?? "";
175
217
  }
176
218
 
219
+ /** The name in a local package folder's package.json, or undefined when there is none. */
220
+ function localPackageName(folder ) {
221
+ try {
222
+ const name = JSON.parse(readFileSync(join(folder, "package.json"), "utf8"))?.name;
223
+ return typeof name === "string" ? name : undefined;
224
+ } catch {
225
+ return undefined;
226
+ }
227
+ }
228
+
177
229
  function samePath(a , b ) {
178
230
  return realOrResolved(a) === realOrResolved(b);
179
231
  }
180
232
 
181
233
  /**
182
234
  * This package's declaration in the current home's settings.json, so a new space loads it the same way.
183
- * A local path is made absolute. Without a match (for example when loaded with -e), the package folder itself.
235
+ * A local path matches when it is the package folder or another folder whose package.json has this package's name,
236
+ * and is made absolute. Without a match (for example when loaded with -e), the fallback, which is the package folder itself
237
+ * unless given. An empty packageRoot (unknown folder) matches a local path only by its package.json name.
184
238
  */
185
- export function findOwnDeclaration(agentHome , packageName , packageRoot ) {
239
+ export function findOwnDeclaration(
240
+ agentHome ,
241
+ packageName ,
242
+ packageRoot ,
243
+ fallback = packageRoot,
244
+ ) {
186
245
  let packages ;
187
246
  try {
188
247
  packages = JSON.parse(readFileSync(join(agentHome, "settings.json"), "utf8"))?.packages;
189
248
  } catch {
190
- return packageRoot;
249
+ return fallback;
191
250
  }
192
- if (!Array.isArray(packages)) return packageRoot;
251
+ if (!Array.isArray(packages)) return fallback;
193
252
  const repo = packageName.split("/").pop() ?? packageName;
194
253
  for (const entry of packages) {
195
254
  const source = typeof entry === "string" ? entry : typeof entry?.source === "string" ? entry.source : undefined;
@@ -199,11 +258,11 @@ export function findOwnDeclaration(agentHome , packageName , packa
199
258
  if (npmName(source) === packageName) match = source;
200
259
  } else if (isLocalSource(source)) {
201
260
  const absolute = isAbsolute(source) ? source : resolve(agentHome, expandTilde(source, osHomedir()));
202
- if (samePath(absolute, packageRoot)) match = absolute;
261
+ if ((packageRoot !== "" && samePath(absolute, packageRoot)) || localPackageName(absolute) === packageName) match = absolute;
203
262
  } else if (gitRepoName(source) === repo) {
204
263
  match = source;
205
264
  }
206
265
  if (match !== undefined) return typeof entry === "string" ? match : { ...entry, source: match };
207
266
  }
208
- return packageRoot;
267
+ return fallback;
209
268
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jals1212/pi-spaces",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Named spaces for Pi: keep several agent homes (sign-ins, packages, settings) and pick one by name.",
5
5
  "license": "MIT",
6
6
  "author": "jals1212",
package/src/command.ts CHANGED
@@ -1,9 +1,24 @@
1
1
  // The /space command, kept apart from Pi's extension API so it can be tested directly.
2
2
  import { join } from "node:path";
3
3
  import { type Command, relaunchEnv, resolveSwitch } from "./relaunch.ts";
4
- import { activeSpace, BASE, checkRemovable, createSpace, type Env, listSpaces, type PackageDeclaration, removeSpace, renameSpace, SpaceError } from "./spaces.ts";
4
+ import {
5
+ activeSpace,
6
+ BACKUPS,
7
+ BASE,
8
+ backupSpace,
9
+ checkRemovable,
10
+ createSpace,
11
+ type Env,
12
+ listSpaces,
13
+ type PackageDeclaration,
14
+ removeSpace,
15
+ renameSpace,
16
+ SpaceError,
17
+ } from "./spaces.ts";
5
18
 
6
- export const USAGE = "Usage: /space [list | new <name> | remove <name> | rename <old> <new> | switch [<name>]]";
19
+ export const USAGE = "Usage: /space [list | new <name> | remove <name> [--yes] [--no-backup] | rename <old> <new> | switch [<name>]]";
20
+ const KEEP_BACKUP = "Keep a backup";
21
+ const NO_BACKUP = "Delete without a backup";
7
22
  const SUBCOMMANDS = ["list", "new", "remove", "rename", "switch"];
8
23
 
9
24
  export interface SpaceCommandContext {
@@ -43,9 +58,8 @@ export async function runSpaceCommand(args: string, deps: SpaceCommandDeps): Pro
43
58
  ctx.ui.notify(`Created space "${rest[0]}" at ${path}. Enter it with /space switch ${rest[0]}.`, "info");
44
59
  return;
45
60
  }
46
- if (sub === "remove" && (rest.length === 1 || (rest.length === 2 && rest[1] === "--yes"))) {
47
- return await remove(rest[0], rest[1] === "--yes", deps);
48
- }
61
+ const removal = sub === "remove" ? parseRemove(rest) : undefined;
62
+ if (removal) return await remove(removal, deps);
49
63
  if (sub === "rename" && rest.length === 2) {
50
64
  renameSpace(deps.dir, rest[0], rest[1], deps.currentHome(), protectedHomes(deps));
51
65
  ctx.ui.notify(`Renamed space "${rest[0]}" to "${rest[1]}".`, "info");
@@ -69,8 +83,24 @@ function list(deps: SpaceCommandDeps): void {
69
83
  deps.ctx.ui.notify(lines.join("\n"), "info");
70
84
  }
71
85
 
72
- async function remove(name: string, yes: boolean, deps: SpaceCommandDeps): Promise<void> {
86
+ interface Removal {
87
+ name: string;
88
+ yes: boolean;
89
+ backup: boolean;
90
+ }
91
+
92
+ /** remove <name> with --yes and --no-backup after it, each at most once, in any order. */
93
+ function parseRemove(rest: string[]): Removal | undefined {
94
+ const [name, ...flags] = rest;
95
+ if (name === undefined || name.startsWith("-")) return undefined;
96
+ if (flags.some((flag) => flag !== "--yes" && flag !== "--no-backup") || new Set(flags).size !== flags.length) return undefined;
97
+ return { name, yes: flags.includes("--yes"), backup: !flags.includes("--no-backup") };
98
+ }
99
+
100
+ /** Without --yes it asks to remove, then (without --no-backup) whether to keep a backup, keeping one first in the list. */
101
+ async function remove({ name, yes, backup }: Removal, deps: SpaceCommandDeps): Promise<void> {
73
102
  const { ctx } = deps;
103
+ let keep = backup;
74
104
  if (!yes) {
75
105
  if (!ctx.hasUI) throw new SpaceError(`Removing a space needs confirmation; run /space remove ${name} --yes.`);
76
106
  // Fail before asking when the space cannot be removed anyway.
@@ -79,10 +109,18 @@ async function remove(name: string, yes: boolean, deps: SpaceCommandDeps): Promi
79
109
  `Remove space "${name}"?`,
80
110
  `This deletes ${join(deps.dir, name)} and everything in it: sign-ins, packages, settings and sessions.`,
81
111
  );
82
- if (!ok) {
112
+ const choice = ok && backup ? await ctx.ui.select(`Keep a backup of "${name}" in ${join(deps.dir, BACKUPS)}?`, [KEEP_BACKUP, NO_BACKUP]) : undefined;
113
+ // Dismissing the backup question is not an answer to it, so nothing is removed.
114
+ if (!ok || (backup && choice === undefined)) {
83
115
  ctx.ui.notify(`Kept space "${name}".`, "info");
84
116
  return;
85
117
  }
118
+ keep = backup && choice === KEEP_BACKUP;
119
+ }
120
+ if (keep) {
121
+ const path = backupSpace(deps.dir, name, deps.currentHome(), protectedHomes(deps));
122
+ ctx.ui.notify(`Removed space "${name}"; its backup is ${path}.`, "info");
123
+ return;
86
124
  }
87
125
  removeSpace(deps.dir, name, deps.currentHome(), protectedHomes(deps));
88
126
  ctx.ui.notify(`Removed space "${name}".`, "info");
package/src/spaces.ts CHANGED
@@ -149,6 +149,48 @@ export function removeSpace(dir: string, name: string, agentHome: string, protec
149
149
  rmSync(checkRemovable(dir, name, agentHome, protect, userHome), { recursive: true, force: true });
150
150
  }
151
151
 
152
+ /** The folder inside the spaces folder that holds removed spaces; its leading dot keeps it from being a space. */
153
+ export const BACKUPS = ".backups";
154
+
155
+ /**
156
+ * Removes a space by moving it to <spaces folder>/.backups/<name>-<UTC time>, after checkRemovable.
157
+ * Returns the backup's path. A second backup in the same second gets a number; nothing is overwritten.
158
+ */
159
+ export function backupSpace(
160
+ dir: string,
161
+ name: string,
162
+ agentHome: string,
163
+ protect: string[] = [],
164
+ userHome: string = osHomedir(),
165
+ now: Date = new Date(),
166
+ ): string {
167
+ const from = checkRemovable(dir, name, agentHome, protect, userHome);
168
+ const folder = join(dir, BACKUPS);
169
+ if ((existsSync(folder) || isSymlink(folder)) && !isRealFolder(folder)) {
170
+ throw new SpaceError(`The backups folder ${folder} is not a folder; move it away and try again.`);
171
+ }
172
+ try {
173
+ mkdirSync(folder, { recursive: true });
174
+ } catch (error) {
175
+ throw backupFailed(name, error);
176
+ }
177
+ const stamp = `${now.toISOString().slice(0, 19).replaceAll(":", "-")}Z`;
178
+ let to = join(folder, `${name}-${stamp}`);
179
+ for (let n = 2; existsSync(to) || isSymlink(to); n++) to = join(folder, `${name}-${stamp}-${n}`);
180
+ // Inside the spaces folder, so the same filesystem: a rename, never a copy.
181
+ try {
182
+ renameSync(from, to);
183
+ } catch (error) {
184
+ throw backupFailed(name, error);
185
+ }
186
+ return to;
187
+ }
188
+
189
+ function backupFailed(name: string, error: unknown): SpaceError {
190
+ const reason = error instanceof Error ? error.message : String(error);
191
+ return new SpaceError(`Could not back up space "${name}": ${reason}. The space was not removed; fix that, or remove it without a backup with --no-backup.`);
192
+ }
193
+
152
194
  /** Renames a space after the same safety checks as removal. The new name must be free. */
153
195
  export function renameSpace(dir: string, oldName: string, newName: string, agentHome: string, protect: string[] = [], userHome: string = osHomedir()): void {
154
196
  assertName(oldName);
@@ -174,22 +216,39 @@ function gitRepoName(source: string): string {
174
216
  return withoutRef.replace(/\.git$/, "").split(/[/:]/).pop() ?? "";
175
217
  }
176
218
 
219
+ /** The name in a local package folder's package.json, or undefined when there is none. */
220
+ function localPackageName(folder: string): string | undefined {
221
+ try {
222
+ const name = JSON.parse(readFileSync(join(folder, "package.json"), "utf8"))?.name;
223
+ return typeof name === "string" ? name : undefined;
224
+ } catch {
225
+ return undefined;
226
+ }
227
+ }
228
+
177
229
  function samePath(a: string, b: string): boolean {
178
230
  return realOrResolved(a) === realOrResolved(b);
179
231
  }
180
232
 
181
233
  /**
182
234
  * This package's declaration in the current home's settings.json, so a new space loads it the same way.
183
- * A local path is made absolute. Without a match (for example when loaded with -e), the package folder itself.
235
+ * A local path matches when it is the package folder or another folder whose package.json has this package's name,
236
+ * and is made absolute. Without a match (for example when loaded with -e), the fallback, which is the package folder itself
237
+ * unless given. An empty packageRoot (unknown folder) matches a local path only by its package.json name.
184
238
  */
185
- export function findOwnDeclaration(agentHome: string, packageName: string, packageRoot: string): PackageDeclaration {
239
+ export function findOwnDeclaration(
240
+ agentHome: string,
241
+ packageName: string,
242
+ packageRoot: string,
243
+ fallback: PackageDeclaration = packageRoot,
244
+ ): PackageDeclaration {
186
245
  let packages: unknown;
187
246
  try {
188
247
  packages = JSON.parse(readFileSync(join(agentHome, "settings.json"), "utf8"))?.packages;
189
248
  } catch {
190
- return packageRoot;
249
+ return fallback;
191
250
  }
192
- if (!Array.isArray(packages)) return packageRoot;
251
+ if (!Array.isArray(packages)) return fallback;
193
252
  const repo = packageName.split("/").pop() ?? packageName;
194
253
  for (const entry of packages) {
195
254
  const source = typeof entry === "string" ? entry : typeof entry?.source === "string" ? entry.source : undefined;
@@ -199,11 +258,11 @@ export function findOwnDeclaration(agentHome: string, packageName: string, packa
199
258
  if (npmName(source) === packageName) match = source;
200
259
  } else if (isLocalSource(source)) {
201
260
  const absolute = isAbsolute(source) ? source : resolve(agentHome, expandTilde(source, osHomedir()));
202
- if (samePath(absolute, packageRoot)) match = absolute;
261
+ if ((packageRoot !== "" && samePath(absolute, packageRoot)) || localPackageName(absolute) === packageName) match = absolute;
203
262
  } else if (gitRepoName(source) === repo) {
204
263
  match = source;
205
264
  }
206
265
  if (match !== undefined) return typeof entry === "string" ? match : { ...entry, source: match };
207
266
  }
208
- return packageRoot;
267
+ return fallback;
209
268
  }