pi-profile-switch 0.9.0 → 0.9.2
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 +3 -3
- package/README.zh-CN.md +3 -3
- package/bin/pi-profile.ts +18 -1
- package/bin/postinstall.js +14 -2
- package/package.json +1 -1
- package/skills/profile-config/SKILL.md +104 -104
- package/src/launcher/initial-profile.ts +8 -4
- package/src/launcher/untrusted-project-diagnostic.ts +84 -0
- package/src/project-trust.ts +10 -7
- package/src/starter-assets.ts +132 -0
package/README.md
CHANGED
|
@@ -36,9 +36,9 @@ Profiles live in two directories, with one JSON file per profile:
|
|
|
36
36
|
|
|
37
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
|
-
You can also configure profiles conversationally: the package ships a **`profile-config`** skill (distributed to `<agentDir>/skills/profile-config/` on install) that guides the agent to clarify requirements, discover resources, and write or remove profile files. Profiles created with a `skills` list include `"profile-config"` by default (unless explicitly opted out or covered by a wildcard like `"*"`), keeping configuration available after switching. Details: [`skills/profile-config/SKILL.md`](skills/profile-config/SKILL.md).
|
|
39
|
+
You can also configure profiles conversationally: the package ships a **`profile-config`** skill (distributed to `<agentDir>/skills/profile-config/` — best-effort on install, and guaranteed in place at every launcher startup) that guides the agent to clarify requirements, discover resources, and write or remove profile files. Profiles created with a `skills` list include `"profile-config"` by default (unless explicitly opted out or covered by a wildcard like `"*"`), keeping configuration available after switching. Details: [`skills/profile-config/SKILL.md`](skills/profile-config/SKILL.md).
|
|
40
40
|
|
|
41
|
-
|
|
41
|
+
pi-profile-switch seeds the global `profiles/` directory with a starter **`ask`** profile (`ask.json`) — best-effort on install, and guaranteed in place at every launcher startup — read-only Q&A and code exploration. It assumes nothing about your setup; edit or delete it freely:
|
|
42
42
|
|
|
43
43
|
```json
|
|
44
44
|
{
|
|
@@ -121,7 +121,7 @@ All commands work in non-interactive modes (`--mode rpc|print|json`); CRUD wizar
|
|
|
121
121
|
|
|
122
122
|
## Docs
|
|
123
123
|
|
|
124
|
-
- [Architecture](docs/architecture/overview.md) · [ADRs](docs/adr/) · [Glossary](CONTEXT.md)
|
|
124
|
+
- [Architecture](docs/architecture/overview.md) · [ADRs](docs/adr/) · [Glossary](CONTEXT.md)
|
|
125
125
|
- JSON Schemas: [`schemas/`](schemas/)
|
|
126
126
|
|
|
127
127
|
## License
|
package/README.zh-CN.md
CHANGED
|
@@ -18,7 +18,7 @@ npm install -g pi-profile-switch
|
|
|
18
18
|
# 使用内建 default profile 启动(全量资源,等同原生 Pi)
|
|
19
19
|
pi-profile
|
|
20
20
|
|
|
21
|
-
#
|
|
21
|
+
# 使用 starter 只读 ask profile 启动
|
|
22
22
|
pi-profile ask
|
|
23
23
|
|
|
24
24
|
# -- 后面的参数原样传给 pi
|
|
@@ -36,9 +36,9 @@ profile 保存在两个目录中,每个 profile 对应一个独立 JSON 文件
|
|
|
36
36
|
|
|
37
37
|
直接编辑或新建 `<name>.json` 即可创建或修改 profile——schema 见 [`schemas/profiles.schema.json`](schemas/profiles.schema.json)。
|
|
38
38
|
|
|
39
|
-
你也可以通过对话让 agent 帮你配置:随包附带的 **`profile-config`** skill
|
|
39
|
+
你也可以通过对话让 agent 帮你配置:随包附带的 **`profile-config`** skill(安装时 best-effort 分发、launcher 启动时保证就位,位于 `<agentDir>/skills/profile-config/`)会指导 agent 澄清需求、发现资源并读写 profile 文件。按约定,生成声明了 `skills` 的 profile 时默认包含 `"profile-config"`(除非明确排除或已被 `*` 等 glob 覆盖),确保切换到新 profile 后仍可持续对话配置。详情见 [`skills/profile-config/SKILL.md`](skills/profile-config/SKILL.md)。
|
|
40
40
|
|
|
41
|
-
|
|
41
|
+
pi-profile-switch 会向全局 `profiles/` 目录播种一个初始 **`ask`** profile(`ask.json`)——安装时 best-effort、launcher 启动时保证就位——它是只读的问答与代码走读模式。它不假设你安装过任何插件,可随意修改或删除:
|
|
42
42
|
|
|
43
43
|
```json
|
|
44
44
|
{
|
package/bin/pi-profile.ts
CHANGED
|
@@ -16,19 +16,29 @@ import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
|
16
16
|
import { ExtensionError } from "../src/extension-discovery.ts";
|
|
17
17
|
import { parseLauncherArgs } from "../src/launcher/args.ts";
|
|
18
18
|
import { UnknownProfileError, resolveInitialProfile } from "../src/launcher/initial-profile.ts";
|
|
19
|
+
import { untrustedProjectDiagnostic } from "../src/launcher/untrusted-project-diagnostic.ts";
|
|
19
20
|
import { sweepStaleInstances } from "../src/launcher/runtime-cleanup.ts";
|
|
20
21
|
import { spawnPi } from "../src/launcher/spawn.ts";
|
|
21
22
|
import { McpConfigError, MissingMcpAdapterError } from "../src/mcp-config.ts";
|
|
22
23
|
import { CatalogError } from "../src/profile-catalog.ts";
|
|
23
24
|
import { ActivationError } from "../src/profile-resolver.ts";
|
|
25
|
+
import { ensureStarterAssets } from "../src/starter-assets.ts";
|
|
24
26
|
import { generateRuntimeDir } from "../src/settings-generator.ts";
|
|
25
27
|
|
|
26
28
|
try {
|
|
27
29
|
const args = parseLauncherArgs(process.argv.slice(2));
|
|
28
30
|
const agentDir = getAgentDir();
|
|
31
|
+
// Starter assets (seed profile + profile-config skill) are ensured before
|
|
32
|
+
// initial profile resolution so the seed is visible to this launch's
|
|
33
|
+
// resolution and /profile list. Best-effort: ensure failures never block
|
|
34
|
+
// the launch (same pattern as the sweep below).
|
|
35
|
+
const starterAssets = await ensureStarterAssets({ agentDir });
|
|
36
|
+
for (const warning of starterAssets.warnings) {
|
|
37
|
+
console.error(`pi-profile: warning: ${warning}`);
|
|
38
|
+
}
|
|
29
39
|
// Fails before spawning when the profile is unknown or cannot activate.
|
|
30
40
|
// --approve/--no-approve are consumed here as a one-run trust input.
|
|
31
|
-
const { plan, discovery, projectDir, warnings } = await resolveInitialProfile(args.profile, {
|
|
41
|
+
const { plan, discovery, projectDir, projectTrusted, warnings } = await resolveInitialProfile(args.profile, {
|
|
32
42
|
agentDir,
|
|
33
43
|
cwd: process.cwd(),
|
|
34
44
|
trustOverride: args.trustOverride,
|
|
@@ -36,6 +46,13 @@ try {
|
|
|
36
46
|
for (const warning of warnings) {
|
|
37
47
|
console.error(`pi-profile: warning: ${warning}`);
|
|
38
48
|
}
|
|
49
|
+
// Untrusted-project notice: fires identically for every profile (default
|
|
50
|
+
// included) because it is driven by the trust determination, not by which
|
|
51
|
+
// profile branch resolution took. Startup continues either way.
|
|
52
|
+
const untrustedNotice = untrustedProjectDiagnostic(process.cwd(), projectTrusted);
|
|
53
|
+
if (untrustedNotice) {
|
|
54
|
+
console.error(`pi-profile: warning: ${untrustedNotice}`);
|
|
55
|
+
}
|
|
39
56
|
// Stale per-launch instance dirs (dead pid, or no pid past the grace
|
|
40
57
|
// window) are swept before this launch materializes its own. Unrecognized
|
|
41
58
|
// entries are dispositioned per content scan: kept + reported when they
|
package/bin/postinstall.js
CHANGED
|
@@ -1,9 +1,21 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* postinstall:
|
|
4
|
-
* and
|
|
3
|
+
* postinstall: best-effort early seeding of the global catalog with the
|
|
4
|
+
* default starter profile, and distribution of the profile-config skill to
|
|
5
|
+
* the agent skills directory.
|
|
5
6
|
*
|
|
6
7
|
* Runs at package install time (`npm install pi-profile-switch` / `pi install`).
|
|
8
|
+
*
|
|
9
|
+
* Role: this hook is an early optimization, NOT the sole distribution
|
|
10
|
+
* channel. npm v12+ blocks dependency lifecycle scripts by default
|
|
11
|
+
* (allowScripts), so installs that skip this hook are covered by the
|
|
12
|
+
* launcher instead: `bin/pi-profile.ts` calls the runtime ensure
|
|
13
|
+
* (src/starter-assets.ts) on every launch, before resolving the initial
|
|
14
|
+
* profile. The authoritative behavior contract for both assets lives in
|
|
15
|
+
* openspec/specs/profile-catalog/spec.md ("Seeding the starter profile" and
|
|
16
|
+
* "Distributing the profile-config skill"); keep this script's rules in sync with
|
|
17
|
+
* src/starter-assets.ts, which is the single TS implementation.
|
|
18
|
+
*
|
|
7
19
|
* Idempotent and conservative:
|
|
8
20
|
* - Writes the shipped `examples/ask.json` starter profile to the
|
|
9
21
|
* profiles dir ONLY when no .json profile exists there yet
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-profile-switch",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.2",
|
|
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,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[];
|
|
@@ -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
|
|
@@ -131,7 +135,7 @@ export async function resolveInitialProfile(
|
|
|
131
135
|
(overlay.disabledMcps?.length ?? 0) > 0 ||
|
|
132
136
|
overlay.tools !== undefined);
|
|
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(
|
|
@@ -151,7 +155,7 @@ export async function resolveInitialProfile(
|
|
|
151
155
|
overlay,
|
|
152
156
|
});
|
|
153
157
|
warnings.push(...discovery.extensions.warnings(), ...unmatchedWarnings(plan));
|
|
154
|
-
return { plan, discovery, projectDir, warnings };
|
|
158
|
+
return { plan, discovery, projectDir, projectTrusted, warnings };
|
|
155
159
|
}
|
|
156
160
|
|
|
157
161
|
const discovery = await discoverLauncherResources({ ...context, projectTrusted });
|
|
@@ -172,5 +176,5 @@ export async function resolveInitialProfile(
|
|
|
172
176
|
// silently do nothing or leak through unfiltered.
|
|
173
177
|
throw new MissingMcpAdapterError(plan.profile);
|
|
174
178
|
}
|
|
175
|
-
return { plan, discovery, projectDir, warnings };
|
|
179
|
+
return { plan, discovery, projectDir, projectTrusted, warnings };
|
|
176
180
|
}
|
|
@@ -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/project-trust.ts
CHANGED
|
@@ -76,11 +76,14 @@ export function resolveProjectTrust(input: ProjectTrustInput): boolean {
|
|
|
76
76
|
return input.userDefaultProjectTrust === "always";
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
-
/** pi-profile's own project files
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
79
|
+
/** pi-profile's own project files, relative to the project's `.pi`
|
|
80
|
+
* directory. They are trust-requiring even though Pi's native list doesn't
|
|
81
|
+
* know them: a committed catalog/state file would otherwise inject profile
|
|
82
|
+
* definitions (and instructions) unguarded. Exported so the launcher's
|
|
83
|
+
* untrusted-project diagnostic names exactly the files the trust gate
|
|
84
|
+
* skips — the two must never drift apart. */
|
|
85
|
+
export const PI_PROFILE_PROJECT_FILES = ["profiles", "pi-profile-state.json"] as const;
|
|
86
|
+
|
|
87
|
+
export function hasPiProfileProjectFiles(cwd: string): boolean {
|
|
88
|
+
return PI_PROFILE_PROJECT_FILES.some((name) => existsSync(path.join(cwd, ".pi", name)));
|
|
86
89
|
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime ensure of starter assets: seeds the starter profile into global
|
|
3
|
+
* profiles/ if no profile exists yet, and distributes/syncs the profile-config
|
|
4
|
+
* skill into the user's agent skills directory.
|
|
5
|
+
*
|
|
6
|
+
* Idempotent, safe against concurrent launches, and downgrades IO errors
|
|
7
|
+
* to warnings without throwing.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { constants } from "node:fs";
|
|
11
|
+
import { copyFile, mkdir, readFile, readdir } from "node:fs/promises";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
|
|
15
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
16
|
+
|
|
17
|
+
import { getGlobalProfilesDir } from "./workspace.ts";
|
|
18
|
+
|
|
19
|
+
export interface StarterAssetFileResult {
|
|
20
|
+
/** Absolute path of the target file */
|
|
21
|
+
path: string;
|
|
22
|
+
/** Whether this call performed a write */
|
|
23
|
+
written: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface StarterAssetsResult {
|
|
27
|
+
profile: StarterAssetFileResult;
|
|
28
|
+
skill: StarterAssetFileResult;
|
|
29
|
+
/** Human-readable degradation warnings; empty means all succeeded or no-op */
|
|
30
|
+
warnings: string[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface EnsureStarterAssetsOptions {
|
|
34
|
+
/** Defaults to getGlobalProfilesDir(); injected by tests */
|
|
35
|
+
globalProfilesDir?: string;
|
|
36
|
+
/** Defaults to Pi's getAgentDir(); injected by tests */
|
|
37
|
+
agentDir?: string;
|
|
38
|
+
/** Defaults to locating the package root from import.meta.url; injected by tests */
|
|
39
|
+
packageRoot?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function hasAnyJsonProfiles(dir: string): Promise<boolean> {
|
|
43
|
+
try {
|
|
44
|
+
const entries = await readdir(dir);
|
|
45
|
+
return entries.some((name) => name.endsWith(".json"));
|
|
46
|
+
} catch (error: unknown) {
|
|
47
|
+
const err = error as NodeJS.ErrnoException;
|
|
48
|
+
if (err.code === "ENOENT") {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function ensureStarterAssets(options?: EnsureStarterAssetsOptions): Promise<StarterAssetsResult> {
|
|
56
|
+
const defaultPackageRoot = fileURLToPath(new URL("..", import.meta.url));
|
|
57
|
+
const packageRoot = options?.packageRoot ? path.resolve(options.packageRoot) : defaultPackageRoot;
|
|
58
|
+
const globalProfiles = options?.globalProfilesDir ? path.resolve(options.globalProfilesDir) : getGlobalProfilesDir();
|
|
59
|
+
const agent = options?.agentDir ? path.resolve(options.agentDir) : getAgentDir();
|
|
60
|
+
|
|
61
|
+
const profileTemplate = path.join(packageRoot, "examples", "ask.json");
|
|
62
|
+
const targetProfilePath = path.join(globalProfiles, "ask.json");
|
|
63
|
+
|
|
64
|
+
const skillTemplate = path.join(packageRoot, "skills", "profile-config", "SKILL.md");
|
|
65
|
+
const targetSkillDir = path.join(agent, "skills", "profile-config");
|
|
66
|
+
const targetSkillPath = path.join(targetSkillDir, "SKILL.md");
|
|
67
|
+
|
|
68
|
+
const warnings: string[] = [];
|
|
69
|
+
const profileResult: StarterAssetFileResult = {
|
|
70
|
+
path: targetProfilePath,
|
|
71
|
+
written: false,
|
|
72
|
+
};
|
|
73
|
+
const skillResult: StarterAssetFileResult = {
|
|
74
|
+
path: targetSkillPath,
|
|
75
|
+
written: false,
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
// 1. Starter profile seeding
|
|
79
|
+
try {
|
|
80
|
+
const hasJson = await hasAnyJsonProfiles(globalProfiles);
|
|
81
|
+
if (!hasJson) {
|
|
82
|
+
await mkdir(globalProfiles, { recursive: true });
|
|
83
|
+
try {
|
|
84
|
+
await copyFile(profileTemplate, targetProfilePath, constants.COPYFILE_EXCL);
|
|
85
|
+
profileResult.written = true;
|
|
86
|
+
} catch (error: unknown) {
|
|
87
|
+
const err = error as NodeJS.ErrnoException;
|
|
88
|
+
if (err.code === "EEXIST") {
|
|
89
|
+
// Lost a create race (concurrent launch); winner stands
|
|
90
|
+
profileResult.written = false;
|
|
91
|
+
} else {
|
|
92
|
+
throw error;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
} catch (error: unknown) {
|
|
97
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
98
|
+
warnings.push(`could not seed starter profile: ${message}`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// 2. Profile-config skill distribution & sync
|
|
102
|
+
try {
|
|
103
|
+
const templateContent = await readFile(skillTemplate, "utf8");
|
|
104
|
+
let needsWrite = true;
|
|
105
|
+
try {
|
|
106
|
+
const existingContent = await readFile(targetSkillPath, "utf8");
|
|
107
|
+
if (existingContent === templateContent) {
|
|
108
|
+
needsWrite = false;
|
|
109
|
+
}
|
|
110
|
+
} catch (error: unknown) {
|
|
111
|
+
const err = error as NodeJS.ErrnoException;
|
|
112
|
+
if (err.code !== "ENOENT") {
|
|
113
|
+
throw error;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (needsWrite) {
|
|
118
|
+
await mkdir(targetSkillDir, { recursive: true });
|
|
119
|
+
await copyFile(skillTemplate, targetSkillPath);
|
|
120
|
+
skillResult.written = true;
|
|
121
|
+
}
|
|
122
|
+
} catch (error: unknown) {
|
|
123
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
124
|
+
warnings.push(`could not distribute profile-config skill: ${message}`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
profile: profileResult,
|
|
129
|
+
skill: skillResult,
|
|
130
|
+
warnings,
|
|
131
|
+
};
|
|
132
|
+
}
|