pi-profile-switch 0.3.1 → 0.4.2
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/README.md +31 -36
- package/README.zh-CN.md +31 -36
- package/bin/pi-profile.js +11 -0
- package/bin/pi-profile.ts +65 -0
- package/bin/postinstall.d.ts +13 -0
- package/bin/postinstall.js +88 -0
- package/defaults/profiles.json +18 -0
- package/examples/profiles.json +31 -5
- package/extensions/pi-profile/index.ts +451 -0
- package/package.json +10 -13
- package/schemas/profiles.schema.json +22 -24
- package/src/extension-discovery.ts +347 -0
- package/src/json-file.ts +1 -21
- package/src/launcher/args.ts +57 -0
- package/src/launcher/discovery.ts +64 -0
- package/src/launcher/initial-profile.ts +179 -0
- package/src/launcher/model-check.ts +52 -0
- package/src/launcher/runtime-cleanup.ts +85 -0
- package/src/launcher/spawn.ts +82 -0
- package/src/mcp-config.ts +37 -153
- package/src/mcp-coordination.ts +29 -10
- package/src/profile-catalog-store.ts +31 -12
- package/src/profile-catalog.ts +45 -93
- package/src/profile-resolver.ts +239 -245
- package/src/project-trust.ts +82 -0
- package/src/runtime-state-store.ts +25 -41
- package/src/settings-generator.ts +541 -0
- package/src/skill-registry.ts +94 -0
- package/src/switching/apply-plan.ts +197 -0
- package/src/switching/customize.ts +62 -33
- package/src/switching/list-profiles.ts +9 -6
- package/src/switching/mcp-toggle.ts +14 -26
- package/src/switching/profile-crud.ts +31 -24
- package/src/switching/profile-wizard.ts +21 -49
- package/src/switching/status.ts +142 -72
- package/src/switching/switch-profile.ts +219 -0
- package/src/switching/tool-references.ts +40 -0
- package/src/workspace.ts +57 -0
- package/LICENSE +0 -21
- package/examples/profiles.example.json +0 -74
- package/extensions/pi-profile-switch/index.ts +0 -778
- package/src/adapter-presence.ts +0 -75
- package/src/default-profiles.ts +0 -59
- package/src/mcp-overlay-file.ts +0 -35
- package/src/mcp-overlay.ts +0 -122
- package/src/model-selection.ts +0 -64
- package/src/name-matching.ts +0 -50
- package/src/profile-badge.ts +0 -142
- package/src/profile-presets.ts +0 -61
- package/src/skill-selection.ts +0 -81
- package/src/startup-mcp-scope.ts +0 -271
- package/src/startup-selection.ts +0 -201
- package/src/switching/activate-profile.ts +0 -144
- package/src/switching/apply-profile.ts +0 -131
|
@@ -4,66 +4,47 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Constructed with the directory holding the state file: the real agent dir
|
|
6
6
|
* for global state, the project's `.pi` dir for project state (project
|
|
7
|
-
* state is only touched when
|
|
7
|
+
* state is only touched when the trust check passed). The launcher only
|
|
8
|
+
* reads — the initial CLI selection is transient by design; `/profile use`
|
|
9
|
+
* (ticket 05) writes both the selection and the rollback anchor.
|
|
8
10
|
*
|
|
9
|
-
* `activeProfile` is the saved selection
|
|
10
|
-
* `
|
|
11
|
-
*
|
|
11
|
+
* `activeProfile` is the saved selection restored on launch;
|
|
12
|
+
* `lastVerifiedProfile` is the rollback anchor: the last profile whose
|
|
13
|
+
* activation completed successfully. They differ only between a failed
|
|
14
|
+
* activation and its rollback.
|
|
12
15
|
*
|
|
13
16
|
* A missing or malformed state file is not an error on read — it simply
|
|
14
17
|
* means "fall back to the default profile". Unexpected I/O errors
|
|
15
|
-
* propagate.
|
|
16
|
-
*
|
|
17
|
-
* ignored; the next write drops them.
|
|
18
|
+
* propagate. Writes replace the file wholesale (both fields are always
|
|
19
|
+
* written together by the switch path).
|
|
18
20
|
*/
|
|
19
21
|
|
|
20
22
|
import { mkdir, writeFile } from "node:fs/promises";
|
|
21
23
|
import path from "node:path";
|
|
22
24
|
|
|
23
25
|
import { isRecord, readJsonFile } from "./json-file.ts";
|
|
24
|
-
import type { ProfileSource } from "./profile-catalog.ts";
|
|
25
|
-
|
|
26
|
-
/** The state directory for one profile's source scope: the project's `.pi`
|
|
27
|
-
* dir for project profiles, the agent dir otherwise (built-in `default` is
|
|
28
|
-
* treated as global). */
|
|
29
|
-
export function stateDirFor(source: ProfileSource, dirs: { agentDir: string; cwd: string }): string {
|
|
30
|
-
return source === "project" ? path.join(dirs.cwd, ".pi") : dirs.agentDir;
|
|
31
|
-
}
|
|
32
26
|
|
|
33
27
|
export interface RuntimeState {
|
|
34
28
|
activeProfile?: string;
|
|
29
|
+
lastVerifiedProfile?: string;
|
|
30
|
+
/** The runtime overlay: temporary narrowing of the active profile
|
|
31
|
+
* (ticket 06). Never written to catalogs, never applied at launch —
|
|
32
|
+
* only in-session switches/reloads read it. */
|
|
35
33
|
overlay?: RuntimeOverlay;
|
|
36
|
-
/** Patch-only marker: clears `activeProfile` from the store it is sent
|
|
37
|
-
* to, and to that store alone. A profile switch sends it to the OTHER
|
|
38
|
-
* scope's store, so a stale project selection cannot shadow the new
|
|
39
|
-
* global choice on the next startup. Never stored, never read back. */
|
|
40
|
-
otherActiveProfile?: undefined;
|
|
41
34
|
}
|
|
42
35
|
|
|
43
36
|
export interface RuntimeOverlay {
|
|
44
37
|
disabledSkills?: string[];
|
|
45
|
-
|
|
38
|
+
disabledExtensions?: string[];
|
|
39
|
+
disabledMcps?: string[];
|
|
46
40
|
/** Replaces the profile's tool references when set. */
|
|
47
41
|
tools?: string[];
|
|
48
42
|
}
|
|
49
43
|
|
|
50
|
-
/** True when an overlay actually narrows the active profile. An overlay whose
|
|
51
|
-
* fields were all removed again (`/profile customize enable …`) is not a
|
|
52
|
-
* difference from the catalog, and `parseOverlay` drops an empty overlay on
|
|
53
|
-
* read. A `tools: []` override is a difference: it selects no tools. */
|
|
54
|
-
export function overlayNarrows(overlay: RuntimeOverlay | undefined): boolean {
|
|
55
|
-
if (overlay === undefined) return false;
|
|
56
|
-
return (
|
|
57
|
-
(overlay.disabledSkills?.length ?? 0) > 0 ||
|
|
58
|
-
(overlay.disabledMcp?.length ?? 0) > 0 ||
|
|
59
|
-
overlay.tools !== undefined
|
|
60
|
-
);
|
|
61
|
-
}
|
|
62
|
-
|
|
63
44
|
function parseOverlay(value: unknown): RuntimeOverlay | undefined {
|
|
64
45
|
if (!isRecord(value)) return undefined;
|
|
65
46
|
const overlay: RuntimeOverlay = {};
|
|
66
|
-
for (const key of ["disabledSkills", "
|
|
47
|
+
for (const key of ["disabledSkills", "disabledExtensions", "disabledMcps", "tools"] as const) {
|
|
67
48
|
const list = value[key];
|
|
68
49
|
if (Array.isArray(list) && list.every((entry) => typeof entry === "string")) {
|
|
69
50
|
overlay[key] = list;
|
|
@@ -88,6 +69,9 @@ export class RuntimeStateStore {
|
|
|
88
69
|
if (typeof result.value.activeProfile === "string") {
|
|
89
70
|
state.activeProfile = result.value.activeProfile;
|
|
90
71
|
}
|
|
72
|
+
if (typeof result.value.lastVerifiedProfile === "string") {
|
|
73
|
+
state.lastVerifiedProfile = result.value.lastVerifiedProfile;
|
|
74
|
+
}
|
|
91
75
|
const overlay = parseOverlay(result.value.overlay);
|
|
92
76
|
if (overlay !== undefined) {
|
|
93
77
|
state.overlay = overlay;
|
|
@@ -97,15 +81,12 @@ export class RuntimeStateStore {
|
|
|
97
81
|
|
|
98
82
|
async write(state: RuntimeState): Promise<void> {
|
|
99
83
|
await mkdir(path.dirname(this.#statePath), { recursive: true });
|
|
100
|
-
|
|
101
|
-
if (state.activeProfile !== undefined) document.activeProfile = state.activeProfile;
|
|
102
|
-
if (state.overlay !== undefined) document.overlay = state.overlay;
|
|
103
|
-
await writeFile(this.#statePath, `${JSON.stringify(document, null, 2)}\n`);
|
|
84
|
+
await writeFile(this.#statePath, `${JSON.stringify(state, null, 2)}\n`);
|
|
104
85
|
}
|
|
105
86
|
|
|
106
87
|
/** Read-modify-write merge. A field set to `undefined` is deleted; absent
|
|
107
88
|
* fields keep their stored value. Used by the switch/customize paths so
|
|
108
|
-
* one concern (selection, overlay) never clobbers another. */
|
|
89
|
+
* one concern (selection, anchor, overlay) never clobbers another. */
|
|
109
90
|
async update(patch: Partial<RuntimeState>): Promise<RuntimeState> {
|
|
110
91
|
const current = await this.read();
|
|
111
92
|
const next: RuntimeState = { ...current };
|
|
@@ -113,11 +94,14 @@ export class RuntimeStateStore {
|
|
|
113
94
|
if (patch.activeProfile === undefined) delete next.activeProfile;
|
|
114
95
|
else next.activeProfile = patch.activeProfile;
|
|
115
96
|
}
|
|
97
|
+
if ("lastVerifiedProfile" in patch) {
|
|
98
|
+
if (patch.lastVerifiedProfile === undefined) delete next.lastVerifiedProfile;
|
|
99
|
+
else next.lastVerifiedProfile = patch.lastVerifiedProfile;
|
|
100
|
+
}
|
|
116
101
|
if ("overlay" in patch) {
|
|
117
102
|
if (patch.overlay === undefined) delete next.overlay;
|
|
118
103
|
else next.overlay = patch.overlay;
|
|
119
104
|
}
|
|
120
|
-
if ("otherActiveProfile" in patch) delete next.activeProfile;
|
|
121
105
|
await this.write(next);
|
|
122
106
|
return next;
|
|
123
107
|
}
|
|
@@ -0,0 +1,541 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SettingsGenerator: materializes an ActivationPlan as a pi-profile-owned
|
|
3
|
+
* runtime directory (ADR-0005).
|
|
4
|
+
*
|
|
5
|
+
* Two entry points:
|
|
6
|
+
* - `generateRuntimeDir` (launcher): mkdtemp a fresh runtime dir, write the
|
|
7
|
+
* files, link state (auth/models/mcp/npm/git/bin; trust.json only for
|
|
8
|
+
* default), derive env + flags.
|
|
9
|
+
* - `writeRuntimeFiles` (in-session switch, ticket 05): rewrite
|
|
10
|
+
* settings.json + pi-profile.json inside the EXISTING runtime dir (the
|
|
11
|
+
* running process's PI_CODING_AGENT_DIR cannot move), and transition the
|
|
12
|
+
* trust.json link to match the new plan's filter mode.
|
|
13
|
+
*
|
|
14
|
+
* For the built-in `default` profile the generated settings preserve the
|
|
15
|
+
* user's global settings untouched and re-include the real agent dir's
|
|
16
|
+
* resource dirs (their discovery root moves with `PI_CODING_AGENT_DIR`), so
|
|
17
|
+
* the spawned pi behaves exactly like native `pi`.
|
|
18
|
+
*
|
|
19
|
+
* For named profiles the generated settings encode the profile's selection
|
|
20
|
+
* per the filtering model (see docs/architecture/overview.md):
|
|
21
|
+
* - agentDir-scope resources: additive allowlist paths (the discovery root
|
|
22
|
+
* moved, so nothing auto-discovered from the real agent dir)
|
|
23
|
+
* - `~/.agents` skills: always auto-discovered, so unselected ones are
|
|
24
|
+
* force-excluded with `-<path>` entries
|
|
25
|
+
* - packages: user-configured package entries rewritten to object form with
|
|
26
|
+
* per-type allowlists (unmanaged types keep the user's key or Pi's default)
|
|
27
|
+
* - `defaultProjectTrust: "never"` suppresses all project auto-discovery
|
|
28
|
+
* (project resources enter only through the trust-gated resolver)
|
|
29
|
+
* - project `packages` are stripped from the settings merge (project
|
|
30
|
+
* packages are unsupported — the key would install into the global npm
|
|
31
|
+
* root as a launch side effect)
|
|
32
|
+
* - unmanaged kinds (prompts, themes) pass through: the user's arrays are
|
|
33
|
+
* preserved and the real agent dir's prompts/themes dirs re-included
|
|
34
|
+
* - tools/model become generated flags; the launch plan file feeds the
|
|
35
|
+
* in-pi extension (instructions injection, status)
|
|
36
|
+
*
|
|
37
|
+
* User configuration files are never modified.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
import { existsSync, realpathSync } from "node:fs";
|
|
41
|
+
import { mkdir, mkdtemp, lstat, readdir, readFile, readlink, rm, stat, symlink, writeFile } from "node:fs/promises";
|
|
42
|
+
import { homedir } from "node:os";
|
|
43
|
+
import path from "node:path";
|
|
44
|
+
|
|
45
|
+
import type { ActivationPlan } from "./profile-resolver.ts";
|
|
46
|
+
import { getInstancesRootDir } from "./workspace.ts";
|
|
47
|
+
import { isRecord } from "./json-file.ts";
|
|
48
|
+
import type { SkillEntry } from "./skill-registry.ts";
|
|
49
|
+
|
|
50
|
+
/** A configured global package and its resolved install/local root. */
|
|
51
|
+
export interface ConfiguredPackageRoot {
|
|
52
|
+
/** The source string exactly as written in the user's settings. */
|
|
53
|
+
source: string;
|
|
54
|
+
/** Absolute install/local root; undefined when not resolvable offline. */
|
|
55
|
+
root?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Full discovery results the generator needs beyond the plan itself:
|
|
59
|
+
* the complete skill set (for `~/.agents` exclusions) and configured
|
|
60
|
+
* package roots (for classifying extension entries). */
|
|
61
|
+
export interface DiscoveryContext {
|
|
62
|
+
skills: SkillEntry[];
|
|
63
|
+
packages: ConfiguredPackageRoot[];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface GenerateOptions {
|
|
67
|
+
/** The user's real agent dir (e.g. ~/.pi/agent). */
|
|
68
|
+
agentDir: string;
|
|
69
|
+
/** Required for selection plans; unused for the default profile. */
|
|
70
|
+
discovery?: DiscoveryContext;
|
|
71
|
+
/** The trusted project's `.pi/settings.json` content (already parsed).
|
|
72
|
+
* Only pass when the resolver's trust check passed; merged into the
|
|
73
|
+
* generated base per Pi's merge rules for selection plans. Ignored for
|
|
74
|
+
* the default profile (Pi reads project settings natively there). */
|
|
75
|
+
projectSettings?: Record<string, unknown>;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface GeneratedRuntime {
|
|
79
|
+
/** The generated runtime directory (becomes PI_CODING_AGENT_DIR). */
|
|
80
|
+
runtimeDir: string;
|
|
81
|
+
/** Environment variables for the spawned pi process. */
|
|
82
|
+
env: Record<string, string>;
|
|
83
|
+
/** Extra pi flags derived from the plan (e.g. --tools, --model). Empty for default. */
|
|
84
|
+
flags: string[];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Files managed explicitly by pi-profile in runtimeDir; excluded from auto-symlinking. */
|
|
88
|
+
export const MANAGED_INSTANCE_FILES = new Set([
|
|
89
|
+
"settings.json",
|
|
90
|
+
"mcp.json",
|
|
91
|
+
"APPEND_SYSTEM.md",
|
|
92
|
+
"pi-profile.json",
|
|
93
|
+
"trust.json",
|
|
94
|
+
"pid",
|
|
95
|
+
"extensions",
|
|
96
|
+
]);
|
|
97
|
+
|
|
98
|
+
/** Resource dirs rooted at the real agent dir, re-included for the default
|
|
99
|
+
* profile because PI_CODING_AGENT_DIR moves the discovery root. */
|
|
100
|
+
const RESOURCE_DIR_KINDS = ["skills", "extensions", "prompts", "themes"] as const;
|
|
101
|
+
|
|
102
|
+
/** Unmanaged resource dirs re-included for every profile (pi-profile does
|
|
103
|
+
* not manage prompt templates or themes). */
|
|
104
|
+
const UNMANAGED_DIR_KINDS = ["prompts", "themes"] as const;
|
|
105
|
+
|
|
106
|
+
async function exists(filePath: string): Promise<boolean> {
|
|
107
|
+
try {
|
|
108
|
+
await stat(filePath);
|
|
109
|
+
return true;
|
|
110
|
+
} catch {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function toPosix(filePath: string): string {
|
|
116
|
+
return filePath.split(path.sep).join("/");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Mirrors Pi's own deepMergeSettings: plain objects merge recursively,
|
|
120
|
+
* everything else (arrays, primitives) is replaced by the override. */
|
|
121
|
+
function deepMergeSettings(base: Record<string, unknown>, overrides: Record<string, unknown>): Record<string, unknown> {
|
|
122
|
+
const result: Record<string, unknown> = { ...base };
|
|
123
|
+
for (const [key, overrideValue] of Object.entries(overrides)) {
|
|
124
|
+
if (overrideValue === undefined) continue;
|
|
125
|
+
const baseValue = result[key];
|
|
126
|
+
result[key] =
|
|
127
|
+
isRecord(baseValue) && isRecord(overrideValue)
|
|
128
|
+
? deepMergeSettings(baseValue, overrideValue)
|
|
129
|
+
: overrideValue;
|
|
130
|
+
}
|
|
131
|
+
return result;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function tryRealpath(p: string): string {
|
|
135
|
+
try {
|
|
136
|
+
return realpathSync(p);
|
|
137
|
+
} catch {
|
|
138
|
+
return path.resolve(p);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function isUnderPath(target: string, root: string): boolean {
|
|
143
|
+
const relative = path.relative(root, target);
|
|
144
|
+
if (relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative)) {
|
|
145
|
+
return true;
|
|
146
|
+
}
|
|
147
|
+
const normRelative = path.relative(tryRealpath(root), tryRealpath(target));
|
|
148
|
+
return normRelative !== "" && !normRelative.startsWith("..") && !path.isAbsolute(normRelative);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** The HOME-level ~/.agents/skills dir: always auto-discovered by Pi,
|
|
152
|
+
* unsuppressible via PI_CODING_AGENT_DIR, so it needs exclusion entries. */
|
|
153
|
+
function homeAgentsSkillsDir(): string {
|
|
154
|
+
return path.join(process.env.HOME ?? homedir(), ".agents", "skills");
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function buildSelectionSettings(
|
|
158
|
+
plan: ActivationPlan,
|
|
159
|
+
userSettings: Record<string, unknown>,
|
|
160
|
+
agentDir: string,
|
|
161
|
+
discovery: DiscoveryContext,
|
|
162
|
+
runtimeDir: string,
|
|
163
|
+
): Record<string, unknown> {
|
|
164
|
+
const settings = { ...userSettings };
|
|
165
|
+
|
|
166
|
+
// --- skills ---
|
|
167
|
+
// Project-scope selections are emitted before user-scope ones: Pi's
|
|
168
|
+
// same-name collision rule is first-wins, and project resources must keep
|
|
169
|
+
// their native priority (ticket 03).
|
|
170
|
+
const orderedSelectedSkills = [...plan.skills].sort((a, b) => {
|
|
171
|
+
const aProject = a.scope === "project" ? 0 : 1;
|
|
172
|
+
const bProject = b.scope === "project" ? 0 : 1;
|
|
173
|
+
return aProject - bProject;
|
|
174
|
+
});
|
|
175
|
+
const selectedPaths = new Set(plan.skills.map((skill) => skill.filePath));
|
|
176
|
+
const skillEntries: string[] = [];
|
|
177
|
+
for (const skill of orderedSelectedSkills) {
|
|
178
|
+
if (skill.origin === "package") continue; // encoded in the packages allowlist
|
|
179
|
+
if (isUnderPath(skill.filePath, homeAgentsSkillsDir())) continue; // auto-discovered anyway
|
|
180
|
+
skillEntries.push(skill.filePath);
|
|
181
|
+
}
|
|
182
|
+
for (const skill of discovery.skills) {
|
|
183
|
+
if (skill.origin === "package") continue;
|
|
184
|
+
if (selectedPaths.has(skill.filePath)) continue;
|
|
185
|
+
|
|
186
|
+
// If the skill is in the real agentDir, Pi will discover it via the symlink.
|
|
187
|
+
// We must exclude the symlink path so Pi actually excludes it.
|
|
188
|
+
// Lexical paths only: Pi matches `-` exclusions against the raw
|
|
189
|
+
// discovered path without resolving symlinks. Resolving realpaths here
|
|
190
|
+
// escapes runtimeDir whenever an agentDir skill is a symlink to outside
|
|
191
|
+
// the agent dir, and the exclusion then silently matches nothing.
|
|
192
|
+
if (isUnderPath(skill.filePath, agentDir)) {
|
|
193
|
+
const rel = path.relative(agentDir, skill.filePath);
|
|
194
|
+
skillEntries.push(`-${path.join(runtimeDir, rel)}`);
|
|
195
|
+
} else {
|
|
196
|
+
skillEntries.push(`-${skill.filePath}`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
settings.skills = skillEntries;
|
|
200
|
+
|
|
201
|
+
// --- extensions ---
|
|
202
|
+
// Entries under a package root are encoded in that package's allowlist;
|
|
203
|
+
// everything else becomes an additive absolute path.
|
|
204
|
+
const packageRoots = discovery.packages
|
|
205
|
+
.filter((pkg): pkg is ConfiguredPackageRoot & { root: string } => pkg.root !== undefined)
|
|
206
|
+
.map((pkg) => ({ ...pkg, root: pkg.root }));
|
|
207
|
+
const packageExtensions = new Map<string, string[]>();
|
|
208
|
+
const extensionEntries: string[] = [];
|
|
209
|
+
for (const extension of plan.extensions) {
|
|
210
|
+
const owner = packageRoots.find((pkg) => isUnderPath(extension.entry, pkg.root));
|
|
211
|
+
if (owner === undefined) {
|
|
212
|
+
extensionEntries.push(extension.entry);
|
|
213
|
+
} else {
|
|
214
|
+
const list = packageExtensions.get(owner.source) ?? [];
|
|
215
|
+
list.push(toPosix(path.relative(owner.root, extension.entry)));
|
|
216
|
+
packageExtensions.set(owner.source, list);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
settings.extensions = extensionEntries;
|
|
220
|
+
|
|
221
|
+
// --- packages ---
|
|
222
|
+
const userPackages = Array.isArray(userSettings.packages) ? userSettings.packages : [];
|
|
223
|
+
if (userPackages.length > 0) {
|
|
224
|
+
const packageSkills = new Map<string, string[]>();
|
|
225
|
+
for (const skill of plan.skills) {
|
|
226
|
+
if (skill.origin !== "package" || skill.baseDir === undefined) continue;
|
|
227
|
+
const list = packageSkills.get(skill.source) ?? [];
|
|
228
|
+
list.push(toPosix(path.relative(skill.baseDir, skill.filePath)));
|
|
229
|
+
packageSkills.set(skill.source, list);
|
|
230
|
+
}
|
|
231
|
+
settings.packages = userPackages.map((pkg) => {
|
|
232
|
+
const source = typeof pkg === "string" ? pkg : (pkg as { source: string }).source;
|
|
233
|
+
const base: Record<string, unknown> =
|
|
234
|
+
typeof pkg === "object" && pkg !== null ? { ...(pkg as Record<string, unknown>) } : { source };
|
|
235
|
+
delete base.extensions;
|
|
236
|
+
delete base.skills;
|
|
237
|
+
const rewritten: Record<string, unknown> = {
|
|
238
|
+
source,
|
|
239
|
+
...base,
|
|
240
|
+
skills: packageSkills.get(source) ?? [],
|
|
241
|
+
extensions: packageExtensions.get(source) ?? [],
|
|
242
|
+
};
|
|
243
|
+
return rewritten;
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// --- unmanaged dirs pass through (prompts/themes) ---
|
|
248
|
+
for (const kind of UNMANAGED_DIR_KINDS) {
|
|
249
|
+
const resourceDir = path.join(agentDir, kind);
|
|
250
|
+
if (existsSync(resourceDir)) {
|
|
251
|
+
const entries = Array.isArray(settings[kind]) ? (settings[kind] as unknown[]) : [];
|
|
252
|
+
settings[kind] = [...entries, resourceDir];
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// --- profile defaults (Ticket 04) ---
|
|
257
|
+
if (plan.model !== undefined) {
|
|
258
|
+
settings.defaultProvider = plan.model.provider;
|
|
259
|
+
settings.defaultModel = plan.model.id;
|
|
260
|
+
if (plan.model.thinkingLevel !== undefined) {
|
|
261
|
+
settings.defaultThinkingLevel = plan.model.thinkingLevel;
|
|
262
|
+
} else {
|
|
263
|
+
delete settings.defaultThinkingLevel;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
if (plan.tools !== undefined) {
|
|
267
|
+
settings.defaultTools = plan.tools;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Project auto-discovery is suppressed entirely; selected project
|
|
271
|
+
// resources enter additively through the trust-gated resolver.
|
|
272
|
+
settings.defaultProjectTrust = "never";
|
|
273
|
+
return settings;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export interface RuntimeFileOptions {
|
|
277
|
+
/** The user's real agent dir (e.g. ~/.pi/agent). */
|
|
278
|
+
agentDir: string;
|
|
279
|
+
/** Required for selection plans; unused for the default profile. */
|
|
280
|
+
discovery?: DiscoveryContext;
|
|
281
|
+
/** The trusted project's `.pi/settings.json` content (already parsed). */
|
|
282
|
+
projectSettings?: Record<string, unknown>;
|
|
283
|
+
/** Extra launch-plan fields written by the in-session switch path:
|
|
284
|
+
* `switchedFrom` triggers the one-shot change summary; `persistSelection`
|
|
285
|
+
* tells the post-reload extension instance to save the selection and
|
|
286
|
+
* record the rollback anchor; `clearOverlay` drops the stored overlay
|
|
287
|
+
* (a profile switch discards the previous profile's overlay).
|
|
288
|
+
* `previousResolved` carries the pre-switch resolved name sets so
|
|
289
|
+
* `/profile status` can report glob deltas (ticket 07). */
|
|
290
|
+
planExtras?: {
|
|
291
|
+
switchedFrom?: string;
|
|
292
|
+
persistSelection?: boolean;
|
|
293
|
+
clearOverlay?: boolean;
|
|
294
|
+
previousResolved?: ResolvedNames;
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Computes the generated settings for a plan (pure-ish: reads the user's
|
|
299
|
+
* real settings + unmanaged dir existence, writes nothing). */
|
|
300
|
+
async function computeSettings(
|
|
301
|
+
plan: ActivationPlan,
|
|
302
|
+
options: RuntimeFileOptions,
|
|
303
|
+
runtimeDir: string,
|
|
304
|
+
): Promise<Record<string, unknown>> {
|
|
305
|
+
const { agentDir } = options;
|
|
306
|
+
const userSettingsPath = path.join(agentDir, "settings.json");
|
|
307
|
+
const userSettings: Record<string, unknown> = (await exists(userSettingsPath))
|
|
308
|
+
? JSON.parse(await readFile(userSettingsPath, "utf8"))
|
|
309
|
+
: {};
|
|
310
|
+
|
|
311
|
+
if (plan.filter === "none") {
|
|
312
|
+
// default profile: the user's global settings plus re-inclusion of the
|
|
313
|
+
// real agent dir's resource dirs. User-defined keys, including their own
|
|
314
|
+
// resource patterns and enable/disable state, are preserved untouched.
|
|
315
|
+
// Project settings are NOT merged here: with native trust behavior, Pi
|
|
316
|
+
// reads the project's settings itself.
|
|
317
|
+
const settings = { ...userSettings };
|
|
318
|
+
for (const kind of RESOURCE_DIR_KINDS) {
|
|
319
|
+
const resourceDir = path.join(agentDir, kind);
|
|
320
|
+
if (await exists(resourceDir)) {
|
|
321
|
+
const entries = Array.isArray(settings[kind]) ? (settings[kind] as unknown[]) : [];
|
|
322
|
+
settings[kind] = [...entries, resourceDir];
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
return settings;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Selection plans: the trusted project's settings merge into the base
|
|
329
|
+
// per Pi's merge rules (project wins, nested objects merge), then the
|
|
330
|
+
// filtering encoding replaces the managed keys on top. With
|
|
331
|
+
// defaultProjectTrust: "never", Pi itself never reads project settings.
|
|
332
|
+
//
|
|
333
|
+
// The project's `packages` key is stripped: project packages install
|
|
334
|
+
// under the project's .pi/npm and are unreferenceable in generated
|
|
335
|
+
// global-scope settings — merging the key would make Pi install them
|
|
336
|
+
// into the (symlinked) global npm root as a launch side effect.
|
|
337
|
+
let base = { ...userSettings };
|
|
338
|
+
if (options.projectSettings !== undefined) {
|
|
339
|
+
const { packages: _stripped, ...mergeable } = options.projectSettings;
|
|
340
|
+
base = deepMergeSettings(base, mergeable);
|
|
341
|
+
}
|
|
342
|
+
return buildSelectionSettings(plan, base, agentDir, options.discovery ?? { skills: [], packages: [] }, runtimeDir);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** Resolved name sets, carried in the launch plan for glob-delta reporting. */
|
|
346
|
+
export interface ResolvedNames {
|
|
347
|
+
skills: string[];
|
|
348
|
+
extensions: string[];
|
|
349
|
+
tools?: string[];
|
|
350
|
+
mcps?: string[];
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** Writes settings.json + pi-profile.json into an existing runtime dir and
|
|
354
|
+
* transitions the trust.json link to the plan's filter mode: linked for
|
|
355
|
+
* `default` (native trust behavior), absent for named profiles (a stored
|
|
356
|
+
* trust decision would beat the generated `defaultProjectTrust: "never"`
|
|
357
|
+
* inside Pi and re-enable unfiltered project auto-discovery). */
|
|
358
|
+
export async function writeRuntimeFiles(
|
|
359
|
+
runtimeDir: string,
|
|
360
|
+
plan: ActivationPlan,
|
|
361
|
+
options: RuntimeFileOptions,
|
|
362
|
+
): Promise<void> {
|
|
363
|
+
const settings = await computeSettings(plan, options, runtimeDir);
|
|
364
|
+
await writeFile(path.join(runtimeDir, "settings.json"), `${JSON.stringify(settings, null, 2)}\n`);
|
|
365
|
+
|
|
366
|
+
// The launch plan feeds the in-pi extension: instructions injection,
|
|
367
|
+
// tool/model re-application after reload, MCP coordination, switching.
|
|
368
|
+
// agentDir is the REAL agent dir — the extension needs it for trust
|
|
369
|
+
// checks, state files, and catalog/registry reads (its own
|
|
370
|
+
// PI_CODING_AGENT_DIR points at this runtime dir).
|
|
371
|
+
await writeFile(
|
|
372
|
+
path.join(runtimeDir, "pi-profile.json"),
|
|
373
|
+
`${JSON.stringify(
|
|
374
|
+
{
|
|
375
|
+
profile: plan.profile,
|
|
376
|
+
source: plan.source,
|
|
377
|
+
agentDir: options.agentDir,
|
|
378
|
+
...(plan.instructions !== undefined ? { instructions: plan.instructions } : {}),
|
|
379
|
+
...(plan.model !== undefined ? { model: plan.model } : {}),
|
|
380
|
+
...(plan.tools !== undefined ? { tools: plan.tools } : {}),
|
|
381
|
+
...(plan.toolReferences !== undefined ? { toolReferences: plan.toolReferences } : {}),
|
|
382
|
+
...(plan.mcps !== undefined ? { mcps: plan.mcps } : {}),
|
|
383
|
+
// The resolved sets feed /profile status (absolute paths) and the
|
|
384
|
+
// glob-delta diff against the previous activation.
|
|
385
|
+
resolved: {
|
|
386
|
+
skills: plan.skills.map((skill) => ({ name: skill.name, filePath: skill.filePath })),
|
|
387
|
+
extensions: plan.extensions,
|
|
388
|
+
},
|
|
389
|
+
// Zero-match glob references (ADR-0006) — surfaced by /profile status
|
|
390
|
+
// so a typo'd glob is visible instead of silently selecting nothing.
|
|
391
|
+
...(plan.unmatched !== undefined ? { unmatched: plan.unmatched } : {}),
|
|
392
|
+
...options.planExtras,
|
|
393
|
+
},
|
|
394
|
+
null,
|
|
395
|
+
2,
|
|
396
|
+
)}\n`,
|
|
397
|
+
);
|
|
398
|
+
|
|
399
|
+
const trustLink = path.join(runtimeDir, "trust.json");
|
|
400
|
+
const trustTarget = path.join(options.agentDir, "trust.json");
|
|
401
|
+
if (plan.filter === "none") {
|
|
402
|
+
if ((await exists(trustTarget)) && !(await exists(trustLink))) {
|
|
403
|
+
await symlink(trustTarget, trustLink);
|
|
404
|
+
}
|
|
405
|
+
} else if (await exists(trustLink)) {
|
|
406
|
+
await rm(trustLink);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// MCP Servers generation (Ticket 04)
|
|
410
|
+
const mcpTarget = path.join(options.agentDir, "mcp.json");
|
|
411
|
+
const mcpInstancePath = path.join(runtimeDir, "mcp.json");
|
|
412
|
+
if (plan.mcps === undefined) {
|
|
413
|
+
// No restrictions, symlink
|
|
414
|
+
if (await exists(mcpTarget)) {
|
|
415
|
+
try { await rm(mcpInstancePath); } catch {}
|
|
416
|
+
await symlink(mcpTarget, mcpInstancePath);
|
|
417
|
+
}
|
|
418
|
+
} else {
|
|
419
|
+
// Filter MCP servers
|
|
420
|
+
try { await rm(mcpInstancePath); } catch {}
|
|
421
|
+
if (await exists(mcpTarget)) {
|
|
422
|
+
try {
|
|
423
|
+
const mcpContent = await readFile(mcpTarget, "utf8");
|
|
424
|
+
let mcpParsed = JSON.parse(mcpContent);
|
|
425
|
+
if (isRecord(mcpParsed) && isRecord(mcpParsed.mcpServers)) {
|
|
426
|
+
const filteredServers: Record<string, unknown> = {};
|
|
427
|
+
for (const serverName of plan.mcps) {
|
|
428
|
+
if (mcpParsed.mcpServers[serverName] !== undefined) {
|
|
429
|
+
filteredServers[serverName] = mcpParsed.mcpServers[serverName];
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
mcpParsed.mcpServers = filteredServers;
|
|
433
|
+
await writeFile(mcpInstancePath, JSON.stringify(mcpParsed, null, 2));
|
|
434
|
+
} else {
|
|
435
|
+
// Malformed or empty, write empty
|
|
436
|
+
await writeFile(mcpInstancePath, JSON.stringify({ mcpServers: {} }, null, 2));
|
|
437
|
+
}
|
|
438
|
+
} catch {
|
|
439
|
+
await writeFile(mcpInstancePath, JSON.stringify({ mcpServers: {} }, null, 2));
|
|
440
|
+
}
|
|
441
|
+
} else {
|
|
442
|
+
await writeFile(mcpInstancePath, JSON.stringify({ mcpServers: {} }, null, 2));
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// Instructions generation (Ticket 04)
|
|
447
|
+
const appendSystemPath = path.join(runtimeDir, "APPEND_SYSTEM.md");
|
|
448
|
+
if (plan.instructions !== undefined && plan.instructions.trim() !== "") {
|
|
449
|
+
await writeFile(appendSystemPath, plan.instructions);
|
|
450
|
+
} else {
|
|
451
|
+
try { await rm(appendSystemPath); } catch {}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// Full-fidelity symlink mirroring and dangling link cleanup (Ticket 02).
|
|
455
|
+
await syncAgentSymlinks(options.agentDir, runtimeDir);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Full-fidelity symlink mirroring of the user's real agentDir into runtimeDir (Ticket 02).
|
|
460
|
+
* - Excludes profile-managed files.
|
|
461
|
+
* - Mirrors both file and directory symlinks.
|
|
462
|
+
* - Detects and cleans up dangling or obsolete symlinks in runtimeDir.
|
|
463
|
+
* - Avoids recreating identical existing symlinks to minimize startup I/O.
|
|
464
|
+
*/
|
|
465
|
+
export async function syncAgentSymlinks(agentDir: string, runtimeDir: string): Promise<void> {
|
|
466
|
+
if (!existsSync(agentDir)) return;
|
|
467
|
+
if (path.resolve(agentDir) === path.resolve(runtimeDir)) return;
|
|
468
|
+
|
|
469
|
+
// Ensure the real sessions directory exists so it is always mirrored
|
|
470
|
+
await mkdir(path.join(agentDir, "sessions"), { recursive: true });
|
|
471
|
+
|
|
472
|
+
// 1. Clean up dangling or obsolete symlinks in runtimeDir
|
|
473
|
+
try {
|
|
474
|
+
const runtimeEntries = await readdir(runtimeDir);
|
|
475
|
+
for (const name of runtimeEntries) {
|
|
476
|
+
if (MANAGED_INSTANCE_FILES.has(name)) continue;
|
|
477
|
+
const linkPath = path.join(runtimeDir, name);
|
|
478
|
+
const target = path.join(agentDir, name);
|
|
479
|
+
try {
|
|
480
|
+
const linkStat = await lstat(linkPath);
|
|
481
|
+
if (linkStat.isSymbolicLink()) {
|
|
482
|
+
if (!existsSync(target)) {
|
|
483
|
+
await rm(linkPath, { recursive: true, force: true });
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
} catch {
|
|
487
|
+
// Best-effort cleanup
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
} catch {}
|
|
491
|
+
|
|
492
|
+
// 2. Mirror files and directories from agentDir to runtimeDir
|
|
493
|
+
try {
|
|
494
|
+
const entries = await readdir(agentDir);
|
|
495
|
+
for (const name of entries) {
|
|
496
|
+
if (MANAGED_INSTANCE_FILES.has(name)) continue;
|
|
497
|
+
|
|
498
|
+
const target = path.join(agentDir, name);
|
|
499
|
+
const linkPath = path.join(runtimeDir, name);
|
|
500
|
+
|
|
501
|
+
try {
|
|
502
|
+
const linkStat = await lstat(linkPath).catch(() => null);
|
|
503
|
+
if (linkStat) {
|
|
504
|
+
if (linkStat.isSymbolicLink()) {
|
|
505
|
+
const currentTarget = await readlink(linkPath).catch(() => null);
|
|
506
|
+
if (currentTarget === target) {
|
|
507
|
+
continue;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
await rm(linkPath, { recursive: true, force: true });
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
const info = await stat(target);
|
|
514
|
+
await symlink(target, linkPath, info.isDirectory() ? "dir" : "file");
|
|
515
|
+
} catch {
|
|
516
|
+
// Ignore broken source links or unreadable files
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
} catch {}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
export async function generateRuntimeDir(
|
|
523
|
+
plan: ActivationPlan,
|
|
524
|
+
options: GenerateOptions,
|
|
525
|
+
): Promise<GeneratedRuntime> {
|
|
526
|
+
const { agentDir } = options;
|
|
527
|
+
const runtimeDir = path.join(getInstancesRootDir(), plan.profile, "agent");
|
|
528
|
+
await mkdir(runtimeDir, { recursive: true });
|
|
529
|
+
|
|
530
|
+
await writeRuntimeFiles(runtimeDir, plan, options);
|
|
531
|
+
|
|
532
|
+
const flags: string[] = [];
|
|
533
|
+
|
|
534
|
+
return {
|
|
535
|
+
runtimeDir,
|
|
536
|
+
env: {
|
|
537
|
+
PI_CODING_AGENT_DIR: runtimeDir,
|
|
538
|
+
},
|
|
539
|
+
flags,
|
|
540
|
+
};
|
|
541
|
+
}
|