@estebanforge/pi-antigravity-bridge 1.4.1 → 1.4.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 +8 -0
- package/README.md +7 -3
- package/extensions/index.ts +83 -14
- package/package.json +1 -1
- package/src/config.ts +8 -4
- package/src/skills.ts +109 -32
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [1.4.2] - 2026-09-04
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
|
|
9
|
+
- `bridgeTools` now defaults to `all` (every registered non-builtin pi tool) instead of `mcp`. The `mcp` surface filters to pi-mcp-adapter tools and serves an empty catalog on installs without that adapter, which left the bridge registered but tool-less from the model's point of view. `none` still opts out entirely; explicit `"bridgeTools": "mcp"` in an existing config keeps pinning the narrow surface.
|
|
10
|
+
- Skill discovery now mirrors pi's directory-based scan (docs/skills.md): global `~/.pi/agent/skills` AND `~/.agents/skills`, project `.pi/skills` plus `.agents/skills` in cwd and ancestors up to the git root (project dirs only when pi has trusted the project, same gate pi applies), recursive SKILL.md discovery with grouping folders, per-style root/`.md` rules, hidden entries skipped, and description-less skills dropped. Pi's other skill sources (`skills` settings array, `package.json`, `--skill`) are not mirrored. Previously only one flat level of two directories was scanned, and `~/.agents/skills` (where pi actually reads most skills) was missing entirely, so `activate_skill` never appeared.
|
|
11
|
+
- `/agy` now exposes the full runtime config surface: new `/agy bridge all|mcp|none` (which pi tools the MCP bridge hands to agy) and `/agy acp-bin <path|auto>` (target a specific ACP server binary; applies on the next ACP turn), plus Bridge tools, Context digest, and System prompt rows in the bare `/agy` settings picker. Usage strings and `/agy status` list every knob.
|
|
12
|
+
|
|
5
13
|
## [1.4.1] - 2026-09-04
|
|
6
14
|
|
|
7
15
|
### Added
|
package/README.md
CHANGED
|
@@ -104,19 +104,21 @@ If `agy models` fails at load (binary missing, auth not done, network stall), a
|
|
|
104
104
|
|
|
105
105
|
| Key | Values | Default |
|
|
106
106
|
| --- | --- | --- |
|
|
107
|
-
| `bridgeTools` | `none` (bridge off), `
|
|
107
|
+
| `bridgeTools` | `none` (bridge off), `all` (every non-builtin tool, incl. other `Ask*` delegations), `mcp` (pi-mcp-adapter tools + skills bridge only) | `all` |
|
|
108
108
|
| `digest` | `off` (stable prompts; agy's prompt cache hits) or `on` (inject a delta of pi-side context - compaction summaries, other-provider turns - into each agy prompt; the delta changes every turn, so agy re-bills the full context). Enable for mixed-provider sessions where agy must see pi-side context | `off` |
|
|
109
109
|
| `systemPrompt` | `on` (prepend pi's system prompt - operating instructions plus the global agent-dir `AGENTS.md` and ancestor `AGENTS.md`/`CLAUDE.md` - to the first prompt of each new agy conversation) or `off` (agy-native behavior) | `on` |
|
|
110
110
|
|
|
111
111
|
Env overrides: `AGY_BRIDGE_TOOLS`, `AGY_DIGEST`, `AGY_SYSTEM_PROMPT`. Env wins over the file, so while `AGY_DIGEST` or `AGY_SYSTEM_PROMPT` is set, the matching `/agy digest` or `/agy system-prompt` toggle persists a value that never takes effect.
|
|
112
112
|
|
|
113
|
+
The `activate_skill` catalog mirrors pi's directory-based skill discovery: the two global dirs plus project dirs, the latter only when pi has trusted the project (same gate pi itself applies). Pi's other skill sources - the `skills` settings array, `package.json` entries, and `--skill` CLI paths - are not mirrored and won't appear in the catalog.
|
|
114
|
+
|
|
113
115
|
### The /agy command
|
|
114
116
|
|
|
115
117
|
`/agy` configures the provider at runtime. Settings persist to `~/.pi/agent/antigravity-bridge/config.json` and take effect on the next turn.
|
|
116
118
|
|
|
117
119
|
```
|
|
118
|
-
/agy status, or open the
|
|
119
|
-
/agy status print current
|
|
120
|
+
/agy status, or open the full settings picker (TUI)
|
|
121
|
+
/agy status print current settings + session counts
|
|
120
122
|
/agy doctor bridge state, driver counters, bridge port, last lifecycle events
|
|
121
123
|
/agy mode plan review-only: agy plans but writes nothing
|
|
122
124
|
/agy mode accept-edits agy applies edits directly (default)
|
|
@@ -125,6 +127,8 @@ Env overrides: `AGY_BRIDGE_TOOLS`, `AGY_DIGEST`, `AGY_SYSTEM_PROMPT`. Env wins o
|
|
|
125
127
|
/agy thinking low|medium|high default thinking tier for the AskAntigravity tool
|
|
126
128
|
/agy digest on|off inject pi-side context into agy prompts (default off; see table above)
|
|
127
129
|
/agy system-prompt on|off send pi's system prompt + AGENTS.md to new agy conversations (default on)
|
|
130
|
+
/agy bridge all|mcp|none which pi tools the MCP bridge exposes to agy (default all; none = bridge off)
|
|
131
|
+
/agy acp-bin <path|auto> point the ACP engine at a specific server binary (auto = setup installs, or AGY_ACP_BIN; applies on the next ACP turn)
|
|
128
132
|
/agy engine acp|stream-json switch the turn engine (restart to apply; default stream-json; acp runs self-service setup: binary install + auth bootstrap)
|
|
129
133
|
/agy acp-auth manual ACP credential setup (fallback; auto-setup normally covers this; default login = your Antigravity subscription, same account as the agy CLI)
|
|
130
134
|
/agy patch-cleanup restore the original pi files if an older version patched them
|
package/extensions/index.ts
CHANGED
|
@@ -11,10 +11,12 @@
|
|
|
11
11
|
// ("[agy tool: editing foo.ts]") for visibility, but the edits already landed
|
|
12
12
|
// on disk and pi's inline diff review does not engage.
|
|
13
13
|
//
|
|
14
|
-
// /agy command:
|
|
15
|
-
//
|
|
16
|
-
//
|
|
14
|
+
// /agy command: full runtime config surface (engine, mode, permissions,
|
|
15
|
+
// bridge tools, model, thinking, digest, system prompt, acp binary) plus
|
|
16
|
+
// doctor, auth, patch-cleanup, and session clear. Config persists to
|
|
17
|
+
// ~/.pi/agent/antigravity-bridge/config.json so toggles survive restarts.
|
|
17
18
|
|
|
19
|
+
import os from "node:os";
|
|
18
20
|
import {
|
|
19
21
|
type ExtensionAPI,
|
|
20
22
|
type ExtensionCommandContext,
|
|
@@ -304,7 +306,9 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
304
306
|
if (bridgeMode === "none") return; // user opted out
|
|
305
307
|
if (mcpHandle) return; // already running (reload re-fires session_start)
|
|
306
308
|
const SKIP = new Set(["AskAntigravity"]);
|
|
307
|
-
|
|
309
|
+
// pi loads project skill locations only after the project is trusted;
|
|
310
|
+
// mirror that gate. Global skill dirs are always scanned.
|
|
311
|
+
const skills: SkillLite[] = scanSkills(ctx.isProjectTrusted() ? process.cwd() : undefined);
|
|
308
312
|
const getAll = (pi as unknown as {
|
|
309
313
|
getAllTools: () => Array<{ name: string; description?: string; parameters?: object; sourceInfo?: { source?: string } }>;
|
|
310
314
|
}).getAllTools.bind(pi);
|
|
@@ -391,6 +395,9 @@ interface PendingConfig {
|
|
|
391
395
|
skipPermissions?: boolean;
|
|
392
396
|
defaultModel?: string;
|
|
393
397
|
defaultThinking?: ThinkingTier;
|
|
398
|
+
bridgeTools?: BridgeTools;
|
|
399
|
+
digest?: boolean;
|
|
400
|
+
systemPrompt?: boolean;
|
|
394
401
|
}
|
|
395
402
|
|
|
396
403
|
function statusText(ctx: AgyCommandCtx): string {
|
|
@@ -411,7 +418,7 @@ function statusText(ctx: AgyCommandCtx): string {
|
|
|
411
418
|
` digest: ${config.digest ? "on" : "off"}`,
|
|
412
419
|
` system prompt: ${config.systemPrompt ? "on" : "off"}`,
|
|
413
420
|
"",
|
|
414
|
-
"Subcommands: /agy engine stream-json|acp, /agy mode plan|accept-edits, /agy permissions on|off, /agy
|
|
421
|
+
"Subcommands: /agy engine stream-json|acp, /agy mode plan|accept-edits, /agy permissions on|off, /agy bridge all|mcp|none, /agy model <alias>, /agy thinking low|medium|high, /agy digest on|off, /agy system-prompt on|off, /agy acp-bin <path|auto>, /agy acp-auth, /agy patch-cleanup, /agy clear",
|
|
415
422
|
].join("\n");
|
|
416
423
|
}
|
|
417
424
|
|
|
@@ -419,7 +426,7 @@ function statusText(ctx: AgyCommandCtx): string {
|
|
|
419
426
|
function registerAgyCommand(pi: ExtensionAPI, ctx: AgyCommandCtx): void {
|
|
420
427
|
pi.registerCommand("agy", {
|
|
421
428
|
description:
|
|
422
|
-
"Antigravity provider: status, doctor,
|
|
429
|
+
"Antigravity provider: status, doctor, settings picker, clear sessions. Usage: /agy [status|doctor|engine stream-json|acp|mode plan|accept-edits|permissions on|off|bridge all|mcp|none|model <alias>|thinking low|medium|high|digest on|off|system-prompt on|off|acp-bin <path|auto>|acp-auth|patch-cleanup|clear]",
|
|
423
430
|
handler: async (args, cmdCtx: ExtensionCommandContext) => {
|
|
424
431
|
const ui = cmdCtx.ui;
|
|
425
432
|
const mode = cmdCtx.mode;
|
|
@@ -487,6 +494,24 @@ function registerAgyCommand(pi: ExtensionAPI, ctx: AgyCommandCtx): void {
|
|
|
487
494
|
}
|
|
488
495
|
return;
|
|
489
496
|
}
|
|
497
|
+
if (sub === "acp-bin") {
|
|
498
|
+
const rest = (args ?? "").trim().split(/\s+/).slice(1).join(" ");
|
|
499
|
+
if (rest.length > 0) {
|
|
500
|
+
// Only the keyword compares case-insensitively; the path keeps its case.
|
|
501
|
+
const bin = rest.toLowerCase() === "auto" ? "" : rest.replace(/^~(?=\/|$)/, os.homedir());
|
|
502
|
+
saveConfig({ acp: { bin, permissions: loadConfig().acp.permissions } });
|
|
503
|
+
ui?.notify(
|
|
504
|
+
bin
|
|
505
|
+
? `acp.bin set to ${bin}. The next ACP turn (re)connects with it.`
|
|
506
|
+
: "acp.bin cleared. Auto-setup (or AGY_ACP_BIN) picks the binary on the next ACP turn.",
|
|
507
|
+
"info",
|
|
508
|
+
);
|
|
509
|
+
} else {
|
|
510
|
+
const cur = loadConfig().acp.bin;
|
|
511
|
+
ui?.notify(`acp.bin: ${cur || "(auto: setup installs, or AGY_ACP_BIN)"}\nusage: /agy acp-bin <path|auto>`, "info");
|
|
512
|
+
}
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
490
515
|
if (sub === "acp-auth") {
|
|
491
516
|
ui?.notify(
|
|
492
517
|
[
|
|
@@ -575,6 +600,20 @@ function registerAgyCommand(pi: ExtensionAPI, ctx: AgyCommandCtx): void {
|
|
|
575
600
|
}
|
|
576
601
|
return;
|
|
577
602
|
}
|
|
603
|
+
if (sub === "bridge") {
|
|
604
|
+
if (val === "all" || val === "mcp" || val === "none") {
|
|
605
|
+
const next = saveConfig({ bridgeTools: val });
|
|
606
|
+
ui?.notify(
|
|
607
|
+
next.bridgeTools === "none"
|
|
608
|
+
? "bridge off. The MCP tool bridge will not start on the next pi start (or /reload)."
|
|
609
|
+
: `bridge tools set to ${next.bridgeTools}. The catalog rebuilds on the next pi start (or /reload).`,
|
|
610
|
+
"info",
|
|
611
|
+
);
|
|
612
|
+
} else {
|
|
613
|
+
ui?.notify(`bridge: ${loadConfig().bridgeTools}\nusage: /agy bridge all|mcp|none\n all: every non-builtin pi tool (default). mcp: pi-mcp-adapter tools + skills only. none: bridge off.`, "info");
|
|
614
|
+
}
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
578
617
|
if (sub === "model") {
|
|
579
618
|
if (val && val.length > 0) {
|
|
580
619
|
const next = saveConfig({ defaultModel: val });
|
|
@@ -640,7 +679,10 @@ function registerAgyCommand(pi: ExtensionAPI, ctx: AgyCommandCtx): void {
|
|
|
640
679
|
});
|
|
641
680
|
}
|
|
642
681
|
|
|
643
|
-
/** Interactive settings picker (TUI only). Rows:
|
|
682
|
+
/** Interactive settings picker (TUI only). Rows: the full runtime config
|
|
683
|
+
* surface (mode, permissions, model, thinking, bridge, digest, system
|
|
684
|
+
* prompt). Engine stays a subcommand: switching runs install + auth setup
|
|
685
|
+
* and needs a restart. */
|
|
644
686
|
async function openAgyPicker(ui: ExtensionUIContext, ctx: AgyCommandCtx): Promise<void> {
|
|
645
687
|
const config = loadConfig();
|
|
646
688
|
const pending: PendingConfig = {};
|
|
@@ -678,6 +720,30 @@ async function openAgyPicker(ui: ExtensionUIContext, ctx: AgyCommandCtx): Promis
|
|
|
678
720
|
currentValue: config.defaultThinking,
|
|
679
721
|
values: ["low", "medium", "high"],
|
|
680
722
|
},
|
|
723
|
+
{
|
|
724
|
+
id: "bridge",
|
|
725
|
+
label: "Bridge tools",
|
|
726
|
+
description:
|
|
727
|
+
"Which pi tools the MCP bridge exposes to agy. all: every non-builtin tool (default). mcp: pi-mcp-adapter tools + skills. none: bridge off.",
|
|
728
|
+
currentValue: config.bridgeTools,
|
|
729
|
+
values: ["all", "mcp", "none"],
|
|
730
|
+
},
|
|
731
|
+
{
|
|
732
|
+
id: "digest",
|
|
733
|
+
label: "Context digest",
|
|
734
|
+
description:
|
|
735
|
+
"Inject a delta of pi-side context into each agy prompt. Defeats agy's prompt cache (~25-30k tokens re-billed per turn).",
|
|
736
|
+
currentValue: config.digest ? "on" : "off",
|
|
737
|
+
values: ["on", "off"],
|
|
738
|
+
},
|
|
739
|
+
{
|
|
740
|
+
id: "system-prompt",
|
|
741
|
+
label: "System prompt",
|
|
742
|
+
description:
|
|
743
|
+
"Prepend pi's system prompt (incl. AGENTS.md files) to the first prompt of each new agy conversation.",
|
|
744
|
+
currentValue: config.systemPrompt ? "on" : "off",
|
|
745
|
+
values: ["on", "off"],
|
|
746
|
+
},
|
|
681
747
|
];
|
|
682
748
|
|
|
683
749
|
await ui.custom((tui, theme, _kb, done) => {
|
|
@@ -698,6 +764,12 @@ async function openAgyPicker(ui: ExtensionUIContext, ctx: AgyCommandCtx): Promis
|
|
|
698
764
|
pending.defaultModel = newValue;
|
|
699
765
|
} else if (id === "thinking") {
|
|
700
766
|
pending.defaultThinking = newValue as ThinkingTier;
|
|
767
|
+
} else if (id === "bridge") {
|
|
768
|
+
pending.bridgeTools = newValue as BridgeTools;
|
|
769
|
+
} else if (id === "digest") {
|
|
770
|
+
pending.digest = newValue === "on";
|
|
771
|
+
} else if (id === "system-prompt") {
|
|
772
|
+
pending.systemPrompt = newValue === "on";
|
|
701
773
|
}
|
|
702
774
|
},
|
|
703
775
|
() => done(undefined),
|
|
@@ -714,13 +786,7 @@ async function openAgyPicker(ui: ExtensionUIContext, ctx: AgyCommandCtx): Promis
|
|
|
714
786
|
};
|
|
715
787
|
});
|
|
716
788
|
|
|
717
|
-
if (
|
|
718
|
-
pending.mode === undefined &&
|
|
719
|
-
pending.skipPermissions === undefined &&
|
|
720
|
-
pending.defaultModel === undefined &&
|
|
721
|
-
pending.defaultThinking === undefined
|
|
722
|
-
)
|
|
723
|
-
return;
|
|
789
|
+
if (Object.keys(pending).length === 0) return;
|
|
724
790
|
|
|
725
791
|
if (pending.mode === "plan" && ctx.engine === "acp") {
|
|
726
792
|
ui.notify("the ACP engine has no plan mode (RC01). Switch /agy engine stream-json first.", "warning");
|
|
@@ -736,6 +802,9 @@ async function openAgyPicker(ui: ExtensionUIContext, ctx: AgyCommandCtx): Promis
|
|
|
736
802
|
: null,
|
|
737
803
|
pending.defaultModel !== undefined ? `tool model=${next.defaultModel}` : null,
|
|
738
804
|
pending.defaultThinking !== undefined ? `tool thinking=${next.defaultThinking}` : null,
|
|
805
|
+
pending.bridgeTools !== undefined ? `bridge=${next.bridgeTools}` : null,
|
|
806
|
+
pending.digest !== undefined ? `digest=${next.digest ? "on" : "off"}` : null,
|
|
807
|
+
pending.systemPrompt !== undefined ? `system-prompt=${next.systemPrompt ? "on" : "off"}` : null,
|
|
739
808
|
]
|
|
740
809
|
.filter(Boolean)
|
|
741
810
|
.join(", ");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@estebanforge/pi-antigravity-bridge",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.2",
|
|
4
4
|
"description": "Gemini provider for Pi on the Antigravity ACP server (official Google ACP) or the stream-json agy CLI. antigravity/* models in Pi's /model picker, no-patch MCP bridge: agy runs Pi's tools. ToS safe to use.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
package/src/config.ts
CHANGED
|
@@ -59,8 +59,10 @@ export interface AgyConfig {
|
|
|
59
59
|
* always available. */
|
|
60
60
|
patchCleanupNotified?: boolean;
|
|
61
61
|
/** Which pi tools the MCP bridge exposes to agy: "none" (bridge off),
|
|
62
|
-
* "
|
|
63
|
-
*
|
|
62
|
+
* "all" (every registered non-builtin tool incl. other Ask* delegations;
|
|
63
|
+
* default - users expect the bridge working out of the box, and the
|
|
64
|
+
* "mcp" surface serves an empty catalog on installs without
|
|
65
|
+
* pi-mcp-adapter), "mcp" (pi-mcp-adapter tools + skills bridge only). */
|
|
64
66
|
bridgeTools: BridgeTools;
|
|
65
67
|
/** Inject a delta digest of pi-side context (compaction summaries, turns
|
|
66
68
|
* handled by other providers or pi's own tools) into each agy prompt.
|
|
@@ -94,7 +96,7 @@ const DEFAULTS: AgyConfig = {
|
|
|
94
96
|
skipPermissions: true,
|
|
95
97
|
defaultModel: "flash",
|
|
96
98
|
defaultThinking: "medium",
|
|
97
|
-
bridgeTools: "
|
|
99
|
+
bridgeTools: "all",
|
|
98
100
|
digest: false,
|
|
99
101
|
systemPrompt: true,
|
|
100
102
|
acp: { bin: "", permissions: "auto" },
|
|
@@ -149,7 +151,9 @@ export function loadConfig(configPath: string = CONFIG_PATH): AgyConfig {
|
|
|
149
151
|
|
|
150
152
|
const bridgeRaw = (process.env.AGY_BRIDGE_TOOLS ?? file.bridgeTools ?? DEFAULTS.bridgeTools).toLowerCase();
|
|
151
153
|
const bridgeTools: BridgeTools =
|
|
152
|
-
bridgeRaw === "none" || bridgeRaw === "all"
|
|
154
|
+
bridgeRaw === "none" || bridgeRaw === "all" || bridgeRaw === "mcp"
|
|
155
|
+
? bridgeRaw
|
|
156
|
+
: DEFAULTS.bridgeTools;
|
|
153
157
|
|
|
154
158
|
const digest = process.env.AGY_DIGEST !== undefined
|
|
155
159
|
? ["1", "true", "on"].includes(process.env.AGY_DIGEST.toLowerCase())
|
package/src/skills.ts
CHANGED
|
@@ -22,7 +22,9 @@ export interface SkillLite {
|
|
|
22
22
|
dir: string;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
/** Parse `name:` / `description:` out of SKILL.md frontmatter.
|
|
25
|
+
/** Parse `name:` / `description:` out of SKILL.md frontmatter. Deliberately
|
|
26
|
+
* line-based, not YAML: multi-line block scalars (`description: >-`) keep
|
|
27
|
+
* only their first line. */
|
|
26
28
|
function parseFrontmatter(raw: string): { name?: string; description?: string } {
|
|
27
29
|
const m = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
28
30
|
if (!m) return {};
|
|
@@ -36,48 +38,123 @@ function parseFrontmatter(raw: string): { name?: string; description?: string }
|
|
|
36
38
|
return out;
|
|
37
39
|
}
|
|
38
40
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
+
// The rest of this module mirrors pi's own skill discovery (pi docs
|
|
42
|
+
// "docs/skills.md - Locations") so the bridge's activate_skill catalog
|
|
43
|
+
// matches the [Skills] list pi shows in its system prompt:
|
|
44
|
+
//
|
|
45
|
+
// - Global: ~/.pi/agent/skills (pi style) and ~/.agents/skills (agents style,
|
|
46
|
+
// the cross-harness standard).
|
|
47
|
+
// - Project: .pi/skills (pi style) and .agents/skills in cwd + ancestors up
|
|
48
|
+
// to the git repo root (agents style).
|
|
49
|
+
// - Both styles discover every directory holding a SKILL.md, recursively;
|
|
50
|
+
// grouping folders (no SKILL.md) are descended into and a skill's contents
|
|
51
|
+
// are freeform (no descent past its SKILL.md).
|
|
52
|
+
// - pi style also treats root .md files as individual skills; agents style
|
|
53
|
+
// ignores root .md but picks up frontmatter .md files inside grouping
|
|
54
|
+
// folders.
|
|
55
|
+
// - Skills without a description are not loaded (same as pi).
|
|
56
|
+
// - Name collisions keep the first skill found, pi global dirs before
|
|
57
|
+
// project dirs.
|
|
58
|
+
|
|
59
|
+
export type SkillStyle = "pi" | "agents";
|
|
60
|
+
|
|
61
|
+
export interface SkillLocation {
|
|
62
|
+
dir: string;
|
|
63
|
+
style: SkillStyle;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const MAX_DEPTH = 16; // symlink-loop insurance; real catalogs nest 1-2 levels
|
|
67
|
+
|
|
68
|
+
/** Ordered skill locations for a session: pi global dirs, then project dirs
|
|
69
|
+
* (project `.agents/skills` walks cwd ancestors up to the git root). */
|
|
70
|
+
export function skillLocations(projectDir?: string, home: string = os.homedir()): SkillLocation[] {
|
|
71
|
+
const out: SkillLocation[] = [
|
|
72
|
+
{ dir: path.join(home, ".pi", "agent", "skills"), style: "pi" },
|
|
73
|
+
{ dir: path.join(home, ".agents", "skills"), style: "agents" },
|
|
74
|
+
];
|
|
75
|
+
if (!projectDir) return out;
|
|
76
|
+
out.push({ dir: path.join(projectDir, ".pi", "skills"), style: "pi" });
|
|
77
|
+
let dir = path.resolve(projectDir);
|
|
78
|
+
for (;;) {
|
|
79
|
+
out.push({ dir: path.join(dir, ".agents", "skills"), style: "agents" });
|
|
80
|
+
if (path.dirname(dir) === dir || fs.existsSync(path.join(dir, ".git"))) break;
|
|
81
|
+
dir = path.dirname(dir);
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function readSkillFile(file: string, fallbackName: string): SkillLite | null {
|
|
87
|
+
let raw = "";
|
|
41
88
|
try {
|
|
42
|
-
|
|
89
|
+
// Strip a UTF-8 BOM so editors that add one do not break the `---` anchor.
|
|
90
|
+
raw = fs.readFileSync(file, "utf8").replace(/^\uFEFF/, "");
|
|
43
91
|
} catch {
|
|
44
|
-
return
|
|
92
|
+
return null;
|
|
45
93
|
}
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
94
|
+
const fm = parseFrontmatter(raw);
|
|
95
|
+
const description = (fm.description ?? "").trim();
|
|
96
|
+
// pi does not load skills without a description.
|
|
97
|
+
if (!description) return null;
|
|
98
|
+
return {
|
|
99
|
+
name: fm.name?.trim() || fallbackName,
|
|
100
|
+
description: description.replace(/\s+/g, " ").slice(0, 160),
|
|
101
|
+
filePath: file,
|
|
102
|
+
dir: path.dirname(file),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function scanLocation(loc: SkillLocation): SkillLite[] {
|
|
107
|
+
const walk = (dir: string, depth: number): SkillLite[] => {
|
|
108
|
+
if (depth > MAX_DEPTH) return [];
|
|
109
|
+
let entries: fs.Dirent[];
|
|
50
110
|
try {
|
|
51
|
-
|
|
111
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
52
112
|
} catch {
|
|
53
|
-
|
|
113
|
+
return [];
|
|
54
114
|
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
continue;
|
|
115
|
+
const found: SkillLite[] = [];
|
|
116
|
+
for (const entry of entries) {
|
|
117
|
+
// Hidden entries are harness internals (e.g. ~/.agents/skills/.system);
|
|
118
|
+
// pi's own [Skills] list never includes them.
|
|
119
|
+
if (entry.name.startsWith(".")) continue;
|
|
120
|
+
const full = path.join(dir, entry.name);
|
|
121
|
+
let isDir: boolean;
|
|
122
|
+
try {
|
|
123
|
+
isDir = entry.isDirectory() || (entry.isSymbolicLink() && fs.statSync(full).isDirectory());
|
|
124
|
+
} catch {
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
if (isDir) {
|
|
128
|
+
if (fs.existsSync(path.join(full, "SKILL.md"))) {
|
|
129
|
+
const skill = readSkillFile(path.join(full, "SKILL.md"), entry.name);
|
|
130
|
+
if (skill) found.push(skill);
|
|
131
|
+
} else {
|
|
132
|
+
found.push(...walk(full, depth + 1));
|
|
133
|
+
}
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
if (!entry.name.endsWith(".md") || entry.name === "SKILL.md") continue;
|
|
137
|
+
const standalone = loc.style === "pi" ? depth === 0 : depth > 0;
|
|
138
|
+
if (!standalone) continue;
|
|
139
|
+
const skill = readSkillFile(full, entry.name.replace(/\.md$/, ""));
|
|
140
|
+
if (skill) found.push(skill);
|
|
60
141
|
}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
description: (fm.description ?? "").replace(/\s+/g, " ").slice(0, 160),
|
|
65
|
-
filePath: skillFile,
|
|
66
|
-
dir: skillDir,
|
|
67
|
-
});
|
|
68
|
-
}
|
|
69
|
-
return found;
|
|
142
|
+
return found;
|
|
143
|
+
};
|
|
144
|
+
return walk(loc.dir, 0);
|
|
70
145
|
}
|
|
71
146
|
|
|
72
|
-
/**
|
|
73
|
-
|
|
74
|
-
|
|
147
|
+
/** pi-faithful skill catalog: every skill pi itself would load, deduped by
|
|
148
|
+
* name (first wins). `home` is injectable for tests. */
|
|
149
|
+
export function scanSkills(projectDir?: string, home: string = os.homedir()): SkillLite[] {
|
|
75
150
|
const seen = new Set<string>();
|
|
76
151
|
const out: SkillLite[] = [];
|
|
77
|
-
for (const
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
152
|
+
for (const loc of skillLocations(projectDir, home)) {
|
|
153
|
+
for (const skill of scanLocation(loc)) {
|
|
154
|
+
if (seen.has(skill.name)) continue;
|
|
155
|
+
seen.add(skill.name);
|
|
156
|
+
out.push(skill);
|
|
157
|
+
}
|
|
81
158
|
}
|
|
82
159
|
return out;
|
|
83
160
|
}
|