pi-profile-switch 0.4.6 → 0.4.9
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 +63 -11
- package/README.zh-CN.md +63 -11
- package/examples/example.json +6 -2
- package/package.json +2 -2
- package/src/extension-discovery.ts +144 -89
- package/src/launcher/discovery.ts +4 -2
package/README.md
CHANGED
|
@@ -2,9 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[English](README.md) | [中文](README.zh-CN.md)
|
|
4
4
|
|
|
5
|
-
Named profiles for [Pi](https://github.com/badlogic/pi-mono). A profile
|
|
6
|
-
|
|
7
|
-
Use a read-only profile for Q&A and code exploration, a full-powered one for implementation — all against the same installed resources.
|
|
5
|
+
Named profiles for [Pi](https://github.com/badlogic/pi-mono). A profile is a named capability bundle you define: skills, extensions, MCP servers, tools (including tools exposed by MCP servers and extensions), model defaults, and extra system-prompt instructions. Switch bundles inside a running Pi session — no restart.
|
|
8
6
|
|
|
9
7
|
## Install
|
|
10
8
|
|
|
@@ -27,7 +25,18 @@ pi-profile ask
|
|
|
27
25
|
pi-profile ask -- --model openai/gpt-5.4
|
|
28
26
|
```
|
|
29
27
|
|
|
30
|
-
|
|
28
|
+
## Define your own profiles
|
|
29
|
+
|
|
30
|
+
Profiles live in two JSON files, both optional:
|
|
31
|
+
|
|
32
|
+
| File | Scope |
|
|
33
|
+
| --- | --- |
|
|
34
|
+
| `~/.pi-profile-switch/profiles.json` | Global, all projects. `PI_PROFILE_SWITCH_DIR` overrides the root; `~/.pi/agent/profiles.json` is read as a legacy fallback for migration. |
|
|
35
|
+
| `<project>/.pi/profiles.json` | Project-level, trusted projects only. |
|
|
36
|
+
|
|
37
|
+
Create or change a profile by editing the JSON directly — schema: [`schemas/profiles.schema.json`](schemas/profiles.schema.json).
|
|
38
|
+
|
|
39
|
+
On install, pi-profile-switch seeds the global file with a starter **`ask`** profile — read-only Q&A and code exploration. It assumes nothing about your setup; edit or delete it freely:
|
|
31
40
|
|
|
32
41
|
```json
|
|
33
42
|
{
|
|
@@ -45,7 +54,56 @@ On install, pi-profile-switch seeds `~/.pi-profile-switch/profiles.json` (global
|
|
|
45
54
|
}
|
|
46
55
|
```
|
|
47
56
|
|
|
48
|
-
|
|
57
|
+
One profile can use every field at once. This example `impl` profile loads the TDD skill, the mcp-scripting skill (shipped by pi-mcp-adapter), and your internal skills; wires up two MCP servers; allows the built-in tools plus both servers' MCP tools by glob; and pins the model and standing instructions:
|
|
58
|
+
|
|
59
|
+
```json
|
|
60
|
+
{
|
|
61
|
+
"schemaVersion": 1,
|
|
62
|
+
"profiles": {
|
|
63
|
+
"impl": {
|
|
64
|
+
"label": "Implementation",
|
|
65
|
+
"description": "Full-powered implementation profile: every available field, pinned model",
|
|
66
|
+
"skills": [
|
|
67
|
+
"tdd",
|
|
68
|
+
"internal-*",
|
|
69
|
+
"mcp-scripting"
|
|
70
|
+
],
|
|
71
|
+
"extensions": [
|
|
72
|
+
"pi-mcp-adapter"
|
|
73
|
+
],
|
|
74
|
+
"mcps": [
|
|
75
|
+
"github",
|
|
76
|
+
"linear"
|
|
77
|
+
],
|
|
78
|
+
"tools": [
|
|
79
|
+
"read",
|
|
80
|
+
"grep",
|
|
81
|
+
"find",
|
|
82
|
+
"ls",
|
|
83
|
+
"bash",
|
|
84
|
+
"edit",
|
|
85
|
+
"write",
|
|
86
|
+
"mcp__*",
|
|
87
|
+
"github_*",
|
|
88
|
+
"linear_*"
|
|
89
|
+
],
|
|
90
|
+
"defaultProvider": "anthropic",
|
|
91
|
+
"defaultModel": "claude-sonnet-4-5",
|
|
92
|
+
"defaultThinkingLevel": "high",
|
|
93
|
+
"instructions": "Prefer small, verifiable changes. Run the test suite before claiming completion."
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
How fields resolve:
|
|
100
|
+
|
|
101
|
+
- `skills`, `extensions`, `mcps`, `tools` take names or globs (e.g. `"internal-*"`) referencing resources you already installed or configured — profiles never copy them. Installed packages and files in standard locations are discovered automatically; no registration needed.
|
|
102
|
+
- `tools` expands against Pi's live tool registry — built-ins, extension-provided tools, and tools exposed by MCP servers. MCP tools are registered as `mcp__<server>` (proxy) and `<server>_<tool>` (direct tools, the adapter's default `toolPrefix`), so globs like `mcp__*` and `github_*` cover them.
|
|
103
|
+
- `mcps` references servers from your pi-mcp-adapter configuration; connection details stay in the adapter's own config.
|
|
104
|
+
- Any field you omit keeps plain Pi behavior.
|
|
105
|
+
|
|
106
|
+
The files in [`examples/`](examples/) mirror the two profiles above: `profiles.json` is the seeded starter, `example.json` the full-field demo.
|
|
49
107
|
|
|
50
108
|
## Commands
|
|
51
109
|
|
|
@@ -61,12 +119,6 @@ In the TUI, the `/profile` command family manages everything in-session:
|
|
|
61
119
|
|
|
62
120
|
All commands work in non-interactive modes (`--mode rpc|print|json`); CRUD wizards are TUI-only.
|
|
63
121
|
|
|
64
|
-
## Guarantees
|
|
65
|
-
|
|
66
|
-
- **Reference, never copy** — profiles point at resources you already own and maintain.
|
|
67
|
-
- **Pi-native** — anything a profile doesn't explicitly control keeps plain Pi behavior.
|
|
68
|
-
- **Fail safe** — untrusted project directories are never read; a failed switch rolls back to the last working configuration.
|
|
69
|
-
|
|
70
122
|
## Docs
|
|
71
123
|
|
|
72
124
|
- [Architecture](docs/architecture/overview.md) · [ADRs](docs/adr/) · [Glossary](CONTEXT.md) (Chinese)
|
package/README.zh-CN.md
CHANGED
|
@@ -2,9 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[English](README.md) | [中文](README.zh-CN.md)
|
|
4
4
|
|
|
5
|
-
[Pi](https://github.com/badlogic/pi-mono) 的命名 profile 扩展。一个 profile
|
|
6
|
-
|
|
7
|
-
问答与代码走读用只读 profile,实现需求用全量 profile——全部基于同一套已安装的资源。
|
|
5
|
+
[Pi](https://github.com/badlogic/pi-mono) 的命名 profile 扩展。一个 profile 是你自定义的命名能力组合:skills、extensions、MCP server、tools(包括 MCP server 和 extension 提供的工具)、默认模型,以及追加到系统提示词的 instructions。在同一个运行中的 Pi 会话里切换这些组合,无需重启。
|
|
8
6
|
|
|
9
7
|
## 安装
|
|
10
8
|
|
|
@@ -27,7 +25,18 @@ pi-profile ask
|
|
|
27
25
|
pi-profile ask -- --model openai/gpt-5.4
|
|
28
26
|
```
|
|
29
27
|
|
|
30
|
-
|
|
28
|
+
## 自定义 profile
|
|
29
|
+
|
|
30
|
+
profile 定义在两个 JSON 文件中,均可选:
|
|
31
|
+
|
|
32
|
+
| 文件 | 作用域 |
|
|
33
|
+
| --- | --- |
|
|
34
|
+
| `~/.pi-profile-switch/profiles.json` | 全局,对所有项目生效。`PI_PROFILE_SWITCH_DIR` 可自定义根目录;`~/.pi/agent/profiles.json` 作为遗留路径向下兼容,用于迁移。 |
|
|
35
|
+
| `<项目>/.pi/profiles.json` | 项目级,仅对已信任项目生效。 |
|
|
36
|
+
|
|
37
|
+
直接编辑 JSON 即可创建或修改 profile——schema 见 [`schemas/profiles.schema.json`](schemas/profiles.schema.json)。
|
|
38
|
+
|
|
39
|
+
安装时,pi-profile-switch 会向全局文件播种一个初始 **`ask`** profile——只读的问答与代码走读模式。它不假设你安装过任何插件,可随意修改或删除:
|
|
31
40
|
|
|
32
41
|
```json
|
|
33
42
|
{
|
|
@@ -45,7 +54,56 @@ pi-profile ask -- --model openai/gpt-5.4
|
|
|
45
54
|
}
|
|
46
55
|
```
|
|
47
56
|
|
|
48
|
-
|
|
57
|
+
一个 profile 可以同时使用全部字段。下面这个 `impl` profile 示例加载 TDD skill、mcp-scripting skill(pi-mcp-adapter 自带)和你的内部 skills;接入两个 MCP server;tool 白名单用 glob 覆盖内建工具和这两个 server 的 MCP 工具;并钉住模型与常驻 instructions:
|
|
58
|
+
|
|
59
|
+
```json
|
|
60
|
+
{
|
|
61
|
+
"schemaVersion": 1,
|
|
62
|
+
"profiles": {
|
|
63
|
+
"impl": {
|
|
64
|
+
"label": "Implementation",
|
|
65
|
+
"description": "Full-powered implementation profile: every available field, pinned model",
|
|
66
|
+
"skills": [
|
|
67
|
+
"tdd",
|
|
68
|
+
"internal-*",
|
|
69
|
+
"mcp-scripting"
|
|
70
|
+
],
|
|
71
|
+
"extensions": [
|
|
72
|
+
"pi-mcp-adapter"
|
|
73
|
+
],
|
|
74
|
+
"mcps": [
|
|
75
|
+
"github",
|
|
76
|
+
"linear"
|
|
77
|
+
],
|
|
78
|
+
"tools": [
|
|
79
|
+
"read",
|
|
80
|
+
"grep",
|
|
81
|
+
"find",
|
|
82
|
+
"ls",
|
|
83
|
+
"bash",
|
|
84
|
+
"edit",
|
|
85
|
+
"write",
|
|
86
|
+
"mcp__*",
|
|
87
|
+
"github_*",
|
|
88
|
+
"linear_*"
|
|
89
|
+
],
|
|
90
|
+
"defaultProvider": "anthropic",
|
|
91
|
+
"defaultModel": "claude-sonnet-4-5",
|
|
92
|
+
"defaultThinkingLevel": "high",
|
|
93
|
+
"instructions": "Prefer small, verifiable changes. Run the test suite before claiming completion."
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
字段解析规则:
|
|
100
|
+
|
|
101
|
+
- `skills`、`extensions`、`mcps`、`tools` 接受名称或 glob(如 `"internal-*"`),引用你已安装或已配置的资源——profile 从不复制资源。已安装的包和标准目录下的文件会被自动发现,无需注册。
|
|
102
|
+
- `tools` 针对 Pi 的实时工具注册表展开——内建工具、extension 提供的工具,以及 MCP server 暴露的工具。MCP 工具注册为 `mcp__<server>`(代理)和 `<server>_<tool>`(直接工具,adapter 默认 `toolPrefix`),因此 `mcp__*`、`github_*` 这类 glob 可以覆盖它们。
|
|
103
|
+
- `mcps` 引用 pi-mcp-adapter 配置中的 server;连接细节留在 adapter 自己的配置里。
|
|
104
|
+
- 未写的字段保持原生 Pi 行为。
|
|
105
|
+
|
|
106
|
+
[`examples/`](examples/) 中的两个文件与上面一一对应:`profiles.json` 是播种的初始 profile,`example.json` 是全字段演示。
|
|
49
107
|
|
|
50
108
|
## 命令
|
|
51
109
|
|
|
@@ -61,12 +119,6 @@ pi-profile ask -- --model openai/gpt-5.4
|
|
|
61
119
|
|
|
62
120
|
非交互模式(`--mode rpc|print|json`)下命令同样生效;CRUD 向导仅 TUI 可用。
|
|
63
121
|
|
|
64
|
-
## 保证
|
|
65
|
-
|
|
66
|
-
- **引用而非复制**——profile 指向你自己拥有和维护的资源。
|
|
67
|
-
- **Pi 原生**——profile 未显式控制的一切保持原生 Pi 行为。
|
|
68
|
-
- **失败安全**——未信任的项目目录从不读取;切换失败回滚到上一份可用配置。
|
|
69
|
-
|
|
70
122
|
## 文档
|
|
71
123
|
|
|
72
124
|
- [架构设计](docs/architecture/overview.md) · [ADR](docs/adr/) · [术语表](CONTEXT.md)
|
package/examples/example.json
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
"description": "Full-powered implementation profile: every available field, pinned model",
|
|
7
7
|
"skills": [
|
|
8
8
|
"tdd",
|
|
9
|
-
"internal-*"
|
|
9
|
+
"internal-*",
|
|
10
|
+
"mcp-scripting"
|
|
10
11
|
],
|
|
11
12
|
"extensions": [
|
|
12
13
|
"pi-mcp-adapter"
|
|
@@ -22,7 +23,10 @@
|
|
|
22
23
|
"ls",
|
|
23
24
|
"bash",
|
|
24
25
|
"edit",
|
|
25
|
-
"write"
|
|
26
|
+
"write",
|
|
27
|
+
"mcp__*",
|
|
28
|
+
"github_*",
|
|
29
|
+
"linear_*"
|
|
26
30
|
],
|
|
27
31
|
"defaultProvider": "anthropic",
|
|
28
32
|
"defaultModel": "claude-sonnet-4-5",
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-profile-switch",
|
|
3
|
-
"version": "0.4.
|
|
4
|
-
"description": "Named profiles for Pi: reference skills, extensions, MCP servers, and tools per workflow, switched without restarting.",
|
|
3
|
+
"version": "0.4.9",
|
|
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": [
|
|
7
7
|
"pi-package",
|
|
@@ -1,27 +1,34 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* ExtensionDiscovery: implicit, read-only discovery of selectable extensions
|
|
3
|
-
* (ADR-0006), so profiles can reference extensions without any
|
|
3
|
+
* (ADR-0006/0007), so profiles can reference extensions without any
|
|
4
4
|
* registration step.
|
|
5
5
|
*
|
|
6
|
-
* Two implicit sources, both
|
|
7
|
-
* - Configured
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
6
|
+
* Two implicit sources, both resolved by Pi itself:
|
|
7
|
+
* - Configured packages: `DefaultPackageManager.resolve()` expands each
|
|
8
|
+
* package's `package.json#pi.extensions` (files, directories, globs, the
|
|
9
|
+
* package's own `+`/`-`/`!` filters, `.gitignore` rules) exactly like the
|
|
10
|
+
* spawned pi does at startup. The package name — or an alias such as the
|
|
11
|
+
* `npm:` source string — is the profile-facing reference.
|
|
12
|
+
* - Loose extension files: `<agentDir>/extensions/**` and, for trusted
|
|
13
|
+
* projects, `<projectDir>/.pi/extensions/**`, referenced by a path-derived
|
|
14
|
+
* ID ("conventions", "guards/review").
|
|
13
15
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
+
* This module owns naming, merging, selection, and glob expansion only. Entry
|
|
17
|
+
* enumeration is Pi's own, so a new Pi convention (or a fix to one) cannot
|
|
18
|
+
* silently diverge from what the spawned pi actually loads.
|
|
19
|
+
*
|
|
20
|
+
* Discovery stays side-effect free: `resolve()` is called with
|
|
21
|
+
* `onMissing: "skip"`, which never installs and never touches the network,
|
|
22
|
+
* and extension code is never imported.
|
|
16
23
|
*/
|
|
17
24
|
|
|
18
|
-
import {
|
|
25
|
+
import { readFile, stat } from "node:fs/promises";
|
|
19
26
|
import path from "node:path";
|
|
20
27
|
|
|
28
|
+
import { DefaultPackageManager, SettingsManager, type PackageManager } from "@earendil-works/pi-coding-agent";
|
|
21
29
|
import { minimatch } from "minimatch";
|
|
22
30
|
|
|
23
31
|
import { isRecord } from "./json-file.ts";
|
|
24
|
-
import type { ConfiguredPackageRoot } from "./settings-generator.ts";
|
|
25
32
|
|
|
26
33
|
export interface DiscoveredPackage {
|
|
27
34
|
/** Selectable package name: package.json "name", or the source minus its
|
|
@@ -32,12 +39,14 @@ export interface DiscoveredPackage {
|
|
|
32
39
|
source: string;
|
|
33
40
|
/** Absolute install/local root of the package. */
|
|
34
41
|
root: string;
|
|
35
|
-
/** Absolute paths of
|
|
42
|
+
/** Absolute paths of the package's enabled extension entries. */
|
|
36
43
|
entries: string[];
|
|
37
44
|
}
|
|
38
45
|
|
|
39
46
|
export interface DiscoveredLocalExtension {
|
|
40
|
-
/** Selectable ID: the
|
|
47
|
+
/** Selectable ID: the path under the extensions dir, without the file
|
|
48
|
+
* extension and with a trailing "/index" collapsed ("conventions",
|
|
49
|
+
* "guards/review"). */
|
|
41
50
|
id: string;
|
|
42
51
|
entry: string;
|
|
43
52
|
}
|
|
@@ -72,7 +81,7 @@ function toPosix(filePath: string): string {
|
|
|
72
81
|
return filePath.split(path.sep).join("/");
|
|
73
82
|
}
|
|
74
83
|
|
|
75
|
-
async function
|
|
84
|
+
async function isFile(filePath: string): Promise<boolean> {
|
|
76
85
|
try {
|
|
77
86
|
return (await stat(filePath)).isFile();
|
|
78
87
|
} catch {
|
|
@@ -205,7 +214,7 @@ export class DiscoveredExtensions {
|
|
|
205
214
|
const pkgEntries = this.#packageEntries(pkg);
|
|
206
215
|
if (pkgEntries.length === 0) {
|
|
207
216
|
throw new ExtensionError(
|
|
208
|
-
`package "${reference}" declares no extension entries (its
|
|
217
|
+
`package "${reference}" declares no extension entries (missing on disk, filtered out by its settings package entry, or shadowed by local files)`,
|
|
209
218
|
);
|
|
210
219
|
}
|
|
211
220
|
for (const pkgEntry of pkgEntries) add(pkgEntry);
|
|
@@ -220,7 +229,7 @@ export class DiscoveredExtensions {
|
|
|
220
229
|
const resolved = reference.startsWith("~/")
|
|
221
230
|
? path.join(process.env.HOME ?? "", reference.slice(1))
|
|
222
231
|
: path.resolve(reference);
|
|
223
|
-
if (!(await
|
|
232
|
+
if (!(await isFile(resolved))) {
|
|
224
233
|
throw new ExtensionError(`extension path not found: ${resolved}`);
|
|
225
234
|
}
|
|
226
235
|
add({ id: resolved, entry: resolved, origin: "path" });
|
|
@@ -233,8 +242,6 @@ export class DiscoveredExtensions {
|
|
|
233
242
|
}
|
|
234
243
|
}
|
|
235
244
|
|
|
236
|
-
const LOOSE_FILE_PATTERN = /\.(ts|js)$/;
|
|
237
|
-
|
|
238
245
|
/** Derives a package name from its source string when package.json is
|
|
239
246
|
* unreadable: strips the npm:/git:/github: prefix and any version spec
|
|
240
247
|
* (scoped names keep their "@scope/" prefix). */
|
|
@@ -252,93 +259,141 @@ export function packageNameFromSource(source: string): string {
|
|
|
252
259
|
return name;
|
|
253
260
|
}
|
|
254
261
|
|
|
255
|
-
/**
|
|
256
|
-
* without a
|
|
257
|
-
*
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
manifest = JSON.parse(await readFile(path.join(pkg.root, "package.json"), "utf8"));
|
|
264
|
-
} catch {
|
|
265
|
-
return undefined; // not installed yet or unreadable — nothing selectable
|
|
266
|
-
}
|
|
267
|
-
if (!isRecord(manifest)) return undefined;
|
|
268
|
-
const pi = manifest.pi;
|
|
269
|
-
const declared =
|
|
270
|
-
isRecord(pi) && Array.isArray(pi.extensions) ? pi.extensions.filter((e): e is string => typeof e === "string") : [];
|
|
271
|
-
if (declared.length === 0) return undefined;
|
|
272
|
-
const entries: string[] = [];
|
|
273
|
-
for (const rel of declared) {
|
|
274
|
-
const entry = path.resolve(pkg.root, rel);
|
|
275
|
-
if (await exists(entry)) entries.push(entry);
|
|
276
|
-
}
|
|
277
|
-
if (entries.length === 0) return undefined;
|
|
278
|
-
const name = typeof manifest.name === "string" && manifest.name.length > 0 ? manifest.name : packageNameFromSource(pkg.source);
|
|
279
|
-
return { name, source: pkg.source, root: pkg.root, entries };
|
|
262
|
+
/** Selectable ID for a loose extension file: its path under the extensions
|
|
263
|
+
* dir without the file extension, with a trailing "/index" collapsed so a
|
|
264
|
+
* directory-style extension is referenced by its directory name. */
|
|
265
|
+
function looseId(filePath: string, extensionsDir: string): string {
|
|
266
|
+
const rel = toPosix(path.relative(extensionsDir, filePath));
|
|
267
|
+
const collapsed = rel.replace(/(?:^|\/)index\.(ts|js)$/, "");
|
|
268
|
+
const base = collapsed === "" ? rel : collapsed;
|
|
269
|
+
return base.replace(/\.(ts|js)$/, "");
|
|
280
270
|
}
|
|
281
271
|
|
|
282
|
-
/**
|
|
283
|
-
*
|
|
284
|
-
|
|
285
|
-
async function scanLooseDir(dir: string, warnings: string[]): Promise<DiscoveredLocalExtension[]> {
|
|
286
|
-
let files: string[];
|
|
272
|
+
/** Reads a package's display name from its manifest, falling back to the
|
|
273
|
+
* source string when the manifest is missing or has no name. */
|
|
274
|
+
async function readPackageName(root: string, source: string): Promise<string> {
|
|
287
275
|
try {
|
|
288
|
-
|
|
276
|
+
const manifest: unknown = JSON.parse(await readFile(path.join(root, "package.json"), "utf8"));
|
|
277
|
+
if (isRecord(manifest) && typeof manifest.name === "string" && manifest.name.length > 0) return manifest.name;
|
|
289
278
|
} catch {
|
|
290
|
-
|
|
291
|
-
}
|
|
292
|
-
const byStem = new Map<string, string>();
|
|
293
|
-
for (const name of files.sort()) {
|
|
294
|
-
const stem = name.replace(LOOSE_FILE_PATTERN, "");
|
|
295
|
-
const full = path.join(dir, name);
|
|
296
|
-
const existing = byStem.get(stem);
|
|
297
|
-
if (existing !== undefined) {
|
|
298
|
-
if (!existing.endsWith(".ts") && name.endsWith(".ts")) {
|
|
299
|
-
warnings.push(`extension "${stem}" exists as both .ts and .js in ${dir}; the .ts file is used`);
|
|
300
|
-
byStem.set(stem, full);
|
|
301
|
-
}
|
|
302
|
-
continue;
|
|
303
|
-
}
|
|
304
|
-
byStem.set(stem, full);
|
|
279
|
+
// Not installed (a skipped source) or unreadable — derive from source.
|
|
305
280
|
}
|
|
306
|
-
return
|
|
281
|
+
return packageNameFromSource(source);
|
|
307
282
|
}
|
|
308
283
|
|
|
309
|
-
export
|
|
284
|
+
export interface DiscoverExtensionsOptions {
|
|
285
|
+
/** Project working directory (Pi's cwd). */
|
|
286
|
+
cwd: string;
|
|
287
|
+
/** The user's real agent dir (e.g. ~/.pi/agent). */
|
|
310
288
|
agentDir: string;
|
|
311
|
-
/**
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
289
|
+
/**
|
|
290
|
+
* Whether the project at `cwd` is trusted (the launcher's trust check).
|
|
291
|
+
* Untrusted projects contribute nothing: no project settings packages, no
|
|
292
|
+
* `.pi/extensions` files. Defaults to false.
|
|
293
|
+
*/
|
|
294
|
+
projectTrusted?: boolean;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Builds the Pi package manager that owns discovery for one cwd/agentDir. */
|
|
298
|
+
function createPackageManager(options: DiscoverExtensionsOptions): PackageManager {
|
|
299
|
+
const settingsManager = SettingsManager.create(options.cwd, options.agentDir, {
|
|
300
|
+
projectTrusted: options.projectTrusted ?? false,
|
|
301
|
+
});
|
|
302
|
+
return new DefaultPackageManager({
|
|
303
|
+
cwd: options.cwd,
|
|
304
|
+
agentDir: options.agentDir,
|
|
305
|
+
settingsManager,
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
interface PackageGroup {
|
|
310
|
+
source: string;
|
|
311
|
+
root: string;
|
|
312
|
+
entries: string[];
|
|
313
|
+
/** Some declared entry exists on disk, even if all are disabled — keeps
|
|
314
|
+
* "package is filtered out" distinguishable from "package has no
|
|
315
|
+
* extensions". */
|
|
316
|
+
known: boolean;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Runs Pi's own resolution once and classifies the result into pi-profile's
|
|
320
|
+
* selectable model. */
|
|
321
|
+
async function resolveImplicit(packageManager: PackageManager, agentDir: string): Promise<ImplicitExtensionDiscovery> {
|
|
316
322
|
const warnings: string[] = [];
|
|
323
|
+
// `skip` is the API's read-only mode: missing sources are reported as
|
|
324
|
+
// absent instead of triggering an install (no network, no mutation).
|
|
325
|
+
const resolved = await packageManager.resolve(async () => "skip");
|
|
326
|
+
// Sort for deterministic IDs and ordering: Pi preserves directory read
|
|
327
|
+
// order, which varies by filesystem.
|
|
328
|
+
const resources = [...resolved.extensions].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
329
|
+
|
|
330
|
+
const groups = new Map<string, PackageGroup>();
|
|
331
|
+
const userLoose = new Map<string, DiscoveredLocalExtension>();
|
|
332
|
+
const projectLoose = new Map<string, DiscoveredLocalExtension>();
|
|
333
|
+
|
|
334
|
+
for (const resource of resources) {
|
|
335
|
+
const { source, scope, origin, baseDir } = resource.metadata;
|
|
336
|
+
if (origin === "package") {
|
|
337
|
+
// Project-scope packages install under the project's .pi/npm, which
|
|
338
|
+
// generated global-scope settings cannot reference.
|
|
339
|
+
if (scope === "project") continue;
|
|
340
|
+
if (baseDir === undefined) continue;
|
|
341
|
+
const group = groups.get(source) ?? { source, root: baseDir, entries: [], known: false };
|
|
342
|
+
// A local source with no manifest and no convention dir resolves to
|
|
343
|
+
// the directory itself; the loader imports paths verbatim, so it is
|
|
344
|
+
// not selectable and must not be advertised.
|
|
345
|
+
if (await isFile(resource.path)) {
|
|
346
|
+
group.known = true;
|
|
347
|
+
if (resource.enabled && !group.entries.includes(resource.path)) group.entries.push(resource.path);
|
|
348
|
+
}
|
|
349
|
+
groups.set(source, group);
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
// Settings-declared paths (`extensions: [...]`) are already concrete
|
|
353
|
+
// files, so profiles reference them directly instead of through a
|
|
354
|
+
// derived ID; only auto-discovered directory contents become IDs.
|
|
355
|
+
if (!resource.enabled || source !== "auto") continue;
|
|
356
|
+
const extensionsDir = path.join(baseDir ?? agentDir, "extensions");
|
|
357
|
+
const id = looseId(resource.path, extensionsDir);
|
|
358
|
+
const target = scope === "project" ? projectLoose : userLoose;
|
|
359
|
+
const existing = target.get(id);
|
|
360
|
+
if (existing === undefined) {
|
|
361
|
+
target.set(id, { id, entry: resource.path });
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
// Pi reports a `.ts`/`.js` stem pair as two entries; keep its
|
|
365
|
+
// TypeScript-first convention, but say so instead of silently
|
|
366
|
+
// shadowing the file the loader would pick.
|
|
367
|
+
if (!existing.entry.endsWith(".ts") && resource.path.endsWith(".ts")) {
|
|
368
|
+
warnings.push(`extension "${id}" exists as both .ts and .js in ${extensionsDir}; the .ts file is used`);
|
|
369
|
+
target.set(id, { id, entry: resource.path });
|
|
370
|
+
}
|
|
371
|
+
}
|
|
317
372
|
|
|
318
373
|
const packages: DiscoveredPackage[] = [];
|
|
319
|
-
for (const
|
|
320
|
-
|
|
321
|
-
|
|
374
|
+
for (const group of groups.values()) {
|
|
375
|
+
if (!group.known) continue;
|
|
376
|
+
packages.push({
|
|
377
|
+
name: await readPackageName(group.root, group.source),
|
|
378
|
+
source: group.source,
|
|
379
|
+
root: group.root,
|
|
380
|
+
entries: group.entries,
|
|
381
|
+
});
|
|
322
382
|
}
|
|
323
383
|
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
// project-over-global catalog override convention.
|
|
329
|
-
for (const entry of await scanLooseDir(path.join(options.projectDir, ".pi", "extensions"), warnings)) {
|
|
330
|
-
merged.set(entry.id, entry);
|
|
331
|
-
}
|
|
332
|
-
}
|
|
384
|
+
// Project loose files override same-ID global ones, mirroring the
|
|
385
|
+
// project-over-global catalog override convention.
|
|
386
|
+
const merged = new Map(userLoose);
|
|
387
|
+
for (const [id, entry] of projectLoose) merged.set(id, entry);
|
|
333
388
|
|
|
334
389
|
return { packages, local: [...merged.values()], warnings };
|
|
335
390
|
}
|
|
336
391
|
|
|
337
|
-
export async function
|
|
338
|
-
agentDir
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
392
|
+
export async function discoverImplicitExtensions(options: DiscoverExtensionsOptions): Promise<ImplicitExtensionDiscovery> {
|
|
393
|
+
return await resolveImplicit(createPackageManager(options), options.agentDir);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
export async function discoverExtensions(options: DiscoverExtensionsOptions): Promise<DiscoveredExtensions> {
|
|
342
397
|
const raw = await discoverImplicitExtensions(options);
|
|
343
398
|
return new DiscoveredExtensions(raw.packages, raw.local, raw.warnings);
|
|
344
399
|
}
|
|
@@ -54,10 +54,12 @@ export async function discoverLauncherResources(
|
|
|
54
54
|
.map((pkg) => ({ source: pkg.source, root: resolvePackageRoot(pkg.source, pkg.installedPath, context.agentDir) }));
|
|
55
55
|
const [skills, extensions] = await Promise.all([
|
|
56
56
|
discoverSkills(context),
|
|
57
|
+
// Extension discovery resolves through Pi's own package manager, so it
|
|
58
|
+
// only needs the same cwd/agentDir/trust inputs as skill discovery.
|
|
57
59
|
discoverExtensions({
|
|
60
|
+
cwd: context.cwd,
|
|
58
61
|
agentDir: context.agentDir,
|
|
59
|
-
|
|
60
|
-
projectDir: context.projectTrusted ? context.cwd : undefined,
|
|
62
|
+
projectTrusted: context.projectTrusted,
|
|
61
63
|
}),
|
|
62
64
|
]);
|
|
63
65
|
return { skills, packages: configured, extensions };
|