pi-profile-switch 0.8.0 → 0.9.1
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 -1
- package/README.zh-CN.md +4 -2
- package/bin/pi-profile.ts +9 -0
- package/bin/postinstall.d.ts +2 -0
- package/bin/postinstall.js +68 -2
- package/package.json +2 -1
- package/skills/profile-config/SKILL.md +167 -0
- package/src/starter-assets.ts +132 -0
package/README.md
CHANGED
|
@@ -36,7 +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
|
-
|
|
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
|
+
|
|
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:
|
|
40
42
|
|
|
41
43
|
```json
|
|
42
44
|
{
|
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,7 +36,9 @@ profile 保存在两个目录中,每个 profile 对应一个独立 JSON 文件
|
|
|
36
36
|
|
|
37
37
|
直接编辑或新建 `<name>.json` 即可创建或修改 profile——schema 见 [`schemas/profiles.schema.json`](schemas/profiles.schema.json)。
|
|
38
38
|
|
|
39
|
-
|
|
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
|
+
|
|
41
|
+
pi-profile-switch 会向全局 `profiles/` 目录播种一个初始 **`ask`** profile(`ask.json`)——安装时 best-effort、launcher 启动时保证就位——它是只读的问答与代码走读模式。它不假设你安装过任何插件,可随意修改或删除:
|
|
40
42
|
|
|
41
43
|
```json
|
|
42
44
|
{
|
package/bin/pi-profile.ts
CHANGED
|
@@ -21,11 +21,20 @@ import { spawnPi } from "../src/launcher/spawn.ts";
|
|
|
21
21
|
import { McpConfigError, MissingMcpAdapterError } from "../src/mcp-config.ts";
|
|
22
22
|
import { CatalogError } from "../src/profile-catalog.ts";
|
|
23
23
|
import { ActivationError } from "../src/profile-resolver.ts";
|
|
24
|
+
import { ensureStarterAssets } from "../src/starter-assets.ts";
|
|
24
25
|
import { generateRuntimeDir } from "../src/settings-generator.ts";
|
|
25
26
|
|
|
26
27
|
try {
|
|
27
28
|
const args = parseLauncherArgs(process.argv.slice(2));
|
|
28
29
|
const agentDir = getAgentDir();
|
|
30
|
+
// Starter assets (seed profile + profile-config skill) are ensured before
|
|
31
|
+
// initial profile resolution so the seed is visible to this launch's
|
|
32
|
+
// resolution and /profile list. Best-effort: ensure failures never block
|
|
33
|
+
// the launch (same pattern as the sweep below).
|
|
34
|
+
const starterAssets = await ensureStarterAssets({ agentDir });
|
|
35
|
+
for (const warning of starterAssets.warnings) {
|
|
36
|
+
console.error(`pi-profile: warning: ${warning}`);
|
|
37
|
+
}
|
|
29
38
|
// Fails before spawning when the profile is unknown or cannot activate.
|
|
30
39
|
// --approve/--no-approve are consumed here as a one-run trust input.
|
|
31
40
|
const { plan, discovery, projectDir, warnings } = await resolveInitialProfile(args.profile, {
|
package/bin/postinstall.d.ts
CHANGED
package/bin/postinstall.js
CHANGED
|
@@ -1,17 +1,34 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* postinstall:
|
|
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.
|
|
4
6
|
*
|
|
5
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 ("播种 starter profile" and
|
|
16
|
+
* "分发 profile-config skill"); keep this script's rules in sync with
|
|
17
|
+
* src/starter-assets.ts, which is the single TS implementation.
|
|
18
|
+
*
|
|
6
19
|
* Idempotent and conservative:
|
|
7
20
|
* - Writes the shipped `examples/ask.json` starter profile to the
|
|
8
21
|
* profiles dir ONLY when no .json profile exists there yet
|
|
9
22
|
* (COPYFILE_EXCL; an existing file — including one written concurrently — is never touched).
|
|
23
|
+
* - Distributes the shipped `skills/profile-config/SKILL.md` to
|
|
24
|
+
* `<agentDir>/skills/profile-config/SKILL.md`, always overwriting with
|
|
25
|
+
* the shipped version so the skill stays in sync with the package.
|
|
10
26
|
* - Never fails the install: errors are downgraded to a warning.
|
|
11
27
|
*
|
|
12
28
|
* This module is plain Node ESM (no jiti/TS): npm may run postinstall in a
|
|
13
29
|
* context where only plain JS is safe. The path rules intentionally mirror
|
|
14
|
-
* src/workspace.ts
|
|
30
|
+
* src/workspace.ts and Pi's getAgentDir() (in @earendil-works/pi-coding-agent)
|
|
31
|
+
* — keep them in sync.
|
|
15
32
|
*/
|
|
16
33
|
import { copyFile, mkdir, readdir } from "node:fs/promises";
|
|
17
34
|
import { constants } from "node:fs";
|
|
@@ -20,6 +37,7 @@ import path from "node:path";
|
|
|
20
37
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
21
38
|
|
|
22
39
|
const DEFAULT_TEMPLATE = fileURLToPath(new URL("../examples/ask.json", import.meta.url));
|
|
40
|
+
const SKILL_TEMPLATE = fileURLToPath(new URL("../skills/profile-config/SKILL.md", import.meta.url));
|
|
23
41
|
|
|
24
42
|
/** Mirrors getProfileSwitchDir() in src/workspace.ts.
|
|
25
43
|
* @param {NodeJS.ProcessEnv} env */
|
|
@@ -35,6 +53,24 @@ function globalProfilesDir(env) {
|
|
|
35
53
|
return path.join(profileSwitchDir(env), "profiles");
|
|
36
54
|
}
|
|
37
55
|
|
|
56
|
+
/** Mirrors Pi's getAgentDir() in @earendil-works/pi-coding-agent.
|
|
57
|
+
* Keep in sync with Pi's config resolution.
|
|
58
|
+
* Deliberate divergence from Pi: trims whitespace and resolves relative
|
|
59
|
+
* paths via path.resolve() for safety during postinstall.
|
|
60
|
+
* @param {NodeJS.ProcessEnv} env */
|
|
61
|
+
function agentDir(env) {
|
|
62
|
+
const override = env.PI_CODING_AGENT_DIR;
|
|
63
|
+
if (override && override.trim()) {
|
|
64
|
+
const trimmed = override.trim();
|
|
65
|
+
if (trimmed === "~") return homedir();
|
|
66
|
+
if (trimmed.startsWith("~/") || (process.platform === "win32" && trimmed.startsWith("~\\"))) {
|
|
67
|
+
return path.join(homedir(), trimmed.slice(2));
|
|
68
|
+
}
|
|
69
|
+
return path.resolve(trimmed);
|
|
70
|
+
}
|
|
71
|
+
return path.join(homedir(), ".pi", "agent");
|
|
72
|
+
}
|
|
73
|
+
|
|
38
74
|
/** Checks whether the directory exists and contains any .json files.
|
|
39
75
|
* @param {string} dir */
|
|
40
76
|
async function hasAnyJsonProfiles(dir) {
|
|
@@ -74,6 +110,28 @@ export async function installDefaultProfiles({ env = process.env } = {}) {
|
|
|
74
110
|
return { path: target, written: true };
|
|
75
111
|
}
|
|
76
112
|
|
|
113
|
+
/**
|
|
114
|
+
* Distributes the shipped profile-config skill to the agent skills directory.
|
|
115
|
+
* Always overwrites with the shipped version. Downgrades failures to a warning.
|
|
116
|
+
* @param {{ env?: NodeJS.ProcessEnv }} [options]
|
|
117
|
+
* @returns {Promise<InstallResult>}
|
|
118
|
+
*/
|
|
119
|
+
export async function installProfileConfigSkill({ env = process.env } = {}) {
|
|
120
|
+
const dir = path.join(agentDir(env), "skills", "profile-config");
|
|
121
|
+
const target = path.join(dir, "SKILL.md");
|
|
122
|
+
|
|
123
|
+
try {
|
|
124
|
+
await mkdir(dir, { recursive: true });
|
|
125
|
+
await copyFile(SKILL_TEMPLATE, target);
|
|
126
|
+
return { path: target, written: true };
|
|
127
|
+
} catch (error) {
|
|
128
|
+
console.warn(
|
|
129
|
+
`pi-profile: could not distribute profile-config skill: ${error instanceof Error ? error.message : error}`,
|
|
130
|
+
);
|
|
131
|
+
return { path: target, written: false };
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
77
135
|
const invokedDirectly = process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
78
136
|
if (invokedDirectly) {
|
|
79
137
|
installDefaultProfiles().then(
|
|
@@ -84,4 +142,12 @@ if (invokedDirectly) {
|
|
|
84
142
|
console.warn(`pi-profile: could not seed starter profile: ${error instanceof Error ? error.message : error}`);
|
|
85
143
|
},
|
|
86
144
|
);
|
|
145
|
+
installProfileConfigSkill().then(
|
|
146
|
+
(result) => {
|
|
147
|
+
if (result.written) console.log(`pi-profile: distributed profile-config skill at ${result.path}`);
|
|
148
|
+
},
|
|
149
|
+
(error) => {
|
|
150
|
+
console.warn(`pi-profile: could not distribute profile-config skill: ${error instanceof Error ? error.message : error}`);
|
|
151
|
+
},
|
|
152
|
+
);
|
|
87
153
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-profile-switch",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.1",
|
|
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": [
|
|
@@ -46,6 +46,7 @@
|
|
|
46
46
|
"files": [
|
|
47
47
|
"bin",
|
|
48
48
|
"extensions",
|
|
49
|
+
"skills",
|
|
49
50
|
"src",
|
|
50
51
|
"schemas",
|
|
51
52
|
"examples",
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: profile-config
|
|
3
|
+
description: 指导创建、修改或删除 pi-profile-switch 的 profile。当用户想要创建、修改、配置或删除 profile 时触发。
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# profile-config
|
|
7
|
+
|
|
8
|
+
本 skill 指导 agent 理解用户的模糊需求或明确指令,协助用户创建、修改或删除 `pi-profile-switch` 的 profile 文件。
|
|
9
|
+
|
|
10
|
+
> **声明**:本 skill 文件由 `pi-profile-switch` package 安装分发并在随包升级时自动覆写,请勿手动修改此文件。
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## 1. 核心概念与约束
|
|
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`。
|
|
20
|
+
|
|
21
|
+
### 1.2 名字字符集规范
|
|
22
|
+
Profile 名字必须完全匹配正则:
|
|
23
|
+
```regex
|
|
24
|
+
^[A-Za-z0-9][A-Za-z0-9._-]*$
|
|
25
|
+
```
|
|
26
|
+
- 必须以英文字母或数字开头。
|
|
27
|
+
- 只允许英文字母、数字、点(`.`)、下划线(`_`)和连字符(`-`)。
|
|
28
|
+
- 不允许包含空格、中文或特殊符号。
|
|
29
|
+
|
|
30
|
+
### 1.3 作用域(Source scope)与存储落点
|
|
31
|
+
Profile 文件存放在两个位置之一:
|
|
32
|
+
|
|
33
|
+
| 作用域 | 路径 | 说明 |
|
|
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` | 仅在当前项目生效,且仅当项目已受信任时可用。 |
|
|
37
|
+
|
|
38
|
+
- **覆盖规则**:项目 scope 的同名 profile 会完整替换(replace)全局条目,**不会**与全局配置合并字段。
|
|
39
|
+
- **项目信任门禁**:若当前项目未受信任,项目 scope 的 profile 无法解析,向项目 scope 写入也会失败。写入项目 scope 前若项目未受信任,必须提示用户在会话中执行 `/trust` 并重启 Pi。
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## 2. Profile 文件格式与字段定义
|
|
44
|
+
|
|
45
|
+
文件内容必须为格式化 JSON,**顶层即为裸定义对象**,严禁包裹 `schemaVersion`、`profiles` 或其他外层信封字段。
|
|
46
|
+
|
|
47
|
+
全部字段均为可选(optional)。未声明的字段保持原生 Pi 行为或当前状态,不产生任何副作用。
|
|
48
|
+
|
|
49
|
+
### 字段详细语义
|
|
50
|
+
|
|
51
|
+
| 字段 | 类型 | 语义与约束 |
|
|
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
|
+
### 示例格式
|
|
65
|
+
```json
|
|
66
|
+
{
|
|
67
|
+
"label": "Review Mode",
|
|
68
|
+
"description": "Read-only code review with linting and analysis tools",
|
|
69
|
+
"skills": [
|
|
70
|
+
"profile-config"
|
|
71
|
+
],
|
|
72
|
+
"extensions": [],
|
|
73
|
+
"mcps": [],
|
|
74
|
+
"tools": [
|
|
75
|
+
"read",
|
|
76
|
+
"grep",
|
|
77
|
+
"find",
|
|
78
|
+
"ls"
|
|
79
|
+
],
|
|
80
|
+
"defaultProvider": "anthropic",
|
|
81
|
+
"defaultModel": "claude-sonnet-4-5",
|
|
82
|
+
"defaultThinkingLevel": "high",
|
|
83
|
+
"instructions": "Focus on code quality, security, and edge cases. Do not edit files."
|
|
84
|
+
}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
---
|
|
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 中声明项目级资源。
|
|
117
|
+
|
|
118
|
+
---
|
|
119
|
+
|
|
120
|
+
## 4. 创作时的默认注入规则
|
|
121
|
+
|
|
122
|
+
当为用户创建或生成声明了 `skills` 的新 profile 时,必须遵守以下约定:
|
|
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` 字段。
|
|
130
|
+
|
|
131
|
+
---
|
|
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`)。
|
|
157
|
+
|
|
158
|
+
---
|
|
159
|
+
|
|
160
|
+
## 6. 边界与退化处理
|
|
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。
|
|
@@ -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
|
+
/** 目标文件绝对路径 */
|
|
21
|
+
path: string;
|
|
22
|
+
/** 本次调用是否发生了写入 */
|
|
23
|
+
written: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface StarterAssetsResult {
|
|
27
|
+
profile: StarterAssetFileResult;
|
|
28
|
+
skill: StarterAssetFileResult;
|
|
29
|
+
/** 人类可读的降级警告;为空表示全部成功或无操作 */
|
|
30
|
+
warnings: string[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface EnsureStarterAssetsOptions {
|
|
34
|
+
/** 默认 getGlobalProfilesDir();测试注入 */
|
|
35
|
+
globalProfilesDir?: string;
|
|
36
|
+
/** 默认 Pi 的 getAgentDir();测试注入 */
|
|
37
|
+
agentDir?: string;
|
|
38
|
+
/** 默认由 import.meta.url 定位包根;测试注入 */
|
|
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
|
+
}
|