pi-nested-skills 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,73 @@
1
+ # pi-nested-skills
2
+
3
+ A Pi extension that discovers skills recursively and provides convenient aliases for nested skill directories.
4
+
5
+ [中文文档](./README.zh-CN.md)
6
+
7
+ ## Features
8
+
9
+ - Recursively discovers `SKILL.md` files below one or more configurable skill roots.
10
+ - Supports package-style aliases such as `/development:code-reviewer` and `/design:icons.favicon`.
11
+ - Keeps Pi's native skill loader responsible for frontmatter validation and skill-body expansion; this package does not duplicate `read` compatibility or skill expansion.
12
+ - Extends the native slash-command completion list and preserves built-in Pi command suggestions.
13
+ - Provides `/skills` to list discovered aliases.
14
+ - Provides `/config:nested-skills` to edit the skill roots through the normal TUI input; use `reset` for the default `skills` root.
15
+ - Contributes discovered skill files through Pi's `resources_discover` event.
16
+ - Uses `pi-extensions-i18n` for all user-visible messages.
17
+
18
+ ## Install
19
+
20
+ ```bash
21
+ pi install npm:pi-nested-skills
22
+ ```
23
+
24
+ Then run `/reload` in Pi.
25
+
26
+ ## Configure skill roots
27
+
28
+ Start from [`config.example.json`](./config.example.json) and write it to:
29
+
30
+ ```text
31
+ <pi-agent-dir>/extensions/pi-nested-skills/config.json
32
+ ```
33
+
34
+ `<pi-agent-dir>` is Pi's configured agent directory, normally `~/.pi/agent`. The `PI_CODING_AGENT_DIR` override is respected by Pi and by this extension.
35
+
36
+ ```json
37
+ {
38
+ "skillRoots": [
39
+ "~/shared-skills",
40
+ "skills"
41
+ ]
42
+ }
43
+ ```
44
+
45
+ A relative root is resolved relative to the Pi agent directory. Each direct child of a root is treated as a skill package, and every visible descendant containing `SKILL.md` is discovered. A root containing `SKILL.md` itself is also accepted as one package.
46
+
47
+ Use `/config:nested-skills` to change the roots in the normal TUI input. Enter comma-separated paths, or use `reset` for the default root. Run `/reload` after saving.
48
+
49
+ ## Aliases and native expansion
50
+
51
+ For a skill at `development/code-reviewer/SKILL.md`, the extension offers:
52
+
53
+ ```text
54
+ /development:code-reviewer [arguments]
55
+ /skill:development.code-reviewer [arguments]
56
+ ```
57
+
58
+ The input hook converts the alias to Pi's native `/skill:<frontmatter-name>` form. Pi then reads the skill body and resolves relative references using its normal skill mechanism. If a frontmatter name is shared by multiple discovered skills, the name-only native form is not guessed; use an unambiguous package/path alias.
59
+
60
+ Use `/skills` to inspect the complete list. Type `/` to search the merged native and nested-skill completion candidates.
61
+
62
+ ## Portability and privacy
63
+
64
+ The package does not access credentials, make network requests, start processes, or assume a particular home directory. It follows Pi's agent-directory setting and accepts explicit roots for project or team layouts.
65
+
66
+ ## Requirements
67
+
68
+ - Node.js 22 or newer.
69
+ - Pi `>=0.80.0 <0.81.0`.
70
+
71
+ ## License
72
+
73
+ MIT — see the repository license.
@@ -0,0 +1,73 @@
1
+ # pi-nested-skills
2
+
3
+ Pi 的嵌套技能扩展:递归发现多级 `SKILL.md`,并为嵌套技能提供快捷别名、列表和补全。
4
+
5
+ [English](./README.md)
6
+
7
+ ## 功能
8
+
9
+ - 递归扫描一个或多个可配置技能根目录下的 `SKILL.md`。
10
+ - 支持 `/development:code-reviewer`、`/design:icons.favicon` 等技能包别名。
11
+ - 保留 Pi 原生技能加载器负责 frontmatter 校验和正文展开;不重复实现 `read` 兼容层或技能展开。
12
+ - 将嵌套技能补全合并到 Pi 原生斜杠命令补全中,不影响内置命令。
13
+ - 提供 `/skills` 列出发现到的全部别名。
14
+ - 提供 `/config:nested-skills` 通过常规 TUI 输入配置技能根目录;输入 `reset` 恢复默认的 `skills` 根目录。
15
+ - 通过 Pi 的 `resources_discover` 事件注册发现到的技能文件。
16
+ - 所有用户可见文案都通过 `pi-extensions-i18n` 提供中英文版本。
17
+
18
+ ## 安装
19
+
20
+ ```bash
21
+ pi install npm:pi-nested-skills
22
+ ```
23
+
24
+ 然后在 Pi 中执行 `/reload`。
25
+
26
+ ## 配置技能根目录
27
+
28
+ 复制 [`config.example.json`](./config.example.json),写入:
29
+
30
+ ```text
31
+ <Pi agent 目录>/extensions/pi-nested-skills/config.json
32
+ ```
33
+
34
+ `<Pi agent 目录>` 是 Pi 配置的 agent 目录,通常为 `~/.pi/agent`。扩展遵循 Pi 的 `PI_CODING_AGENT_DIR` 设置。
35
+
36
+ ```json
37
+ {
38
+ "skillRoots": [
39
+ "~/shared-skills",
40
+ "skills"
41
+ ]
42
+ }
43
+ ```
44
+
45
+ 相对路径以 Pi agent 目录为基准。根目录的每个直接子目录视为一个技能包,扩展会递归发现其中所有可见目录下的 `SKILL.md`。如果根目录本身包含 `SKILL.md`,也会把它作为一个技能包处理。
46
+
47
+ 使用 `/config:nested-skills` 在 TUI 输入框中修改配置,多个目录用逗号分隔;输入 `reset` 恢复默认目录。保存后执行 `/reload`。
48
+
49
+ ## 别名与原生展开
50
+
51
+ 对于 `development/code-reviewer/SKILL.md`,扩展提供:
52
+
53
+ ```text
54
+ /development:code-reviewer [参数]
55
+ /skill:development.code-reviewer [参数]
56
+ ```
57
+
58
+ 输入事件会把别名转换为 Pi 原生的 `/skill:<frontmatter-name>` 形式,再由 Pi 读取技能正文,并按原生机制解析相对引用。如果多个技能共用同一个 frontmatter name,扩展不会猜测无路径的名称;请使用明确的技能包/路径别名。
59
+
60
+ 使用 `/skills` 查看完整列表。输入 `/` 可在同一个列表中搜索 Pi 内置命令和嵌套技能。
61
+
62
+ ## 可移植性与隐私
63
+
64
+ 本包不读取凭据、不发起网络请求、不启动进程,也不假设固定的用户目录。它遵循 Pi 的 agent 目录配置,并支持显式指定项目或团队技能目录。
65
+
66
+ ## 要求
67
+
68
+ - Node.js 22 或更高版本。
69
+ - Pi `>=0.80.0 <0.81.0`。
70
+
71
+ ## 许可证
72
+
73
+ MIT — 详见仓库许可证。
package/SKILL.md ADDED
@@ -0,0 +1,34 @@
1
+ ---
2
+ name: configure-pi-nested-skills
3
+ description: "配置与排查 pi-nested-skills 的技能根目录、递归发现、别名调用和补全。Use when configuring nested skill roots, aliases, discovery, or completion."
4
+ ---
5
+
6
+ # 配置 pi-nested-skills
7
+
8
+ ## 诊断
9
+
10
+ 读取实际 Pi agent 目录下的:
11
+
12
+ ```text
13
+ extensions/pi-nested-skills/config.json
14
+ ```
15
+
16
+ 默认路径是 `~/.pi/agent/extensions/pi-nested-skills/config.json`;设置 `PI_CODING_AGENT_DIR` 时跟随 Pi 的 agent 目录。配置字段只有 `skillRoots`,可以是一个路径字符串或路径字符串数组。
17
+
18
+ 技能根目录下的一级目录视为技能包;扩展会递归查找其中的 `SKILL.md`。如果根目录本身直接包含 `SKILL.md`,则把这个目录作为一个技能包处理。
19
+
20
+ ## 修改
21
+
22
+ 优先复制包内 [`config.example.json`](./config.example.json),再修改 `skillRoots`。路径可以是绝对路径、`~/` 路径,或相对于 Pi agent 目录的路径。
23
+
24
+ 也可以使用 `/config:nested-skills` 修改配置;命令会在 TUI 输入框中接收逗号分隔的目录,输入 `reset` 恢复默认值。保存后执行 `/reload`。
25
+
26
+ 别名形式为 `/技能包:子目录.子目录`,例如 `/development:code-reviewer` 或 `/design:icons.favicon`。也支持 `/skill:技能包.子目录`;扩展会把它转换为 Pi 原生 `/skill:<name>`,由 Pi 负责技能正文展开。
27
+
28
+ ## 验证
29
+
30
+ - 执行 `/skills` 查看递归发现的别名。
31
+ - 在输入框输入 `/`,确认补全同时保留 Pi 内置命令和嵌套技能。
32
+ - 手动调用一个嵌套别名并带参数,确认参数仍传给原生技能展开。
33
+ - 配置空目录、缺失目录或无 description 的 `SKILL.md` 时,确认 Pi/扩展明确报告诊断;不要把无法加载的技能静默显示为可用。
34
+ - 不要复制 Pi 原生 `read` 路径处理或技能正文展开逻辑。
@@ -0,0 +1,5 @@
1
+ {
2
+ "skillRoots": [
3
+ "skills"
4
+ ]
5
+ }
package/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export { default } from "./src/index.ts";
2
+ export * from "./src/index.ts";
@@ -0,0 +1,54 @@
1
+ {
2
+ "commandDescription": {
3
+ "zh-CN": "列出所有可用的嵌套技能及其命令",
4
+ "en-US": "List all available nested skills and their commands"
5
+ },
6
+ "title": {
7
+ "zh-CN": "## 可用嵌套技能命令\n",
8
+ "en-US": "## Available nested skill commands\n"
9
+ },
10
+ "footer": {
11
+ "zh-CN": "在聊天中输入上述命令即可手动调用对应技能。",
12
+ "en-US": "Enter one of the commands above in chat to invoke the corresponding skill."
13
+ },
14
+ "noSkills": {
15
+ "zh-CN": "技能根目录中没有发现可用技能:{roots}",
16
+ "en-US": "No usable skills were found in the configured roots: {roots}"
17
+ },
18
+ "noRoots": {
19
+ "zh-CN": "未配置根目录",
20
+ "en-US": "no configured roots"
21
+ },
22
+ "configWarning": {
23
+ "zh-CN": "嵌套技能配置警告:{reason}",
24
+ "en-US": "Nested skills configuration warning: {reason}"
25
+ },
26
+ "scanWarning": {
27
+ "zh-CN": "扫描技能目录失败:{path}({reason})",
28
+ "en-US": "Failed to scan skill path: {path} ({reason})"
29
+ },
30
+ "configCommandDescription": {
31
+ "zh-CN": "配置嵌套技能根目录",
32
+ "en-US": "Configure nested skill roots"
33
+ },
34
+ "configCommandInput": {
35
+ "zh-CN": "输入技能根目录(多个目录用逗号分隔)",
36
+ "en-US": "Enter skill roots (separate multiple roots with commas)"
37
+ },
38
+ "configCommandUsage": {
39
+ "zh-CN": "用法:/config:nested-skills(打开配置输入)或 /config:nested-skills reset(恢复默认值)",
40
+ "en-US": "Usage: /config:nested-skills (open settings) or /config:nested-skills reset (restore defaults)"
41
+ },
42
+ "configCommandInteractiveOnly": {
43
+ "zh-CN": "嵌套技能配置需要在 TUI 中修改;请在交互式 Pi 会话中执行命令。",
44
+ "en-US": "Nested skill configuration requires the TUI; run this command in an interactive Pi session."
45
+ },
46
+ "configCommandSaved": {
47
+ "zh-CN": "嵌套技能配置已保存:{path}。请执行 /reload 使配置生效。",
48
+ "en-US": "Nested skill configuration saved to {path}. Run /reload to apply it."
49
+ },
50
+ "configCommandInvalid": {
51
+ "zh-CN": "嵌套技能配置无效:{error}",
52
+ "en-US": "Invalid nested skill configuration: {error}"
53
+ }
54
+ }
package/package.json ADDED
@@ -0,0 +1,74 @@
1
+ {
2
+ "name": "pi-nested-skills",
3
+ "version": "0.1.0",
4
+ "description": "Pi extension for recursive nested-skill discovery and aliases",
5
+ "type": "module",
6
+ "main": "./index.ts",
7
+ "exports": {
8
+ ".": "./index.ts"
9
+ },
10
+ "files": [
11
+ "index.ts",
12
+ "src",
13
+ "locales",
14
+ "config.example.json",
15
+ "README.md",
16
+ "README.zh-CN.md",
17
+ "SKILL.md",
18
+ "tsconfig.json"
19
+ ],
20
+ "scripts": {
21
+ "test": "tsx --test tests/*.test.ts",
22
+ "typecheck": "tsc --noEmit --pretty false",
23
+ "build": "npm run typecheck",
24
+ "check": "npm run typecheck && npm test && npm pack --dry-run --json > /dev/null"
25
+ },
26
+ "pi": {
27
+ "extensions": [
28
+ "./index.ts",
29
+ "../pi-extensions-i18n/index.ts"
30
+ ],
31
+ "skills": [
32
+ "./SKILL.md"
33
+ ]
34
+ },
35
+ "engines": {
36
+ "node": ">=22"
37
+ },
38
+ "license": "MIT",
39
+ "author": "maplezzk",
40
+ "homepage": "https://github.com/maplezzk/pi-extensions/tree/main/packages/pi-nested-skills",
41
+ "bugs": "https://github.com/maplezzk/pi-extensions/issues",
42
+ "repository": {
43
+ "type": "git",
44
+ "url": "git+https://github.com/maplezzk/pi-extensions.git",
45
+ "directory": "packages/pi-nested-skills"
46
+ },
47
+ "publishConfig": {
48
+ "access": "public",
49
+ "registry": "https://registry.npmjs.org"
50
+ },
51
+ "keywords": [
52
+ "pi-package",
53
+ "pi",
54
+ "pi-extension",
55
+ "coding-agent",
56
+ "skills",
57
+ "nested-skills",
58
+ "autocomplete"
59
+ ],
60
+ "peerDependencies": {
61
+ "@earendil-works/pi-coding-agent": ">=0.80.0 <0.81.0",
62
+ "@earendil-works/pi-tui": ">=0.80.0 <0.81.0"
63
+ },
64
+ "dependencies": {
65
+ "pi-extensions-i18n": "^0.4.0"
66
+ },
67
+ "devDependencies": {
68
+ "@earendil-works/pi-coding-agent": "0.80.10",
69
+ "@earendil-works/pi-tui": "0.80.10",
70
+ "@types/node": "24.12.4",
71
+ "tsx": "4.23.1",
72
+ "typescript": "5.9.3"
73
+ }
74
+ }
package/src/config.ts ADDED
@@ -0,0 +1,143 @@
1
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { isAbsolute, join, resolve } from "node:path";
5
+
6
+ export const CONFIG_FILE_NAME = "config.json";
7
+ export const CONFIG_DIRECTORY_NAME = "pi-nested-skills";
8
+
9
+ export interface NestedSkillsConfig {
10
+ /** 包含技能包目录的根目录列表。 */
11
+ skillRoots: string[];
12
+ }
13
+
14
+ export type ConfigSource = "file" | "default";
15
+
16
+ export interface LoadedNestedSkillsConfig {
17
+ config: NestedSkillsConfig;
18
+ source: ConfigSource;
19
+ /** 配置文件存在但无法使用时的明确诊断。 */
20
+ warnings: string[];
21
+ /** 是否由用户显式提供过根目录配置。 */
22
+ explicit: boolean;
23
+ }
24
+
25
+ interface ConfigObject {
26
+ skillRoots?: unknown;
27
+ /** 早期试用版本使用的单数名称,读取时保留兼容性。 */
28
+ skillsDir?: unknown;
29
+ }
30
+
31
+ /** 返回实际 Pi agent 目录下的扩展配置文件路径。 */
32
+ export function configPath(agentDir = getAgentDir()): string {
33
+ return join(agentDir, "extensions", CONFIG_DIRECTORY_NAME, CONFIG_FILE_NAME);
34
+ }
35
+
36
+ /** 默认使用 Pi 的标准全局技能目录;需要兼容其他技能树时可通过配置覆盖。 */
37
+ export function defaultSkillRoots(agentDir = getAgentDir()): string[] {
38
+ return [join(agentDir, "skills")];
39
+ }
40
+
41
+ function expandHomePath(value: string, homeDirectory = homedir()): string {
42
+ if (value === "~") return homeDirectory;
43
+ if (value.startsWith("~/") || value.startsWith("~\\")) {
44
+ return join(homeDirectory, value.slice(2));
45
+ }
46
+ return value;
47
+ }
48
+
49
+ /** 将配置中的绝对、~/ 和相对 Pi agent 目录路径统一为绝对路径。 */
50
+ export function resolveSkillRoot(
51
+ value: string,
52
+ agentDir: string,
53
+ homeDirectory = homedir(),
54
+ ): string {
55
+ const expanded = expandHomePath(value.trim(), homeDirectory);
56
+ return isAbsolute(expanded) ? expanded : resolve(agentDir, expanded);
57
+ }
58
+
59
+ function normalizeRootValues(value: unknown): string[] | undefined {
60
+ if (typeof value === "string") {
61
+ const trimmed = value.trim();
62
+ return trimmed ? [trimmed] : [];
63
+ }
64
+ if (!Array.isArray(value)) return undefined;
65
+
66
+ const roots: string[] = [];
67
+ for (const item of value) {
68
+ if (typeof item !== "string" || item.trim() === "") return undefined;
69
+ roots.push(item.trim());
70
+ }
71
+ return roots;
72
+ }
73
+
74
+ /** 校验 TUI 配置输入,并保留早期配置文件的 skillsDir 兼容字段。 */
75
+ export function parseConfig(value: unknown): NestedSkillsConfig {
76
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
77
+ throw new Error("configuration must be an object");
78
+ }
79
+ const raw = value as ConfigObject;
80
+ const configured = raw.skillRoots === undefined ? raw.skillsDir : raw.skillRoots;
81
+ const roots = normalizeRootValues(configured);
82
+ if (roots === undefined) {
83
+ throw new Error('configuration field "skillRoots" must be a string or an array of strings');
84
+ }
85
+ return { skillRoots: roots };
86
+ }
87
+
88
+ /** 读取配置文件并返回规范化前的根目录值或明确诊断。 */
89
+ function readConfigFile(path: string): { value?: string[]; warning?: string } {
90
+ if (!existsSync(path)) return {};
91
+
92
+ try {
93
+ const parsed = JSON.parse(readFileSync(path, "utf8")) as ConfigObject;
94
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
95
+ return { warning: `Invalid configuration object in ${path}.` };
96
+ }
97
+
98
+ const configured = parsed.skillRoots === undefined ? parsed.skillsDir : parsed.skillRoots;
99
+ if (configured === undefined) return {};
100
+ const roots = normalizeRootValues(configured);
101
+ if (roots === undefined) {
102
+ return { warning: `Configuration field "skillRoots" must be a string or an array of strings in ${path}.` };
103
+ }
104
+ return { value: roots };
105
+ } catch (error) {
106
+ return {
107
+ warning: `Failed to read ${path}: ${error instanceof Error ? error.message : String(error)}`,
108
+ };
109
+ }
110
+ }
111
+
112
+ /** 将技能根目录配置写入 Pi agent 配置目录。 */
113
+ export function saveConfig(config: NestedSkillsConfig, agentDir = getAgentDir()): string {
114
+ const path = configPath(agentDir);
115
+ mkdirSync(join(agentDir, "extensions", CONFIG_DIRECTORY_NAME), { recursive: true });
116
+ writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`, "utf8");
117
+ return path;
118
+ }
119
+
120
+ /** 读取技能根目录配置:配置文件 > Pi 标准默认目录。 */
121
+ export function loadConfig(agentDir = getAgentDir()): LoadedNestedSkillsConfig {
122
+ const path = configPath(agentDir);
123
+ const fileResult = readConfigFile(path);
124
+ const warnings = fileResult.warning ? [fileResult.warning] : [];
125
+
126
+ if (fileResult.value !== undefined) {
127
+ return {
128
+ config: {
129
+ skillRoots: fileResult.value.map((value) => resolveSkillRoot(value, agentDir)),
130
+ },
131
+ source: "file",
132
+ warnings,
133
+ explicit: true,
134
+ };
135
+ }
136
+
137
+ return {
138
+ config: { skillRoots: defaultSkillRoots(agentDir) },
139
+ source: "default",
140
+ warnings,
141
+ explicit: false,
142
+ };
143
+ }
package/src/index.ts ADDED
@@ -0,0 +1,319 @@
1
+ import type {
2
+ ExtensionAPI,
3
+ ExtensionCommandContext,
4
+ ExtensionContext,
5
+ } from "@earendil-works/pi-coding-agent";
6
+ import { fuzzyFilter } from "@earendil-works/pi-tui";
7
+ import type { AutocompleteItem, AutocompleteProvider } from "@earendil-works/pi-tui";
8
+ import { createTranslator, loadCatalog } from "pi-extensions-i18n";
9
+ import { loadConfig, parseConfig, saveConfig, type LoadedNestedSkillsConfig } from "./config.ts";
10
+ import { scanSkillRoots, type NestedSkill, type SkillScanResult } from "./skills.ts";
11
+
12
+ const messages = loadCatalog(new URL("../locales/index.json", import.meta.url));
13
+ const i18n = createTranslator(messages);
14
+
15
+ const COMMAND_NAME = "skills";
16
+ const COMMAND_ALIASES = [COMMAND_NAME] as const;
17
+ const CONFIG_COMMAND_ALIASES = ["config:nested-skills", "nested-skills-config", "pi-nested-skills-config"] as const;
18
+ const CONFIG_RESET_COMMAND = "reset";
19
+ const DEFAULT_CONFIG_ROOT = "skills";
20
+ const ROOT_INPUT_SEPARATOR = ",";
21
+ const ROOT_DISPLAY_SEPARATOR = ", ";
22
+ const NOTICE_WARNING = "warning" as const;
23
+ const NOTICE_INFO = "info" as const;
24
+ const NOTICE_ERROR = "error" as const;
25
+ const SKILL_COMMAND_PREFIX = "skill:";
26
+ const INPUT_SOURCES = new Set(["interactive", "rpc"]);
27
+
28
+ type SkillCommandContext = ExtensionCommandContext;
29
+
30
+ interface SkillCandidate {
31
+ alias: string;
32
+ skill: NestedSkill;
33
+ }
34
+
35
+ interface SkillIndex {
36
+ readonly skills: NestedSkill[];
37
+ readonly skillPaths: string[];
38
+ readonly warnings: SkillScanResult["warnings"];
39
+ readonly aliases: Map<string, NestedSkill>;
40
+ readonly names: Map<string, NestedSkill | null>;
41
+ }
42
+
43
+ /** 将扫描结果转换为稳定的别名索引,并标记 frontmatter name 冲突。 */
44
+ function buildSkillIndex(scan: SkillScanResult): SkillIndex {
45
+ const aliases = new Map<string, NestedSkill>();
46
+ const names = new Map<string, NestedSkill | null>();
47
+
48
+ for (const skill of scan.skills) {
49
+ const alias = skillAlias(skill);
50
+ if (!aliases.has(alias)) aliases.set(alias, skill);
51
+
52
+ const existing = names.get(skill.skillName);
53
+ if (existing === undefined) names.set(skill.skillName, skill);
54
+ else if (existing !== skill) names.set(skill.skillName, null);
55
+ }
56
+
57
+ return {
58
+ skills: scan.skills,
59
+ skillPaths: scan.skillPaths,
60
+ warnings: scan.warnings,
61
+ aliases,
62
+ names,
63
+ };
64
+ }
65
+
66
+ /** 返回用户输入和补全展示使用的别名,不改变 frontmatter 中的原生技能名称。 */
67
+ export function skillAlias(skill: Pick<NestedSkill, "packName" | "skillDir">): string {
68
+ return skill.skillDir ? `${skill.packName}:${skill.skillDir}` : skill.packName;
69
+ }
70
+
71
+ /** 将 /skill:pack.path、/pack:path 等形式解析为一个已扫描技能。 */
72
+ export function resolveSkillAlias(raw: string, index: Pick<SkillIndex, "aliases" | "names">): NestedSkill | undefined {
73
+ const candidates = new Set<string>();
74
+ const addCandidate = (candidate: string): void => {
75
+ const normalized = candidate.trim();
76
+ if (normalized) candidates.add(normalized);
77
+ };
78
+
79
+ addCandidate(raw);
80
+ if (raw.startsWith(SKILL_COMMAND_PREFIX)) {
81
+ const body = raw.slice(SKILL_COMMAND_PREFIX.length);
82
+ addCandidate(body);
83
+
84
+ // /skill:pack.path.to.skill 使用第一个点分隔技能包和嵌套路径。
85
+ const firstDot = body.indexOf(".");
86
+ if (firstDot > 0) addCandidate(`${body.slice(0, firstDot)}:${body.slice(firstDot + 1)}`);
87
+ } else {
88
+ // 也接受不带 skill: 前缀的点号路径,方便旧别名和手工输入互通。
89
+ const firstDot = raw.indexOf(".");
90
+ if (firstDot > 0) addCandidate(`${raw.slice(0, firstDot)}:${raw.slice(firstDot + 1)}`);
91
+ }
92
+
93
+ for (const candidate of candidates) {
94
+ const direct = index.aliases.get(candidate);
95
+ if (direct) return direct;
96
+ }
97
+
98
+ // /skill:<frontmatter name> 交给索引映射;冲突名称不猜测,避免调用错误技能。
99
+ if (!raw.startsWith(SKILL_COMMAND_PREFIX)) return undefined;
100
+ const named = index.names.get(raw.slice(SKILL_COMMAND_PREFIX.length));
101
+ return named ?? undefined;
102
+ }
103
+
104
+ /** 将原生 /skill:<name> 调用交给 Pi 自己展开,避免复制技能正文展开逻辑。 */
105
+ export function transformSkillInput(
106
+ text: string,
107
+ index: Pick<SkillIndex, "aliases" | "names">,
108
+ ): string | undefined {
109
+ const match = text.match(/^\/([^\s]+)(?:\s+([\s\S]*))?$/);
110
+ if (!match) return undefined;
111
+
112
+ const skill = resolveSkillAlias(match[1], index);
113
+ if (!skill || index.names.get(skill.skillName) !== skill) return undefined;
114
+
115
+ const args = match[2]?.trim();
116
+ return `/skill:${skill.skillName}${args ? ` ${args}` : ""}`;
117
+ }
118
+
119
+ function skillCandidates(index: SkillIndex): SkillCandidate[] {
120
+ return [...index.aliases.entries()].map(([alias, skill]) => ({ alias, skill }));
121
+ }
122
+
123
+ function nativeSkillValue(alias: string): string {
124
+ const separator = alias.indexOf(":");
125
+ return separator === -1
126
+ ? `${SKILL_COMMAND_PREFIX}${alias}`
127
+ : `${SKILL_COMMAND_PREFIX}${alias.slice(0, separator)}.${alias.slice(separator + 1)}`;
128
+ }
129
+
130
+ function createAutocompleteProvider(
131
+ current: AutocompleteProvider,
132
+ index: SkillIndex,
133
+ ): AutocompleteProvider {
134
+ return {
135
+ async getSuggestions(lines, cursorLine, cursorCol, options) {
136
+ const base = await current.getSuggestions(lines, cursorLine, cursorCol, options);
137
+ const beforeCursor = (lines[cursorLine] ?? "").slice(0, cursorCol);
138
+ const match = beforeCursor.match(/^\/([^\s]*)$/);
139
+ if (!match) return base;
140
+
141
+ const query = match[1].startsWith(SKILL_COMMAND_PREFIX)
142
+ ? match[1].slice(SKILL_COMMAND_PREFIX.length)
143
+ : match[1];
144
+ const items = fuzzyFilter(
145
+ skillCandidates(index),
146
+ query,
147
+ ({ alias, skill }) => `${alias} ${skill.skillName} ${skill.description}`,
148
+ ).map(({ alias, skill }): AutocompleteItem => ({
149
+ value: nativeSkillValue(alias),
150
+ label: alias,
151
+ description: skill.description || skill.skillName,
152
+ }));
153
+
154
+ if (items.length === 0) return base;
155
+ const existingValues = new Set((base?.items ?? []).map((item) => item.value));
156
+ const merged = [
157
+ ...(base?.items ?? []),
158
+ ...items.filter((item) => !existingValues.has(item.value)),
159
+ ];
160
+ return { prefix: beforeCursor, items: merged };
161
+ },
162
+
163
+ applyCompletion(lines, cursorLine, cursorCol, item, prefix) {
164
+ return current.applyCompletion(lines, cursorLine, cursorCol, item, prefix);
165
+ },
166
+
167
+ shouldTriggerFileCompletion(lines, cursorLine, cursorCol) {
168
+ return current.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ?? true;
169
+ },
170
+ };
171
+ }
172
+
173
+ function commandItems(index: SkillIndex): AutocompleteItem[] {
174
+ return skillCandidates(index).map(({ alias, skill }) => ({
175
+ value: alias,
176
+ label: alias,
177
+ description: skill.description || skill.skillName,
178
+ }));
179
+ }
180
+
181
+ function formatSkillsMessage(index: SkillIndex): string {
182
+ const lines = [i18n.t("title")];
183
+ const grouped = new Map<string, NestedSkill[]>();
184
+ for (const skill of index.skills) {
185
+ const skills = grouped.get(skill.packName) ?? [];
186
+ skills.push(skill);
187
+ grouped.set(skill.packName, skills);
188
+ }
189
+
190
+ for (const [pack, skills] of grouped) {
191
+ lines.push(`### ${pack}\n`);
192
+ for (const skill of skills) {
193
+ const description = skill.description ? ` - ${skill.description}` : "";
194
+ const alias = skillAlias(skill);
195
+ const indent = " ".repeat(Math.max(skill.depth - 1, 0));
196
+ lines.push(`${indent}- \`/${alias}\` ${description}`);
197
+ }
198
+ lines.push("");
199
+ }
200
+
201
+ lines.push("---", i18n.t("footer"));
202
+ return lines.join("\n");
203
+ }
204
+
205
+ /** 注册配置命令,通过 TUI 输入逗号分隔的技能根目录。 */
206
+ function registerConfigCommand(pi: ExtensionAPI): void {
207
+ const command = {
208
+ description: i18n.t("configCommandDescription"),
209
+ getArgumentCompletions: () => [{ value: CONFIG_RESET_COMMAND, label: CONFIG_RESET_COMMAND }],
210
+ handler: async (args: string, ctx: ExtensionCommandContext): Promise<void> => {
211
+ const argument = args.trim();
212
+ if (argument && argument !== CONFIG_RESET_COMMAND) {
213
+ ctx.ui.notify(i18n.t("configCommandUsage"), NOTICE_WARNING);
214
+ return;
215
+ }
216
+ if (argument === CONFIG_RESET_COMMAND) {
217
+ try {
218
+ const path = saveConfig(parseConfig({ skillRoots: [DEFAULT_CONFIG_ROOT] }));
219
+ ctx.ui.notify(i18n.t("configCommandSaved", { path }), NOTICE_INFO);
220
+ } catch (error) {
221
+ ctx.ui.notify(i18n.t("configCommandInvalid", {
222
+ error: error instanceof Error ? error.message : String(error),
223
+ }), NOTICE_ERROR);
224
+ }
225
+ return;
226
+ }
227
+ if (!ctx.hasUI) {
228
+ ctx.ui.notify(i18n.t("configCommandInteractiveOnly"), NOTICE_WARNING);
229
+ return;
230
+ }
231
+
232
+ const current = loadConfig().config;
233
+ const input = await ctx.ui.input(
234
+ i18n.t("configCommandInput"),
235
+ current.skillRoots.join(ROOT_DISPLAY_SEPARATOR),
236
+ );
237
+ if (input === undefined) return;
238
+ try {
239
+ const roots = input.split(ROOT_INPUT_SEPARATOR).map((root) => root.trim()).filter(Boolean);
240
+ const path = saveConfig(parseConfig({ skillRoots: roots }));
241
+ ctx.ui.notify(i18n.t("configCommandSaved", { path }), NOTICE_INFO);
242
+ } catch (error) {
243
+ ctx.ui.notify(i18n.t("configCommandInvalid", {
244
+ error: error instanceof Error ? error.message : String(error),
245
+ }), NOTICE_ERROR);
246
+ }
247
+ },
248
+ };
249
+ for (const name of CONFIG_COMMAND_ALIASES) pi.registerCommand(name, command);
250
+ }
251
+
252
+ function registerSkillsCommand(pi: ExtensionAPI, index: SkillIndex): void {
253
+ const command = {
254
+ description: i18n.t("commandDescription"),
255
+ getArgumentCompletions: (): AutocompleteItem[] => commandItems(index),
256
+ handler: async (_args: string, _ctx: SkillCommandContext) => {
257
+ await pi.sendUserMessage(formatSkillsMessage(index), { deliverAs: "followUp" });
258
+ },
259
+ };
260
+
261
+ for (const name of COMMAND_ALIASES) pi.registerCommand(name, command);
262
+ }
263
+
264
+ function notifyDiagnostics(ctx: ExtensionContext, loaded: LoadedNestedSkillsConfig, index: SkillIndex): void {
265
+ if (!ctx.hasUI) return;
266
+ for (const warning of loaded.warnings) {
267
+ ctx.ui.notify(i18n.t("configWarning", { reason: warning }), NOTICE_WARNING);
268
+ }
269
+ for (const warning of index.warnings) {
270
+ ctx.ui.notify(
271
+ i18n.t("scanWarning", { path: warning.path, reason: warning.reason }),
272
+ NOTICE_WARNING,
273
+ );
274
+ }
275
+ if (loaded.explicit && index.skills.length === 0) {
276
+ ctx.ui.notify(
277
+ i18n.t("noSkills", { roots: loaded.config.skillRoots.join(", ") || i18n.t("noRoots") }),
278
+ NOTICE_WARNING,
279
+ );
280
+ }
281
+ }
282
+
283
+ /** 注册递归技能别名,同时把实际技能正文交给 Pi 原生技能加载和展开流程。 */
284
+ export default function nestedSkillsExtension(pi: ExtensionAPI): void {
285
+ const loaded = loadConfig();
286
+ const scan = scanSkillRoots(loaded.config.skillRoots);
287
+ const index = buildSkillIndex(scan);
288
+
289
+ pi.on("resources_discover", () => ({
290
+ // 每个 SKILL.md 单独交给原生 loader,绕过“父目录含 SKILL.md 后停止递归”的规则,
291
+ // 同时保留 Pi 原生的 frontmatter 校验、正文展开和资源来源信息。
292
+ skillPaths: index.skillPaths,
293
+ }));
294
+
295
+ pi.on("input", (event) => {
296
+ if (!INPUT_SOURCES.has(event.source)) return { action: "continue" as const };
297
+ const transformed = transformSkillInput(event.text, index);
298
+ if (!transformed) return { action: "continue" as const };
299
+ return {
300
+ action: "transform" as const,
301
+ text: transformed,
302
+ images: event.images,
303
+ };
304
+ });
305
+
306
+ pi.on("session_start", (_event, ctx) => {
307
+ notifyDiagnostics(ctx, loaded, index);
308
+ if (index.aliases.size === 0) return;
309
+ ctx.ui.addAutocompleteProvider((current) => createAutocompleteProvider(current, index));
310
+ });
311
+
312
+ registerConfigCommand(pi);
313
+ registerSkillsCommand(pi, index);
314
+ }
315
+
316
+ export { buildSkillIndex, createAutocompleteProvider, formatSkillsMessage };
317
+ export type { SkillIndex };
318
+ export * from "./config.ts";
319
+ export * from "./skills.ts";
package/src/skills.ts ADDED
@@ -0,0 +1,271 @@
1
+ import {
2
+ existsSync,
3
+ lstatSync,
4
+ readdirSync,
5
+ readFileSync,
6
+ realpathSync,
7
+ statSync,
8
+ } from "node:fs";
9
+ import { basename, dirname, join, relative } from "node:path";
10
+
11
+ const SKILL_FILE_NAME = "SKILL.md";
12
+ const PATH_SEPARATOR = ".";
13
+
14
+ export interface NestedSkill {
15
+ /** 根目录下的技能包名称。 */
16
+ packName: string;
17
+ /** 技能包内的目录标识,嵌套层级用点号连接。 */
18
+ skillDir: string;
19
+ /** SKILL.md frontmatter 中的技能名称。 */
20
+ skillName: string;
21
+ /** SKILL.md frontmatter 中的技能说明。 */
22
+ description: string;
23
+ /** SKILL.md 的绝对路径。 */
24
+ skillPath: string;
25
+ /** 从技能包目录开始计算的层级,顶层为 1。 */
26
+ depth: number;
27
+ }
28
+
29
+ export interface SkillScanWarning {
30
+ path: string;
31
+ reason: string;
32
+ }
33
+
34
+ export interface SkillScanResult {
35
+ /** 交给 Pi 原生 loader 的全部 SKILL.md 文件路径。 */
36
+ skillPaths: string[];
37
+ /** 具有有效 description、可用于别名和补全的技能。 */
38
+ skills: NestedSkill[];
39
+ warnings: SkillScanWarning[];
40
+ }
41
+
42
+ export interface SkillFrontmatter {
43
+ name?: string;
44
+ description?: string;
45
+ }
46
+
47
+ /** 跳过隐藏目录、依赖目录和特殊目录,行为与 Pi 技能发现保持一致。 */
48
+ export function isVisibleSkillDirectory(name: string): boolean {
49
+ return !name.startsWith(".") && name !== "node_modules" && name !== ".DS_Store";
50
+ }
51
+
52
+ function unquote(value: string): string {
53
+ const trimmed = value.trim();
54
+ if (trimmed.length < 2) return trimmed;
55
+ const first = trimmed[0];
56
+ const last = trimmed.at(-1);
57
+ if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
58
+ return trimmed.slice(1, -1);
59
+ }
60
+ return trimmed;
61
+ }
62
+
63
+ /**
64
+ * 读取技能所需的两个 frontmatter 字段。
65
+ * 完整的技能解析和正文展开仍由 Pi 原生 loader 负责;这里仅建立别名索引。
66
+ */
67
+ export function parseSkillFrontmatter(content: string): SkillFrontmatter {
68
+ const match = content.match(/^\uFEFF?---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
69
+ if (!match) return {};
70
+
71
+ const result: SkillFrontmatter = {};
72
+ for (const line of match[1].split(/\r?\n/)) {
73
+ const field = line.match(/^(name|description):\s*(.*)$/);
74
+ if (!field) continue;
75
+ const value = unquote(field[2]);
76
+ if (field[1] === "name") result.name = value;
77
+ else result.description = value;
78
+ }
79
+ return result;
80
+ }
81
+
82
+ function resolveRealPath(path: string): string {
83
+ try {
84
+ return realpathSync(path);
85
+ } catch {
86
+ return path;
87
+ }
88
+ }
89
+
90
+ function readSkillFile(
91
+ skillPath: string,
92
+ packName: string,
93
+ packDir: string,
94
+ segments: string[],
95
+ warnings: SkillScanWarning[],
96
+ ): NestedSkill | undefined {
97
+ let content: string;
98
+ try {
99
+ content = readFileSync(skillPath, "utf8");
100
+ } catch (error) {
101
+ warnings.push({
102
+ path: skillPath,
103
+ reason: error instanceof Error ? error.message : String(error),
104
+ });
105
+ return undefined;
106
+ }
107
+
108
+ const frontmatter = parseSkillFrontmatter(content);
109
+ const description = frontmatter.description?.trim() ?? "";
110
+ if (!description) {
111
+ // 仍把文件交给 Pi 原生 loader,让 Pi 的标准诊断负责报告格式问题。
112
+ return undefined;
113
+ }
114
+
115
+ const relativeDir = relative(packDir, dirname(skillPath));
116
+ const relativeSegments = relativeDir
117
+ ? relativeDir.split(/[\\/]/).filter(Boolean)
118
+ : segments;
119
+ const skillDir = relativeSegments.join(PATH_SEPARATOR);
120
+
121
+ return {
122
+ packName,
123
+ skillDir,
124
+ skillName: frontmatter.name?.trim() || basename(skillPath.replace(/[\\/]SKILL\.md$/, "")),
125
+ description,
126
+ skillPath,
127
+ depth: Math.max(relativeSegments.length, 1),
128
+ };
129
+ }
130
+
131
+ function scanPack(
132
+ packDir: string,
133
+ packName: string,
134
+ packRoot: string,
135
+ segments: string[],
136
+ result: SkillScanResult,
137
+ visitedDirectories: Set<string>,
138
+ visitedFiles: Set<string>,
139
+ ): void {
140
+ const realDirectory = resolveRealPath(packDir);
141
+ if (visitedDirectories.has(realDirectory)) return;
142
+ visitedDirectories.add(realDirectory);
143
+
144
+ let entries;
145
+ try {
146
+ entries = readdirSync(packDir, { withFileTypes: true }).sort((left, right) =>
147
+ left.name.localeCompare(right.name),
148
+ );
149
+ } catch (error) {
150
+ result.warnings.push({
151
+ path: packDir,
152
+ reason: error instanceof Error ? error.message : String(error),
153
+ });
154
+ return;
155
+ }
156
+
157
+ const skillPath = join(packDir, SKILL_FILE_NAME);
158
+ if (existsSync(skillPath)) {
159
+ const realSkillPath = resolveRealPath(skillPath);
160
+ if (!visitedFiles.has(realSkillPath)) {
161
+ visitedFiles.add(realSkillPath);
162
+ result.skillPaths.push(skillPath);
163
+ const skill = readSkillFile(skillPath, packName, packRoot, segments, result.warnings);
164
+ if (skill) result.skills.push(skill);
165
+ }
166
+ }
167
+
168
+ for (const entry of entries) {
169
+ if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
170
+ if (!isVisibleSkillDirectory(entry.name)) continue;
171
+
172
+ const childPath = join(packDir, entry.name);
173
+ try {
174
+ if (!statSync(childPath).isDirectory()) continue;
175
+ } catch (error) {
176
+ result.warnings.push({
177
+ path: childPath,
178
+ reason: error instanceof Error ? error.message : String(error),
179
+ });
180
+ continue;
181
+ }
182
+
183
+ scanPack(
184
+ childPath,
185
+ packName,
186
+ packRoot,
187
+ [...segments, entry.name],
188
+ result,
189
+ visitedDirectories,
190
+ visitedFiles,
191
+ );
192
+ }
193
+ }
194
+
195
+ function scanRoot(
196
+ root: string,
197
+ result: SkillScanResult,
198
+ visitedDirectories: Set<string>,
199
+ visitedFiles: Set<string>,
200
+ ): void {
201
+ if (!existsSync(root)) {
202
+ result.warnings.push({ path: root, reason: "directory does not exist" });
203
+ return;
204
+ }
205
+
206
+ let rootIsDirectory = false;
207
+ try {
208
+ rootIsDirectory = lstatSync(root).isDirectory();
209
+ } catch (error) {
210
+ result.warnings.push({
211
+ path: root,
212
+ reason: error instanceof Error ? error.message : String(error),
213
+ });
214
+ return;
215
+ }
216
+ if (!rootIsDirectory) {
217
+ result.warnings.push({ path: root, reason: "skill root is not a directory" });
218
+ return;
219
+ }
220
+
221
+ let entries;
222
+ try {
223
+ entries = readdirSync(root, { withFileTypes: true }).sort((left, right) =>
224
+ left.name.localeCompare(right.name),
225
+ );
226
+ } catch (error) {
227
+ result.warnings.push({
228
+ path: root,
229
+ reason: error instanceof Error ? error.message : String(error),
230
+ });
231
+ return;
232
+ }
233
+
234
+ // 支持把单个技能目录直接作为 root,同时保留“root 下是多个技能包”的约定。
235
+ if (entries.some((entry) => entry.name === SKILL_FILE_NAME)) {
236
+ scanPack(root, basename(root), root, [], result, visitedDirectories, visitedFiles);
237
+ return;
238
+ }
239
+
240
+ for (const entry of entries) {
241
+ if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
242
+ if (!isVisibleSkillDirectory(entry.name)) continue;
243
+ const packPath = join(root, entry.name);
244
+ try {
245
+ if (!statSync(packPath).isDirectory()) continue;
246
+ } catch (error) {
247
+ result.warnings.push({
248
+ path: packPath,
249
+ reason: error instanceof Error ? error.message : String(error),
250
+ });
251
+ continue;
252
+ }
253
+ scanPack(packPath, entry.name, packPath, [], result, visitedDirectories, visitedFiles);
254
+ }
255
+ }
256
+
257
+ /** 递归发现多个技能根目录下的全部嵌套技能。 */
258
+ export function scanSkillRoots(roots: readonly string[]): SkillScanResult {
259
+ const result: SkillScanResult = {
260
+ skillPaths: [],
261
+ skills: [],
262
+ warnings: [],
263
+ };
264
+ const visitedDirectories = new Set<string>();
265
+ const visitedFiles = new Set<string>();
266
+
267
+ for (const root of roots) {
268
+ scanRoot(root, result, visitedDirectories, visitedFiles);
269
+ }
270
+ return result;
271
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,13 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "Bundler",
6
+ "allowImportingTsExtensions": true,
7
+ "noEmit": true,
8
+ "skipLibCheck": true,
9
+ "strict": false,
10
+ "types": ["node"]
11
+ },
12
+ "include": ["index.ts", "src/**/*.ts", "tests/**/*.ts"]
13
+ }