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.
- package/README.md +54 -54
- package/README.zh-CN.md +54 -54
- package/bin/postinstall.d.ts +2 -0
- package/bin/postinstall.js +81 -29
- package/examples/ask.json +13 -0
- package/examples/example.json +30 -35
- package/package.json +2 -1
- package/schemas/profiles.schema.json +50 -76
- package/skills/profile-config/SKILL.md +167 -0
- package/src/profile-catalog-store.ts +51 -74
- package/src/profile-catalog.ts +81 -50
- package/src/project-trust.ts +1 -1
- package/src/switching/profile-crud.ts +3 -6
- package/src/workspace.ts +3 -16
- package/examples/profiles.json +0 -18
package/src/profile-catalog.ts
CHANGED
|
@@ -1,27 +1,31 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* ProfileCatalog: reads profile definitions from the global catalog
|
|
3
|
-
* (`~/.pi-profile-switch/profiles
|
|
4
|
-
* and, for trusted projects, the project catalog (`<projectDir>/.pi/profiles
|
|
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
|
-
* -
|
|
8
|
-
*
|
|
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
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
113
|
-
async function
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
133
|
-
|
|
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
|
-
`${
|
|
152
|
+
`${fullPath}: "${DEFAULT_PROFILE_NAME}" is built in and must not be defined in the catalog`,
|
|
136
153
|
);
|
|
137
154
|
}
|
|
138
|
-
|
|
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
|
|
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(
|
|
157
|
-
const globalProfiles = await
|
|
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
|
|
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)!,
|
package/src/project-trust.ts
CHANGED
|
@@ -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
|
|
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 {
|
|
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
|
|
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
|
|
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
|
|
23
|
-
return path.join(getProfileSwitchDir(), "profiles
|
|
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 {
|
package/examples/profiles.json
DELETED
|
@@ -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
|
-
}
|