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.
Files changed (54) hide show
  1. package/README.md +31 -36
  2. package/README.zh-CN.md +31 -36
  3. package/bin/pi-profile.js +11 -0
  4. package/bin/pi-profile.ts +65 -0
  5. package/bin/postinstall.d.ts +13 -0
  6. package/bin/postinstall.js +88 -0
  7. package/defaults/profiles.json +18 -0
  8. package/examples/profiles.json +31 -5
  9. package/extensions/pi-profile/index.ts +451 -0
  10. package/package.json +10 -13
  11. package/schemas/profiles.schema.json +22 -24
  12. package/src/extension-discovery.ts +347 -0
  13. package/src/json-file.ts +1 -21
  14. package/src/launcher/args.ts +57 -0
  15. package/src/launcher/discovery.ts +64 -0
  16. package/src/launcher/initial-profile.ts +179 -0
  17. package/src/launcher/model-check.ts +52 -0
  18. package/src/launcher/runtime-cleanup.ts +85 -0
  19. package/src/launcher/spawn.ts +82 -0
  20. package/src/mcp-config.ts +37 -153
  21. package/src/mcp-coordination.ts +29 -10
  22. package/src/profile-catalog-store.ts +31 -12
  23. package/src/profile-catalog.ts +45 -93
  24. package/src/profile-resolver.ts +239 -245
  25. package/src/project-trust.ts +82 -0
  26. package/src/runtime-state-store.ts +25 -41
  27. package/src/settings-generator.ts +541 -0
  28. package/src/skill-registry.ts +94 -0
  29. package/src/switching/apply-plan.ts +197 -0
  30. package/src/switching/customize.ts +62 -33
  31. package/src/switching/list-profiles.ts +9 -6
  32. package/src/switching/mcp-toggle.ts +14 -26
  33. package/src/switching/profile-crud.ts +31 -24
  34. package/src/switching/profile-wizard.ts +21 -49
  35. package/src/switching/status.ts +142 -72
  36. package/src/switching/switch-profile.ts +219 -0
  37. package/src/switching/tool-references.ts +40 -0
  38. package/src/workspace.ts +57 -0
  39. package/LICENSE +0 -21
  40. package/examples/profiles.example.json +0 -74
  41. package/extensions/pi-profile-switch/index.ts +0 -778
  42. package/src/adapter-presence.ts +0 -75
  43. package/src/default-profiles.ts +0 -59
  44. package/src/mcp-overlay-file.ts +0 -35
  45. package/src/mcp-overlay.ts +0 -122
  46. package/src/model-selection.ts +0 -64
  47. package/src/name-matching.ts +0 -50
  48. package/src/profile-badge.ts +0 -142
  49. package/src/profile-presets.ts +0 -61
  50. package/src/skill-selection.ts +0 -81
  51. package/src/startup-mcp-scope.ts +0 -271
  52. package/src/startup-selection.ts +0 -201
  53. package/src/switching/activate-profile.ts +0 -144
  54. package/src/switching/apply-profile.ts +0 -131
@@ -1,23 +1,7 @@
1
1
  /**
2
2
  * ProfileCatalog: reads profile definitions from the global catalog
3
- * (`<agentDir>/profiles.json`) and, for trusted projects, the project
4
- * catalog (`<projectDir>/.pi/profiles.json`).
5
- *
6
- * ADR-0007 semantics:
7
- * - A profile references skills, MCP servers, and tools, and may declare
8
- * instructions and a model preset. Extensions are not a profile resource:
9
- * every installed extension loads natively in every profile.
10
- * - schemaVersion 1 is the only accepted version: any other value fails
11
- * loudly instead of guessing at a shape.
12
- * - Unknown fields (an `extensions` declaration left over from v0.1.0, an
13
- * inheritance key) are ignored silently; saving drops them, so a written
14
- * definition always matches the current shape.
15
- * - The catalog key for MCP servers is `mcps` (plural, like `skills` and
16
- * `tools`). The legacy name `mcp` is still READ as an alias so an
17
- * existing catalog keeps its allowlist; `mcps` wins when both keys are
18
- * present, and a value of the wrong type fails loudly either way.
19
- * Definitions always carry the canonical `mcps` field in memory, and
20
- * profile-catalog-store.ts drops a file's legacy key on its next write.
3
+ * (`~/.pi-profile-switch/profiles.json`, legacy fallback `<agentDir>/profiles.json`)
4
+ * and, for trusted projects, the project catalog (`<projectDir>/.pi/profiles.json`).
21
5
  *
22
6
  * Invariants:
23
7
  * - The built-in `default` profile never exists in either file and cannot be
@@ -25,14 +9,15 @@
25
9
  * - A project profile with the same name fully replaces the global
26
10
  * definition (no merge, no inheritance); removing the project entry
27
11
  * immediately reveals the global one.
28
- * - The caller passes `projectDir` only when Pi reports the project trusted
29
- * an untrusted project's catalog is never read.
12
+ * - The caller passes `projectDir` only when the resolver's trust check
13
+ * passed — an untrusted project's catalog is never read.
30
14
  * - A malformed catalog fails loudly (CatalogError) rather than silently
31
- * starting with an unintended selection.
15
+ * starting unfiltered.
32
16
  */
33
17
 
34
18
  import path from "node:path";
35
19
 
20
+ import { resolveGlobalProfilesPath } from "./workspace.ts";
36
21
  import { isRecord, readJsonFile } from "./json-file.ts";
37
22
 
38
23
  export const PROFILE_SCHEMA_VERSION = 1;
@@ -45,14 +30,19 @@ export interface ProfileModel {
45
30
  }
46
31
 
47
32
  /** A profile definition as stored in a catalog file. All fields optional:
48
- * undeclared fields leave Pi's behavior untouched. */
33
+ * undeclared fields leave Pi's behavior untouched (PRD default-first rule).
34
+ * Model fields mirror Pi's settings.json keys (defaultProvider/defaultModel/
35
+ * defaultThinkingLevel) for direct compatibility. */
49
36
  export interface ProfileDefinition {
50
37
  label?: string;
51
38
  description?: string;
52
39
  skills?: string[];
40
+ extensions?: string[];
53
41
  mcps?: string[];
54
42
  tools?: string[];
55
- model?: ProfileModel;
43
+ defaultProvider?: string;
44
+ defaultModel?: string;
45
+ defaultThinkingLevel?: string;
56
46
  instructions?: string;
57
47
  }
58
48
 
@@ -85,27 +75,6 @@ function readStringArray(value: unknown, field: string, profileName: string): st
85
75
  return value as string[];
86
76
  }
87
77
 
88
- /** Legacy catalog key names, still accepted on read (canonical name wins).
89
- * The write paths (/profile CRUD, /mcp enable|disable) delete the alias, so
90
- * a file migrates to the canonical spelling the next time it is saved. */
91
- export const LEGACY_FIELD_ALIASES = { mcps: "mcp" } as const;
92
-
93
- /** Reads an array-of-strings field under its canonical name or a legacy
94
- * alias. Both spellings are validated when present, so a value of the
95
- * wrong type never passes silently through either key. */
96
- function readAliasedStringArray(
97
- raw: Record<string, unknown>,
98
- canonical: string,
99
- legacy: string,
100
- profileName: string,
101
- ): string[] | undefined {
102
- for (const key of [canonical, legacy]) {
103
- if (raw[key] !== undefined) readStringArray(raw[key], key, profileName);
104
- }
105
- const source = raw[canonical] !== undefined ? canonical : legacy;
106
- return readStringArray(raw[source], source, profileName);
107
- }
108
-
109
78
  function readOptionalString(value: unknown, field: string, profileName: string): string | undefined {
110
79
  if (value === undefined) return undefined;
111
80
  if (typeof value !== "string") {
@@ -115,9 +84,7 @@ function readOptionalString(value: unknown, field: string, profileName: string):
115
84
  }
116
85
 
117
86
  /** Parses one raw profile definition; exported for the write-side store
118
- * (profile-catalog-store.ts) so anything written is loadable. Unknown
119
- * fields are ignored by design — an `extensions` key left over from
120
- * v0.1.0 is dropped silently, exactly like any other unknown key. */
87
+ * (profile-catalog-store.ts) so anything written is loadable. */
121
88
  export function parseProfileDefinition(name: string, raw: unknown): ProfileDefinition {
122
89
  if (!isRecord(raw)) {
123
90
  throw new CatalogError(`profile "${name}" must be an object`);
@@ -127,61 +94,52 @@ export function parseProfileDefinition(name: string, raw: unknown): ProfileDefin
127
94
  if (label !== undefined) definition.label = label;
128
95
  const description = readOptionalString(raw.description, "description", name);
129
96
  if (description !== undefined) definition.description = description;
130
- for (const field of ["skills", "tools"] as const) {
97
+ for (const field of ["skills", "extensions", "mcps", "tools"] as const) {
131
98
  const entries = readStringArray(raw[field], field, name);
132
99
  if (entries !== undefined) definition[field] = entries;
133
100
  }
134
- const mcps = readAliasedStringArray(raw, "mcps", LEGACY_FIELD_ALIASES.mcps, name);
135
- if (mcps !== undefined) definition.mcps = mcps;
136
- if (raw.model !== undefined) {
137
- if (!isRecord(raw.model) || typeof raw.model.provider !== "string" || typeof raw.model.id !== "string") {
138
- throw new CatalogError(`profile "${name}": "model" must be an object with string "provider" and "id"`);
139
- }
140
- const thinkingLevel = readOptionalString(raw.model.thinkingLevel, "model.thinkingLevel", name);
141
- definition.model = { provider: raw.model.provider, id: raw.model.id, ...(thinkingLevel ? { thinkingLevel } : {}) };
142
- }
101
+ const defaultProvider = readOptionalString(raw.defaultProvider, "defaultProvider", name);
102
+ if (defaultProvider !== undefined) definition.defaultProvider = defaultProvider;
103
+ const defaultModel = readOptionalString(raw.defaultModel, "defaultModel", name);
104
+ if (defaultModel !== undefined) definition.defaultModel = defaultModel;
105
+ const defaultThinkingLevel = readOptionalString(raw.defaultThinkingLevel, "defaultThinkingLevel", name);
106
+ if (defaultThinkingLevel !== undefined) definition.defaultThinkingLevel = defaultThinkingLevel;
143
107
  const instructions = readOptionalString(raw.instructions, "instructions", name);
144
108
  if (instructions !== undefined) definition.instructions = instructions;
145
109
  return definition;
146
110
  }
147
111
 
148
- /** Parses one catalog document. Missing files are handled by the caller;
149
- * this function sees only parsed JSON. */
150
- export function parseCatalogDocument(value: unknown, filePath: string): Map<string, ProfileDefinition> {
151
- if (!isRecord(value)) {
152
- throw new CatalogError(`${filePath}: catalog must be an object`);
112
+ /** Reads one catalog file; missing empty map, malformed CatalogError. */
113
+ async function loadCatalogFile(catalogPath: string): Promise<Map<string, ProfileDefinition>> {
114
+ const result = await readJsonFile(catalogPath);
115
+ const profiles = new Map<string, ProfileDefinition>();
116
+ if (!result.ok) {
117
+ if (result.reason === "missing") return profiles;
118
+ throw new CatalogError(`invalid JSON in ${catalogPath}`);
153
119
  }
154
- const version = value.schemaVersion;
155
- if (version !== PROFILE_SCHEMA_VERSION) {
120
+ const parsed = result.value;
121
+ if (!isRecord(parsed)) {
122
+ throw new CatalogError(`${catalogPath}: catalog must be an object`);
123
+ }
124
+ if (parsed.schemaVersion !== PROFILE_SCHEMA_VERSION) {
156
125
  throw new CatalogError(
157
- `${filePath}: unsupported schemaVersion ${JSON.stringify(version)} (expected ${PROFILE_SCHEMA_VERSION})`,
126
+ `${catalogPath}: unsupported schemaVersion ${JSON.stringify(parsed.schemaVersion)} (expected ${PROFILE_SCHEMA_VERSION})`,
158
127
  );
159
128
  }
160
- if (!isRecord(value.profiles)) {
161
- throw new CatalogError(`${filePath}: "profiles" must be an object mapping names to definitions`);
129
+ if (!isRecord(parsed.profiles)) {
130
+ throw new CatalogError(`${catalogPath}: "profiles" must be an object mapping names to definitions`);
162
131
  }
163
- const profiles = new Map<string, ProfileDefinition>();
164
- for (const [name, raw] of Object.entries(value.profiles)) {
132
+ for (const [name, definition] of Object.entries(parsed.profiles)) {
165
133
  if (name === DEFAULT_PROFILE_NAME) {
166
134
  throw new CatalogError(
167
- `${filePath}: "${DEFAULT_PROFILE_NAME}" is built in and must not be defined in the catalog`,
135
+ `${catalogPath}: "${DEFAULT_PROFILE_NAME}" is built in and must not be defined in the catalog`,
168
136
  );
169
137
  }
170
- profiles.set(name, parseProfileDefinition(name, raw));
138
+ profiles.set(name, parseProfileDefinition(name, definition));
171
139
  }
172
140
  return profiles;
173
141
  }
174
142
 
175
- /** Reads one catalog file; missing → empty map, malformed → CatalogError. */
176
- async function loadCatalogFile(catalogPath: string): Promise<Map<string, ProfileDefinition>> {
177
- const result = await readJsonFile(catalogPath);
178
- if (!result.ok) {
179
- if (result.reason === "missing") return new Map();
180
- throw new CatalogError(`invalid JSON in ${catalogPath}`);
181
- }
182
- return parseCatalogDocument(result.value, catalogPath);
183
- }
184
-
185
143
  export class ProfileCatalog {
186
144
  readonly #profiles: ReadonlyMap<string, CatalogEntry>;
187
145
 
@@ -191,19 +149,19 @@ export class ProfileCatalog {
191
149
 
192
150
  /**
193
151
  * Reads the global catalog, plus the project catalog when `projectDir` is
194
- * given (trusted projects only — the caller gates on Pi's trust check).
152
+ * given (trusted projects only — the caller gates on the trust check).
195
153
  * Missing files mean an empty catalog; malformed content throws
196
154
  * CatalogError. Project entries replace same-name global entries.
197
155
  */
198
156
  static async load(agentDir: string, options?: { projectDir?: string }): Promise<ProfileCatalog> {
199
- const global = await loadCatalogFile(path.join(agentDir, "profiles.json"));
157
+ const globalProfiles = await loadCatalogFile(resolveGlobalProfilesPath(agentDir));
200
158
  const profiles = new Map<string, CatalogEntry>();
201
- for (const [name, definition] of global) {
159
+ for (const [name, definition] of globalProfiles) {
202
160
  profiles.set(name, { source: "global", definition });
203
161
  }
204
162
  if (options?.projectDir !== undefined) {
205
- const project = await loadCatalogFile(path.join(options.projectDir, ".pi", "profiles.json"));
206
- for (const [name, definition] of project) {
163
+ const projectProfiles = await loadCatalogFile(path.join(options.projectDir, ".pi", "profiles.json"));
164
+ for (const [name, definition] of projectProfiles) {
207
165
  profiles.set(name, { source: "project", definition });
208
166
  }
209
167
  }
@@ -211,7 +169,7 @@ export class ProfileCatalog {
211
169
  }
212
170
 
213
171
  /** Resolves a profile by name. `default` always resolves to the built-in
214
- * no-op profile; unknown names return undefined. */
172
+ * full-resource profile; unknown names return undefined. */
215
173
  resolve(name: string): ResolvedProfile | undefined {
216
174
  if (name === DEFAULT_PROFILE_NAME) {
217
175
  return { name: DEFAULT_PROFILE_NAME, source: "builtin", definition: {} };
@@ -228,10 +186,4 @@ export class ProfileCatalog {
228
186
  ...[...this.#profiles.keys()].map((name) => this.resolve(name)!),
229
187
  ];
230
188
  }
231
-
232
- /** True when a global definition of the same name is shadowed by the
233
- * project entry. */
234
- shadowsGlobal(name: string): boolean {
235
- return this.#profiles.get(name)?.source === "project";
236
- }
237
189
  }