pi-profile-switch 0.3.0 → 0.4.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.
Files changed (53) hide show
  1. package/README.md +32 -32
  2. package/README.zh-CN.md +32 -32
  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 -17
  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 +44 -63
  24. package/src/profile-resolver.ts +239 -245
  25. package/src/project-trust.ts +82 -0
  26. package/src/runtime-state-store.ts +25 -35
  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 +21 -25
  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 -744
  42. package/src/adapter-presence.ts +0 -75
  43. package/src/mcp-overlay-file.ts +0 -35
  44. package/src/mcp-overlay.ts +0 -122
  45. package/src/model-selection.ts +0 -64
  46. package/src/name-matching.ts +0 -50
  47. package/src/profile-badge.ts +0 -142
  48. package/src/profile-presets.ts +0 -61
  49. package/src/skill-selection.ts +0 -81
  50. package/src/startup-mcp-scope.ts +0 -262
  51. package/src/startup-selection.ts +0 -144
  52. package/src/switching/activate-profile.ts +0 -115
  53. package/src/switching/apply-profile.ts +0 -131
@@ -3,30 +3,21 @@
3
3
  * (`<agentDir>/profiles.json`) and, for trusted projects, the project
4
4
  * catalog (`<projectDir>/.pi/profiles.json`).
5
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
- *
16
6
  * Invariants:
17
7
  * - The built-in `default` profile never exists in either file and cannot be
18
8
  * redefined there.
19
9
  * - A project profile with the same name fully replaces the global
20
10
  * definition (no merge, no inheritance); removing the project entry
21
11
  * immediately reveals the global one.
22
- * - The caller passes `projectDir` only when Pi reports the project trusted —
23
- * 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.
24
14
  * - A malformed catalog fails loudly (CatalogError) rather than silently
25
- * starting with an unintended selection.
15
+ * starting unfiltered.
26
16
  */
27
17
 
28
18
  import path from "node:path";
29
19
 
20
+ import { resolveGlobalProfilesPath } from "./workspace.ts";
30
21
  import { isRecord, readJsonFile } from "./json-file.ts";
31
22
 
32
23
  export const PROFILE_SCHEMA_VERSION = 1;
@@ -39,14 +30,19 @@ export interface ProfileModel {
39
30
  }
40
31
 
41
32
  /** A profile definition as stored in a catalog file. All fields optional:
42
- * 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. */
43
36
  export interface ProfileDefinition {
44
37
  label?: string;
45
38
  description?: string;
46
39
  skills?: string[];
47
- mcp?: string[];
40
+ extensions?: string[];
41
+ mcps?: string[];
48
42
  tools?: string[];
49
- model?: ProfileModel;
43
+ defaultProvider?: string;
44
+ defaultModel?: string;
45
+ defaultThinkingLevel?: string;
50
46
  instructions?: string;
51
47
  }
52
48
 
@@ -88,9 +84,7 @@ function readOptionalString(value: unknown, field: string, profileName: string):
88
84
  }
89
85
 
90
86
  /** Parses one raw profile definition; exported for the write-side store
91
- * (profile-catalog-store.ts) so anything written is loadable. Unknown
92
- * fields are ignored by design — an `extensions` key left over from
93
- * v0.1.0 is dropped silently, exactly like any other unknown key. */
87
+ * (profile-catalog-store.ts) so anything written is loadable. */
94
88
  export function parseProfileDefinition(name: string, raw: unknown): ProfileDefinition {
95
89
  if (!isRecord(raw)) {
96
90
  throw new CatalogError(`profile "${name}" must be an object`);
@@ -100,59 +94,52 @@ export function parseProfileDefinition(name: string, raw: unknown): ProfileDefin
100
94
  if (label !== undefined) definition.label = label;
101
95
  const description = readOptionalString(raw.description, "description", name);
102
96
  if (description !== undefined) definition.description = description;
103
- for (const field of ["skills", "mcp", "tools"] as const) {
97
+ for (const field of ["skills", "extensions", "mcps", "tools"] as const) {
104
98
  const entries = readStringArray(raw[field], field, name);
105
99
  if (entries !== undefined) definition[field] = entries;
106
100
  }
107
- if (raw.model !== undefined) {
108
- if (!isRecord(raw.model) || typeof raw.model.provider !== "string" || typeof raw.model.id !== "string") {
109
- throw new CatalogError(`profile "${name}": "model" must be an object with string "provider" and "id"`);
110
- }
111
- const thinkingLevel = readOptionalString(raw.model.thinkingLevel, "model.thinkingLevel", name);
112
- definition.model = { provider: raw.model.provider, id: raw.model.id, ...(thinkingLevel ? { thinkingLevel } : {}) };
113
- }
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;
114
107
  const instructions = readOptionalString(raw.instructions, "instructions", name);
115
108
  if (instructions !== undefined) definition.instructions = instructions;
116
109
  return definition;
117
110
  }
118
111
 
119
- /** Parses one catalog document. Missing files are handled by the caller;
120
- * this function sees only parsed JSON. */
121
- export function parseCatalogDocument(value: unknown, filePath: string): Map<string, ProfileDefinition> {
122
- if (!isRecord(value)) {
123
- 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}`);
124
119
  }
125
- const version = value.schemaVersion;
126
- 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) {
127
125
  throw new CatalogError(
128
- `${filePath}: unsupported schemaVersion ${JSON.stringify(version)} (expected ${PROFILE_SCHEMA_VERSION})`,
126
+ `${catalogPath}: unsupported schemaVersion ${JSON.stringify(parsed.schemaVersion)} (expected ${PROFILE_SCHEMA_VERSION})`,
129
127
  );
130
128
  }
131
- if (!isRecord(value.profiles)) {
132
- 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`);
133
131
  }
134
- const profiles = new Map<string, ProfileDefinition>();
135
- for (const [name, raw] of Object.entries(value.profiles)) {
132
+ for (const [name, definition] of Object.entries(parsed.profiles)) {
136
133
  if (name === DEFAULT_PROFILE_NAME) {
137
134
  throw new CatalogError(
138
- `${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`,
139
136
  );
140
137
  }
141
- profiles.set(name, parseProfileDefinition(name, raw));
138
+ profiles.set(name, parseProfileDefinition(name, definition));
142
139
  }
143
140
  return profiles;
144
141
  }
145
142
 
146
- /** Reads one catalog file; missing → empty map, malformed → CatalogError. */
147
- async function loadCatalogFile(catalogPath: string): Promise<Map<string, ProfileDefinition>> {
148
- const result = await readJsonFile(catalogPath);
149
- if (!result.ok) {
150
- if (result.reason === "missing") return new Map();
151
- throw new CatalogError(`invalid JSON in ${catalogPath}`);
152
- }
153
- return parseCatalogDocument(result.value, catalogPath);
154
- }
155
-
156
143
  export class ProfileCatalog {
157
144
  readonly #profiles: ReadonlyMap<string, CatalogEntry>;
158
145
 
@@ -162,19 +149,19 @@ export class ProfileCatalog {
162
149
 
163
150
  /**
164
151
  * Reads the global catalog, plus the project catalog when `projectDir` is
165
- * given (trusted projects only — the caller gates on Pi's trust check).
152
+ * given (trusted projects only — the caller gates on the trust check).
166
153
  * Missing files mean an empty catalog; malformed content throws
167
154
  * CatalogError. Project entries replace same-name global entries.
168
155
  */
169
156
  static async load(agentDir: string, options?: { projectDir?: string }): Promise<ProfileCatalog> {
170
- const global = await loadCatalogFile(path.join(agentDir, "profiles.json"));
157
+ const globalProfiles = await loadCatalogFile(resolveGlobalProfilesPath(agentDir));
171
158
  const profiles = new Map<string, CatalogEntry>();
172
- for (const [name, definition] of global) {
159
+ for (const [name, definition] of globalProfiles) {
173
160
  profiles.set(name, { source: "global", definition });
174
161
  }
175
162
  if (options?.projectDir !== undefined) {
176
- const project = await loadCatalogFile(path.join(options.projectDir, ".pi", "profiles.json"));
177
- 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) {
178
165
  profiles.set(name, { source: "project", definition });
179
166
  }
180
167
  }
@@ -182,7 +169,7 @@ export class ProfileCatalog {
182
169
  }
183
170
 
184
171
  /** Resolves a profile by name. `default` always resolves to the built-in
185
- * no-op profile; unknown names return undefined. */
172
+ * full-resource profile; unknown names return undefined. */
186
173
  resolve(name: string): ResolvedProfile | undefined {
187
174
  if (name === DEFAULT_PROFILE_NAME) {
188
175
  return { name: DEFAULT_PROFILE_NAME, source: "builtin", definition: {} };
@@ -199,10 +186,4 @@ export class ProfileCatalog {
199
186
  ...[...this.#profiles.keys()].map((name) => this.resolve(name)!),
200
187
  ];
201
188
  }
202
-
203
- /** True when a global definition of the same name is shadowed by the
204
- * project entry. */
205
- shadowsGlobal(name: string): boolean {
206
- return this.#profiles.get(name)?.source === "project";
207
- }
208
189
  }