1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
/**
 * Claude Project Skills Bridge
 *
 * Loads project-local Claude Code skills (`.claude/skills/`) into pi.
 *
 * pi auto-discovers `.pi/skills` and `.agents/skills` from cwd up to the repo
 * root, but not `.claude/skills`. A relative `"skills"` entry in the global
 * settings.json cannot cover the gap either: global entries resolve against
 * `~/.pi/agent`, not the cwd, so `".claude/skills"` there points at a
 * directory that does not exist. This extension contributes the paths at
 * `resources_discover` time instead, so the skills register normally and get
 * their own `/skill:name` commands.
 *
 * Companion to claude-plugins.ts, which covers the plugin cache
 * (~/.claude/plugins/cache). User-level ~/.claude/skills is skipped here
 * because settings.json already loads ~/.dotfiles/ai/skills.
 */

import * as fs from "node:fs";
import * as path from "node:path";
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";

/**
 * Directories from `startDir` up to the repo root, inclusive. Mirrors pi's own
 * ancestor walk for `.agents/skills`, and treats a jj repo without a colocated
 * git dir as a root too.
 */
function ancestorsToRepoRoot(startDir: string): string[] {
  const dirs: string[] = [];
  let dir = path.resolve(startDir);

  while (true) {
    dirs.push(dir);
    if (isRepoRoot(dir)) break;

    const parent = path.dirname(dir);
    if (parent === dir) break;
    dir = parent;
  }

  return dirs;
}

function isRepoRoot(dir: string): boolean {
  return fs.existsSync(path.join(dir, ".git")) || fs.existsSync(path.join(dir, ".jj"));
}

function isDir(p: string): boolean {
  try {
    return fs.statSync(p).isDirectory();
  } catch {
    return false;
  }
}

function findSkillDirs(cwd: string): string[] {
  return ancestorsToRepoRoot(cwd)
    .map((dir) => path.join(dir, ".claude", "skills"))
    .filter(isDir);
}

export default function claudeProjectSkills(pi: ExtensionAPI) {
  pi.on("resources_discover", async (event, ctx) => {
    // `.claude/skills` is project-local content, so honor the same trust gate
    // pi applies to `.pi/` and `.agents/skills`.
    if (!ctx.isProjectTrusted()) return;

    const skillPaths = findSkillDirs(event.cwd);
    if (skillPaths.length === 0) return;

    return { skillPaths };
  });
}