pi-profile-switch 0.7.0 → 0.8.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 +52 -54
- package/README.zh-CN.md +52 -54
- package/bin/postinstall.js +26 -28
- package/examples/ask.json +13 -0
- package/examples/example.json +30 -35
- package/package.json +1 -1
- package/schemas/profiles.schema.json +50 -76
- 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/README.md
CHANGED
|
@@ -27,72 +27,62 @@ pi-profile ask -- --model openai/gpt-5.4
|
|
|
27
27
|
|
|
28
28
|
## Define your own profiles
|
|
29
29
|
|
|
30
|
-
Profiles live in two JSON
|
|
30
|
+
Profiles live in two directories, with one JSON file per profile:
|
|
31
31
|
|
|
32
|
-
|
|
|
32
|
+
| Path | Scope |
|
|
33
33
|
| --- | --- |
|
|
34
|
-
| `~/.pi-profile-switch/profiles
|
|
35
|
-
| `<project>/.pi/profiles
|
|
34
|
+
| `~/.pi-profile-switch/profiles/<name>.json` | Global, all projects. `PI_PROFILE_SWITCH_DIR` overrides the workspace root. |
|
|
35
|
+
| `<project>/.pi/profiles/<name>.json` | Project-level, trusted projects only. |
|
|
36
36
|
|
|
37
|
-
Create or change a profile by editing
|
|
37
|
+
Create or change a profile by editing or creating a `<name>.json` file directly — schema: [`schemas/profiles.schema.json`](schemas/profiles.schema.json).
|
|
38
38
|
|
|
39
|
-
On install, pi-profile-switch seeds the global
|
|
39
|
+
On install, pi-profile-switch seeds the global `profiles/` directory with a starter **`ask`** profile (`ask.json`) — read-only Q&A and code exploration. It assumes nothing about your setup; edit or delete it freely:
|
|
40
40
|
|
|
41
41
|
```json
|
|
42
42
|
{
|
|
43
|
-
"
|
|
44
|
-
"
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
"extensions": [],
|
|
50
|
-
"tools": ["read", "grep", "find", "ls"],
|
|
51
|
-
"instructions": "You are in read-only discussion mode. Answer questions and explain code without modifying any files or running shell commands."
|
|
52
|
-
}
|
|
53
|
-
}
|
|
43
|
+
"label": "Ask & Discuss",
|
|
44
|
+
"description": "Read-only Q&A and code exploration; no file modifications or command execution",
|
|
45
|
+
"skills": [],
|
|
46
|
+
"extensions": [],
|
|
47
|
+
"tools": ["read", "grep", "find", "ls"],
|
|
48
|
+
"instructions": "You are in read-only discussion mode. Answer questions and explain code without modifying any files or running shell commands."
|
|
54
49
|
}
|
|
55
50
|
```
|
|
56
51
|
|
|
57
|
-
One profile can use every field at once. This example `impl` profile loads the TDD skill, the mcp-scripting skill (shipped by pi-mcp-adapter), and your internal skills; wires up two MCP servers; allows the built-in tools plus both servers' MCP tools by glob; and pins the model and standing instructions:
|
|
52
|
+
One profile can use every field at once. This example `impl` profile (`impl.json`) loads the TDD skill, the mcp-scripting skill (shipped by pi-mcp-adapter), and your internal skills; wires up two MCP servers; allows the built-in tools plus both servers' MCP tools by glob; and pins the model and standing instructions:
|
|
58
53
|
|
|
59
54
|
```json
|
|
60
55
|
{
|
|
61
|
-
"
|
|
62
|
-
"
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
"defaultModel": "claude-sonnet-4-5",
|
|
92
|
-
"defaultThinkingLevel": "high",
|
|
93
|
-
"instructions": "Prefer small, verifiable changes. Run the test suite before claiming completion."
|
|
94
|
-
}
|
|
95
|
-
}
|
|
56
|
+
"label": "Implementation",
|
|
57
|
+
"description": "Full-powered implementation profile: every available field, pinned model",
|
|
58
|
+
"skills": [
|
|
59
|
+
"tdd",
|
|
60
|
+
"internal-*",
|
|
61
|
+
"mcp-scripting"
|
|
62
|
+
],
|
|
63
|
+
"extensions": [
|
|
64
|
+
"pi-mcp-adapter"
|
|
65
|
+
],
|
|
66
|
+
"mcps": [
|
|
67
|
+
"github",
|
|
68
|
+
"linear"
|
|
69
|
+
],
|
|
70
|
+
"tools": [
|
|
71
|
+
"read",
|
|
72
|
+
"grep",
|
|
73
|
+
"find",
|
|
74
|
+
"ls",
|
|
75
|
+
"bash",
|
|
76
|
+
"edit",
|
|
77
|
+
"write",
|
|
78
|
+
"mcp__*",
|
|
79
|
+
"github_*",
|
|
80
|
+
"linear_*"
|
|
81
|
+
],
|
|
82
|
+
"defaultProvider": "anthropic",
|
|
83
|
+
"defaultModel": "claude-sonnet-4-5",
|
|
84
|
+
"defaultThinkingLevel": "high",
|
|
85
|
+
"instructions": "Prefer small, verifiable changes. Run the test suite before claiming completion."
|
|
96
86
|
}
|
|
97
87
|
```
|
|
98
88
|
|
|
@@ -103,7 +93,15 @@ How fields resolve:
|
|
|
103
93
|
- `mcps` references servers from your pi-mcp-adapter configuration; connection details stay in the adapter's own config.
|
|
104
94
|
- Any field you omit keeps plain Pi behavior.
|
|
105
95
|
|
|
106
|
-
The files in [`examples/`](examples/) mirror the two profiles above: `
|
|
96
|
+
The files in [`examples/`](examples/) mirror the two profiles above: `ask.json` is the seeded starter, `example.json` the full-field demo.
|
|
97
|
+
|
|
98
|
+
### Migrating from earlier versions
|
|
99
|
+
|
|
100
|
+
If you used an earlier version that stored all profiles in a single `profiles.json` (`schemaVersion: 1`), migrate manually by creating a file for each profile under the `profiles/` directory:
|
|
101
|
+
|
|
102
|
+
1. Create directory `~/.pi-profile-switch/profiles/` (or `<project>/.pi/profiles/`).
|
|
103
|
+
2. For each key `<name>` in your old `profiles.json`'s `profiles` object, save its value directly as `<name>.json`.
|
|
104
|
+
3. Drop the outer `schemaVersion` and `profiles` envelope.
|
|
107
105
|
|
|
108
106
|
## Commands
|
|
109
107
|
|
package/README.zh-CN.md
CHANGED
|
@@ -27,72 +27,62 @@ pi-profile ask -- --model openai/gpt-5.4
|
|
|
27
27
|
|
|
28
28
|
## 自定义 profile
|
|
29
29
|
|
|
30
|
-
profile
|
|
30
|
+
profile 保存在两个目录中,每个 profile 对应一个独立 JSON 文件:
|
|
31
31
|
|
|
32
|
-
|
|
|
32
|
+
| 路径 | 作用域 |
|
|
33
33
|
| --- | --- |
|
|
34
|
-
| `~/.pi-profile-switch/profiles
|
|
35
|
-
| `<项目>/.pi/profiles
|
|
34
|
+
| `~/.pi-profile-switch/profiles/<name>.json` | 全局,对所有项目生效。`PI_PROFILE_SWITCH_DIR` 可自定义根目录。 |
|
|
35
|
+
| `<项目>/.pi/profiles/<name>.json` | 项目级,仅对已信任项目生效。 |
|
|
36
36
|
|
|
37
|
-
|
|
37
|
+
直接编辑或新建 `<name>.json` 即可创建或修改 profile——schema 见 [`schemas/profiles.schema.json`](schemas/profiles.schema.json)。
|
|
38
38
|
|
|
39
|
-
安装时,pi-profile-switch
|
|
39
|
+
安装时,pi-profile-switch 会向全局 `profiles/` 目录播种一个初始 **`ask`** profile(`ask.json`)——只读的问答与代码走读模式。它不假设你安装过任何插件,可随意修改或删除:
|
|
40
40
|
|
|
41
41
|
```json
|
|
42
42
|
{
|
|
43
|
-
"
|
|
44
|
-
"
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
"extensions": [],
|
|
50
|
-
"tools": ["read", "grep", "find", "ls"],
|
|
51
|
-
"instructions": "You are in read-only discussion mode. Answer questions and explain code without modifying any files or running shell commands."
|
|
52
|
-
}
|
|
53
|
-
}
|
|
43
|
+
"label": "Ask & Discuss",
|
|
44
|
+
"description": "Read-only Q&A and code exploration; no file modifications or command execution",
|
|
45
|
+
"skills": [],
|
|
46
|
+
"extensions": [],
|
|
47
|
+
"tools": ["read", "grep", "find", "ls"],
|
|
48
|
+
"instructions": "You are in read-only discussion mode. Answer questions and explain code without modifying any files or running shell commands."
|
|
54
49
|
}
|
|
55
50
|
```
|
|
56
51
|
|
|
57
|
-
一个 profile 可以同时使用全部字段。下面这个 `impl` profile
|
|
52
|
+
一个 profile 可以同时使用全部字段。下面这个 `impl` profile 示例(`impl.json`)加载 TDD skill、mcp-scripting skill(pi-mcp-adapter 自带)和你的内部 skills;接入两个 MCP server;tool 白名单用 glob 覆盖内建工具和这两个 server 的 MCP 工具;并钉住模型与常驻 instructions:
|
|
58
53
|
|
|
59
54
|
```json
|
|
60
55
|
{
|
|
61
|
-
"
|
|
62
|
-
"
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
"defaultModel": "claude-sonnet-4-5",
|
|
92
|
-
"defaultThinkingLevel": "high",
|
|
93
|
-
"instructions": "Prefer small, verifiable changes. Run the test suite before claiming completion."
|
|
94
|
-
}
|
|
95
|
-
}
|
|
56
|
+
"label": "Implementation",
|
|
57
|
+
"description": "Full-powered implementation profile: every available field, pinned model",
|
|
58
|
+
"skills": [
|
|
59
|
+
"tdd",
|
|
60
|
+
"internal-*",
|
|
61
|
+
"mcp-scripting"
|
|
62
|
+
],
|
|
63
|
+
"extensions": [
|
|
64
|
+
"pi-mcp-adapter"
|
|
65
|
+
],
|
|
66
|
+
"mcps": [
|
|
67
|
+
"github",
|
|
68
|
+
"linear"
|
|
69
|
+
],
|
|
70
|
+
"tools": [
|
|
71
|
+
"read",
|
|
72
|
+
"grep",
|
|
73
|
+
"find",
|
|
74
|
+
"ls",
|
|
75
|
+
"bash",
|
|
76
|
+
"edit",
|
|
77
|
+
"write",
|
|
78
|
+
"mcp__*",
|
|
79
|
+
"github_*",
|
|
80
|
+
"linear_*"
|
|
81
|
+
],
|
|
82
|
+
"defaultProvider": "anthropic",
|
|
83
|
+
"defaultModel": "claude-sonnet-4-5",
|
|
84
|
+
"defaultThinkingLevel": "high",
|
|
85
|
+
"instructions": "Prefer small, verifiable changes. Run the test suite before claiming completion."
|
|
96
86
|
}
|
|
97
87
|
```
|
|
98
88
|
|
|
@@ -103,7 +93,15 @@ profile 定义在两个 JSON 文件中,均可选:
|
|
|
103
93
|
- `mcps` 引用 pi-mcp-adapter 配置中的 server;连接细节留在 adapter 自己的配置里。
|
|
104
94
|
- 未写的字段保持原生 Pi 行为。
|
|
105
95
|
|
|
106
|
-
[`examples/`](examples/) 中的两个文件与上面一一对应:`
|
|
96
|
+
[`examples/`](examples/) 中的两个文件与上面一一对应:`ask.json` 是播种的初始 profile,`example.json` 是全字段演示。
|
|
97
|
+
|
|
98
|
+
### 从旧版本迁移
|
|
99
|
+
|
|
100
|
+
如果你之前使用了把全部 profile 存在单个 `profiles.json`(含 `schemaVersion: 1`)的旧版本,请手动把每个 profile 拆分到 `profiles/` 目录:
|
|
101
|
+
|
|
102
|
+
1. 创建 `~/.pi-profile-switch/profiles/`(或 `<项目>/.pi/profiles/`)目录。
|
|
103
|
+
2. 将旧 `profiles.json` 中 `profiles` 下的每个 `<name>` 键值提取为独立的 `<name>.json` 文件。
|
|
104
|
+
3. 去除外层的 `schemaVersion` 与 `profiles` 信封,文件顶层即为裸 profile 定义。
|
|
107
105
|
|
|
108
106
|
## 命令
|
|
109
107
|
|
package/bin/postinstall.js
CHANGED
|
@@ -1,29 +1,25 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* postinstall: seed the global catalog with the default
|
|
3
|
+
* postinstall: seed the global catalog with the default starter profile.
|
|
4
4
|
*
|
|
5
5
|
* Runs at package install time (`npm install pi-profile-switch` / `pi install`).
|
|
6
6
|
* Idempotent and conservative:
|
|
7
|
-
* - Writes the shipped `examples/
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* one written concurrently — is never touched).
|
|
11
|
-
* - Skips entirely when a legacy `~/.pi/agent/profiles.json` exists, because
|
|
12
|
-
* the resolver still falls back to it and a new preferred-path file would
|
|
13
|
-
* silently shadow it (see src/workspace.ts resolveGlobalProfilesPath).
|
|
7
|
+
* - Writes the shipped `examples/ask.json` starter profile to the
|
|
8
|
+
* profiles dir ONLY when no .json profile exists there yet
|
|
9
|
+
* (COPYFILE_EXCL; an existing file — including one written concurrently — is never touched).
|
|
14
10
|
* - Never fails the install: errors are downgraded to a warning.
|
|
15
11
|
*
|
|
16
12
|
* This module is plain Node ESM (no jiti/TS): npm may run postinstall in a
|
|
17
13
|
* context where only plain JS is safe. The path rules intentionally mirror
|
|
18
14
|
* src/workspace.ts — keep them in sync.
|
|
19
15
|
*/
|
|
20
|
-
import {
|
|
16
|
+
import { copyFile, mkdir, readdir } from "node:fs/promises";
|
|
21
17
|
import { constants } from "node:fs";
|
|
22
18
|
import { homedir } from "node:os";
|
|
23
19
|
import path from "node:path";
|
|
24
20
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
25
21
|
|
|
26
|
-
const DEFAULT_TEMPLATE = fileURLToPath(new URL("../examples/
|
|
22
|
+
const DEFAULT_TEMPLATE = fileURLToPath(new URL("../examples/ask.json", import.meta.url));
|
|
27
23
|
|
|
28
24
|
/** Mirrors getProfileSwitchDir() in src/workspace.ts.
|
|
29
25
|
* @param {NodeJS.ProcessEnv} env */
|
|
@@ -33,38 +29,40 @@ function profileSwitchDir(env) {
|
|
|
33
29
|
return path.join(homedir(), ".pi-profile-switch");
|
|
34
30
|
}
|
|
35
31
|
|
|
36
|
-
/**
|
|
32
|
+
/** Mirrors getGlobalProfilesDir() in src/workspace.ts.
|
|
37
33
|
* @param {NodeJS.ProcessEnv} env */
|
|
38
|
-
function
|
|
39
|
-
|
|
40
|
-
return path.join(agentDir, "profiles.json");
|
|
34
|
+
function globalProfilesDir(env) {
|
|
35
|
+
return path.join(profileSwitchDir(env), "profiles");
|
|
41
36
|
}
|
|
42
37
|
|
|
43
|
-
/**
|
|
44
|
-
|
|
38
|
+
/** Checks whether the directory exists and contains any .json files.
|
|
39
|
+
* @param {string} dir */
|
|
40
|
+
async function hasAnyJsonProfiles(dir) {
|
|
45
41
|
try {
|
|
46
|
-
await
|
|
47
|
-
return
|
|
42
|
+
const entries = await readdir(dir);
|
|
43
|
+
return entries.some((name) => name.endsWith(".json"));
|
|
48
44
|
} catch {
|
|
49
45
|
return false;
|
|
50
46
|
}
|
|
51
47
|
}
|
|
52
48
|
|
|
53
|
-
/** @typedef {{ path: string, written: boolean
|
|
49
|
+
/** @typedef {{ path: string, written: boolean }} InstallResult */
|
|
54
50
|
|
|
55
51
|
/**
|
|
56
|
-
* Seeds the default
|
|
57
|
-
*
|
|
52
|
+
* Seeds the default starter profile. Safe to call repeatedly; only writes
|
|
53
|
+
* if no .json files exist in the global profiles directory.
|
|
58
54
|
* @param {{ env?: NodeJS.ProcessEnv }} [options]
|
|
59
55
|
* @returns {Promise<InstallResult>}
|
|
60
56
|
*/
|
|
61
57
|
export async function installDefaultProfiles({ env = process.env } = {}) {
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
58
|
+
const dir = globalProfilesDir(env);
|
|
59
|
+
const target = path.join(dir, "ask.json");
|
|
60
|
+
|
|
61
|
+
if (await hasAnyJsonProfiles(dir)) {
|
|
62
|
+
return { path: target, written: false };
|
|
66
63
|
}
|
|
67
|
-
|
|
64
|
+
|
|
65
|
+
await mkdir(dir, { recursive: true });
|
|
68
66
|
try {
|
|
69
67
|
await copyFile(DEFAULT_TEMPLATE, target, constants.COPYFILE_EXCL);
|
|
70
68
|
} catch (error) {
|
|
@@ -80,10 +78,10 @@ const invokedDirectly = process.argv[1] !== undefined && import.meta.url === pat
|
|
|
80
78
|
if (invokedDirectly) {
|
|
81
79
|
installDefaultProfiles().then(
|
|
82
80
|
(result) => {
|
|
83
|
-
if (result.written) console.log(`pi-profile: seeded
|
|
81
|
+
if (result.written) console.log(`pi-profile: seeded starter profile at ${result.path}`);
|
|
84
82
|
},
|
|
85
83
|
(error) => {
|
|
86
|
-
console.warn(`pi-profile: could not seed
|
|
84
|
+
console.warn(`pi-profile: could not seed starter profile: ${error instanceof Error ? error.message : error}`);
|
|
87
85
|
},
|
|
88
86
|
);
|
|
89
87
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"label": "Ask & Discuss",
|
|
3
|
+
"description": "Read-only Q&A and code exploration; no file modifications or command execution",
|
|
4
|
+
"skills": [],
|
|
5
|
+
"extensions": [],
|
|
6
|
+
"tools": [
|
|
7
|
+
"read",
|
|
8
|
+
"grep",
|
|
9
|
+
"find",
|
|
10
|
+
"ls"
|
|
11
|
+
],
|
|
12
|
+
"instructions": "You are in read-only discussion mode. Answer questions and explain code without modifying any files or running shell commands."
|
|
13
|
+
}
|
package/examples/example.json
CHANGED
|
@@ -1,37 +1,32 @@
|
|
|
1
1
|
{
|
|
2
|
-
"
|
|
3
|
-
"
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
"defaultModel": "claude-sonnet-4-5",
|
|
33
|
-
"defaultThinkingLevel": "high",
|
|
34
|
-
"instructions": "Prefer small, verifiable changes. Run the test suite before claiming completion."
|
|
35
|
-
}
|
|
36
|
-
}
|
|
2
|
+
"label": "Implementation",
|
|
3
|
+
"description": "Full-powered implementation profile: every available field, pinned model",
|
|
4
|
+
"skills": [
|
|
5
|
+
"tdd",
|
|
6
|
+
"internal-*",
|
|
7
|
+
"mcp-scripting"
|
|
8
|
+
],
|
|
9
|
+
"extensions": [
|
|
10
|
+
"pi-mcp-adapter"
|
|
11
|
+
],
|
|
12
|
+
"mcps": [
|
|
13
|
+
"github",
|
|
14
|
+
"linear"
|
|
15
|
+
],
|
|
16
|
+
"tools": [
|
|
17
|
+
"read",
|
|
18
|
+
"grep",
|
|
19
|
+
"find",
|
|
20
|
+
"ls",
|
|
21
|
+
"bash",
|
|
22
|
+
"edit",
|
|
23
|
+
"write",
|
|
24
|
+
"mcp__*",
|
|
25
|
+
"github_*",
|
|
26
|
+
"linear_*"
|
|
27
|
+
],
|
|
28
|
+
"defaultProvider": "anthropic",
|
|
29
|
+
"defaultModel": "claude-sonnet-4-5",
|
|
30
|
+
"defaultThinkingLevel": "high",
|
|
31
|
+
"instructions": "Prefer small, verifiable changes. Run the test suite before claiming completion."
|
|
37
32
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-profile-switch",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Named profiles for Pi: reference skills, extensions, MCP servers, and tools per workflow, switched without restarting. Install: npm install -g pi-profile-switch (not pi install).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
@@ -1,86 +1,60 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
-
"title": "pi-profile-switch profile
|
|
4
|
-
"description": "
|
|
3
|
+
"title": "pi-profile-switch profile definition",
|
|
4
|
+
"description": "A named profile definition stored in ~/.pi-profile-switch/profiles/<name>.json or <project>/.pi/profiles/<name>.json. Definitions are complete and self-contained: there is no inheritance field, and resource references are names/globs, never copies.",
|
|
5
5
|
"type": "object",
|
|
6
|
-
"required": [
|
|
7
|
-
"schemaVersion",
|
|
8
|
-
"profiles"
|
|
9
|
-
],
|
|
10
6
|
"additionalProperties": false,
|
|
11
7
|
"properties": {
|
|
12
|
-
"
|
|
13
|
-
"
|
|
8
|
+
"label": {
|
|
9
|
+
"type": "string",
|
|
10
|
+
"description": "Display label in the selector and /profile list."
|
|
14
11
|
},
|
|
15
|
-
"
|
|
16
|
-
"type": "
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
12
|
+
"description": {
|
|
13
|
+
"type": "string"
|
|
14
|
+
},
|
|
15
|
+
"skills": {
|
|
16
|
+
"type": "array",
|
|
17
|
+
"items": {
|
|
18
|
+
"type": "string"
|
|
21
19
|
},
|
|
22
|
-
"
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
"
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
"tools": {
|
|
61
|
-
"type": "array",
|
|
62
|
-
"items": {
|
|
63
|
-
"type": "string"
|
|
64
|
-
},
|
|
65
|
-
"description": "Tool names or globs expanded against Pi's live registry (including extension tools)."
|
|
66
|
-
},
|
|
67
|
-
"defaultProvider": {
|
|
68
|
-
"type": "string",
|
|
69
|
-
"description": "Startup provider for this profile (e.g. \"anthropic\", \"openai\"). Mirrors Pi's settings.defaultProvider."
|
|
70
|
-
},
|
|
71
|
-
"defaultModel": {
|
|
72
|
-
"type": "string",
|
|
73
|
-
"description": "Startup model ID for this profile. Mirrors Pi's settings.defaultModel."
|
|
74
|
-
},
|
|
75
|
-
"defaultThinkingLevel": {
|
|
76
|
-
"type": "string",
|
|
77
|
-
"description": "Startup thinking level for this profile. Mirrors Pi's settings.defaultThinkingLevel."
|
|
78
|
-
},
|
|
79
|
-
"instructions": {
|
|
80
|
-
"type": "string",
|
|
81
|
-
"description": "Appended to Pi's fully built system prompt on every turn."
|
|
82
|
-
}
|
|
83
|
-
}
|
|
20
|
+
"description": "Skill names or globs (e.g. \"review\", \"internal-*\") referencing discovered skills."
|
|
21
|
+
},
|
|
22
|
+
"extensions": {
|
|
23
|
+
"type": "array",
|
|
24
|
+
"items": {
|
|
25
|
+
"type": "string"
|
|
26
|
+
},
|
|
27
|
+
"description": "Extension names or globs. Package names, source aliases, loose-file stems, glob patterns, or direct file paths, resolved against discovered extensions."
|
|
28
|
+
},
|
|
29
|
+
"mcps": {
|
|
30
|
+
"type": "array",
|
|
31
|
+
"items": {
|
|
32
|
+
"type": "string"
|
|
33
|
+
},
|
|
34
|
+
"description": "MCP server names or globs, discovered by pi-mcp-adapter configuration. Connection details stay in adapter-managed config."
|
|
35
|
+
},
|
|
36
|
+
"tools": {
|
|
37
|
+
"type": "array",
|
|
38
|
+
"items": {
|
|
39
|
+
"type": "string"
|
|
40
|
+
},
|
|
41
|
+
"description": "Tool names or globs expanded against Pi's live registry (including extension tools)."
|
|
42
|
+
},
|
|
43
|
+
"defaultProvider": {
|
|
44
|
+
"type": "string",
|
|
45
|
+
"description": "Startup provider for this profile (e.g. \"anthropic\", \"openai\"). Mirrors Pi's settings.defaultProvider."
|
|
46
|
+
},
|
|
47
|
+
"defaultModel": {
|
|
48
|
+
"type": "string",
|
|
49
|
+
"description": "Startup model ID for this profile. Mirrors Pi's settings.defaultModel."
|
|
50
|
+
},
|
|
51
|
+
"defaultThinkingLevel": {
|
|
52
|
+
"type": "string",
|
|
53
|
+
"description": "Startup thinking level for this profile. Mirrors Pi's settings.defaultThinkingLevel."
|
|
54
|
+
},
|
|
55
|
+
"instructions": {
|
|
56
|
+
"type": "string",
|
|
57
|
+
"description": "Appended to Pi's fully built system prompt on every turn."
|
|
84
58
|
}
|
|
85
59
|
}
|
|
86
60
|
}
|
|
@@ -1,109 +1,86 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* ProfileCatalogStore: the WRITE side of
|
|
3
|
-
*
|
|
2
|
+
* ProfileCatalogStore: the WRITE side of profile catalog files,
|
|
3
|
+
* storing each profile in its own `<name>.json` file.
|
|
4
4
|
*
|
|
5
5
|
* Invariants:
|
|
6
|
-
* -
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* - Definitions are complete and self-contained: no inheritance fields
|
|
10
|
-
* (`extends`, merge, array append) exist or are accepted.
|
|
6
|
+
* - Single-file operations (pretty-printed JSON without schemaVersion envelope).
|
|
7
|
+
* - Writes land in `<dir>/<name>.json` via a temporary file + rename.
|
|
8
|
+
* - Names must match `PROFILE_NAME_PATTERN` and cannot be "default".
|
|
9
|
+
* - Definitions are complete and self-contained: no inheritance fields exist.
|
|
11
10
|
* - Definitions are re-parsed through the catalog's own
|
|
12
|
-
* `parseProfileDefinition`, so anything written is loadable
|
|
13
|
-
*
|
|
14
|
-
* - Writes land in exactly one scope file (global or project).
|
|
11
|
+
* `parseProfileDefinition`, so anything written is loadable.
|
|
12
|
+
* - Writes land in exactly one scope directory (global or project).
|
|
15
13
|
*/
|
|
16
14
|
|
|
17
|
-
import { mkdir, writeFile } from "node:fs/promises";
|
|
15
|
+
import { mkdir, rename, rm, unlink, writeFile } from "node:fs/promises";
|
|
18
16
|
import path from "node:path";
|
|
19
17
|
|
|
20
|
-
import { isRecord, readJsonFile } from "./json-file.ts";
|
|
21
18
|
import {
|
|
22
19
|
CatalogError,
|
|
23
20
|
DEFAULT_PROFILE_NAME,
|
|
21
|
+
loadCatalogDirectory,
|
|
24
22
|
parseProfileDefinition,
|
|
25
|
-
|
|
23
|
+
PROFILE_NAME_PATTERN,
|
|
26
24
|
type ProfileDefinition,
|
|
27
25
|
} from "./profile-catalog.ts";
|
|
28
26
|
|
|
29
27
|
export class ProfileCatalogStore {
|
|
30
|
-
readonly #
|
|
31
|
-
readonly #fallbackPath?: string;
|
|
28
|
+
readonly #dirPath: string;
|
|
32
29
|
|
|
33
|
-
constructor(
|
|
34
|
-
this.#
|
|
35
|
-
this.#fallbackPath = fallbackPath;
|
|
30
|
+
constructor(catalogDir: string) {
|
|
31
|
+
this.#dirPath = catalogDir;
|
|
36
32
|
}
|
|
37
33
|
|
|
38
|
-
/** Validated definitions: missing
|
|
34
|
+
/** Validated definitions: missing directory → empty; malformed → CatalogError
|
|
39
35
|
* (catalog errors never pass silently, even on the write path). */
|
|
40
36
|
async readDefinitions(): Promise<Map<string, ProfileDefinition>> {
|
|
41
|
-
|
|
42
|
-
if (!result.ok && result.reason === "missing" && this.#fallbackPath) {
|
|
43
|
-
result = await readJsonFile(this.#fallbackPath);
|
|
44
|
-
}
|
|
45
|
-
if (!result.ok) {
|
|
46
|
-
if (result.reason === "missing") return new Map();
|
|
47
|
-
throw new CatalogError(`invalid JSON in ${this.#filePath}`);
|
|
48
|
-
}
|
|
49
|
-
if (!isRecord(result.value)) {
|
|
50
|
-
throw new CatalogError(`${this.#filePath}: catalog must be an object`);
|
|
51
|
-
}
|
|
52
|
-
if (result.value.schemaVersion !== PROFILE_SCHEMA_VERSION) {
|
|
53
|
-
throw new CatalogError(
|
|
54
|
-
`${this.#filePath}: unsupported schemaVersion ${JSON.stringify(result.value.schemaVersion)} (expected ${PROFILE_SCHEMA_VERSION})`,
|
|
55
|
-
);
|
|
56
|
-
}
|
|
57
|
-
if (!isRecord(result.value.profiles)) {
|
|
58
|
-
throw new CatalogError(`${this.#filePath}: "profiles" must be an object mapping names to definitions`);
|
|
59
|
-
}
|
|
60
|
-
const definitions = new Map<string, ProfileDefinition>();
|
|
61
|
-
for (const [name, raw] of Object.entries(result.value.profiles)) {
|
|
62
|
-
if (name === DEFAULT_PROFILE_NAME) {
|
|
63
|
-
throw new CatalogError(
|
|
64
|
-
`${this.#filePath}: "${DEFAULT_PROFILE_NAME}" is built in and must not be defined in the catalog`,
|
|
65
|
-
);
|
|
66
|
-
}
|
|
67
|
-
definitions.set(name, parseProfileDefinition(name, raw));
|
|
68
|
-
}
|
|
69
|
-
return definitions;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/** Overwrites the file with the given definitions (last write wins). */
|
|
73
|
-
async writeDefinitions(definitions: ReadonlyMap<string, ProfileDefinition>): Promise<void> {
|
|
74
|
-
const profiles: Record<string, unknown> = {};
|
|
75
|
-
for (const [name, definition] of [...definitions.entries()].sort(([a], [b]) => a.localeCompare(b))) {
|
|
76
|
-
// Round-trip through the parser: only declared, validated fields
|
|
77
|
-
// are written back (self-contained; no unknown keys survive).
|
|
78
|
-
profiles[name] = parseProfileDefinition(name, definition);
|
|
79
|
-
}
|
|
80
|
-
await mkdir(path.dirname(this.#filePath), { recursive: true });
|
|
81
|
-
await writeFile(
|
|
82
|
-
this.#filePath,
|
|
83
|
-
`${JSON.stringify({ schemaVersion: PROFILE_SCHEMA_VERSION, profiles }, null, 2)}\n`,
|
|
84
|
-
);
|
|
37
|
+
return loadCatalogDirectory(this.#dirPath);
|
|
85
38
|
}
|
|
86
39
|
|
|
87
|
-
/** Inserts or replaces one complete definition. */
|
|
40
|
+
/** Inserts or replaces one complete definition (via atomic rename). */
|
|
88
41
|
async upsert(name: string, definition: ProfileDefinition): Promise<void> {
|
|
89
|
-
if (
|
|
90
|
-
throw new CatalogError(`profile name must
|
|
42
|
+
if (!PROFILE_NAME_PATTERN.test(name)) {
|
|
43
|
+
throw new CatalogError(`invalid profile name "${name}" (must match ${PROFILE_NAME_PATTERN})`);
|
|
91
44
|
}
|
|
92
45
|
if (name === DEFAULT_PROFILE_NAME) {
|
|
93
46
|
throw new CatalogError(`"${DEFAULT_PROFILE_NAME}" is built in and must not be defined in the catalog`);
|
|
94
47
|
}
|
|
95
|
-
|
|
96
|
-
// Validate before mutating: the
|
|
97
|
-
|
|
98
|
-
|
|
48
|
+
|
|
49
|
+
// Validate before mutating: the definition must parse.
|
|
50
|
+
const validated = parseProfileDefinition(name, definition);
|
|
51
|
+
|
|
52
|
+
await mkdir(this.#dirPath, { recursive: true });
|
|
53
|
+
const targetPath = path.join(this.#dirPath, `${name}.json`);
|
|
54
|
+
const tempPath = path.join(
|
|
55
|
+
this.#dirPath,
|
|
56
|
+
`.${name}.json.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`,
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
await writeFile(tempPath, `${JSON.stringify(validated, null, 2)}\n`);
|
|
61
|
+
await rename(tempPath, targetPath);
|
|
62
|
+
} catch (error) {
|
|
63
|
+
await rm(tempPath, { force: true });
|
|
64
|
+
throw error;
|
|
65
|
+
}
|
|
99
66
|
}
|
|
100
67
|
|
|
101
68
|
/** Removes one profile; unknown names are a loud error, not a no-op. */
|
|
102
69
|
async remove(name: string): Promise<void> {
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
70
|
+
if (!PROFILE_NAME_PATTERN.test(name)) {
|
|
71
|
+
throw new CatalogError(`invalid profile name "${name}" (must match ${PROFILE_NAME_PATTERN})`);
|
|
72
|
+
}
|
|
73
|
+
if (name === DEFAULT_PROFILE_NAME) {
|
|
74
|
+
throw new CatalogError(`"${DEFAULT_PROFILE_NAME}" is built in and cannot be deleted`);
|
|
75
|
+
}
|
|
76
|
+
const targetPath = path.join(this.#dirPath, `${name}.json`);
|
|
77
|
+
try {
|
|
78
|
+
await unlink(targetPath);
|
|
79
|
+
} catch (error) {
|
|
80
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
81
|
+
throw new CatalogError(`profile "${name}" not found in ${this.#dirPath}`);
|
|
82
|
+
}
|
|
83
|
+
throw error;
|
|
106
84
|
}
|
|
107
|
-
await this.writeDefinitions(definitions);
|
|
108
85
|
}
|
|
109
86
|
}
|
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
|
-
}
|