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 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
- On install, pi-profile-switch seeds the global `profiles/` directory with a starter **`ask`** profile (`ask.json`) — read-only Q&A and code exploration. It assumes nothing about your setup; edit or delete it freely:
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) (Chinese)
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
- # 使用安装时播种的只读 ask profile 启动
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(安装时分发至 `<agentDir>/skills/profile-config/`)会指导 agent 澄清需求、发现资源并读写 profile 文件。按约定,生成声明了 `skills` 的 profile 时默认包含 `"profile-config"`(除非明确排除或已被 `*` 等 glob 覆盖),确保切换到新 profile 后仍可持续对话配置。详情见 [`skills/profile-config/SKILL.md`](skills/profile-config/SKILL.md)。
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
- 安装时,pi-profile-switch 会向全局 `profiles/` 目录播种一个初始 **`ask`** profile(`ask.json`)——只读的问答与代码走读模式。它不假设你安装过任何插件,可随意修改或删除:
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
@@ -1,9 +1,21 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * postinstall: seed the global catalog with the default starter profile,
4
- * and distribute the profile-config skill to the agent skills directory.
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.0",
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: 指导创建、修改或删除 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[];
@@ -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
+ }
@@ -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 are trust-requiring even though Pi's
80
- * native list doesn't know them: a committed catalog/state file would
81
- * otherwise inject profile definitions (and instructions) unguarded. */
82
- function hasPiProfileProjectFiles(cwd: string): boolean {
83
- return ["profiles", "pi-profile-state.json"].some((name) =>
84
- existsSync(path.join(cwd, ".pi", name)),
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
+ }