pi-profile-switch 0.9.1 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,67 +1,67 @@
1
1
  ---
2
2
  name: profile-config
3
- description: 指导创建、修改或删除 pi-profile-switch 的 profile。当用户想要创建、修改、配置或删除 profile 时触发。
3
+ description: Guides the creation, modification, and deletion of pi-profile-switch profiles. Trigger when the user wants to create, modify, configure, or delete a profile.
4
4
  ---
5
5
 
6
6
  # profile-config
7
7
 
8
- 本 skill 指导 agent 理解用户的模糊需求或明确指令,协助用户创建、修改或删除 `pi-profile-switch` 的 profile 文件。
8
+ This skill guides the agent in understanding the user's vague requirements or explicit instructions, and assists in creating, modifying, or deleting `pi-profile-switch` profile files.
9
9
 
10
- > **声明**:本 skill 文件由 `pi-profile-switch` package 安装分发并在随包升级时自动覆写,请勿手动修改此文件。
10
+ > **Notice**: This skill file is distributed by the `pi-profile-switch` package install and is automatically overwritten on package upgrades. Do not modify this file manually.
11
11
 
12
12
  ---
13
13
 
14
- ## 1. 核心概念与约束
14
+ ## 1. Core concepts and constraints
15
15
 
16
- ### 1.1 Profile 与 Catalog
17
- - **Profile**:命名的能力定义,引用 skills、extensions、MCP servers 与 tools,可选声明 model、thinking level 与 instructions。
18
- - **Catalog**:保存 profile 定义的 `profiles/` 目录。每个 profile 对应目录下的一个独立 JSON 文件:`<name>.json`。
19
- - **default profile**:由 Pi 提供,不可删除、不可编辑的 profile,加载 Pi 可发现的全部资源。**严禁**在任何 profiles 目录下创建 `default.json`。
16
+ ### 1.1 Profiles and catalogs
17
+ - **Profile**: a named capability definition referencing skills, extensions, MCP servers, and tools, optionally declaring model, thinking level, and instructions.
18
+ - **Catalog**: the `profiles/` directory holding profile definitions. Each profile corresponds to one standalone JSON file in the directory: `<name>.json`.
19
+ - **default profile**: provided by Pi; cannot be deleted or edited; loads every resource Pi can discover. **Strictly forbidden** to create `default.json` in any profiles directory.
20
20
 
21
- ### 1.2 名字字符集规范
22
- Profile 名字必须完全匹配正则:
21
+ ### 1.2 Name charset rules
22
+ A profile name must fully match the regex:
23
23
  ```regex
24
24
  ^[A-Za-z0-9][A-Za-z0-9._-]*$
25
25
  ```
26
- - 必须以英文字母或数字开头。
27
- - 只允许英文字母、数字、点(`.`)、下划线(`_`)和连字符(`-`)。
28
- - 不允许包含空格、中文或特殊符号。
26
+ - Must start with an ASCII letter or digit.
27
+ - Only ASCII letters, digits, dots (`.`), underscores (`_`), and hyphens (`-`) are allowed.
28
+ - Spaces, CJK characters, and special symbols are not allowed.
29
29
 
30
- ### 1.3 作用域(Source scope)与存储落点
31
- Profile 文件存放在两个位置之一:
30
+ ### 1.3 Scopes (source scope) and storage locations
31
+ Profile files live in one of two locations:
32
32
 
33
- | 作用域 | 路径 | 说明 |
33
+ | Scope | Path | Notes |
34
34
  | --- | --- | --- |
35
- | **全局(global)** | `$PI_PROFILE_SWITCH_DIR/profiles/<name>.json`<br>(缺省为 `~/.pi-profile-switch/profiles/<name>.json`) | 对所有项目通用。若环境变量 `PI_PROFILE_SWITCH_DIR` 存在且非空,则以其下的 `profiles/` 目录为准。 |
36
- | **项目(project)** | `<projectDir>/.pi/profiles/<name>.json` | 仅在当前项目生效,且仅当项目已受信任时可用。 |
35
+ | **Global** | `$PI_PROFILE_SWITCH_DIR/profiles/<name>.json`<br>(default `~/.pi-profile-switch/profiles/<name>.json`) | Applies to all projects. If the `PI_PROFILE_SWITCH_DIR` environment variable exists and is non-empty, the `profiles/` directory under it is authoritative. |
36
+ | **Project** | `<projectDir>/.pi/profiles/<name>.json` | Effective only in the current project, and only when the project is trusted. |
37
37
 
38
- - **覆盖规则**:项目 scope 的同名 profile 会完整替换(replace)全局条目,**不会**与全局配置合并字段。
39
- - **项目信任门禁**:若当前项目未受信任,项目 scope 的 profile 无法解析,向项目 scope 写入也会失败。写入项目 scope 前若项目未受信任,必须提示用户在会话中执行 `/trust` 并重启 Pi。
38
+ - **Override rule**: a same-named profile in project scope completely replaces the global entry; fields are **not** merged with the global configuration.
39
+ - **Project trust gate**: if the current project is untrusted, profiles in project scope cannot resolve, and writes to project scope fail. Before writing to project scope while the project is untrusted, you must tell the user to run `/trust` in the session and restart Pi.
40
40
 
41
41
  ---
42
42
 
43
- ## 2. Profile 文件格式与字段定义
43
+ ## 2. Profile file format and fields
44
44
 
45
- 文件内容必须为格式化 JSON,**顶层即为裸定义对象**,严禁包裹 `schemaVersion`、`profiles` 或其他外层信封字段。
45
+ The file content must be formatted JSON, with **the bare definition object at the top level** — strictly no `schemaVersion`, `profiles`, or other outer envelope fields.
46
46
 
47
- 全部字段均为可选(optional)。未声明的字段保持原生 Pi 行为或当前状态,不产生任何副作用。
47
+ All fields are optional. Undeclared fields keep native Pi behavior or current state and produce no side effects.
48
48
 
49
- ### 字段详细语义
49
+ ### Field semantics
50
50
 
51
- | 字段 | 类型 | 语义与约束 |
51
+ | Field | Type | Semantics and constraints |
52
52
  | --- | --- | --- |
53
- | `label` | `string` | 人类可读的显示名称(如 `"Code Review"`)。 |
54
- | `description` | `string` | Profile 的简要描述(如 `"Read-only review profile"`)。 |
55
- | `skills` | `string[]` | 引用的 skill 名称或 glob 列表。未声明时不收窄可用 skills。 |
56
- | `extensions` | `string[]` | 引用的 extension 标识或 glob 列表。未声明时不收窄可用 extensions。 |
57
- | `mcps` | `string[]` | 引用的 MCP server 名称或 glob 列表。未声明时不依赖 `pi-mcp-adapter`。 |
58
- | `tools` | `string[]` | 白名单工具名称或 glob 列表。未声明时不收窄工具,保持 Pi 原生工具集合。 |
59
- | `defaultProvider` | `string` | 默认模型提供商(如 `"anthropic"`、`"openai"`)。与 `defaultModel` 必须同时声明才生效。 |
60
- | `defaultModel` | `string` | 默认模型名称(如 `"claude-sonnet-4-5"`)。与 `defaultProvider` 必须同时声明才生效。 |
61
- | `defaultThinkingLevel` | `string` | 默认思考等级,可选值:`"off"`、`"minimal"`、`"low"`、`"medium"`、`"high"`、`"xhigh"`、`"max"`。仅在模型声明成立时生效。 |
62
- | `instructions` | `string` | 激活该 profile 时追加到系统提示词的指令文本。 |
63
-
64
- ### 示例格式
53
+ | `label` | `string` | Human-readable display name (e.g. `"Code Review"`). |
54
+ | `description` | `string` | Short description of the profile (e.g. `"Read-only review profile"`). |
55
+ | `skills` | `string[]` | Skill names or globs to reference. When undeclared, available skills are not narrowed. |
56
+ | `extensions` | `string[]` | Extension identifiers or globs to reference. When undeclared, available extensions are not narrowed. |
57
+ | `mcps` | `string[]` | MCP server names or globs to reference. When undeclared, there is no dependency on `pi-mcp-adapter`. |
58
+ | `tools` | `string[]` | Whitelisted tool names or globs. When undeclared, tools are not narrowed and Pi's native tool set is kept. |
59
+ | `defaultProvider` | `string` | Default model provider (e.g. `"anthropic"`, `"openai"`). Effective only when declared together with `defaultModel`. |
60
+ | `defaultModel` | `string` | Default model name (e.g. `"claude-sonnet-4-5"`). Effective only when declared together with `defaultProvider`. |
61
+ | `defaultThinkingLevel` | `string` | Default thinking level; allowed values: `"off"`, `"minimal"`, `"low"`, `"medium"`, `"high"`, `"xhigh"`, `"max"`. Effective only when the model declaration holds. |
62
+ | `instructions` | `string` | Instruction text appended to the system prompt when this profile is active. |
63
+
64
+ ### Example
65
65
  ```json
66
66
  {
67
67
  "label": "Review Mode",
@@ -86,82 +86,82 @@ Profile 文件存放在两个位置之一:
86
86
 
87
87
  ---
88
88
 
89
- ## 3. 可引用资源发现指引
90
-
91
- 当帮助用户配置 profile 时,可检查或参考以下位置发现用户当前已有的可用资源:
92
-
93
- 1. **Skills**:
94
- - 发现位置:`<agentDir>/skills/` 以及全局 `~/.agents/skills/`。
95
- - 引用身份:Pi 的 skill 名称(即 skill 目录下的 `SKILL.md` frontmatter 中声明的 `name`,或目录名)。
96
- - 支持 glob(如 `"git-*"`)。
97
- 2. **Extensions**:
98
- - 发现位置:`<agentDir>/settings.json` 中声明的已安装 packages、`<agentDir>/extensions/` 下的散装文件(`.ts` 或 `.js`)。
99
- - 引用形式:
100
- - 已安装包的包名或 source 别名。
101
- - 多入口包的入口 ID:`<包名>:<相对路径>`。
102
- - 散装文件 ID:相对扩展目录的路径去掉 `.ts`/`.js`(如 `sub/index.ts` 引用为 `sub`)。
103
- - 绝对路径或 `~/` 路径。
104
- - glob 匹配。
105
- 3. **MCP Servers**:
106
- - 发现位置:`pi-mcp-adapter` 识别的标准配置位置——全局侧 `~/.config/mcp/mcp.json`、`~/.agents/mcp.json`、`~/.agents/mcp/mcp.json`、`<agentDir>/mcp.json`;受信任项目另有 `<projectDir>/.mcp.json` 与 `<projectDir>/.pi/mcp.json`。
107
- - 引用身份:上述配置文件中 `mcpServers` 对象下的 server 键名。
108
- - 声明了 `mcps` 的 profile 需要同时确保 `pi-mcp-adapter` extension 处于可用状态。
109
- 4. **Tools**:
110
- - 引用身份:Pi 实时工具注册表中的工具名。
111
- - 包括内建工具(`read`、`write`、`edit`、`bash` 等)、extension 贡献的工具、以及 MCP server 暴露的工具(代理工具 `mcp__<server>` 与直接工具 `<server>_<tool>`)。
112
- - 支持 glob(如 `"mcp__*"`、`"github_*"`)。
113
- 5. **项目级资源的收窄边界(重要)**:
114
- - Profile 的资源选择(`skills`、`extensions`)**仅作用于用户级资源**(真实 agentDir 与 `~/.agents/skills`)。
115
- - 项目级资源(如项目 `.pi/skills`、项目 `.pi/extensions`、上级 `.agents/skills`)的可见性由 Pi 项目信任判定决定:在受信任项目中,它们在**任何** profile 下都始终可见;在未受信任项目中均不可见。
116
- - 因此,项目级资源的可见性**不随 profile 收窄**,无需也不指导在 profile 中声明项目级资源。
89
+ ## 3. Discovering referenceable resources
90
+
91
+ When helping the user configure a profile, check or consult the following locations to discover the resources the user currently has:
92
+
93
+ 1. **Skills**:
94
+ - Discovery locations: `<agentDir>/skills/` and the global `~/.agents/skills/`.
95
+ - Reference identity: Pi's skill name (the `name` declared in the frontmatter of the skill directory's `SKILL.md`, or the directory name).
96
+ - Globs supported (e.g. `"git-*"`).
97
+ 2. **Extensions**:
98
+ - Discovery locations: installed packages declared in `<agentDir>/settings.json`, and loose files (`.ts` or `.js`) under `<agentDir>/extensions/`.
99
+ - Reference forms:
100
+ - An installed package's package name or source alias.
101
+ - Entry ID of a multi-entry package: `<package>:<relative path>`.
102
+ - Loose-file ID: the path relative to the extension directory minus the `.ts`/`.js` suffix (e.g. `sub/index.ts` is referenced as `sub`).
103
+ - Absolute paths or `~/` paths.
104
+ - Glob matching.
105
+ 3. **MCP servers**:
106
+ - Discovery locations: the standard configuration locations recognized by `pi-mcp-adapter` — on the global side `~/.config/mcp/mcp.json`, `~/.agents/mcp.json`, `~/.agents/mcp/mcp.json`, `<agentDir>/mcp.json`; trusted projects additionally have `<projectDir>/.mcp.json` and `<projectDir>/.pi/mcp.json`.
107
+ - Reference identity: the server key names under the `mcpServers` object in those configuration files.
108
+ - A profile declaring `mcps` must also ensure the `pi-mcp-adapter` extension is available.
109
+ 4. **Tools**:
110
+ - Reference identity: tool names in Pi's live tool registry.
111
+ - Includes built-in tools (`read`, `write`, `edit`, `bash`, etc.), extension-contributed tools, and tools exposed by MCP servers (the proxy tool `mcp__<server>` and direct tools `<server>_<tool>`).
112
+ - Globs supported (e.g. `"mcp__*"`, `"github_*"`).
113
+ 5. **Narrowing boundary of project-level resources (important)**:
114
+ - A profile's resource selection (`skills`, `extensions`) **applies only to user-level resources** (the real agentDir and `~/.agents/skills`).
115
+ - The visibility of project-level resources (project `.pi/skills`, project `.pi/extensions`, ancestor `.agents/skills`) is decided by Pi's project-trust determination: in a trusted project they are always visible under **any** profile; in an untrusted project they are never visible.
116
+ - Therefore project-level resource visibility **does not narrow with profiles** — there is no need, and no guidance, to declare project-level resources in a profile.
117
117
 
118
118
  ---
119
119
 
120
- ## 4. 创作时的默认注入规则
120
+ ## 4. Default injection rule at authoring time
121
121
 
122
- 当为用户创建或生成声明了 `skills` 的新 profile 时,必须遵守以下约定:
122
+ When creating or generating a new profile that declares `skills` for the user, follow these conventions:
123
123
 
124
- 1. **默认注入 `"profile-config"`**:
125
- - 若生成的 profile 声明了 `skills` 数组,默认在 `skills` 列表中包含 `"profile-config"`,以保证切入该 profile 后用户仍可继续通过本 skill 配置 profile。
126
- - **例外 1**:用户明确要求不包含 `"profile-config"` 时除外。
127
- - **例外 2**:若 `skills` 列表中已包含具有覆盖性的 glob(例如 `"*"`),则无需重复显式添加 `"profile-config"`。
128
- 2. **未声明 `skills` 时不动作**:
129
- - 若 profile 未声明 `skills` 字段,表示不收窄 skills,全部 skill(包括 `profile-config`)天然可用,因此绝对不要主动添加 `skills` 字段。
124
+ 1. **Inject `"profile-config"` by default**:
125
+ - If the generated profile declares a `skills` array, include `"profile-config"` in the `skills` list by default, so that after switching into the profile the user can still configure profiles through this skill.
126
+ - **Exception 1**: the user explicitly asks not to include `"profile-config"`.
127
+ - **Exception 2**: the `skills` list already contains a covering glob (e.g. `"*"`), so there is no need to add `"profile-config"` explicitly again.
128
+ 2. **Do nothing when `skills` is undeclared**:
129
+ - If the profile does not declare the `skills` field, skills are not narrowed and every skill (including `profile-config`) is naturally available — never proactively add a `skills` field in that case.
130
130
 
131
131
  ---
132
132
 
133
- ## 5. 交互与执行流程
134
-
135
- ### 5.1 创建(Create)
136
- 1. **需求澄清**:根据用户自然语言描述(如「帮我配一个用于安全审计的只读 profile」),明确:
137
- - 目标名称(校验符合 `^[A-Za-z0-9][A-Za-z0-9._-]*$`,且非 `default`)。
138
- - 目标 scope(全局还是项目级)。
139
- - 需要收窄的工具、skills、extensions、MCP servers 或特定模型设定。
140
- 2. **资源与环境核对**:根据上述规则构造合法 JSON 定义,应用创作时默认注入规则。
141
- 3. **写入文件**:
142
- - 全局路径:`$PI_PROFILE_SWITCH_DIR/profiles/<name>.json`(默认 `~/.pi-profile-switch/profiles/<name>.json`)。
143
- - 项目路径:`<projectDir>/.pi/profiles/<name>.json`。
144
- - 确保目录存在,写入格式化的 JSON。
145
- 4. **提示用户生效**:告知用户可通过 `/profile reload` 或 `/profile use <name>` 立即使用新 profile。
146
-
147
- ### 5.2 修改(Edit)
148
- 1. 读取目标 profile 文件已有内容。
149
- 2. 根据用户要求调整对应字段,保持其余字段完整。
150
- 3. 校验并写回格式化 JSON。
151
- 4. 提示用户执行 `/profile reload`。
152
-
153
- ### 5.3 删除(Delete)
154
- 1. 确认要删除的 profile 存在于指定 scope。
155
- 2. 严禁尝试删除 `default` profile。
156
- 3. 删除对应的 `<name>.json` 文件。若当前正在使用该 profile,提醒用户先切换到其他 profile(如 `/profile use default`)。
133
+ ## 5. Interaction and execution flows
134
+
135
+ ### 5.1 Create
136
+ 1. **Clarify requirements**: from the user's natural-language description (e.g. "set up a read-only profile for security auditing"), determine:
137
+ - The target name (validate against `^[A-Za-z0-9][A-Za-z0-9._-]*$`, and not `default`).
138
+ - The target scope (global or project).
139
+ - The tools, skills, extensions, MCP servers, or specific model settings to narrow.
140
+ 2. **Check resources and environment**: construct a legal JSON definition per the rules above, applying the default injection rule.
141
+ 3. **Write the file**:
142
+ - Global path: `$PI_PROFILE_SWITCH_DIR/profiles/<name>.json` (default `~/.pi-profile-switch/profiles/<name>.json`).
143
+ - Project path: `<projectDir>/.pi/profiles/<name>.json`.
144
+ - Ensure the directory exists and write formatted JSON.
145
+ 4. **Tell the user how to take effect**: the new profile is immediately usable via `/profile reload` or `/profile use <name>`.
146
+
147
+ ### 5.2 Edit
148
+ 1. Read the existing content of the target profile file.
149
+ 2. Adjust the requested fields per the user's requirements, keeping all other fields intact.
150
+ 3. Validate and write back formatted JSON.
151
+ 4. Tell the user to run `/profile reload`.
152
+
153
+ ### 5.3 Delete
154
+ 1. Confirm the profile to delete exists in the specified scope.
155
+ 2. Never attempt to delete the `default` profile.
156
+ 3. Delete the corresponding `<name>.json` file. If the profile is currently in use, remind the user to switch to another profile first (e.g. `/profile use default`).
157
157
 
158
158
  ---
159
159
 
160
- ## 6. 边界与退化处理
160
+ ## 6. Boundaries and degradation
161
161
 
162
- 1. **只读 / 无 `write` 工具环境**:
163
- - 如果当前会话处于收窄工具的 profile 中(例如没有 `write` 工具的只读模式):
164
- - 退化为在回复中输出完整的格式化 JSON 内容与建议保存的文件绝对路径,建议用户手动保存或切换至具备文件写入能力的 profile(如 `/profile use default`)后再行保存。
165
- 2. **未受信任项目**:
166
- - 如果需要写入项目 scope(`<projectDir>/.pi/profiles/`),而当前项目尚未受信任:
167
- - 必须向用户说明项目未受信任无法生效,提示用户执行 `/trust` 并重启 Pi,或改将 profile 保存至全局 scope。
162
+ 1. **Read-only / no `write` tool environments**:
163
+ - If the current session is in a profile with narrowed tools (e.g. a read-only mode without the `write` tool):
164
+ - Degrade to outputting the complete formatted JSON content in the reply along with the suggested absolute file path, and suggest the user save it manually or switch to a profile with file-write capability (e.g. `/profile use default`) before saving.
165
+ 2. **Untrusted projects**:
166
+ - If a write to project scope (`<projectDir>/.pi/profiles/`) is needed while the current project is not yet trusted:
167
+ - You must explain to the user that an untrusted project cannot take effect, and suggest running `/trust` and restarting Pi, or saving the profile to global scope instead.
@@ -55,6 +55,10 @@ export interface InitialProfile {
55
55
  discovery?: LauncherDiscovery;
56
56
  /** The trusted project directory, when trusted. */
57
57
  projectDir?: string;
58
+ /** The launcher's project-trust determination. Drives the untrusted-
59
+ * project diagnostic; not inferable from projectDir, which the dangling
60
+ * saved-profile fallback returns unset while the project is trusted. */
61
+ projectTrusted: boolean;
58
62
  /** Non-fatal notices for the user (e.g. a dangling restored profile that
59
63
  * fell back to default). The launcher prints them. */
60
64
  warnings: string[];
@@ -90,7 +94,7 @@ export async function readTrustInputs(context: LauncherContext): Promise<{
90
94
  export async function resolveInitialProfile(
91
95
  name: string | undefined,
92
96
  context: LauncherContext,
93
- options?: { overlay?: RuntimeOverlay },
97
+ options?: { overlay?: RuntimeOverlay; liveToolNames?: string[] },
94
98
  ): Promise<InitialProfile> {
95
99
  const { projectTrusted } = await readTrustInputs(context);
96
100
  const projectDir = projectTrusted ? context.cwd : undefined;
@@ -116,7 +120,7 @@ export async function resolveInitialProfile(
116
120
  throw new UnknownProfileError(selected);
117
121
  }
118
122
  warnings.push(`saved profile "${selected}" no longer exists; starting the default profile`);
119
- return { plan: defaultPlan(), warnings };
123
+ return { plan: defaultPlan(), projectTrusted, warnings };
120
124
  }
121
125
  if (profile.source === "builtin") {
122
126
  // The default profile is normally unfiltered. With an overlay it becomes
@@ -129,9 +133,9 @@ export async function resolveInitialProfile(
129
133
  ((overlay.disabledSkills?.length ?? 0) > 0 ||
130
134
  (overlay.disabledExtensions?.length ?? 0) > 0 ||
131
135
  (overlay.disabledMcps?.length ?? 0) > 0 ||
132
- overlay.tools !== undefined);
136
+ (overlay.disabledTools?.length ?? 0) > 0);
133
137
  if (!narrowed) {
134
- return { plan: defaultPlan(), warnings };
138
+ return { plan: defaultPlan(), projectTrusted, warnings };
135
139
  }
136
140
  if ((overlay.disabledMcps?.length ?? 0) > 0) {
137
141
  throw new ActivationError(
@@ -149,9 +153,10 @@ export async function resolveInitialProfile(
149
153
  skills: discovery.skills,
150
154
  extensions: discovery.extensions,
151
155
  overlay,
156
+ liveToolNames: options?.liveToolNames,
152
157
  });
153
158
  warnings.push(...discovery.extensions.warnings(), ...unmatchedWarnings(plan));
154
- return { plan, discovery, projectDir, warnings };
159
+ return { plan, discovery, projectDir, projectTrusted, warnings };
155
160
  }
156
161
 
157
162
  const discovery = await discoverLauncherResources({ ...context, projectTrusted });
@@ -164,6 +169,7 @@ export async function resolveInitialProfile(
164
169
  ? await discoverAdapterServerNames(context.agentDir, projectDir)
165
170
  : undefined,
166
171
  overlay: options?.overlay,
172
+ liveToolNames: options?.liveToolNames,
167
173
  });
168
174
  warnings.push(...discovery.extensions.warnings(), ...unmatchedWarnings(plan));
169
175
  if (plan.mcps !== undefined && !plan.extensions.some(isAdapterExtension)) {
@@ -172,5 +178,5 @@ export async function resolveInitialProfile(
172
178
  // silently do nothing or leak through unfiltered.
173
179
  throw new MissingMcpAdapterError(plan.profile);
174
180
  }
175
- return { plan, discovery, projectDir, warnings };
181
+ return { plan, discovery, projectDir, projectTrusted, warnings };
176
182
  }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Untrusted-project launch diagnostic.
3
+ *
4
+ * After the launcher's project-trust determination judges the project
5
+ * untrusted, the project catalog, project runtime state, project MCP
6
+ * configuration, and Pi's own project resources are skipped silently
7
+ * (ADR-0011). This module names the skipped content and how to authorize it
8
+ * so the result of the determination is observable. Detection mirrors the
9
+ * trust-requiring set: pi-profile's project files (see project-trust.ts) and
10
+ * Pi's project resources (see pi-coding-agent's trust manager).
11
+ */
12
+
13
+ import { existsSync } from "node:fs";
14
+ import path from "node:path";
15
+
16
+ import { hasTrustRequiringProjectResources } from "@earendil-works/pi-coding-agent";
17
+
18
+ import { PI_PROFILE_PROJECT_FILES } from "../project-trust.ts";
19
+
20
+ /** The project's MCP configuration files (the project sources recognized by
21
+ * mcp-config.ts). */
22
+ const PROJECT_MCP_CONFIGS = [".mcp.json", path.join(".pi", "mcp.json")] as const;
23
+
24
+ /** Pi's trust-requiring `.pi` entries (mirrors
25
+ * TRUST_REQUIRING_PROJECT_CONFIG_RESOURCES in pi-coding-agent's trust
26
+ * manager, which doesn't export the list). */
27
+ const PI_PROJECT_RESOURCES = [
28
+ "settings.json",
29
+ "extensions",
30
+ "skills",
31
+ "prompts",
32
+ "themes",
33
+ "SYSTEM.md",
34
+ "APPEND_SYSTEM.md",
35
+ ] as const;
36
+
37
+ /** Relative paths of project content skipped while the project is
38
+ * untrusted; empty when the project contains nothing trust-requiring. */
39
+ function skippedProjectContent(cwd: string): string[] {
40
+ const skipped: string[] = [];
41
+ for (const name of PI_PROFILE_PROJECT_FILES) {
42
+ if (existsSync(path.join(cwd, ".pi", name))) {
43
+ skipped.push(path.join(".pi", name));
44
+ }
45
+ }
46
+ for (const mcpConfig of PROJECT_MCP_CONFIGS) {
47
+ if (existsSync(path.join(cwd, mcpConfig))) {
48
+ skipped.push(mcpConfig);
49
+ }
50
+ }
51
+ for (const resource of PI_PROJECT_RESOURCES) {
52
+ if (existsSync(path.join(cwd, ".pi", resource))) {
53
+ skipped.push(path.join(".pi", resource));
54
+ }
55
+ }
56
+ if (skipped.length === 0 && hasTrustRequiringProjectResources(cwd)) {
57
+ // Pi gated something this list doesn't name: a cwd-local
58
+ // .agents/skills, or one in a parent directory.
59
+ skipped.push(
60
+ existsSync(path.join(cwd, ".agents", "skills"))
61
+ ? path.join(".agents", "skills")
62
+ : "Pi project resources in a parent directory",
63
+ );
64
+ }
65
+ return skipped;
66
+ }
67
+
68
+ /** The untrusted-project diagnostic for this launch, or undefined when there
69
+ * is nothing to report (trusted project, or untrusted with no
70
+ * trust-requiring content). The launcher prints the result to stderr. */
71
+ export function untrustedProjectDiagnostic(cwd: string, projectTrusted: boolean): string | undefined {
72
+ if (projectTrusted) {
73
+ return undefined;
74
+ }
75
+ const skipped = skippedProjectContent(cwd);
76
+ if (skipped.length === 0) {
77
+ return undefined;
78
+ }
79
+ return (
80
+ `project is untrusted; skipped project content is invisible to this launch: ${skipped.join(", ")}. ` +
81
+ 'To authorize it, run "/trust" in Pi to persist the decision (effective on the next launch), ' +
82
+ 'or relaunch with "-- --approve" to grant one-shot trust for this launch.'
83
+ );
84
+ }
@@ -89,8 +89,8 @@ function readOptionalString(value: unknown, field: string, profileName: string,
89
89
  return value;
90
90
  }
91
91
 
92
- /** Parses one raw profile definition; exported for the write-side store
93
- * (profile-catalog-store.ts) so anything written is loadable. */
92
+ /** Parses one raw profile definition; the single read-time validator so
93
+ * catalog files and any external writer stay loadable. */
94
94
  export function parseProfileDefinition(name: string, raw: unknown, filePath?: string): ProfileDefinition {
95
95
  if (!isRecord(raw)) {
96
96
  const prefix = filePath ? `${filePath}: ` : "";
@@ -67,6 +67,10 @@ export interface ActivationPlan {
67
67
  extensions: Array<{ id: string; entry: string; origin?: "package" | "local" | "path" }>;
68
68
  /** Expanded tool allowlist; undefined when the profile declares no tools. */
69
69
  tools?: string[];
70
+ /** Overlay tool disable entries (names or globs, verbatim); undefined
71
+ * when the overlay disables no tools. The session-start application
72
+ * subtracts their live matches from the base expansion. */
73
+ disabledTools?: string[];
70
74
  /** The raw tool references (globs included) for extension-side expansion
71
75
  * against Pi's live tool registry, which includes extension-provided
72
76
  * tools the pre-spawn expansion cannot know. Set iff `tools` is set. */
@@ -114,12 +118,54 @@ export interface ResolveInput {
114
118
  * any resolved reference may be narrowed or disabled.
115
119
  */
116
120
  overlay?: RuntimeOverlay;
121
+ /**
122
+ * Pi's live tool names (`pi.getAllTools()`), supplied by the in-session
123
+ * switch path. Required when the overlay disables tools on a profile
124
+ * without declared `tools` — the live registry is then the base set the
125
+ * entries disable from. The launcher never passes an overlay, so it never
126
+ * needs this.
127
+ */
128
+ liveToolNames?: string[];
117
129
  }
118
130
 
119
131
  function isGlob(reference: string): boolean {
120
132
  return reference.includes("*") || reference.includes("?");
121
133
  }
122
134
 
135
+ /** Expands overlay disable entries against the names the profile resolved.
136
+ * Entries are names or globs stored as written, re-expanded at every
137
+ * resolution with the same matcher as profile references (ADR-0009 tiering):
138
+ * an unmatched literal fails and identifies the entry; a zero-match glob is
139
+ * reported through `onZeroMatch` instead of failing. Returns the resolved
140
+ * names to disable. */
141
+ function expandDisableEntries(
142
+ entries: string[],
143
+ activeNames: readonly string[],
144
+ kind: string,
145
+ profileName: string,
146
+ onZeroMatch: (entry: string) => void,
147
+ ): Set<string> {
148
+ const disabled = new Set<string>();
149
+ for (const entry of entries) {
150
+ if (isGlob(entry)) {
151
+ let matched = 0;
152
+ for (const name of activeNames) {
153
+ if (minimatch(name, entry)) {
154
+ disabled.add(name);
155
+ matched += 1;
156
+ }
157
+ }
158
+ if (matched === 0) onZeroMatch(entry);
159
+ continue;
160
+ }
161
+ if (!activeNames.includes(entry)) {
162
+ throw new ActivationError(`profile "${profileName}": overlay disables unknown ${kind} "${entry}"`);
163
+ }
164
+ disabled.add(entry);
165
+ }
166
+ return disabled;
167
+ }
168
+
123
169
  /** Expands one reference list against a named universe. Literal misses fail
124
170
  * when `literalMustExist`; globs expand to zero or more matches, and a
125
171
  * zero-match glob is reported through `onZeroMatch`. */
@@ -195,42 +241,48 @@ export async function resolveProfile(input: ResolveInput): Promise<ActivationPla
195
241
  );
196
242
 
197
243
  // --- overlay narrowing (ticket 06) ---
198
- // Overlay references must name resources the profile actually resolves
199
- // (typos fail loudly). Overlays can disable any resolved extension.
200
- let toolReferences = definition.tools;
244
+ // Overlay disable entries are names or globs stored as written and
245
+ // re-expanded at every resolution; unmatched literals fail and identify
246
+ // the entry, zero-match globs join `unmatched` with an `overlay ` prefix.
247
+ // Overlays can disable any resolved extension.
248
+ const toolReferences = definition.tools;
201
249
  if (overlay !== undefined) {
202
250
  if (overlay.disabledSkills !== undefined && overlay.disabledSkills.length > 0) {
203
- const active = new Set(selectedSkills.map((skill) => skill.name));
204
- for (const name of overlay.disabledSkills) {
205
- if (!active.has(name)) {
206
- throw new ActivationError(`profile "${profile.name}": overlay disables unknown skill "${name}"`);
207
- }
208
- }
209
- const disabled = new Set(overlay.disabledSkills);
251
+ const disabled = expandDisableEntries(
252
+ overlay.disabledSkills,
253
+ selectedSkills.map((skill) => skill.name),
254
+ "skill",
255
+ profile.name,
256
+ (entry) => unmatched.push(`overlay skill:${entry}`),
257
+ );
210
258
  selectedSkills = selectedSkills.filter((skill) => !disabled.has(skill.name));
211
259
  }
212
260
  if (overlay.disabledExtensions !== undefined && overlay.disabledExtensions.length > 0) {
213
- const activeIds = new Set(planExtensions.map((entry) => entry.id));
214
- for (const id of overlay.disabledExtensions) {
215
- if (!activeIds.has(id)) {
216
- throw new ActivationError(`profile "${profile.name}": overlay disables unknown extension "${id}"`);
217
- }
218
- }
219
- const disabled = new Set(overlay.disabledExtensions);
261
+ const disabled = expandDisableEntries(
262
+ overlay.disabledExtensions,
263
+ planExtensions.map((entry) => entry.id),
264
+ "extension",
265
+ profile.name,
266
+ (entry) => unmatched.push(`overlay extension:${entry}`),
267
+ );
220
268
  planExtensions = planExtensions.filter((entry) => !disabled.has(entry.id));
221
269
  }
222
270
  if (overlay.disabledMcps !== undefined && overlay.disabledMcps.length > 0) {
223
- const active = new Set(mcps ?? []);
224
- for (const name of overlay.disabledMcps) {
225
- if (!active.has(name)) {
226
- throw new ActivationError(`profile "${profile.name}": overlay disables unknown MCP server "${name}"`);
227
- }
271
+ const active = mcps ?? [];
272
+ const disabled = expandDisableEntries(
273
+ overlay.disabledMcps,
274
+ active,
275
+ "MCP server",
276
+ profile.name,
277
+ (entry) => unmatched.push(`overlay mcp:${entry}`),
278
+ );
279
+ // A profile that declared no `mcps` has no MCP restriction; overlay
280
+ // narrowing must not turn that into an empty whitelist. Literals
281
+ // still fail and zero-match globs still warn — the expansion above
282
+ // runs either way — only the reassignment is guarded.
283
+ if (mcps !== undefined) {
284
+ mcps = active.filter((name) => !disabled.has(name));
228
285
  }
229
- const disabled = new Set(overlay.disabledMcps);
230
- mcps = (mcps ?? []).filter((name) => !disabled.has(name));
231
- }
232
- if (overlay.tools !== undefined) {
233
- toolReferences = overlay.tools;
234
286
  }
235
287
  }
236
288
 
@@ -241,6 +293,40 @@ export async function resolveProfile(input: ResolveInput): Promise<ActivationPla
241
293
  });
242
294
  }
243
295
 
296
+ // Tool disable entries join the uniform grammar: the base set is the
297
+ // profile's resolved tool references when declared (expanded against the
298
+ // live registry, which the in-session switch path always supplies) and
299
+ // the live registry itself when the profile declares no tools. The
300
+ // plan carries the entries verbatim; session-start application subtracts
301
+ // their live matches from the base expansion at that moment.
302
+ let disabledTools: string[] | undefined;
303
+ if (overlay?.disabledTools !== undefined && overlay.disabledTools.length > 0) {
304
+ if (input.liveToolNames === undefined) {
305
+ throw new ActivationError(
306
+ `profile "${profile.name}": overlay disables tools but no live tool registry is available`,
307
+ );
308
+ }
309
+ const base =
310
+ toolReferences !== undefined
311
+ ? expandReferences(toolReferences, input.liveToolNames, (name) => name, "tool", {
312
+ literalMustExist: false,
313
+ })
314
+ : input.liveToolNames;
315
+ const disabled = expandDisableEntries(
316
+ overlay.disabledTools,
317
+ base,
318
+ "tool",
319
+ profile.name,
320
+ (entry) => unmatched.push(`overlay tool:${entry}`),
321
+ );
322
+ disabledTools = [...overlay.disabledTools];
323
+ // The pre-computed boot baseline additionally subtracts the disabled
324
+ // matches (a no-op for tools only the live registry knows).
325
+ if (tools !== undefined) {
326
+ tools = tools.filter((name) => !disabled.has(name));
327
+ }
328
+ }
329
+
244
330
  let model: ProfileModel | undefined;
245
331
  const declaredModel = extractModel(definition);
246
332
  if (declaredModel !== undefined) {
@@ -267,6 +353,7 @@ export async function resolveProfile(input: ResolveInput): Promise<ActivationPla
267
353
  skills: selectedSkills,
268
354
  extensions: planExtensions.map((entry) => ({ id: entry.id, entry: entry.entry })),
269
355
  ...(tools !== undefined && toolReferences !== undefined ? { tools, toolReferences: [...toolReferences] } : {}),
356
+ ...(disabledTools !== undefined ? { disabledTools } : {}),
270
357
  ...(model !== undefined ? { model } : {}),
271
358
  ...(definition.instructions !== undefined ? { instructions: definition.instructions } : {}),
272
359
  ...(mcps !== undefined ? { mcps } : {}),