@jals1212/pi-spaces 0.1.1 → 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 +46 -0
- package/README.md +25 -5
- package/dist/command.js +46 -8
- package/dist/context.js +63 -0
- package/dist/spaces.js +65 -6
- package/extensions/spaces.ts +6 -0
- package/package.json +9 -2
- package/skills/pi-spaces/SKILL.md +48 -0
- package/src/command.ts +46 -8
- package/src/context.ts +63 -0
- package/src/spaces.ts +65 -6
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,52 @@ 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 knows the space it runs in. Every Pi with the extension sets
|
|
14
|
+
`PI_SPACE` (unset in the base home), `PI_SPACE_HOME` (its agent home) and
|
|
15
|
+
`PI_SPACES_COMMAND` (its configured value, else `pi`), with or without a
|
|
16
|
+
switch, and the system prompt gets a `<pi_space>` section with the same
|
|
17
|
+
facts. `spaceInfo`, `exportSpaceInfo` and `spacePrompt` in the core build
|
|
18
|
+
them.
|
|
19
|
+
- A `pi-spaces` skill, declared in the package, that tells agents what a
|
|
20
|
+
space is, how to read the current space, home and command, and which
|
|
21
|
+
`/space` or `pi-space` action to use.
|
|
22
|
+
|
|
23
|
+
### Changed
|
|
24
|
+
|
|
25
|
+
- The status line shows the active space as `◆ <name>` instead of
|
|
26
|
+
`space: <name>`, so it stands out among other extensions' statuses.
|
|
27
|
+
|
|
28
|
+
## 0.2.0
|
|
29
|
+
|
|
30
|
+
### Added
|
|
31
|
+
|
|
32
|
+
- `/space remove` asks, after the confirmation, whether to keep a backup:
|
|
33
|
+
the space moves to `.backups/<name>-<UTC time>` in the spaces folder, and
|
|
34
|
+
keeping one is the first choice. `--yes` keeps a backup, and `--no-backup`
|
|
35
|
+
deletes without one. `backupSpace` and `BACKUPS` in the core do it, with the
|
|
36
|
+
same checks as `removeSpace`.
|
|
37
|
+
|
|
38
|
+
### Changed
|
|
39
|
+
|
|
40
|
+
- `/space remove <name> --yes` now keeps a backup instead of deleting the
|
|
41
|
+
space; add `--no-backup` to delete it as before.
|
|
42
|
+
|
|
43
|
+
### Fixed
|
|
44
|
+
|
|
45
|
+
- `/space new` copies a local path that loads another copy of this package
|
|
46
|
+
(a folder whose `package.json` is named `@jals1212/pi-spaces`) instead of
|
|
47
|
+
writing the folder of the copy that runs.
|
|
48
|
+
- `/space remove` and `backupSpace` report a backup that cannot be written
|
|
49
|
+
(for example a read-only spaces folder) as an error that says the space was
|
|
50
|
+
not removed and offers `--no-backup`, instead of a raw filesystem error.
|
|
51
|
+
- `findOwnDeclaration` takes an optional fallback, answered when nothing
|
|
52
|
+
matches, and an empty package folder matches a local path only by its
|
|
53
|
+
`package.json` name.
|
|
54
|
+
|
|
9
55
|
## 0.1.1
|
|
10
56
|
|
|
11
57
|
### 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,10 +62,10 @@ 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
|
|
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
|
-
The status line shows
|
|
68
|
+
The status line shows `◆ <name>` while a space is active, and nothing in the base home.
|
|
67
69
|
|
|
68
70
|
Choosing an argument such as `switch` from the `/space ` suggestions only fills the line; press Enter again to run it. `/space` and Enter opens the picker in one step.
|
|
69
71
|
|
|
@@ -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`
|
|
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
|
|
|
@@ -97,6 +99,22 @@ Set for the Pi a switch starts:
|
|
|
97
99
|
| `PI_SPACE` | The space's name. Unset in the base home. |
|
|
98
100
|
| `PI_SPACES_BASE_HOME` | The home Pi started with, so `base` and the spaces folder survive switches. |
|
|
99
101
|
|
|
102
|
+
Set in every Pi this extension runs in, even one started without a switch, for its tools and the programs they start:
|
|
103
|
+
|
|
104
|
+
| Variable | Meaning |
|
|
105
|
+
| --- | --- |
|
|
106
|
+
| `PI_SPACE` | The active space's name, unset in the base home: the same variable a switch sets. |
|
|
107
|
+
| `PI_SPACE_HOME` | The agent home this Pi runs with: the space's folder, or the base home. |
|
|
108
|
+
| `PI_SPACES_COMMAND` | Kept when it has a value; set to `pi` when it is unset or blank. |
|
|
109
|
+
|
|
110
|
+
### What the model is told
|
|
111
|
+
|
|
112
|
+
Before each run the system prompt gets a `<pi_space>` section with the same three facts: the space (or the base home), the agent home, and the command, with `<command> --space <name>` and `/space` as the ways to change space. The model knows where it runs without reading the environment. A Pi without structured prompt sections gets no section; the variables are still set.
|
|
113
|
+
|
|
114
|
+
### The `pi-spaces` skill
|
|
115
|
+
|
|
116
|
+
The package also ships a `pi-spaces` skill (`skills/pi-spaces/SKILL.md`). It tells agents what a space is, how to read the current space, home and command, and which `/space` or `pi-space` action to use or propose for listing, creating, switching and removing spaces. Pi loads it when a task is about spaces; `/skill:pi-spaces` loads it by hand.
|
|
117
|
+
|
|
100
118
|
### Exit status
|
|
101
119
|
|
|
102
120
|
| Status | `pi --space` |
|
|
@@ -119,17 +137,19 @@ Pi reads its agent home once, when it starts, so a running session cannot move t
|
|
|
119
137
|
|
|
120
138
|
## What it leaves on your machine
|
|
121
139
|
|
|
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
|
|
140
|
+
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
141
|
|
|
124
142
|
## Safety
|
|
125
143
|
|
|
126
|
-
`/space remove` and
|
|
144
|
+
`/space remove`, `/space rename` and `pi-space --remove` refuse, before they ask:
|
|
127
145
|
|
|
128
146
|
- `base` and the active space;
|
|
129
147
|
- symlinks, and anything that is not a real folder directly inside the spaces folder;
|
|
130
148
|
- a space that holds the current or base home;
|
|
131
149
|
- a spaces folder that is the filesystem root, your home folder, or the agent home (or inside it).
|
|
132
150
|
|
|
151
|
+
A backup also needs `.backups` to be a real folder, not a symlink or a file.
|
|
152
|
+
|
|
133
153
|
## Development
|
|
134
154
|
|
|
135
155
|
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 {
|
|
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
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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");
|
|
@@ -119,7 +157,7 @@ function switchTo(name , deps ) {
|
|
|
119
157
|
/** Status line text: the active space, or nothing in the base home. */
|
|
120
158
|
export function statusText(dir , currentHome ) {
|
|
121
159
|
const active = activeSpace(dir, currentHome);
|
|
122
|
-
return active === undefined ? undefined :
|
|
160
|
+
return active === undefined ? undefined : `◆ ${active}`;
|
|
123
161
|
}
|
|
124
162
|
|
|
125
163
|
export function completeSpaceArgs(prefix , dir ) {
|
package/dist/context.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// What this Pi process knows about its space: the active space, the agent home and the command
|
|
2
|
+
// that starts Pi. It is exported to the environment (for tools and child processes) and told to the model.
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { activeSpace, } from "./spaces.js";
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Read once, when the extension loads: Pi keeps its agent home for the whole process, and
|
|
17
|
+
* the active space cannot be renamed or removed, so the facts hold until Pi exits.
|
|
18
|
+
*/
|
|
19
|
+
export function spaceInfo(dir , agentHome , env ) {
|
|
20
|
+
return {
|
|
21
|
+
space: activeSpace(dir, agentHome),
|
|
22
|
+
home: resolve(agentHome),
|
|
23
|
+
// A blank value counts as unset, so the default is pi, as in the pi-space command.
|
|
24
|
+
command: env.PI_SPACES_COMMAND?.trim() || "pi",
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Sets PI_SPACE (removed in the base home), PI_SPACE_HOME and PI_SPACES_COMMAND. */
|
|
29
|
+
export function exportSpaceInfo(env , info ) {
|
|
30
|
+
if (info.space === undefined) delete env.PI_SPACE;
|
|
31
|
+
else env.PI_SPACE = info.space;
|
|
32
|
+
env.PI_SPACE_HOME = info.home;
|
|
33
|
+
env.PI_SPACES_COMMAND = info.command;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The system prompt section that tells the model where it runs. */
|
|
37
|
+
export function spacePrompt(info ) {
|
|
38
|
+
const where = info.space === undefined
|
|
39
|
+
? "This Pi runs in the base home, outside any space."
|
|
40
|
+
: `This Pi runs in the space "${info.space}" (PI_SPACE).`;
|
|
41
|
+
const home = `Agent home: ${info.home} (PI_SPACE_HOME).`;
|
|
42
|
+
const start = `\`${info.command} --space <name>\` starts it in a space`;
|
|
43
|
+
const manage = "`/space` lists, creates, removes and switches spaces";
|
|
44
|
+
const command = `Command that starts Pi: ${info.command} (PI_SPACES_COMMAND); ${start}, and ${manage}.`;
|
|
45
|
+
return [where, home, command].join("\n");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The part of a run's structured prompt options this package writes. */
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Sets the <pi_space> section in a run's structured prompt options, creating the section map when
|
|
55
|
+
* it is missing and replacing an earlier pi_space section. Other sections and the rest of the
|
|
56
|
+
* prompt are left alone. Without structured options (an older Pi) nothing is added; the
|
|
57
|
+
* environment still has the facts.
|
|
58
|
+
*/
|
|
59
|
+
export function addSpaceSection(options , info ) {
|
|
60
|
+
if (options === undefined) return;
|
|
61
|
+
options.sections ??= {};
|
|
62
|
+
options.sections.pi_space = spacePrompt(info);
|
|
63
|
+
}
|
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
|
|
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(
|
|
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
|
|
249
|
+
return fallback;
|
|
191
250
|
}
|
|
192
|
-
if (!Array.isArray(packages)) return
|
|
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
|
|
267
|
+
return fallback;
|
|
209
268
|
}
|
package/extensions/spaces.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { dirname, join } from "node:path";
|
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { type ExtensionAPI, getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
6
6
|
import { completeSpaceArgs, runSpaceCommand, statusText } from "../src/command.ts";
|
|
7
|
+
import { addSpaceSection, exportSpaceInfo, spaceInfo } from "../src/context.ts";
|
|
7
8
|
import { planStartup, relaunchCommand, runNow, scheduleRelaunch } from "../src/relaunch.ts";
|
|
8
9
|
import { findOwnDeclaration, spacesDir } from "../src/spaces.ts";
|
|
9
10
|
|
|
@@ -20,6 +21,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
20
21
|
}
|
|
21
22
|
if (plan.kind === "relaunch") process.exit(runNow(plan.command, plan.env));
|
|
22
23
|
|
|
24
|
+
// Where this Pi runs: exported for tools and the programs they start, and told to the model before each run.
|
|
25
|
+
const info = spaceInfo(spacesDir(), getAgentDir(), process.env);
|
|
26
|
+
exportSpaceInfo(process.env, info);
|
|
27
|
+
pi.on("before_agent_start", (event) => addSpaceSection(event.systemPromptOptions, info));
|
|
28
|
+
|
|
23
29
|
pi.registerFlag("space", { description: "Start Pi in this space (see /space list)", type: "string" });
|
|
24
30
|
|
|
25
31
|
pi.on("session_start", (_event, ctx) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jals1212/pi-spaces",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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",
|
|
@@ -25,12 +25,16 @@
|
|
|
25
25
|
"pi": {
|
|
26
26
|
"extensions": [
|
|
27
27
|
"./extensions/spaces.ts"
|
|
28
|
+
],
|
|
29
|
+
"skills": [
|
|
30
|
+
"./skills"
|
|
28
31
|
]
|
|
29
32
|
},
|
|
30
33
|
"files": [
|
|
31
34
|
"dist",
|
|
32
35
|
"src",
|
|
33
36
|
"extensions",
|
|
37
|
+
"skills",
|
|
34
38
|
"README.md",
|
|
35
39
|
"CHANGELOG.md",
|
|
36
40
|
"LICENSE"
|
|
@@ -41,7 +45,10 @@
|
|
|
41
45
|
"scripts": {
|
|
42
46
|
"build": "node --disable-warning=ExperimentalWarning scripts/build.mjs",
|
|
43
47
|
"prepack": "npm run build",
|
|
44
|
-
"test": "node scripts/link-self.mjs && npm run build && node --test
|
|
48
|
+
"test": "node scripts/link-self.mjs && npm run build && node --test \"tests/*.test.ts\" && npm test --prefix cli"
|
|
49
|
+
},
|
|
50
|
+
"devDependencies": {
|
|
51
|
+
"cross-spawn": "^7.0.6"
|
|
45
52
|
},
|
|
46
53
|
"peerDependencies": {
|
|
47
54
|
"@earendil-works/pi-coding-agent": "*",
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: pi-spaces
|
|
3
|
+
description: "Trigger: pi-spaces, space, spaces, /space, pi-space, pi --space, PI_SPACE, PI_SPACE_HOME, PI_SPACES_COMMAND, which space, agent home, base home. Know which Pi space this session runs in, its home and start command, and how spaces are listed, created, switched and removed."
|
|
4
|
+
license: MIT
|
|
5
|
+
metadata:
|
|
6
|
+
author: jals1212
|
|
7
|
+
version: "1.0"
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## Activation Contract
|
|
11
|
+
|
|
12
|
+
Use when a task depends on which Pi agent home this session uses (settings, sign-ins, installed packages, sessions), or when the user asks about spaces. A space is a separate Pi agent home kept in a folder of the spaces folder; the base home is the one Pi started with. Pi reads its home once at startup, so one process stays in one space.
|
|
13
|
+
|
|
14
|
+
## Hard Rules
|
|
15
|
+
|
|
16
|
+
- Read where you run from the `<pi_space>` system prompt section; in a shell, from `PI_SPACE` (unset in the base home), `PI_SPACE_HOME` (this Pi's agent home) and `PI_SPACES_COMMAND` (the program that starts Pi, default `pi`). Never guess from `~/.pi/agent`.
|
|
17
|
+
- Files of this session's home (`settings.json`, `extensions/`, `sessions/`, installed packages) live under `PI_SPACE_HOME`; `pi install` and settings changes apply to this space only.
|
|
18
|
+
- Never read `auth.json` or other credential files in any home.
|
|
19
|
+
- You cannot move this session to another space. Ask the user to run `/space switch <name>` (interactive terminal only; Pi restarts), or start another Pi with `$PI_SPACES_COMMAND --space <name>`.
|
|
20
|
+
- Never set `PI_CODING_AGENT_DIR` yourself, and never create, rename or delete space folders with file commands; use `/space` or `pi-space`.
|
|
21
|
+
- Removing a space deletes everything in it: only on the user's explicit request, and keep the backup unless they say otherwise.
|
|
22
|
+
|
|
23
|
+
## Decision Gates
|
|
24
|
+
|
|
25
|
+
| Need | Action |
|
|
26
|
+
| --- | --- |
|
|
27
|
+
| Which space, home, command | `<pi_space>` section, or `echo "$PI_SPACE|$PI_SPACE_HOME|$PI_SPACES_COMMAND"` |
|
|
28
|
+
| List spaces from a shell | `pi-space list` (optional `@jals1212/pi-spaces-cli`) |
|
|
29
|
+
| Spaces folder | `PI_SPACES_DIR`, else `spaces` next to the base home (`PI_SPACES_BASE_HOME`, else this home) |
|
|
30
|
+
| Create a space | `pi-space --new <name>`, or ask the user for `/space new <name>` |
|
|
31
|
+
| Work in another space | User runs `/space switch <name>`, or `$PI_SPACES_COMMAND --space <name> "<prompt>"` |
|
|
32
|
+
| Remove a space | User request only: `pi-space --remove <name> --yes` keeps a backup in `.backups` |
|
|
33
|
+
|
|
34
|
+
Names: lowercase letters, digits, `-` and `_`, starting with a letter or digit, at most 64 characters; `base` is reserved for the base home.
|
|
35
|
+
|
|
36
|
+
## Execution Steps
|
|
37
|
+
|
|
38
|
+
1. Read the `<pi_space>` section or the three variables before touching any home file.
|
|
39
|
+
2. Pick the action from Decision Gates; prefer telling the user the `/space` command when it needs the interactive terminal.
|
|
40
|
+
3. Report the space and home you acted on.
|
|
41
|
+
|
|
42
|
+
## Output Contract
|
|
43
|
+
|
|
44
|
+
State the space (or "base home"), the agent home path, and the command or `/space` action used or proposed.
|
|
45
|
+
|
|
46
|
+
## References
|
|
47
|
+
|
|
48
|
+
- `../../README.md` — commands, environment variables and how switching works.
|
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 {
|
|
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
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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");
|
|
@@ -119,7 +157,7 @@ function switchTo(name: string, deps: SpaceCommandDeps): void {
|
|
|
119
157
|
/** Status line text: the active space, or nothing in the base home. */
|
|
120
158
|
export function statusText(dir: string, currentHome: string): string | undefined {
|
|
121
159
|
const active = activeSpace(dir, currentHome);
|
|
122
|
-
return active === undefined ? undefined :
|
|
160
|
+
return active === undefined ? undefined : `◆ ${active}`;
|
|
123
161
|
}
|
|
124
162
|
|
|
125
163
|
export function completeSpaceArgs(prefix: string, dir: string): { value: string; label: string }[] | null {
|
package/src/context.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// What this Pi process knows about its space: the active space, the agent home and the command
|
|
2
|
+
// that starts Pi. It is exported to the environment (for tools and child processes) and told to the model.
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { activeSpace, type Env } from "./spaces.ts";
|
|
5
|
+
|
|
6
|
+
export interface SpaceInfo {
|
|
7
|
+
/** The active space, or undefined in the base home. */
|
|
8
|
+
space: string | undefined;
|
|
9
|
+
/** The agent home this Pi runs with. */
|
|
10
|
+
home: string;
|
|
11
|
+
/** PI_SPACES_COMMAND, the program that starts Pi (default pi). */
|
|
12
|
+
command: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Read once, when the extension loads: Pi keeps its agent home for the whole process, and
|
|
17
|
+
* the active space cannot be renamed or removed, so the facts hold until Pi exits.
|
|
18
|
+
*/
|
|
19
|
+
export function spaceInfo(dir: string, agentHome: string, env: Env): SpaceInfo {
|
|
20
|
+
return {
|
|
21
|
+
space: activeSpace(dir, agentHome),
|
|
22
|
+
home: resolve(agentHome),
|
|
23
|
+
// A blank value counts as unset, so the default is pi, as in the pi-space command.
|
|
24
|
+
command: env.PI_SPACES_COMMAND?.trim() || "pi",
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Sets PI_SPACE (removed in the base home), PI_SPACE_HOME and PI_SPACES_COMMAND. */
|
|
29
|
+
export function exportSpaceInfo(env: Env, info: SpaceInfo): void {
|
|
30
|
+
if (info.space === undefined) delete env.PI_SPACE;
|
|
31
|
+
else env.PI_SPACE = info.space;
|
|
32
|
+
env.PI_SPACE_HOME = info.home;
|
|
33
|
+
env.PI_SPACES_COMMAND = info.command;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The system prompt section that tells the model where it runs. */
|
|
37
|
+
export function spacePrompt(info: SpaceInfo): string {
|
|
38
|
+
const where = info.space === undefined
|
|
39
|
+
? "This Pi runs in the base home, outside any space."
|
|
40
|
+
: `This Pi runs in the space "${info.space}" (PI_SPACE).`;
|
|
41
|
+
const home = `Agent home: ${info.home} (PI_SPACE_HOME).`;
|
|
42
|
+
const start = `\`${info.command} --space <name>\` starts it in a space`;
|
|
43
|
+
const manage = "`/space` lists, creates, removes and switches spaces";
|
|
44
|
+
const command = `Command that starts Pi: ${info.command} (PI_SPACES_COMMAND); ${start}, and ${manage}.`;
|
|
45
|
+
return [where, home, command].join("\n");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The part of a run's structured prompt options this package writes. */
|
|
49
|
+
export interface PromptSections {
|
|
50
|
+
sections?: Record<string, string>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Sets the <pi_space> section in a run's structured prompt options, creating the section map when
|
|
55
|
+
* it is missing and replacing an earlier pi_space section. Other sections and the rest of the
|
|
56
|
+
* prompt are left alone. Without structured options (an older Pi) nothing is added; the
|
|
57
|
+
* environment still has the facts.
|
|
58
|
+
*/
|
|
59
|
+
export function addSpaceSection(options: PromptSections | undefined, info: SpaceInfo): void {
|
|
60
|
+
if (options === undefined) return;
|
|
61
|
+
options.sections ??= {};
|
|
62
|
+
options.sections.pi_space = spacePrompt(info);
|
|
63
|
+
}
|
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
|
|
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(
|
|
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
|
|
249
|
+
return fallback;
|
|
191
250
|
}
|
|
192
|
-
if (!Array.isArray(packages)) return
|
|
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
|
|
267
|
+
return fallback;
|
|
209
268
|
}
|