@gpambrozio/paseo-skills 0.1.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/CHANGELOG.md +53 -0
- package/LICENSE +21 -0
- package/README.md +98 -0
- package/client/panel.tsx +542 -0
- package/client/pill.tsx +116 -0
- package/client/skills-query.tsx +29 -0
- package/index.client.tsx +27 -0
- package/index.server.ts +10 -0
- package/package.json +44 -0
- package/paseo-plugin.json +7 -0
- package/server/resolve/claude.ts +101 -0
- package/server/resolve/codex.ts +50 -0
- package/server/resolve/frontmatter.ts +88 -0
- package/server/resolve/repo-root.ts +43 -0
- package/server/resolve/reported.ts +80 -0
- package/server/resolve/skill-directory.ts +93 -0
- package/server/resolve/skill-entry.ts +39 -0
- package/server/sdk-types.ts +24 -0
- package/server/skills.ts +152 -0
- package/shared/skills.ts +67 -0
package/index.client.tsx
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { PluginClientContext } from "@getpaseo/plugin/client";
|
|
2
|
+
|
|
3
|
+
import { SkillsPanel } from "./client/panel";
|
|
4
|
+
import { contributePills } from "./client/pill";
|
|
5
|
+
|
|
6
|
+
export default function contribute(client: PluginClientContext) {
|
|
7
|
+
client.addWorkspacePanel({
|
|
8
|
+
id: "skills",
|
|
9
|
+
title: "Skills",
|
|
10
|
+
icon: "Sparkles",
|
|
11
|
+
context: "agent",
|
|
12
|
+
Component: SkillsPanel,
|
|
13
|
+
});
|
|
14
|
+
client.addCommandCenterItem({
|
|
15
|
+
id: "open-skills",
|
|
16
|
+
title: "Skills",
|
|
17
|
+
icon: "Sparkles",
|
|
18
|
+
keywords: ["skill", "skills", "agent skills"],
|
|
19
|
+
context: "agent",
|
|
20
|
+
onSelect: ({ openPanel }) => {
|
|
21
|
+
openPanel("skills");
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
// The pill reaches the panel from the composer; the Command Center item is
|
|
25
|
+
// the keyboard path to the same panel.
|
|
26
|
+
return contributePills(client);
|
|
27
|
+
}
|
package/index.server.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { PluginServerContext } from "@getpaseo/plugin/server";
|
|
2
|
+
|
|
3
|
+
import { createListSkillsHandler, createReadSkillHandler } from "./server/skills";
|
|
4
|
+
import { listSkills, readSkill } from "./shared/skills";
|
|
5
|
+
|
|
6
|
+
export default function contribute(server: PluginServerContext) {
|
|
7
|
+
server.handle(listSkills, createListSkillsHandler());
|
|
8
|
+
server.handle(readSkill, createReadSkillHandler());
|
|
9
|
+
return () => {};
|
|
10
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@gpambrozio/paseo-skills",
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"description": "Paseo plugin: lists the skills an agent can use, shows where each comes from, renders its SKILL.md, and invokes it",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"homepage": "https://github.com/gpambrozio/paseo-plugins/tree/main/skills",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/gpambrozio/paseo-plugins.git",
|
|
10
|
+
"directory": "skills"
|
|
11
|
+
},
|
|
12
|
+
"type": "module",
|
|
13
|
+
"files": [
|
|
14
|
+
"paseo-plugin.json",
|
|
15
|
+
"index.client.tsx",
|
|
16
|
+
"index.server.ts",
|
|
17
|
+
"client/",
|
|
18
|
+
"server/",
|
|
19
|
+
"shared/",
|
|
20
|
+
"CHANGELOG.md",
|
|
21
|
+
"!**/*.test.ts"
|
|
22
|
+
],
|
|
23
|
+
"publishConfig": {
|
|
24
|
+
"access": "public",
|
|
25
|
+
"registry": "https://registry.npmjs.org"
|
|
26
|
+
},
|
|
27
|
+
"scripts": {
|
|
28
|
+
"typecheck": "tsc --noEmit",
|
|
29
|
+
"test": "vitest run --passWithNoTests"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"@getpaseo/client": "0.8.0",
|
|
33
|
+
"@getpaseo/plugin": "0.8.0",
|
|
34
|
+
"@getpaseo/protocol": "0.8.0",
|
|
35
|
+
"@tanstack/react-query": "^5.102.8",
|
|
36
|
+
"@types/node": "^26.5.0",
|
|
37
|
+
"@types/react": "~19.2.0",
|
|
38
|
+
"react": "19.1.0",
|
|
39
|
+
"react-native": "0.81.5",
|
|
40
|
+
"typescript": "^7.0.2",
|
|
41
|
+
"vitest": "^5.0.0",
|
|
42
|
+
"zod": "^4.5.4"
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import { dirsUpToRepoRoot } from "./repo-root";
|
|
5
|
+
import { readSkillCandidates, type SkillDirectoryCandidate } from "./skill-directory";
|
|
6
|
+
import type { SkillEntry } from "./skill-entry";
|
|
7
|
+
|
|
8
|
+
export interface ClaudeResolveOptions {
|
|
9
|
+
cwd: string;
|
|
10
|
+
claudeHome: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface InstalledPluginEntry {
|
|
14
|
+
scope?: string;
|
|
15
|
+
projectPath?: string;
|
|
16
|
+
installPath?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function isInside(child: string, parent: string): boolean {
|
|
20
|
+
const normalizedChild = path.resolve(child);
|
|
21
|
+
const normalizedParent = path.resolve(parent);
|
|
22
|
+
return (
|
|
23
|
+
normalizedChild === normalizedParent ||
|
|
24
|
+
normalizedChild.startsWith(`${normalizedParent}${path.sep}`)
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The plugin cache keeps every version ever fetched, so the directory listing
|
|
30
|
+
* cannot tell you what is live. installed_plugins.json can: each entry names the
|
|
31
|
+
* exact installPath in use. An entry with a projectPath applies only inside that
|
|
32
|
+
* directory, whatever its `scope` string says — real manifests carry
|
|
33
|
+
* `scope: "local"` entries that are per-project. An entry with no projectPath
|
|
34
|
+
* applies everywhere.
|
|
35
|
+
*/
|
|
36
|
+
async function readInstalledPluginDirs(
|
|
37
|
+
claudeHome: string,
|
|
38
|
+
cwd: string,
|
|
39
|
+
): Promise<Array<{ pluginName: string; dir: string }>> {
|
|
40
|
+
const manifestPath = path.join(claudeHome, "plugins", "installed_plugins.json");
|
|
41
|
+
let parsed: unknown;
|
|
42
|
+
try {
|
|
43
|
+
parsed = JSON.parse(await readFile(manifestPath, "utf8"));
|
|
44
|
+
} catch {
|
|
45
|
+
return [];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const plugins = (parsed as { plugins?: Record<string, unknown> } | null)?.plugins;
|
|
49
|
+
if (!plugins || typeof plugins !== "object") return [];
|
|
50
|
+
|
|
51
|
+
const results: Array<{ pluginName: string; dir: string }> = [];
|
|
52
|
+
for (const [key, value] of Object.entries(plugins)) {
|
|
53
|
+
if (!Array.isArray(value)) continue;
|
|
54
|
+
const pluginName = key.split("@")[0] ?? key;
|
|
55
|
+
for (const entry of value as InstalledPluginEntry[]) {
|
|
56
|
+
if (!entry || typeof entry.installPath !== "string") continue;
|
|
57
|
+
// A projectPath scopes the entry to that directory regardless of what
|
|
58
|
+
// `scope` says — real manifests carry `scope: "local"` entries that are
|
|
59
|
+
// per-project. An entry with no projectPath applies everywhere.
|
|
60
|
+
if (typeof entry.projectPath === "string" && !isInside(cwd, entry.projectPath)) continue;
|
|
61
|
+
results.push({ pluginName, dir: path.join(entry.installPath, "skills") });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return results;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Precedence: the working directory, then each directory up to the repository
|
|
69
|
+
* root, then personal, then plugins. Claude loads `.claude/skills` from every
|
|
70
|
+
* one of those ancestors, so an agent working in a subdirectory still sees the
|
|
71
|
+
* skills checked in at the repo root. Plugin skills are namespaced
|
|
72
|
+
* `plugin:skill`, so in practice they never collide with the rest.
|
|
73
|
+
*/
|
|
74
|
+
export async function resolveClaudeSkills(options: ClaudeResolveOptions): Promise<SkillEntry[]> {
|
|
75
|
+
const [pluginDirs, dirs] = await Promise.all([
|
|
76
|
+
readInstalledPluginDirs(options.claudeHome, options.cwd),
|
|
77
|
+
dirsUpToRepoRoot(options.cwd),
|
|
78
|
+
]);
|
|
79
|
+
|
|
80
|
+
const candidates: SkillDirectoryCandidate[] = dirs.map((dir, index) => ({
|
|
81
|
+
dir: path.join(dir, ".claude", "skills"),
|
|
82
|
+
kind: index === 0 ? "project" : "repo",
|
|
83
|
+
label: index === 0 ? "Project" : "Repository",
|
|
84
|
+
}));
|
|
85
|
+
|
|
86
|
+
candidates.push({
|
|
87
|
+
dir: path.join(options.claudeHome, "skills"),
|
|
88
|
+
kind: "personal",
|
|
89
|
+
label: "Personal",
|
|
90
|
+
});
|
|
91
|
+
for (const { pluginName, dir } of pluginDirs) {
|
|
92
|
+
candidates.push({
|
|
93
|
+
dir,
|
|
94
|
+
kind: "plugin",
|
|
95
|
+
label: pluginName,
|
|
96
|
+
nameFor: (name) => `${pluginName}:${name}`,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return readSkillCandidates(candidates);
|
|
101
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
import { dirsUpToRepoRoot } from "./repo-root";
|
|
4
|
+
import { readSkillCandidates, type SkillDirectoryCandidate } from "./skill-directory";
|
|
5
|
+
import type { SkillEntry } from "./skill-entry";
|
|
6
|
+
|
|
7
|
+
export interface CodexResolveOptions {
|
|
8
|
+
cwd: string;
|
|
9
|
+
/** `$CODEX_HOME` or `~/.codex`. Only the legacy `skills` directory is read from it. */
|
|
10
|
+
codexHome: string;
|
|
11
|
+
/** `~/.agents`, the documented home for user-scoped skills. */
|
|
12
|
+
agentsHome: string;
|
|
13
|
+
/** `/etc/codex/skills`, the admin scope. */
|
|
14
|
+
adminSkillsDir: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Follows Codex's documented search path: `.agents/skills` in every directory
|
|
19
|
+
* from cwd up to the repository root, then `$HOME/.agents/skills`, then
|
|
20
|
+
* `/etc/codex/skills`.
|
|
21
|
+
*
|
|
22
|
+
* `.codex/skills` is read alongside each `.agents/skills` and at `$CODEX_HOME`
|
|
23
|
+
* because that is where Paseo's own orchestration sync writes and where older
|
|
24
|
+
* Codex builds looked. It sits second within each scope, so when a name lives
|
|
25
|
+
* in both the documented directory wins.
|
|
26
|
+
*
|
|
27
|
+
* Codex's bundled system skills have no path on disk and stay invisible here.
|
|
28
|
+
*/
|
|
29
|
+
export async function resolveCodexSkills(options: CodexResolveOptions): Promise<SkillEntry[]> {
|
|
30
|
+
const dirs = await dirsUpToRepoRoot(options.cwd);
|
|
31
|
+
|
|
32
|
+
const candidates: SkillDirectoryCandidate[] = dirs.flatMap((dir, index) => {
|
|
33
|
+
const scope =
|
|
34
|
+
index === 0
|
|
35
|
+
? { kind: "project" as const, label: "Project" }
|
|
36
|
+
: { kind: "repo" as const, label: "Repository" };
|
|
37
|
+
return [
|
|
38
|
+
{ dir: path.join(dir, ".agents", "skills"), ...scope },
|
|
39
|
+
{ dir: path.join(dir, ".codex", "skills"), ...scope },
|
|
40
|
+
];
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
candidates.push(
|
|
44
|
+
{ dir: path.join(options.agentsHome, "skills"), kind: "personal", label: "Personal" },
|
|
45
|
+
{ dir: path.join(options.codexHome, "skills"), kind: "personal", label: "Personal" },
|
|
46
|
+
{ dir: options.adminSkillsDir, kind: "admin", label: "Admin" },
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
return readSkillCandidates(candidates);
|
|
50
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
export interface ParsedSkillDocument {
|
|
2
|
+
frontmatter: Record<string, string>;
|
|
3
|
+
body: string;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
function unquote(value: string): string {
|
|
7
|
+
if (value.length >= 2) {
|
|
8
|
+
const first = value[0];
|
|
9
|
+
const last = value[value.length - 1];
|
|
10
|
+
if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
|
|
11
|
+
return value.slice(1, -1);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
return value;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function isIndented(line: string): boolean {
|
|
18
|
+
return /^[ \t]/.test(line);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Reads the leading `---` block of a SKILL.md.
|
|
23
|
+
*
|
|
24
|
+
* Supports the flat `key: value` pairs most skills use, plus `>` folded and `|`
|
|
25
|
+
* literal block scalars, which real skills do use for long descriptions. Lines
|
|
26
|
+
* that are indented belong to a nested structure, not the top level: they are
|
|
27
|
+
* skipped rather than trimmed into a top-level key, so a nested `metadata.name`
|
|
28
|
+
* cannot overwrite the skill's own name.
|
|
29
|
+
*
|
|
30
|
+
* A file whose fence is missing or unterminated is reported as all body with no
|
|
31
|
+
* frontmatter, which callers treat as "not a skill".
|
|
32
|
+
*/
|
|
33
|
+
export function parseFrontmatter(raw: string): ParsedSkillDocument {
|
|
34
|
+
const normalized = raw.replace(/\r\n/g, "\n");
|
|
35
|
+
if (!normalized.startsWith("---\n")) {
|
|
36
|
+
return { frontmatter: {}, body: raw };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const lines = normalized.split("\n");
|
|
40
|
+
let closingIndex = -1;
|
|
41
|
+
for (let index = 1; index < lines.length; index += 1) {
|
|
42
|
+
if (lines[index]!.trim() === "---") {
|
|
43
|
+
closingIndex = index;
|
|
44
|
+
break;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (closingIndex === -1) {
|
|
48
|
+
return { frontmatter: {}, body: raw };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const frontmatter: Record<string, string> = {};
|
|
52
|
+
let index = 1;
|
|
53
|
+
while (index < closingIndex) {
|
|
54
|
+
const line = lines[index]!;
|
|
55
|
+
index += 1;
|
|
56
|
+
if (isIndented(line)) continue;
|
|
57
|
+
|
|
58
|
+
const separator = line.indexOf(":");
|
|
59
|
+
if (separator === -1) continue;
|
|
60
|
+
const key = line.slice(0, separator).trim();
|
|
61
|
+
if (key.length === 0) continue;
|
|
62
|
+
const rest = line.slice(separator + 1).trim();
|
|
63
|
+
|
|
64
|
+
if (/^[>|][-+]?$/.test(rest)) {
|
|
65
|
+
const continuation: string[] = [];
|
|
66
|
+
while (index < closingIndex && (isIndented(lines[index]!) || lines[index]!.trim() === "")) {
|
|
67
|
+
continuation.push(lines[index]!.trim());
|
|
68
|
+
index += 1;
|
|
69
|
+
}
|
|
70
|
+
while (continuation.length > 0 && continuation[continuation.length - 1] === "") {
|
|
71
|
+
continuation.pop();
|
|
72
|
+
}
|
|
73
|
+
frontmatter[key] =
|
|
74
|
+
rest[0] === ">"
|
|
75
|
+
? continuation.filter((entry) => entry !== "").join(" ")
|
|
76
|
+
: continuation.join("\n");
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
frontmatter[key] = unquote(rest);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const body = lines
|
|
84
|
+
.slice(closingIndex + 1)
|
|
85
|
+
.join("\n")
|
|
86
|
+
.replace(/^\n+/, "");
|
|
87
|
+
return { frontmatter, body };
|
|
88
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { stat } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Walks up from cwd looking for a `.git` entry. Matches a file as well as a
|
|
6
|
+
* directory so worktrees and submodules resolve. Stops at the filesystem root.
|
|
7
|
+
*/
|
|
8
|
+
export async function findRepoRoot(cwd: string): Promise<string | null> {
|
|
9
|
+
let current = path.resolve(cwd);
|
|
10
|
+
for (;;) {
|
|
11
|
+
try {
|
|
12
|
+
await stat(path.join(current, ".git"));
|
|
13
|
+
return current;
|
|
14
|
+
} catch {
|
|
15
|
+
const parent = path.dirname(current);
|
|
16
|
+
if (parent === current) return null;
|
|
17
|
+
current = parent;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Every directory from cwd up to the repository root, cwd first. Both providers
|
|
24
|
+
* scan their skills directory in each of these, not only in cwd — a skill
|
|
25
|
+
* checked in at the repo root is available to an agent working in a
|
|
26
|
+
* subdirectory. Outside a repository the walk has nowhere to stop, so it yields
|
|
27
|
+
* cwd alone rather than climbing to the filesystem root.
|
|
28
|
+
*/
|
|
29
|
+
export async function dirsUpToRepoRoot(cwd: string): Promise<string[]> {
|
|
30
|
+
const start = path.resolve(cwd);
|
|
31
|
+
const repoRoot = await findRepoRoot(start);
|
|
32
|
+
if (!repoRoot || repoRoot === start) return [start];
|
|
33
|
+
|
|
34
|
+
const dirs = [start];
|
|
35
|
+
let current = start;
|
|
36
|
+
while (current !== repoRoot) {
|
|
37
|
+
const parent = path.dirname(current);
|
|
38
|
+
if (parent === current) break;
|
|
39
|
+
dirs.push(parent);
|
|
40
|
+
current = parent;
|
|
41
|
+
}
|
|
42
|
+
return dirs;
|
|
43
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The session's own view of what it can run, as reported by the provider rather
|
|
3
|
+
* than found on disk. This is the only way to see skills bundled inside an agent
|
|
4
|
+
* binary, which live on no scannable path.
|
|
5
|
+
*
|
|
6
|
+
* Deliberately free of Node imports and of any `@getpaseo/client` type, so it
|
|
7
|
+
* compiles into either bundle and does not depend on the daemon's SDK version.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export interface ReportedCommand {
|
|
11
|
+
name: string;
|
|
12
|
+
description: string;
|
|
13
|
+
argumentHint: string;
|
|
14
|
+
kind?: "command" | "skill";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ReportedSkill {
|
|
18
|
+
name: string;
|
|
19
|
+
description: string;
|
|
20
|
+
argumentHint: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface CommandsResult {
|
|
24
|
+
commands: ReportedCommand[];
|
|
25
|
+
error: string | null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface CommandsCapableHandle {
|
|
29
|
+
commands(): Promise<CommandsResult>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The `paseo` object comes from the daemon's bundled client, not from this
|
|
34
|
+
* project's node_modules, so the method may be absent no matter what the types
|
|
35
|
+
* say. Structural detection keeps the plugin working on a daemon that predates
|
|
36
|
+
* `agent.commands()` instead of throwing at runtime.
|
|
37
|
+
*/
|
|
38
|
+
export function supportsCommands(handle: unknown): handle is CommandsCapableHandle {
|
|
39
|
+
return typeof (handle as Partial<CommandsCapableHandle> | null)?.commands === "function";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface ReportedSplit {
|
|
43
|
+
skills: ReportedSkill[];
|
|
44
|
+
commands: ReportedSkill[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Reported entries that filesystem discovery did not already find, split into
|
|
49
|
+
* skills and session controls on the `kind` the provider assigned.
|
|
50
|
+
*
|
|
51
|
+
* `kind` is optional and is the provider's own judgement, not ground truth.
|
|
52
|
+
* Claude derives it from a hardcoded denylist of root-only commands and calls
|
|
53
|
+
* everything else a skill, so plenty of session controls land in the skills
|
|
54
|
+
* bucket. There is no better signal available: Claude's built-ins are compiled
|
|
55
|
+
* into its binary, so nothing on disk can confirm the split.
|
|
56
|
+
*
|
|
57
|
+
* An entry with no `kind` counts as a skill. Bucketing unclassified entries as
|
|
58
|
+
* commands would empty the skills section for any provider that omits the field.
|
|
59
|
+
*/
|
|
60
|
+
export function selectReported(
|
|
61
|
+
commands: readonly ReportedCommand[],
|
|
62
|
+
discoveredNames: readonly string[],
|
|
63
|
+
): ReportedSplit {
|
|
64
|
+
const seen = new Set(discoveredNames);
|
|
65
|
+
const split: ReportedSplit = { skills: [], commands: [] };
|
|
66
|
+
for (const command of commands) {
|
|
67
|
+
if (seen.has(command.name)) continue;
|
|
68
|
+
seen.add(command.name);
|
|
69
|
+
const bucket = command.kind === "command" ? split.commands : split.skills;
|
|
70
|
+
bucket.push({
|
|
71
|
+
name: command.name,
|
|
72
|
+
description: command.description,
|
|
73
|
+
argumentHint: command.argumentHint,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
const byName = (a: ReportedSkill, b: ReportedSkill) => a.name.localeCompare(b.name);
|
|
77
|
+
split.skills.sort(byName);
|
|
78
|
+
split.commands.sort(byName);
|
|
79
|
+
return split;
|
|
80
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import { parseFrontmatter } from "./frontmatter";
|
|
5
|
+
import { dedupeByName, makeSkillId, type SkillEntry, type SkillSourceKind } from "./skill-entry";
|
|
6
|
+
|
|
7
|
+
export interface SkillDirectoryCandidate {
|
|
8
|
+
dir: string;
|
|
9
|
+
kind: SkillSourceKind;
|
|
10
|
+
label: string;
|
|
11
|
+
/** Plugin skills are invoked as `plugin:skill`; everything else keeps its name. */
|
|
12
|
+
nameFor?: (frontmatterName: string) => string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Reads a provider's whole search path. Candidates arrive in precedence order
|
|
17
|
+
* and the first copy of a name wins, so a project skill shadows a personal one
|
|
18
|
+
* without either being listed twice.
|
|
19
|
+
*
|
|
20
|
+
* The same directory can be named twice — a repository rooted at `$HOME` puts
|
|
21
|
+
* `~/.claude/skills` in both the repo walk and the personal scope — so a
|
|
22
|
+
* directory is read once, under the first scope that reached it.
|
|
23
|
+
*/
|
|
24
|
+
export async function readSkillCandidates(
|
|
25
|
+
candidates: SkillDirectoryCandidate[],
|
|
26
|
+
): Promise<SkillEntry[]> {
|
|
27
|
+
const seen = new Set<string>();
|
|
28
|
+
const unique = candidates.filter((candidate) => {
|
|
29
|
+
const resolved = path.resolve(candidate.dir);
|
|
30
|
+
if (seen.has(resolved)) return false;
|
|
31
|
+
seen.add(resolved);
|
|
32
|
+
return true;
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const groups = await Promise.all(
|
|
36
|
+
unique.map((candidate) =>
|
|
37
|
+
readSkillsFromDirectory(candidate.dir, candidate.kind, candidate.label, candidate.nameFor),
|
|
38
|
+
),
|
|
39
|
+
);
|
|
40
|
+
return dedupeByName(groups.flat()).sort((a, b) => a.name.localeCompare(b.name));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Scans one `skills` directory. Each direct child directory (or symlink to one)
|
|
45
|
+
* holding a SKILL.md is a skill. A missing directory is normal, not an error.
|
|
46
|
+
* An entry whose frontmatter lacks name or description is skipped, matching the
|
|
47
|
+
* providers — listing it would show a skill the agent cannot actually see.
|
|
48
|
+
*/
|
|
49
|
+
export async function readSkillsFromDirectory(
|
|
50
|
+
dir: string,
|
|
51
|
+
kind: SkillSourceKind,
|
|
52
|
+
label: string,
|
|
53
|
+
nameFor: (frontmatterName: string) => string = (name) => name,
|
|
54
|
+
): Promise<SkillEntry[]> {
|
|
55
|
+
let dirEntries;
|
|
56
|
+
try {
|
|
57
|
+
dirEntries = await readdir(dir, { withFileTypes: true });
|
|
58
|
+
} catch {
|
|
59
|
+
return [];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const candidates = dirEntries.filter((entry) => entry.isDirectory() || entry.isSymbolicLink());
|
|
63
|
+
const results = await Promise.all(
|
|
64
|
+
candidates.map(async (entry): Promise<SkillEntry | null> => {
|
|
65
|
+
const skillPath = path.join(dir, entry.name, "SKILL.md");
|
|
66
|
+
let raw: string;
|
|
67
|
+
try {
|
|
68
|
+
raw = await readFile(skillPath, "utf8");
|
|
69
|
+
} catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
const { frontmatter } = parseFrontmatter(raw);
|
|
73
|
+
const rawName = frontmatter.name;
|
|
74
|
+
const description = frontmatter.description;
|
|
75
|
+
if (!rawName || !description) return null;
|
|
76
|
+
const name = nameFor(rawName);
|
|
77
|
+
const userInvocable = frontmatter["user-invocable"] !== "false";
|
|
78
|
+
return {
|
|
79
|
+
id: makeSkillId(kind, dir, name),
|
|
80
|
+
name,
|
|
81
|
+
description,
|
|
82
|
+
source: { kind, label, dir },
|
|
83
|
+
path: skillPath,
|
|
84
|
+
userInvocable,
|
|
85
|
+
status: "discovered",
|
|
86
|
+
};
|
|
87
|
+
}),
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
return results
|
|
91
|
+
.filter((entry): entry is SkillEntry => entry !== null)
|
|
92
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
93
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scopes, not directories. Both providers scan several directories per scope —
|
|
3
|
+
* Codex reads `.agents/skills` and legacy `.codex/skills` in the same walk — so
|
|
4
|
+
* a kind names where a skill applies, and `SkillSource.dir` names where it is.
|
|
5
|
+
*/
|
|
6
|
+
export type SkillSourceKind = "project" | "repo" | "personal" | "admin" | "plugin";
|
|
7
|
+
|
|
8
|
+
export interface SkillSource {
|
|
9
|
+
kind: SkillSourceKind;
|
|
10
|
+
label: string;
|
|
11
|
+
dir: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface SkillEntry {
|
|
15
|
+
id: string;
|
|
16
|
+
name: string;
|
|
17
|
+
description: string;
|
|
18
|
+
source: SkillSource;
|
|
19
|
+
path: string;
|
|
20
|
+
userInvocable: boolean;
|
|
21
|
+
status: "discovered";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function makeSkillId(kind: SkillSourceKind, dir: string, name: string): string {
|
|
25
|
+
return `${kind}:${dir}:${name}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* First name wins. Callers pass directories in precedence order, so a project
|
|
30
|
+
* skill shadows a personal one of the same name without either being reported
|
|
31
|
+
* twice.
|
|
32
|
+
*/
|
|
33
|
+
export function dedupeByName(entries: SkillEntry[]): SkillEntry[] {
|
|
34
|
+
const byName = new Map<string, SkillEntry>();
|
|
35
|
+
for (const entry of entries) {
|
|
36
|
+
if (!byName.has(entry.name)) byName.set(entry.name, entry);
|
|
37
|
+
}
|
|
38
|
+
return [...byName.values()];
|
|
39
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A tripwire for the one hazard `skipLibCheck: true` hides.
|
|
3
|
+
*
|
|
4
|
+
* When `@getpaseo/client` fails to resolve, TypeScript does not complain: every
|
|
5
|
+
* Paseo API type silently degrades to `any` and `tsc` still exits 0, so a clean
|
|
6
|
+
* typecheck stops proving that any host call in this plugin is type-checked at
|
|
7
|
+
* all. `noImplicitAny` catches part of it — but only where our code destructures
|
|
8
|
+
* an SDK value. A handler that merely passes `paseo` through type-checks just as
|
|
9
|
+
* happily against `any`.
|
|
10
|
+
*
|
|
11
|
+
* The directive below is the check, and it fails in both directions:
|
|
12
|
+
*
|
|
13
|
+
* - types resolved → the indexed access errors → the directive is used → pass
|
|
14
|
+
* - types degraded → the access is `any`, no error → TS2578 "Unused
|
|
15
|
+
* '@ts-expect-error' directive" → fail
|
|
16
|
+
*
|
|
17
|
+
* This replaces the throwaway file the root CLAUDE.md used to ask contributors
|
|
18
|
+
* to write by hand. It is type-only, so it contributes nothing to either bundle.
|
|
19
|
+
*/
|
|
20
|
+
import type { PaseoAgentHandle } from "@getpaseo/client";
|
|
21
|
+
|
|
22
|
+
// @ts-expect-error - Must stay an error. See above: if this member ever stops
|
|
23
|
+
// erroring, the SDK types are `any` and nothing here is really being checked.
|
|
24
|
+
export type SdkTypesResolved = PaseoAgentHandle["thisMemberDoesNotExist"];
|