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 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 files, both optional:
30
+ Profiles live in two directories, with one JSON file per profile:
31
31
 
32
- | File | Scope |
32
+ | Path | Scope |
33
33
  | --- | --- |
34
- | `~/.pi-profile-switch/profiles.json` | Global, all projects. `PI_PROFILE_SWITCH_DIR` overrides the root; `~/.pi/agent/profiles.json` is read as a legacy fallback for migration. |
35
- | `<project>/.pi/profiles.json` | Project-level, trusted projects only. |
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 the JSON directly — schema: [`schemas/profiles.schema.json`](schemas/profiles.schema.json).
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 file with a starter **`ask`** profile — read-only Q&A and code exploration. It assumes nothing about your setup; edit or delete it freely:
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
- "schemaVersion": 1,
44
- "profiles": {
45
- "ask": {
46
- "label": "Ask & Discuss",
47
- "description": "Read-only Q&A and code exploration; no file modifications or command execution",
48
- "skills": [],
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
- "schemaVersion": 1,
62
- "profiles": {
63
- "impl": {
64
- "label": "Implementation",
65
- "description": "Full-powered implementation profile: every available field, pinned model",
66
- "skills": [
67
- "tdd",
68
- "internal-*",
69
- "mcp-scripting"
70
- ],
71
- "extensions": [
72
- "pi-mcp-adapter"
73
- ],
74
- "mcps": [
75
- "github",
76
- "linear"
77
- ],
78
- "tools": [
79
- "read",
80
- "grep",
81
- "find",
82
- "ls",
83
- "bash",
84
- "edit",
85
- "write",
86
- "mcp__*",
87
- "github_*",
88
- "linear_*"
89
- ],
90
- "defaultProvider": "anthropic",
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: `profiles.json` is the seeded starter, `example.json` the full-field demo.
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 定义在两个 JSON 文件中,均可选:
30
+ profile 保存在两个目录中,每个 profile 对应一个独立 JSON 文件:
31
31
 
32
- | 文件 | 作用域 |
32
+ | 路径 | 作用域 |
33
33
  | --- | --- |
34
- | `~/.pi-profile-switch/profiles.json` | 全局,对所有项目生效。`PI_PROFILE_SWITCH_DIR` 可自定义根目录;`~/.pi/agent/profiles.json` 作为遗留路径向下兼容,用于迁移。 |
35
- | `<项目>/.pi/profiles.json` | 项目级,仅对已信任项目生效。 |
34
+ | `~/.pi-profile-switch/profiles/<name>.json` | 全局,对所有项目生效。`PI_PROFILE_SWITCH_DIR` 可自定义根目录。 |
35
+ | `<项目>/.pi/profiles/<name>.json` | 项目级,仅对已信任项目生效。 |
36
36
 
37
- 直接编辑 JSON 即可创建或修改 profile——schema 见 [`schemas/profiles.schema.json`](schemas/profiles.schema.json)。
37
+ 直接编辑或新建 `<name>.json` 即可创建或修改 profile——schema 见 [`schemas/profiles.schema.json`](schemas/profiles.schema.json)。
38
38
 
39
- 安装时,pi-profile-switch 会向全局文件播种一个初始 **`ask`** profile——只读的问答与代码走读模式。它不假设你安装过任何插件,可随意修改或删除:
39
+ 安装时,pi-profile-switch 会向全局 `profiles/` 目录播种一个初始 **`ask`** profile(`ask.json`)——只读的问答与代码走读模式。它不假设你安装过任何插件,可随意修改或删除:
40
40
 
41
41
  ```json
42
42
  {
43
- "schemaVersion": 1,
44
- "profiles": {
45
- "ask": {
46
- "label": "Ask & Discuss",
47
- "description": "Read-only Q&A and code exploration; no file modifications or command execution",
48
- "skills": [],
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 示例加载 TDD skill、mcp-scripting skill(pi-mcp-adapter 自带)和你的内部 skills;接入两个 MCP server;tool 白名单用 glob 覆盖内建工具和这两个 server 的 MCP 工具;并钉住模型与常驻 instructions:
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
- "schemaVersion": 1,
62
- "profiles": {
63
- "impl": {
64
- "label": "Implementation",
65
- "description": "Full-powered implementation profile: every available field, pinned model",
66
- "skills": [
67
- "tdd",
68
- "internal-*",
69
- "mcp-scripting"
70
- ],
71
- "extensions": [
72
- "pi-mcp-adapter"
73
- ],
74
- "mcps": [
75
- "github",
76
- "linear"
77
- ],
78
- "tools": [
79
- "read",
80
- "grep",
81
- "find",
82
- "ls",
83
- "bash",
84
- "edit",
85
- "write",
86
- "mcp__*",
87
- "github_*",
88
- "linear_*"
89
- ],
90
- "defaultProvider": "anthropic",
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/) 中的两个文件与上面一一对应:`profiles.json` 是播种的初始 profile,`example.json` 是全字段演示。
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
 
@@ -1,29 +1,25 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * postinstall: seed the global catalog with the default profiles file.
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/profiles.json` starter catalog to the
8
- * profile-switch dir ONLY when no
9
- * catalog exists there yet (COPYFILE_EXCL; an existing file — including
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 { access, copyFile, mkdir } from "node:fs/promises";
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/profiles.json", import.meta.url));
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
- /** The legacy migration fallback the resolver still reads (~/.pi/agent).
32
+ /** Mirrors getGlobalProfilesDir() in src/workspace.ts.
37
33
  * @param {NodeJS.ProcessEnv} env */
38
- function legacyCatalogPath(env) {
39
- const agentDir = env.PI_CODING_AGENT_DIR ?? path.join(homedir(), ".pi", "agent");
40
- return path.join(agentDir, "profiles.json");
34
+ function globalProfilesDir(env) {
35
+ return path.join(profileSwitchDir(env), "profiles");
41
36
  }
42
37
 
43
- /** @param {string} filePath */
44
- async function exists(filePath) {
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 access(filePath);
47
- return true;
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, skipped?: string }} InstallResult */
49
+ /** @typedef {{ path: string, written: boolean }} InstallResult */
54
50
 
55
51
  /**
56
- * Seeds the default catalog. Safe to call repeatedly; only the first call
57
- * writes.
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 target = path.join(profileSwitchDir(env), "profiles.json");
63
- if (await exists(target)) return { path: target, written: false };
64
- if (await exists(legacyCatalogPath(env))) {
65
- return { path: target, written: false, skipped: "legacy-catalog-present" };
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
- await mkdir(path.dirname(target), { recursive: true });
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 default profiles at ${result.path}`);
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 default profiles: ${error instanceof Error ? error.message : error}`);
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
+ }
@@ -1,37 +1,32 @@
1
1
  {
2
- "schemaVersion": 1,
3
- "profiles": {
4
- "impl": {
5
- "label": "Implementation",
6
- "description": "Full-powered implementation profile: every available field, pinned model",
7
- "skills": [
8
- "tdd",
9
- "internal-*",
10
- "mcp-scripting"
11
- ],
12
- "extensions": [
13
- "pi-mcp-adapter"
14
- ],
15
- "mcps": [
16
- "github",
17
- "linear"
18
- ],
19
- "tools": [
20
- "read",
21
- "grep",
22
- "find",
23
- "ls",
24
- "bash",
25
- "edit",
26
- "write",
27
- "mcp__*",
28
- "github_*",
29
- "linear_*"
30
- ],
31
- "defaultProvider": "anthropic",
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.7.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 catalog",
4
- "description": "Named profile definitions (global ~/.pi-profile-switch/profiles.json, legacy fallback ~/.pi/agent/profiles.json; or project .pi/profiles.json). Definitions are complete and self-contained: there is no inheritance field, and resource references are names/globs, never copies. The built-in \"default\" profile must not be defined here.",
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
- "schemaVersion": {
13
- "const": 1
8
+ "label": {
9
+ "type": "string",
10
+ "description": "Display label in the selector and /profile list."
14
11
  },
15
- "profiles": {
16
- "type": "object",
17
- "propertyNames": {
18
- "not": {
19
- "const": "default"
20
- }
12
+ "description": {
13
+ "type": "string"
14
+ },
15
+ "skills": {
16
+ "type": "array",
17
+ "items": {
18
+ "type": "string"
21
19
  },
22
- "additionalProperties": {
23
- "$ref": "#/$defs/profile"
24
- }
25
- }
26
- },
27
- "$defs": {
28
- "profile": {
29
- "type": "object",
30
- "additionalProperties": false,
31
- "properties": {
32
- "label": {
33
- "type": "string",
34
- "description": "Display label in the selector and /profile list."
35
- },
36
- "description": {
37
- "type": "string"
38
- },
39
- "skills": {
40
- "type": "array",
41
- "items": {
42
- "type": "string"
43
- },
44
- "description": "Skill names or globs (e.g. \"review\", \"internal-*\") referencing discovered skills."
45
- },
46
- "extensions": {
47
- "type": "array",
48
- "items": {
49
- "type": "string"
50
- },
51
- "description": "Extension names or globs. Package names, source aliases, loose-file stems, glob patterns, or direct file paths, resolved against discovered extensions."
52
- },
53
- "mcps": {
54
- "type": "array",
55
- "items": {
56
- "type": "string"
57
- },
58
- "description": "MCP server names or globs, discovered by pi-mcp-adapter configuration. Connection details stay in adapter-managed config."
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 a profile catalog file
3
- * (ticket 09), kept separate from the read-only ProfileCatalog.
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
- * - Whole-file overwrites (pretty-printed, schemaVersion envelope);
7
- * wizard saves never block on concurrent edits — re-read at write time,
8
- * same-name conflicts resolve last-write-wins.
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
- * "default" is built in and can never be written.
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
- PROFILE_SCHEMA_VERSION,
23
+ PROFILE_NAME_PATTERN,
26
24
  type ProfileDefinition,
27
25
  } from "./profile-catalog.ts";
28
26
 
29
27
  export class ProfileCatalogStore {
30
- readonly #filePath: string;
31
- readonly #fallbackPath?: string;
28
+ readonly #dirPath: string;
32
29
 
33
- constructor(catalogPath: string, fallbackPath?: string) {
34
- this.#filePath = catalogPath;
35
- this.#fallbackPath = fallbackPath;
30
+ constructor(catalogDir: string) {
31
+ this.#dirPath = catalogDir;
36
32
  }
37
33
 
38
- /** Validated definitions: missing file → empty; malformed → CatalogError
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
- let result = await readJsonFile(this.#filePath);
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 (name.trim().length === 0) {
90
- throw new CatalogError(`profile name must be non-empty`);
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
- const definitions = await this.readDefinitions();
96
- // Validate before mutating: the wizard's definition must parse.
97
- definitions.set(name, parseProfileDefinition(name, definition));
98
- await this.writeDefinitions(definitions);
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
- const definitions = await this.readDefinitions();
104
- if (!definitions.delete(name)) {
105
- throw new CatalogError(`profile "${name}" not found in ${this.#filePath}`);
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
  }
@@ -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
- }