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.
- package/README.md +14 -7
- package/README.zh-CN.md +13 -6
- package/bin/pi-profile.ts +9 -1
- package/bin/postinstall.js +2 -2
- package/extensions/pi-profile/index.ts +31 -188
- package/package.json +1 -1
- package/skills/profile-config/SKILL.md +104 -104
- package/src/launcher/initial-profile.ts +12 -6
- package/src/launcher/untrusted-project-diagnostic.ts +84 -0
- package/src/profile-catalog.ts +2 -2
- package/src/profile-resolver.ts +114 -27
- package/src/project-trust.ts +10 -7
- package/src/runtime-state-store.ts +3 -4
- package/src/settings-generator.ts +1 -0
- package/src/starter-assets.ts +6 -6
- package/src/switching/apply-plan.ts +36 -8
- package/src/switching/list-profiles.ts +2 -2
- package/src/switching/{customize.ts → overlay.ts} +67 -35
- package/src/switching/status.ts +1 -1
- package/src/switching/switch-profile.ts +7 -3
- package/src/profile-catalog-store.ts +0 -86
- package/src/switching/profile-crud.ts +0 -136
- package/src/switching/profile-wizard.ts +0 -163
|
@@ -1,67 +1,67 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: profile-config
|
|
3
|
-
description:
|
|
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
|
-
|
|
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
|
-
>
|
|
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
|
|
17
|
-
- **Profile
|
|
18
|
-
- **Catalog
|
|
19
|
-
- **default profile
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
|
36
|
-
|
|
|
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
|
-
-
|
|
39
|
-
-
|
|
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
|
-
|
|
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
|
-
|
|
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` |
|
|
54
|
-
| `description` | `string` |
|
|
55
|
-
| `skills` | `string[]` |
|
|
56
|
-
| `extensions` | `string[]` |
|
|
57
|
-
| `mcps` | `string[]` |
|
|
58
|
-
| `tools` | `string[]` |
|
|
59
|
-
| `defaultProvider` | `string` |
|
|
60
|
-
| `defaultModel` | `string` |
|
|
61
|
-
| `defaultThinkingLevel` | `string` |
|
|
62
|
-
| `instructions` | `string` |
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
1. **Skills
|
|
94
|
-
-
|
|
95
|
-
-
|
|
96
|
-
-
|
|
97
|
-
2. **Extensions
|
|
98
|
-
-
|
|
99
|
-
-
|
|
100
|
-
-
|
|
101
|
-
-
|
|
102
|
-
-
|
|
103
|
-
-
|
|
104
|
-
-
|
|
105
|
-
3. **MCP
|
|
106
|
-
-
|
|
107
|
-
-
|
|
108
|
-
-
|
|
109
|
-
4. **Tools
|
|
110
|
-
-
|
|
111
|
-
-
|
|
112
|
-
-
|
|
113
|
-
5.
|
|
114
|
-
-
|
|
115
|
-
-
|
|
116
|
-
-
|
|
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
|
-
|
|
122
|
+
When creating or generating a new profile that declares `skills` for the user, follow these conventions:
|
|
123
123
|
|
|
124
|
-
1.
|
|
125
|
-
-
|
|
126
|
-
-
|
|
127
|
-
-
|
|
128
|
-
2.
|
|
129
|
-
-
|
|
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
|
|
136
|
-
1.
|
|
137
|
-
-
|
|
138
|
-
-
|
|
139
|
-
-
|
|
140
|
-
2.
|
|
141
|
-
3.
|
|
142
|
-
-
|
|
143
|
-
-
|
|
144
|
-
-
|
|
145
|
-
4.
|
|
146
|
-
|
|
147
|
-
### 5.2
|
|
148
|
-
1.
|
|
149
|
-
2.
|
|
150
|
-
3.
|
|
151
|
-
4.
|
|
152
|
-
|
|
153
|
-
### 5.3
|
|
154
|
-
1.
|
|
155
|
-
2.
|
|
156
|
-
3.
|
|
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.
|
|
163
|
-
-
|
|
164
|
-
-
|
|
165
|
-
2.
|
|
166
|
-
-
|
|
167
|
-
-
|
|
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.
|
|
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
|
+
}
|
package/src/profile-catalog.ts
CHANGED
|
@@ -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;
|
|
93
|
-
*
|
|
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}: ` : "";
|
package/src/profile-resolver.ts
CHANGED
|
@@ -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
|
|
199
|
-
//
|
|
200
|
-
|
|
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
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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 =
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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 } : {}),
|