pi-profile-switch 0.7.0 → 0.9.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.
@@ -1,27 +1,31 @@
1
1
  /**
2
2
  * ProfileCatalog: reads profile definitions from the global catalog
3
- * (`~/.pi-profile-switch/profiles.json`, legacy fallback `<agentDir>/profiles.json`)
4
- * and, for trusted projects, the project catalog (`<projectDir>/.pi/profiles.json`).
3
+ * (`~/.pi-profile-switch/profiles/`, or `PI_PROFILE_SWITCH_DIR/profiles/`)
4
+ * and, for trusted projects, the project catalog (`<projectDir>/.pi/profiles/`).
5
5
  *
6
6
  * Invariants:
7
- * - The built-in `default` profile never exists in either file and cannot be
8
- * redefined there.
7
+ * - Each profile is stored in a separate `<name>.json` file.
8
+ * - The built-in `default` profile never exists as a file and cannot be
9
+ * defined in any catalog (`default.json` is a hard error).
10
+ * - Profile names must match `^[A-Za-z0-9][A-Za-z0-9._-]*$`.
9
11
  * - A project profile with the same name fully replaces the global
10
- * definition (no merge, no inheritance); removing the project entry
12
+ * definition (no merge, no inheritance); removing the project file
11
13
  * immediately reveals the global one.
12
14
  * - The caller passes `projectDir` only when the resolver's trust check
13
15
  * passed — an untrusted project's catalog is never read.
14
- * - A malformed catalog fails loudly (CatalogError) rather than silently
15
- * starting unfiltered.
16
+ * - A malformed profile file fails loudly (CatalogError) with its file path
17
+ * rather than silently starting unfiltered.
16
18
  */
17
19
 
20
+ import type { Dirent } from "node:fs";
21
+ import { readdir, stat } from "node:fs/promises";
18
22
  import path from "node:path";
19
23
 
20
- import { resolveGlobalProfilesPath } from "./workspace.ts";
24
+ import { getGlobalProfilesDir } from "./workspace.ts";
21
25
  import { isRecord, readJsonFile } from "./json-file.ts";
22
26
 
23
- export const PROFILE_SCHEMA_VERSION = 1;
24
27
  export const DEFAULT_PROFILE_NAME = "default";
28
+ export const PROFILE_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
25
29
 
26
30
  export interface ProfileModel {
27
31
  provider: string;
@@ -67,75 +71,102 @@ export class CatalogError extends Error {
67
71
  }
68
72
  }
69
73
 
70
- function readStringArray(value: unknown, field: string, profileName: string): string[] | undefined {
74
+ function readStringArray(value: unknown, field: string, profileName: string, filePath?: string): string[] | undefined {
71
75
  if (value === undefined) return undefined;
72
76
  if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string")) {
73
- throw new CatalogError(`profile "${profileName}": "${field}" must be an array of strings`);
77
+ const prefix = filePath ? `${filePath}: ` : "";
78
+ throw new CatalogError(`${prefix}profile "${profileName}": "${field}" must be an array of strings`);
74
79
  }
75
80
  return value as string[];
76
81
  }
77
82
 
78
- function readOptionalString(value: unknown, field: string, profileName: string): string | undefined {
83
+ function readOptionalString(value: unknown, field: string, profileName: string, filePath?: string): string | undefined {
79
84
  if (value === undefined) return undefined;
80
85
  if (typeof value !== "string") {
81
- throw new CatalogError(`profile "${profileName}": "${field}" must be a string`);
86
+ const prefix = filePath ? `${filePath}: ` : "";
87
+ throw new CatalogError(`${prefix}profile "${profileName}": "${field}" must be a string`);
82
88
  }
83
89
  return value;
84
90
  }
85
91
 
86
92
  /** Parses one raw profile definition; exported for the write-side store
87
93
  * (profile-catalog-store.ts) so anything written is loadable. */
88
- export function parseProfileDefinition(name: string, raw: unknown): ProfileDefinition {
94
+ export function parseProfileDefinition(name: string, raw: unknown, filePath?: string): ProfileDefinition {
89
95
  if (!isRecord(raw)) {
90
- throw new CatalogError(`profile "${name}" must be an object`);
96
+ const prefix = filePath ? `${filePath}: ` : "";
97
+ throw new CatalogError(`${prefix}profile "${name}" must be an object`);
91
98
  }
92
99
  const definition: ProfileDefinition = {};
93
- const label = readOptionalString(raw.label, "label", name);
100
+ const label = readOptionalString(raw.label, "label", name, filePath);
94
101
  if (label !== undefined) definition.label = label;
95
- const description = readOptionalString(raw.description, "description", name);
102
+ const description = readOptionalString(raw.description, "description", name, filePath);
96
103
  if (description !== undefined) definition.description = description;
97
104
  for (const field of ["skills", "extensions", "mcps", "tools"] as const) {
98
- const entries = readStringArray(raw[field], field, name);
105
+ const entries = readStringArray(raw[field], field, name, filePath);
99
106
  if (entries !== undefined) definition[field] = entries;
100
107
  }
101
- const defaultProvider = readOptionalString(raw.defaultProvider, "defaultProvider", name);
108
+ const defaultProvider = readOptionalString(raw.defaultProvider, "defaultProvider", name, filePath);
102
109
  if (defaultProvider !== undefined) definition.defaultProvider = defaultProvider;
103
- const defaultModel = readOptionalString(raw.defaultModel, "defaultModel", name);
110
+ const defaultModel = readOptionalString(raw.defaultModel, "defaultModel", name, filePath);
104
111
  if (defaultModel !== undefined) definition.defaultModel = defaultModel;
105
- const defaultThinkingLevel = readOptionalString(raw.defaultThinkingLevel, "defaultThinkingLevel", name);
112
+ const defaultThinkingLevel = readOptionalString(raw.defaultThinkingLevel, "defaultThinkingLevel", name, filePath);
106
113
  if (defaultThinkingLevel !== undefined) definition.defaultThinkingLevel = defaultThinkingLevel;
107
- const instructions = readOptionalString(raw.instructions, "instructions", name);
114
+ const instructions = readOptionalString(raw.instructions, "instructions", name, filePath);
108
115
  if (instructions !== undefined) definition.instructions = instructions;
109
116
  return definition;
110
117
  }
111
118
 
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}`);
119
- }
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) {
125
- throw new CatalogError(
126
- `${catalogPath}: unsupported schemaVersion ${JSON.stringify(parsed.schemaVersion)} (expected ${PROFILE_SCHEMA_VERSION})`,
127
- );
128
- }
129
- if (!isRecord(parsed.profiles)) {
130
- throw new CatalogError(`${catalogPath}: "profiles" must be an object mapping names to definitions`);
119
+ /** Reads one catalog directory; missing directory → empty map, malformed file → CatalogError. */
120
+ export async function loadCatalogDirectory(dirPath: string): Promise<Map<string, ProfileDefinition>> {
121
+ let entries: Dirent[];
122
+ try {
123
+ entries = await readdir(dirPath, { withFileTypes: true });
124
+ } catch (error) {
125
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
126
+ return new Map();
127
+ }
128
+ throw error;
131
129
  }
132
- for (const [name, definition] of Object.entries(parsed.profiles)) {
133
- if (name === DEFAULT_PROFILE_NAME) {
130
+
131
+ // Filter to .json files and sort alphabetically by file name
132
+ const jsonEntries = entries.filter((entry) => entry.name.endsWith(".json"));
133
+ jsonEntries.sort((a, b) => a.name.localeCompare(b.name));
134
+
135
+ const profiles = new Map<string, ProfileDefinition>();
136
+ for (const entry of jsonEntries) {
137
+ const fullPath = path.join(dirPath, entry.name);
138
+ let isFile = entry.isFile();
139
+ if (!isFile && entry.isSymbolicLink()) {
140
+ try {
141
+ const st = await stat(fullPath);
142
+ isFile = st.isFile();
143
+ } catch {
144
+ continue;
145
+ }
146
+ }
147
+ if (!isFile) continue;
148
+
149
+ const profileName = entry.name.slice(0, -".json".length);
150
+ if (profileName === DEFAULT_PROFILE_NAME) {
134
151
  throw new CatalogError(
135
- `${catalogPath}: "${DEFAULT_PROFILE_NAME}" is built in and must not be defined in the catalog`,
152
+ `${fullPath}: "${DEFAULT_PROFILE_NAME}" is built in and must not be defined in the catalog`,
136
153
  );
137
154
  }
138
- profiles.set(name, parseProfileDefinition(name, definition));
155
+ if (!PROFILE_NAME_PATTERN.test(profileName)) {
156
+ throw new CatalogError(
157
+ `${fullPath}: invalid profile name "${profileName}" (must match ${PROFILE_NAME_PATTERN})`,
158
+ );
159
+ }
160
+
161
+ const result = await readJsonFile(fullPath);
162
+ if (!result.ok) {
163
+ throw new CatalogError(`invalid JSON in ${fullPath}`);
164
+ }
165
+ const parsed = result.value;
166
+ if (!isRecord(parsed)) {
167
+ throw new CatalogError(`${fullPath}: profile definition must be an object`);
168
+ }
169
+ profiles.set(profileName, parseProfileDefinition(profileName, parsed, fullPath));
139
170
  }
140
171
  return profiles;
141
172
  }
@@ -150,17 +181,17 @@ export class ProfileCatalog {
150
181
  /**
151
182
  * Reads the global catalog, plus the project catalog when `projectDir` is
152
183
  * given (trusted projects only — the caller gates on the trust check).
153
- * Missing files mean an empty catalog; malformed content throws
184
+ * Missing directories mean an empty catalog; malformed content throws
154
185
  * CatalogError. Project entries replace same-name global entries.
155
186
  */
156
- static async load(agentDir: string, options?: { projectDir?: string }): Promise<ProfileCatalog> {
157
- const globalProfiles = await loadCatalogFile(resolveGlobalProfilesPath(agentDir));
187
+ static async load(_agentDir: string, options?: { projectDir?: string }): Promise<ProfileCatalog> {
188
+ const globalProfiles = await loadCatalogDirectory(getGlobalProfilesDir());
158
189
  const profiles = new Map<string, CatalogEntry>();
159
190
  for (const [name, definition] of globalProfiles) {
160
191
  profiles.set(name, { source: "global", definition });
161
192
  }
162
193
  if (options?.projectDir !== undefined) {
163
- const projectProfiles = await loadCatalogFile(path.join(options.projectDir, ".pi", "profiles.json"));
194
+ const projectProfiles = await loadCatalogDirectory(path.join(options.projectDir, ".pi", "profiles"));
164
195
  for (const [name, definition] of projectProfiles) {
165
196
  profiles.set(name, { source: "project", definition });
166
197
  }
@@ -179,7 +210,7 @@ export class ProfileCatalog {
179
210
  }
180
211
 
181
212
  /** Lists the built-in default first, then profiles in file order (global
182
- * entries in global order, project-only names appended after). */
213
+ * entries in global alphabetical order, project-only names appended after in alphabetical order). */
183
214
  list(): ResolvedProfile[] {
184
215
  return [
185
216
  this.resolve(DEFAULT_PROFILE_NAME)!,
@@ -80,7 +80,7 @@ export function resolveProjectTrust(input: ProjectTrustInput): boolean {
80
80
  * native list doesn't know them: a committed catalog/state file would
81
81
  * otherwise inject profile definitions (and instructions) unguarded. */
82
82
  function hasPiProfileProjectFiles(cwd: string): boolean {
83
- return ["profiles.json", "pi-profile-state.json"].some((name) =>
83
+ return ["profiles", "pi-profile-state.json"].some((name) =>
84
84
  existsSync(path.join(cwd, ".pi", name)),
85
85
  );
86
86
  }
@@ -16,7 +16,7 @@
16
16
 
17
17
  import path from "node:path";
18
18
 
19
- import { getGlobalProfilesPath } from "../workspace.ts";
19
+ import { getGlobalProfilesDir } from "../workspace.ts";
20
20
  import { readTrustInputs } from "../launcher/initial-profile.ts";
21
21
  import { CatalogError, DEFAULT_PROFILE_NAME, type ProfileDefinition } from "../profile-catalog.ts";
22
22
  import { ProfileCatalogStore } from "../profile-catalog-store.ts";
@@ -31,12 +31,9 @@ export function catalogStore(
31
31
  scope: CatalogScope,
32
32
  ): ProfileCatalogStore {
33
33
  if (scope === "global") {
34
- return new ProfileCatalogStore(
35
- getGlobalProfilesPath(),
36
- path.join(input.realAgentDir, "profiles.json")
37
- );
34
+ return new ProfileCatalogStore(getGlobalProfilesDir());
38
35
  }
39
- return new ProfileCatalogStore(path.join(input.cwd, ".pi", "profiles.json"));
36
+ return new ProfileCatalogStore(path.join(input.cwd, ".pi", "profiles"));
40
37
  }
41
38
 
42
39
  async function requireScope(input: { realAgentDir: string; cwd: string }, scope: CatalogScope): Promise<void> {
package/src/workspace.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Rooted at `~/.pi-profile-switch` (or `PI_PROFILE_SWITCH_DIR` override).
5
5
  * Contains:
6
- * - `profiles.json`: global profile definitions catalog
6
+ * - `profiles/`: global profile definitions catalog
7
7
  * - `instances/`: instance runtime directories for forked pi processes
8
8
  */
9
9
 
@@ -19,21 +19,8 @@ export function getProfileSwitchDir(): string {
19
19
  return path.join(homedir(), ".pi-profile-switch");
20
20
  }
21
21
 
22
- export function getGlobalProfilesPath(): string {
23
- return path.join(getProfileSwitchDir(), "profiles.json");
24
- }
25
-
26
- export function resolveGlobalProfilesPath(agentDir: string): string {
27
- const preferred = getGlobalProfilesPath();
28
- if (existsSync(preferred)) {
29
- return preferred;
30
- }
31
- // Fallback for legacy ~/.pi/agent/profiles.json
32
- const fallback = path.join(agentDir, "profiles.json");
33
- if (existsSync(fallback)) {
34
- return fallback;
35
- }
36
- return preferred;
22
+ export function getGlobalProfilesDir(): string {
23
+ return path.join(getProfileSwitchDir(), "profiles");
37
24
  }
38
25
 
39
26
  export function getInstancesRootDir(): string {
@@ -1,18 +0,0 @@
1
- {
2
- "schemaVersion": 1,
3
- "profiles": {
4
- "ask": {
5
- "label": "Ask & Discuss",
6
- "description": "Read-only Q&A and code exploration; no file modifications or command execution",
7
- "skills": [],
8
- "extensions": [],
9
- "tools": [
10
- "read",
11
- "grep",
12
- "find",
13
- "ls"
14
- ],
15
- "instructions": "You are in read-only discussion mode. Answer questions and explain code without modifying any files or running shell commands."
16
- }
17
- }
18
- }