one-code-extension 0.1.4 → 0.3.1
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/LICENSE +20 -0
- package/README.md +222 -177
- package/agents/explore.md +9 -6
- package/agents/general-purpose.md +1 -1
- package/agents/plan.md +6 -4
- package/docs/guide/README.md +52 -0
- package/docs/guide/bring-your-claude-code-setup.md +128 -0
- package/docs/guide/configuration.md +173 -0
- package/docs/guide/differences-from-claude-code.md +152 -0
- package/docs/guide/doctor.md +88 -0
- package/docs/guide/hooks.md +111 -0
- package/docs/guide/installation.md +126 -0
- package/docs/guide/permissions-modes-and-auto-mode.md +452 -0
- package/docs/guide/providers-and-models.md +196 -0
- package/docs/guide/reference.md +226 -0
- package/docs/guide/sessions-and-context.md +192 -0
- package/docs/guide/skills-plugins-and-mcp.md +178 -0
- package/docs/guide/subagents-and-workflows.md +195 -0
- package/docs/guide/tasks-and-background-work.md +113 -0
- package/docs/guide/terminal-interface.md +157 -0
- package/docs/guide/tools.md +177 -0
- package/docs/guide/troubleshooting.md +177 -0
- package/extensions/ask-user/index.ts +65 -71
- package/extensions/ask-user/questions.ts +15 -28
- package/extensions/ask-user/widget.ts +533 -0
- package/extensions/auto-mode/actions.ts +9 -26
- package/extensions/auto-mode/classifier-prompt.ts +127 -0
- package/extensions/auto-mode/classifier.ts +305 -227
- package/extensions/auto-mode/config.ts +279 -77
- package/extensions/auto-mode/defaults.ts +17 -60
- package/extensions/auto-mode/instructions.ts +2 -1
- package/extensions/auto-mode/model-picker.ts +53 -31
- package/extensions/auto-mode/model-select.ts +153 -82
- package/extensions/auto-mode/paths.ts +13 -1
- package/extensions/auto-mode/pause.ts +8 -0
- package/extensions/auto-mode/prompt.ts +205 -238
- package/extensions/auto-mode/recoverability.ts +229 -0
- package/extensions/auto-mode/rules.ts +92 -75
- package/extensions/auto-mode/safety-floor.ts +43 -12
- package/extensions/auto-mode/sensitive.ts +11 -0
- package/extensions/auto-mode/setup-run.ts +245 -0
- package/extensions/auto-mode/setup.ts +235 -0
- package/extensions/auto-mode/shell-analysis.ts +332 -26
- package/extensions/auto-mode/transcript.ts +102 -0
- package/extensions/background/index.ts +256 -43
- package/extensions/background/monitor-batch.ts +58 -0
- package/extensions/background/registry.ts +9 -0
- package/extensions/background/wakeup.ts +45 -0
- package/extensions/bash/background.ts +47 -22
- package/extensions/bash/guards.ts +229 -0
- package/extensions/bash/index.ts +81 -19
- package/extensions/branding/index.ts +44 -18
- package/extensions/branding/startup.ts +23 -8
- package/extensions/claude-compat/index.ts +17 -2
- package/extensions/claude-context/index.ts +41 -12
- package/extensions/clear/index.ts +27 -0
- package/extensions/compaction/fit.ts +121 -0
- package/extensions/compaction/index.ts +176 -71
- package/extensions/compaction/prompt.ts +21 -1
- package/extensions/context-budget/budget.ts +61 -0
- package/extensions/context-budget/index.ts +66 -0
- package/extensions/context-management/index.ts +40 -16
- package/extensions/doctor/build.ts +226 -0
- package/extensions/doctor/cli.ts +99 -0
- package/extensions/doctor/compat.ts +532 -0
- package/extensions/doctor/dependencies.ts +186 -0
- package/extensions/doctor/fix-prompt.ts +125 -0
- package/extensions/doctor/index.ts +308 -0
- package/extensions/doctor/models.ts +231 -0
- package/extensions/doctor/presets.ts +208 -0
- package/extensions/doctor/report.ts +189 -0
- package/extensions/doctor/update-lookup.ts +46 -0
- package/extensions/doctor/viewer.ts +124 -0
- package/extensions/effort/index.ts +41 -4
- package/extensions/effort/slider.ts +33 -2
- package/extensions/file-tracker/index.ts +143 -20
- package/extensions/file-tracker/replay.ts +46 -0
- package/extensions/file-tracker/tracker.ts +51 -4
- package/extensions/footer/footer-line.ts +207 -0
- package/extensions/footer/index.ts +152 -0
- package/extensions/footer/pr.ts +53 -0
- package/extensions/hooks/executor.ts +57 -20
- package/extensions/hooks/formatter-notice.ts +76 -0
- package/extensions/hooks/index.ts +314 -73
- package/extensions/hooks/matcher.ts +35 -1
- package/extensions/hooks/plugin-hooks.ts +3 -3
- package/extensions/hooks/protocol.ts +58 -0
- package/extensions/hooks/subagent-bridge.ts +77 -0
- package/extensions/hooks/trust.ts +15 -4
- package/extensions/init/index.ts +26 -0
- package/extensions/init/prompt.ts +40 -0
- package/extensions/interrupted/index.ts +35 -0
- package/extensions/interrupted/line.ts +9 -0
- package/extensions/lib/abort.ts +15 -0
- package/extensions/lib/agent-loader.ts +183 -0
- package/extensions/lib/anthropic-payload.ts +15 -0
- package/extensions/lib/atomic-write.ts +56 -0
- package/extensions/lib/bridge-watch.ts +20 -0
- package/extensions/lib/cache-retention.ts +60 -0
- package/extensions/lib/capability-index.ts +466 -0
- package/extensions/lib/claude-context.ts +318 -27
- package/extensions/lib/claude-settings.ts +64 -0
- package/extensions/lib/consent-preview.ts +30 -0
- package/extensions/lib/deferred.ts +144 -13
- package/extensions/lib/favorites.ts +43 -0
- package/extensions/lib/fetch-timeout.ts +15 -0
- package/extensions/lib/frontmatter.ts +31 -0
- package/extensions/lib/git.ts +44 -2
- package/extensions/lib/hook-gate.ts +90 -0
- package/extensions/lib/interrupt.ts +94 -0
- package/extensions/lib/mcp-overrides.ts +61 -0
- package/extensions/lib/mcp-share.ts +25 -0
- package/extensions/lib/mcp-status.ts +34 -0
- package/extensions/lib/memory.ts +41 -6
- package/extensions/lib/model-facts.generated.json +2480 -0
- package/extensions/lib/model-facts.ts +162 -0
- package/extensions/lib/model-policy.ts +145 -189
- package/extensions/lib/model-tier.ts +480 -64
- package/extensions/lib/model-unusable.ts +37 -0
- package/extensions/lib/notifications.ts +320 -1
- package/extensions/lib/one-code-settings.ts +63 -0
- package/extensions/lib/original-command.ts +71 -0
- package/extensions/lib/package-version.ts +31 -0
- package/extensions/lib/paths.ts +39 -4
- package/extensions/lib/permission-gate.ts +192 -0
- package/extensions/lib/persisted-output.ts +18 -0
- package/extensions/lib/pi-ai-estimate.ts +160 -0
- package/extensions/lib/pi-version.ts +1 -1
- package/extensions/lib/plan-mode-channels.ts +11 -0
- package/extensions/lib/plugin-overrides.ts +24 -0
- package/extensions/lib/plugin-root.ts +44 -0
- package/extensions/lib/plugins.ts +233 -54
- package/extensions/lib/prefix-warm-gate.ts +157 -0
- package/extensions/lib/process-tree.ts +56 -0
- package/extensions/lib/reminders.ts +321 -68
- package/extensions/lib/scratchpad.ts +11 -8
- package/extensions/lib/session-lifecycle.ts +28 -0
- package/extensions/lib/settings-channels.ts +14 -0
- package/extensions/lib/shell-tasks.ts +54 -0
- package/extensions/lib/skill-overrides.ts +86 -0
- package/extensions/lib/skill-scan.ts +110 -0
- package/extensions/lib/text-width.ts +267 -0
- package/extensions/lib/tui-render.ts +281 -54
- package/extensions/lib/usage-bus.ts +75 -0
- package/extensions/lib/usage-tracker.ts +67 -0
- package/extensions/lib/worktree-isolation.ts +170 -0
- package/extensions/lsp/client.ts +69 -12
- package/extensions/lsp/format.ts +12 -2
- package/extensions/lsp/index.ts +273 -84
- package/extensions/lsp/install-hints.ts +43 -0
- package/extensions/lsp/keep-alive.ts +19 -0
- package/extensions/lsp/plugin-servers.ts +331 -0
- package/extensions/lsp/watcher.ts +157 -0
- package/extensions/mcp/announce.ts +99 -0
- package/extensions/mcp/client.ts +181 -22
- package/extensions/mcp/config.ts +55 -24
- package/extensions/mcp/index.ts +586 -86
- package/extensions/mcp/oauth/browser.ts +26 -0
- package/extensions/mcp/oauth/callback.ts +104 -0
- package/extensions/mcp/oauth/flow.ts +68 -0
- package/extensions/mcp/oauth/provider.ts +147 -0
- package/extensions/mcp/oauth/store.ts +86 -0
- package/extensions/mcp/panel/keys.ts +34 -0
- package/extensions/mcp/panel/model.ts +62 -0
- package/extensions/mcp/panel/render.ts +141 -0
- package/extensions/mcp/panel/state.ts +108 -0
- package/extensions/mcp/schema.ts +83 -6
- package/extensions/mcp/trust.ts +340 -0
- package/extensions/memory/entries.ts +163 -0
- package/extensions/memory/index.ts +119 -2
- package/extensions/memory/open-external.ts +97 -0
- package/extensions/memory/panel.ts +124 -0
- package/extensions/model-default/index.ts +74 -0
- package/extensions/notebook/index.ts +36 -18
- package/extensions/notebook/notebook.ts +110 -17
- package/extensions/permissions/index.ts +1012 -247
- package/extensions/permissions/matcher.ts +488 -57
- package/extensions/permissions/modes.ts +55 -7
- package/extensions/permissions/project-trust.ts +81 -0
- package/extensions/permissions/protected-paths.ts +16 -3
- package/extensions/permissions/rule-prose.ts +79 -0
- package/extensions/permissions/session-grant.ts +91 -0
- package/extensions/permissions/settings.ts +112 -33
- package/extensions/permissions/subagent-gate.ts +57 -0
- package/extensions/plan-mode/index.ts +143 -33
- package/extensions/plan-mode/reminder.ts +16 -19
- package/extensions/plan-mode/viewer.ts +40 -31
- package/extensions/plugins/counts.ts +68 -0
- package/extensions/plugins/index.ts +496 -32
- package/extensions/plugins/install/install.ts +167 -0
- package/extensions/plugins/install/paths.ts +23 -0
- package/extensions/plugins/install/registry.ts +72 -0
- package/extensions/plugins/marketplace/git.ts +103 -0
- package/extensions/plugins/marketplace/official.ts +36 -0
- package/extensions/plugins/marketplace/parse.ts +94 -0
- package/extensions/plugins/marketplace/registry.ts +60 -0
- package/extensions/plugins/marketplace/sync.ts +146 -0
- package/extensions/plugins/marketplace/types.ts +128 -0
- package/extensions/plugins/panel/keys.ts +52 -0
- package/extensions/plugins/panel/render.ts +315 -0
- package/extensions/plugins/panel/rows.ts +230 -0
- package/extensions/plugins/panel/state.ts +333 -0
- package/extensions/recap/index.ts +215 -0
- package/extensions/recap/prompt.ts +44 -0
- package/extensions/recap/scheduler.ts +87 -0
- package/extensions/search-tools/index.ts +0 -0
- package/extensions/search-tools/tools.ts +7 -5
- package/extensions/skill/index.ts +360 -34
- package/extensions/skill/invoke.ts +185 -0
- package/extensions/skill/listing.ts +21 -0
- package/extensions/skill/panel/keys.ts +41 -0
- package/extensions/skill/panel/render.ts +91 -0
- package/extensions/skill/panel/state.ts +115 -0
- package/extensions/spinner/index.ts +110 -0
- package/extensions/spinner/line.ts +51 -0
- package/extensions/spinner/verbs.ts +201 -0
- package/extensions/subagents/agents.ts +88 -44
- package/extensions/subagents/default-model.ts +62 -32
- package/extensions/subagents/delegation-steer.ts +23 -0
- package/extensions/subagents/hand-back-review.ts +67 -0
- package/extensions/subagents/index.ts +1545 -346
- package/extensions/subagents/live-runs.ts +293 -0
- package/extensions/subagents/model-select.ts +252 -82
- package/extensions/subagents/outcome.ts +90 -0
- package/extensions/subagents/panel-keys.ts +41 -0
- package/extensions/subagents/panel-render.ts +357 -0
- package/extensions/subagents/panel-widget.ts +330 -0
- package/extensions/subagents/pending-claim.ts +45 -0
- package/extensions/subagents/prose.ts +105 -0
- package/extensions/subagents/runner.ts +674 -0
- package/extensions/subagents/runs.ts +68 -1
- package/extensions/subagents/send-to-main-tool.ts +55 -0
- package/extensions/subagents/session-turns.ts +138 -0
- package/extensions/subagents/shell-panel.ts +198 -0
- package/extensions/subagents/usage.ts +16 -4
- package/extensions/subagents/worktree.ts +13 -0
- package/extensions/system-prompt/environment.ts +5 -3
- package/extensions/system-prompt/git-status.ts +93 -0
- package/extensions/system-prompt/index.ts +23 -1
- package/extensions/system-prompt/template.ts +34 -4
- package/extensions/system-prompt/tiers/common.ts +31 -6
- package/extensions/system-prompt/tiers/low.ts +13 -3
- package/extensions/system-prompt/tiers/mid.ts +21 -8
- package/extensions/system-reminder/index.ts +53 -9
- package/extensions/tasks/index.ts +65 -16
- package/extensions/tasks/store.ts +76 -3
- package/extensions/tool-search/announce.ts +42 -0
- package/extensions/tool-search/index.ts +136 -13
- package/extensions/turn-duration/index.ts +60 -0
- package/extensions/turn-duration/line.ts +22 -0
- package/extensions/turn-duration/span.ts +41 -0
- package/extensions/turn-duration/verbs.ts +24 -0
- package/extensions/web/backends.ts +421 -0
- package/extensions/web/index.ts +178 -21
- package/extensions/web-fetch/index.ts +74 -18
- package/extensions/web-fetch/summarize.ts +1 -1
- package/extensions/workflow/agent-session.ts +182 -112
- package/extensions/workflow/globals.ts +129 -106
- package/extensions/workflow/index.ts +343 -28
- package/extensions/workflow/records.ts +118 -0
- package/extensions/workflow/run-manager.ts +80 -31
- package/extensions/workflow/saved-workflows.ts +2 -1
- package/extensions/workflow/script-worker.mjs +110 -0
- package/extensions/workflow/types.ts +74 -8
- package/extensions/workflow/viewer.ts +562 -0
- package/extensions/workflow/vm-runtime.ts +118 -22
- package/extensions/workflow/widget.ts +126 -18
- package/extensions/worktree/guards.ts +240 -0
- package/extensions/worktree/index.ts +30 -5
- package/extensions/worktree/rewrite.ts +32 -16
- package/package.json +20 -10
- package/skills/code-review/SKILL.md +165 -0
- package/skills/fewer-permission-prompts/SKILL.md +61 -0
- package/skills/security-review/SKILL.md +178 -0
- package/skills/simplify/SKILL.md +70 -0
- package/themes/{one-code-light.json → onecode-light.json} +6 -1
- package/themes/{one-code.json → onecode.json} +6 -1
- package/types/app.d.ts +8 -1
- package/types/community-packages.d.ts +45 -2
- package/docs/decisions/auto-mode.md +0 -526
- package/docs/decisions/branding.md +0 -127
- package/docs/decisions/code-review-remediation.md +0 -106
- package/docs/decisions/compaction.md +0 -140
- package/docs/decisions/distribution.md +0 -168
- package/docs/decisions/hooks.md +0 -64
- package/docs/decisions/lsp.md +0 -58
- package/docs/decisions/mcp.md +0 -104
- package/docs/decisions/memory-state.md +0 -170
- package/docs/decisions/model-policy.md +0 -126
- package/docs/decisions/modes.md +0 -178
- package/docs/decisions/skills-plugins.md +0 -56
- package/docs/decisions/subagents-workflows.md +0 -225
- package/docs/decisions/system-prompt.md +0 -98
- package/docs/decisions/tools.md +0 -407
- package/docs/decisions/tui.md +0 -217
- package/docs/decisions.md +0 -148
- package/extensions/subagents/child.ts +0 -414
- package/extensions/subagents/rpc-turns.ts +0 -123
- package/extensions/todo/index.ts +0 -133
- package/extensions/workflow/permission-gate.ts +0 -71
|
@@ -12,35 +12,71 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import { readFileSync } from "node:fs";
|
|
15
|
-
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
16
|
-
import { parseFrontmatter } from "@earendil-works/pi-coding-agent";
|
|
17
|
-
import { Type } from "typebox";
|
|
18
15
|
import os from "node:os";
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
16
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
17
|
+
import { getAgentDir, stripFrontmatter } from "@earendil-works/pi-coding-agent";
|
|
18
|
+
import { Type } from "typebox";
|
|
19
|
+
import { pluginRoot } from "../lib/plugin-root.ts";
|
|
20
|
+
import { defaultDiscoverRoots, discoverPlugins } from "../lib/plugins.ts";
|
|
21
21
|
import { CONTEXT_ORDER, REMINDER_CHANNEL } from "../lib/reminders.ts";
|
|
22
|
-
import {
|
|
22
|
+
import {
|
|
23
|
+
nextSkillState,
|
|
24
|
+
readSkillStates,
|
|
25
|
+
setSkillState,
|
|
26
|
+
type SkillScope,
|
|
27
|
+
type SkillState,
|
|
28
|
+
skillOverrideKey,
|
|
29
|
+
skillStateFor,
|
|
30
|
+
} from "../lib/skill-overrides.ts";
|
|
31
|
+
import { BUNDLED_SKILLS_DIR, estimateSkillTokens, promptTemplateNames, scanSkills, scopeForPath } from "../lib/skill-scan.ts";
|
|
32
|
+
import { recordUsage } from "../lib/usage-tracker.ts";
|
|
33
|
+
import { boundedDockHeight, ccToolRenderers, safeThemeBold, safeThemePaint, truncateLine } from "../lib/tui-render.ts";
|
|
34
|
+
import {
|
|
35
|
+
bareSkillMatches,
|
|
36
|
+
buildSkillBlock,
|
|
37
|
+
parseSkillCommand,
|
|
38
|
+
redactOffSkillMessages,
|
|
39
|
+
resolveSkill,
|
|
40
|
+
skillCommandCandidates,
|
|
41
|
+
} from "./invoke.ts";
|
|
42
|
+
import { decodeSkillsKey } from "./panel/keys.ts";
|
|
43
|
+
import { skillListingText } from "./listing.ts";
|
|
44
|
+
import { renderSkillsPanel, type SkillsPaint } from "./panel/render.ts";
|
|
45
|
+
import { applySkillsKey, initialSkillsState, type SkillsRow, visibleRows } from "./panel/state.ts";
|
|
46
|
+
import { parseFrontmatterLoosely } from "../lib/frontmatter.ts";
|
|
23
47
|
|
|
24
48
|
interface IndexedSkill {
|
|
25
49
|
name: string;
|
|
26
50
|
description?: string;
|
|
27
51
|
path: string;
|
|
28
52
|
source: "project" | "plugin";
|
|
53
|
+
scope: SkillScope;
|
|
54
|
+
state: SkillState;
|
|
55
|
+
pluginName?: string;
|
|
29
56
|
}
|
|
30
57
|
|
|
58
|
+
/** Bounded dock like the /plugins panel — keeps the transcript visible above. */
|
|
59
|
+
const SKILLS_PANEL_MAX_HEIGHT = 24;
|
|
60
|
+
|
|
31
61
|
export default function skillExtension(pi: ExtensionAPI) {
|
|
32
62
|
/** pi resolves skills per turn; cache the latest list for the tool to use. */
|
|
33
63
|
let piSkills: IndexedSkill[] = [];
|
|
64
|
+
/** Session cwd, so project-level enabledPlugins settings apply to plugin skills. */
|
|
65
|
+
let sessionCwd: string | undefined;
|
|
34
66
|
|
|
35
|
-
pi.on("before_agent_start", (event) => {
|
|
67
|
+
pi.on("before_agent_start", (event, ctx) => {
|
|
68
|
+
sessionCwd = ctx.cwd;
|
|
36
69
|
const skills = event.systemPromptOptions.skills ?? [];
|
|
37
70
|
piSkills = skills.map((skill) => {
|
|
38
71
|
const record = skill as unknown as { name: string; description?: string; path?: string; filePath?: string };
|
|
72
|
+
const path = record.path ?? record.filePath ?? "";
|
|
39
73
|
return {
|
|
40
74
|
name: record.name,
|
|
41
75
|
description: record.description,
|
|
42
|
-
path
|
|
76
|
+
path,
|
|
43
77
|
source: "project" as const,
|
|
78
|
+
scope: scopeForPath(path, os.homedir(), getAgentDir()),
|
|
79
|
+
state: "on" as const, // resolved per index() call below
|
|
44
80
|
};
|
|
45
81
|
});
|
|
46
82
|
// Claude Code lists skills as a <system-reminder> on the first user message
|
|
@@ -57,23 +93,78 @@ export default function skillExtension(pi: ExtensionAPI) {
|
|
|
57
93
|
}
|
|
58
94
|
});
|
|
59
95
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
96
|
+
/**
|
|
97
|
+
* Description from a SKILL.md frontmatter, for skills pi hasn't resolved (the
|
|
98
|
+
* pre-first-turn scan) and for plugin skills (discoverPlugins carries none).
|
|
99
|
+
* Cached per path: the listing is rebuilt every turn and a description is
|
|
100
|
+
* stable for the session anyway (the listing must stay byte-stable for the
|
|
101
|
+
* cache prefix).
|
|
102
|
+
*/
|
|
103
|
+
const descriptionCache = new Map<string, string | undefined>();
|
|
104
|
+
const readDescription = (path: string): string | undefined => {
|
|
105
|
+
if (descriptionCache.has(path)) return descriptionCache.get(path);
|
|
106
|
+
const description = readDescriptionUncached(path);
|
|
107
|
+
descriptionCache.set(path, description);
|
|
108
|
+
return description;
|
|
109
|
+
};
|
|
110
|
+
const readDescriptionUncached = (path: string): string | undefined => {
|
|
111
|
+
try {
|
|
112
|
+
const { frontmatter } = parseFrontmatterLoosely(readFileSync(path, "utf-8")) as {
|
|
113
|
+
frontmatter?: { description?: unknown };
|
|
114
|
+
};
|
|
115
|
+
return typeof frontmatter?.description === "string" ? frontmatter.description : undefined;
|
|
116
|
+
} catch {
|
|
117
|
+
return undefined;
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
// Per-skill availability comes from the skill-overrides store (the /skills
|
|
122
|
+
// panel cycles it, the /plugins panel manages plugin skills). The listing
|
|
123
|
+
// and the tool both re-read it, so a change applies live.
|
|
124
|
+
//
|
|
125
|
+
// During a turn, project/user skills come from `piSkills` — pi already
|
|
126
|
+
// resolved them for this turn (with descriptions), so no disk scan runs on
|
|
127
|
+
// the per-turn listing path. Before the first turn `piSkills` is empty, so
|
|
128
|
+
// the /skills command/panel falls back to a disk scan (reading each
|
|
129
|
+
// SKILL.md's frontmatter for its description) — that's the gap that made a
|
|
130
|
+
// fresh session's /skills show only plugin skills.
|
|
131
|
+
const index = (cwd = sessionCwd): IndexedSkill[] => {
|
|
132
|
+
const agentDir = getAgentDir();
|
|
133
|
+
const home = os.homedir();
|
|
134
|
+
const states = readSkillStates(pluginRoot(agentDir));
|
|
135
|
+
const resolved = piSkills.filter((skill) => skill.path);
|
|
136
|
+
const base =
|
|
137
|
+
resolved.length > 0
|
|
138
|
+
? resolved
|
|
139
|
+
: scanSkills(cwd ?? process.cwd(), home, agentDir, [], BUNDLED_SKILLS_DIR).map((skill) => ({
|
|
140
|
+
name: skill.name,
|
|
141
|
+
description: readDescription(skill.path),
|
|
142
|
+
path: skill.path,
|
|
143
|
+
source: "project" as const,
|
|
144
|
+
scope: skill.scope,
|
|
145
|
+
state: "on" as const,
|
|
146
|
+
}));
|
|
147
|
+
const project = base.map((skill) => ({
|
|
148
|
+
...skill,
|
|
149
|
+
state: skillStateFor(states, skillOverrideKey(skill.scope, skill.name)),
|
|
150
|
+
}));
|
|
151
|
+
// Plugin skills from discoverPlugins are already filtered to enabled by
|
|
152
|
+
// the same store; anything it returns is fully available (plugin skills
|
|
153
|
+
// aren't governed by skillOverrides — managed via /plugins).
|
|
154
|
+
const plugin = discoverPlugins(defaultDiscoverRoots(agentDir, cwd, home)).skills.map((skill) => ({
|
|
63
155
|
name: skill.name,
|
|
156
|
+
description: readDescription(skill.path),
|
|
64
157
|
path: skill.path,
|
|
65
158
|
source: "plugin" as const,
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
if (all.length === 0) return "(no skills available)";
|
|
72
|
-
return all
|
|
73
|
-
.map((skill) => `- ${skill.name}${skill.description ? `: ${skill.description.split("\n")[0]}` : ""}`)
|
|
74
|
-
.join("\n");
|
|
159
|
+
scope: "plugin" as const,
|
|
160
|
+
state: "on" as const,
|
|
161
|
+
pluginName: skill.plugin,
|
|
162
|
+
}));
|
|
163
|
+
return [...project, ...plugin];
|
|
75
164
|
};
|
|
76
165
|
|
|
166
|
+
const describe = () => skillListingText(index());
|
|
167
|
+
|
|
77
168
|
pi.registerTool({
|
|
78
169
|
name: "skill",
|
|
79
170
|
label: "Skill",
|
|
@@ -83,7 +174,7 @@ export default function skillExtension(pi: ExtensionAPI) {
|
|
|
83
174
|
result: (_r, a, isError) => (isError ? undefined : a?.skill ? `Loaded ${a.skill}` : undefined),
|
|
84
175
|
}),
|
|
85
176
|
description:
|
|
86
|
-
"Invoke a skill
|
|
177
|
+
"Invoke a skill.\n\nA skill is a packaged set of instructions the user or project has set up for a particular kind of task (deploy steps, a review checklist, a repo-specific workflow). Available skills appear in a system-reminder listing with one-line descriptions. When the task at hand is one a listed skill covers, call this tool first — the skill's instructions load into the turn for you to follow in place of your default approach. Users may also ask for one by name (`/<name>`, or \"slash command\"); that's a request to invoke it.\n\n- `skill`: exact name from the listing, no leading slash. Plugin skills are named `<plugin>:<skill>`.\n- `args`: optional arguments to pass through.\n\nOnly names from the listing (or that the user typed explicitly) are valid. Built-in CLI commands (`/help`, `/clear`, …) aren't skills. Use `list` to see what is available.",
|
|
87
178
|
promptSnippet: "Load packaged instructions for a task (see the skills listing)",
|
|
88
179
|
parameters: Type.Object({
|
|
89
180
|
skill: Type.Optional(Type.String({ description: "Exact skill name, no leading slash" })),
|
|
@@ -96,10 +187,11 @@ export default function skillExtension(pi: ExtensionAPI) {
|
|
|
96
187
|
// name the skill — fail loudly rather than silently returning the
|
|
97
188
|
// catalog, which a weak model reads as a non-sequitur (same archetype as
|
|
98
189
|
// the subagent tool; see docs/decisions/subagents-workflows.md).
|
|
190
|
+
const all = index();
|
|
99
191
|
if (params.list || (!params.skill && params.args == null)) {
|
|
100
192
|
return {
|
|
101
|
-
content: [{ type: "text", text: `Available skills:\n${
|
|
102
|
-
details: { skills:
|
|
193
|
+
content: [{ type: "text", text: `Available skills:\n${skillListingText(all)}` }],
|
|
194
|
+
details: { skills: all.map((s) => s.name) } as Record<string, unknown>,
|
|
103
195
|
};
|
|
104
196
|
}
|
|
105
197
|
if (!params.skill) {
|
|
@@ -107,7 +199,7 @@ export default function skillExtension(pi: ExtensionAPI) {
|
|
|
107
199
|
content: [
|
|
108
200
|
{
|
|
109
201
|
type: "text",
|
|
110
|
-
text: `No \`skill\` given, but you passed \`args\` — this looks like an invocation that forgot to name the skill. Set \`skill\` to one of the names below, or call with \`list: true\` to just browse.\n\nAvailable skills:\n${
|
|
202
|
+
text: `No \`skill\` given, but you passed \`args\` — this looks like an invocation that forgot to name the skill. Set \`skill\` to one of the names below, or call with \`list: true\` to just browse.\n\nAvailable skills:\n${skillListingText(all)}`,
|
|
111
203
|
},
|
|
112
204
|
],
|
|
113
205
|
details: {} as Record<string, unknown>,
|
|
@@ -116,24 +208,40 @@ export default function skillExtension(pi: ExtensionAPI) {
|
|
|
116
208
|
}
|
|
117
209
|
|
|
118
210
|
const wanted = params.skill.replace(/^\//, "");
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
all.find((skill) => skill.name.endsWith(`:${wanted}`));
|
|
211
|
+
// A bare name may match a plugin skill (`<plugin>:<name>`), but only
|
|
212
|
+
// when exactly one plugin ships it — otherwise resolving silently would
|
|
213
|
+
// run an arbitrary one, so ask which.
|
|
214
|
+
const bareMatches = bareSkillMatches(all, wanted);
|
|
215
|
+
const found = resolveSkill(all, wanted);
|
|
125
216
|
|
|
126
217
|
if (!found) {
|
|
218
|
+
const ambiguous = bareMatches.length > 1;
|
|
219
|
+
const text = ambiguous
|
|
220
|
+
? `"${params.skill}" is ambiguous — it matches ${bareMatches.map((s) => s.name).join(", ")}. Use the full \`<plugin>:${wanted}\` name.`
|
|
221
|
+
: `No skill named "${params.skill}".\n\nAvailable skills:\n${skillListingText(all)}`;
|
|
127
222
|
return {
|
|
128
|
-
content: [{ type: "text", text
|
|
223
|
+
content: [{ type: "text", text }],
|
|
129
224
|
details: {} as Record<string, unknown>,
|
|
130
225
|
isError: true,
|
|
131
226
|
};
|
|
132
227
|
}
|
|
133
228
|
|
|
229
|
+
// "off" is the only state that refuses invocation (matching Claude Code,
|
|
230
|
+
// where invoking an off skill by name returns the skillOverrides error).
|
|
231
|
+
// "user-only" is invocable — it's just hidden from the model's listing,
|
|
232
|
+
// so the model won't auto-trigger it, but /skill-name still runs it.
|
|
233
|
+
if (found.state === "off") {
|
|
234
|
+
const where = found.source === "plugin" ? "/plugins" : "/skills";
|
|
235
|
+
return {
|
|
236
|
+
content: [{ type: "text", text: `Skill "${found.name}" is turned off — the user can re-enable it from ${where}.` }],
|
|
237
|
+
details: { skill: found.name, state: found.state } as Record<string, unknown>,
|
|
238
|
+
isError: true,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
134
242
|
let body: string;
|
|
135
243
|
try {
|
|
136
|
-
const parsed =
|
|
244
|
+
const parsed = parseFrontmatterLoosely(readFileSync(found.path, "utf-8")) as { body: string };
|
|
137
245
|
body = parsed.body.trim();
|
|
138
246
|
} catch (error) {
|
|
139
247
|
return {
|
|
@@ -143,6 +251,8 @@ export default function skillExtension(pi: ExtensionAPI) {
|
|
|
143
251
|
};
|
|
144
252
|
}
|
|
145
253
|
|
|
254
|
+
recordUsage(pluginRoot(getAgentDir()), "skill", found.name);
|
|
255
|
+
|
|
146
256
|
// Resource paths in a skill are relative to its own directory, so the
|
|
147
257
|
// model needs to know where it lives to read references/ or scripts/.
|
|
148
258
|
const header = [
|
|
@@ -161,10 +271,226 @@ export default function skillExtension(pi: ExtensionAPI) {
|
|
|
161
271
|
},
|
|
162
272
|
});
|
|
163
273
|
|
|
274
|
+
/**
|
|
275
|
+
* Run a resolved skill the way a user-typed command does: refuse an "off"
|
|
276
|
+
* skill (in EVERY mode — falling through would hand `/skill:` to pi's native
|
|
277
|
+
* expansion, which knows nothing of the overrides store and would run it; a
|
|
278
|
+
* headless run gets the refusal as a next-turn reminder instead of a UI
|
|
279
|
+
* notice, never as silent execution), else re-deliver pi's exact `<skill>`
|
|
280
|
+
* block as a hidden custom message. The model receives the same bytes pi's
|
|
281
|
+
* own expansion would submit as a user turn (convertToLlm maps a custom
|
|
282
|
+
* message to a user message regardless of `display`), but nothing renders.
|
|
283
|
+
* Shared by the `/skill:<name>` interception and the bare `/<name>` commands.
|
|
284
|
+
*/
|
|
285
|
+
const deliverSkill = (
|
|
286
|
+
found: IndexedSkill,
|
|
287
|
+
args: string,
|
|
288
|
+
ctx: { hasUI: boolean; ui: { notify(message: string, level: "info" | "warning" | "error"): void } },
|
|
289
|
+
extra: { images?: Array<{ type: "image"; data: string; mimeType: string }>; streamingBehavior?: "steer" | "followUp" } = {},
|
|
290
|
+
): "handled" | "unavailable" => {
|
|
291
|
+
if (found.state === "off") {
|
|
292
|
+
const where = found.source === "plugin" ? "/plugins" : "/skills";
|
|
293
|
+
const message = `Skill "${found.name}" is turned off — enable it from ${where} to run it.`;
|
|
294
|
+
if (ctx.hasUI) ctx.ui.notify(message, "warning");
|
|
295
|
+
else pi.events.emit(REMINDER_CHANNEL, { text: message });
|
|
296
|
+
return "handled";
|
|
297
|
+
}
|
|
298
|
+
let body: string;
|
|
299
|
+
try {
|
|
300
|
+
body = stripFrontmatter(readFileSync(found.path, "utf-8")).trim();
|
|
301
|
+
} catch {
|
|
302
|
+
return "unavailable";
|
|
303
|
+
}
|
|
304
|
+
recordUsage(pluginRoot(getAgentDir()), "skill", found.name);
|
|
305
|
+
const block = buildSkillBlock({ name: found.name, filePath: found.path }, body, args);
|
|
306
|
+
// Carry any attached images alongside the block, as pi's native path would.
|
|
307
|
+
const content = extra.images?.length ? [{ type: "text" as const, text: block }, ...extra.images] : block;
|
|
308
|
+
pi.sendMessage(
|
|
309
|
+
{ customType: "one-code:skill-invocation", content, display: false, details: { skill: found.name, args } },
|
|
310
|
+
{ triggerTurn: true, ...(extra.streamingBehavior ? { deliverAs: extra.streamingBehavior } : {}) },
|
|
311
|
+
);
|
|
312
|
+
return "handled";
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
// A user-typed `/skill:<name>` normally runs pi's own expansion, which submits
|
|
316
|
+
// the skill's `<skill>` block as a *user message* — so loading a skill shows in
|
|
317
|
+
// the transcript as a new user turn. Intercept it here, suppress pi's
|
|
318
|
+
// expansion (return "handled"), and deliver through deliverSkill. An unknown
|
|
319
|
+
// name, an ambiguous plugin match, or an unreadable file falls through to
|
|
320
|
+
// pi's native handling. This hook only covers prompt(); paths that expand
|
|
321
|
+
// without an `input` event (steer/followUp) are caught by the context-hook
|
|
322
|
+
// redaction below.
|
|
323
|
+
pi.on("input", (event, ctx) => {
|
|
324
|
+
const cmd = parseSkillCommand(event.text);
|
|
325
|
+
if (!cmd) return { action: "continue" };
|
|
326
|
+
const found = resolveSkill(index(), cmd.name);
|
|
327
|
+
if (!found) return { action: "continue" };
|
|
328
|
+
const outcome = deliverSkill(found, cmd.args, ctx, {
|
|
329
|
+
images: event.images as Array<{ type: "image"; data: string; mimeType: string }> | undefined,
|
|
330
|
+
streamingBehavior: event.streamingBehavior,
|
|
331
|
+
});
|
|
332
|
+
return { action: outcome === "handled" ? "handled" : "continue" };
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
// Claude Code invokes a user/project skill as a bare `/<name>` (only plugin
|
|
336
|
+
// skills carry a `<plugin>:` prefix); pi registers `/skill:<name>`. Register
|
|
337
|
+
// each project/user/bundled skill as a bare extension command too, so
|
|
338
|
+
// `/simplify` works and autocompletes. pi runs extension commands before the
|
|
339
|
+
// `input` hook and before its own `/skill:`/template expansion — in prompt(),
|
|
340
|
+
// so interactive, `-p` and RPC alike — and emits session_start before it
|
|
341
|
+
// builds the autocomplete list, so aliases registered here are listed. A name
|
|
342
|
+
// another command already owns is skipped (pi would rename BOTH to `name:1`/
|
|
343
|
+
// `name:2`), as are pi's built-ins (matched by literal text before extensions
|
|
344
|
+
// see them) and `.claude/commands` templates (pi resolves those per turn,
|
|
345
|
+
// AFTER session_start, so they are pre-scanned from disk here — otherwise a
|
|
346
|
+
// bare skill command would shadow a same-named template for the whole
|
|
347
|
+
// session); those skills keep only their `/skill:` form. Commands can never
|
|
348
|
+
// be unregistered, so the handler resolves the skill afresh at invocation:
|
|
349
|
+
// a skill deleted or turned off since registration is refused, never run.
|
|
350
|
+
const registeredSkillCommands = new Set<string>();
|
|
351
|
+
const registerSkillCommands = (cwd: string | undefined) => {
|
|
352
|
+
let taken: string[];
|
|
353
|
+
try {
|
|
354
|
+
taken = pi.getCommands().map((command) => command.name);
|
|
355
|
+
} catch {
|
|
356
|
+
return; // not bound yet (load time) — session_start retries
|
|
357
|
+
}
|
|
358
|
+
const templates = promptTemplateNames(cwd ?? process.cwd(), os.homedir(), getAgentDir());
|
|
359
|
+
for (const skill of skillCommandCandidates(index(cwd), [...taken, ...templates, ...registeredSkillCommands])) {
|
|
360
|
+
registeredSkillCommands.add(skill.name);
|
|
361
|
+
pi.registerCommand(skill.name, {
|
|
362
|
+
description: skill.description ? `${skill.description} (skill)` : `Run the ${skill.name} skill`,
|
|
363
|
+
handler: async (args, ctx) => {
|
|
364
|
+
const found = resolveSkill(index(ctx.cwd), skill.name);
|
|
365
|
+
if (!found || deliverSkill(found, args.trim(), ctx) === "unavailable") {
|
|
366
|
+
ctx.ui.notify(`Skill "${skill.name}" is no longer available (its SKILL.md was removed or is unreadable).`, "error");
|
|
367
|
+
}
|
|
368
|
+
},
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
};
|
|
372
|
+
pi.on("session_start", (_event, ctx) => registerSkillCommands(ctx.cwd));
|
|
373
|
+
// pi's per-turn resolution (frontmatter `name`, description required) can
|
|
374
|
+
// surface skills the pre-turn disk scan missed; late registrations still
|
|
375
|
+
// execute (autocomplete lists them after a /reload). Gated on an unseen
|
|
376
|
+
// name so the steady state costs one Set lookup per skill per turn, not a
|
|
377
|
+
// re-index (before_agent_start runs every turn).
|
|
378
|
+
pi.on("before_agent_start", (_event, ctx) => {
|
|
379
|
+
if (piSkills.some((skill) => !registeredSkillCommands.has(skill.name))) registerSkillCommands(ctx.cwd);
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
// Fail-closed backstop for the `input` interception above: pi's steer() and
|
|
383
|
+
// followUp() expand `/skill:<name>` natively WITHOUT firing an `input` event
|
|
384
|
+
// (queued interactive messages after the first, RPC steer/followUp), so a
|
|
385
|
+
// turned-off skill's instructions can land in the session history anyway.
|
|
386
|
+
// Strip them from every outgoing request instead — the wire copy carries the
|
|
387
|
+
// refusal notice, the session file keeps the original bytes, and untouched
|
|
388
|
+
// requests return undefined so the message array stays byte-identical
|
|
389
|
+
// (prompt-cache stable). Override states are read lazily, only when a
|
|
390
|
+
// message actually contains a `<skill>` block.
|
|
391
|
+
pi.on("context", (event) => {
|
|
392
|
+
let states: Map<string, string> | undefined;
|
|
393
|
+
const isOff = (name: string) => {
|
|
394
|
+
states ??= new Map(index().map((skill) => [skill.name, skill.state]));
|
|
395
|
+
return states.get(name) === "off";
|
|
396
|
+
};
|
|
397
|
+
const redacted = redactOffSkillMessages(event.messages, isOff);
|
|
398
|
+
return redacted ? { messages: redacted } : undefined;
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
const buildSkillsRows = (cwd: string | undefined): SkillsRow[] =>
|
|
402
|
+
index(cwd).map((skill) => ({
|
|
403
|
+
key: skillOverrideKey(skill.scope, skill.name),
|
|
404
|
+
name: skill.name,
|
|
405
|
+
scope: skill.scope,
|
|
406
|
+
tokens: estimateSkillTokens(skill.path),
|
|
407
|
+
state: skill.state,
|
|
408
|
+
locked: skill.source === "plugin",
|
|
409
|
+
pluginName: skill.pluginName,
|
|
410
|
+
}));
|
|
411
|
+
|
|
412
|
+
// The /skills panel: a bounded dock (like /plugins) that cycles each
|
|
413
|
+
// project/user skill through on / name-only / user-only / off, persisting to
|
|
414
|
+
// the skill-overrides store live. Plugin skills show locked (managed via
|
|
415
|
+
// /plugins). Pure state/render live in ./panel; this owns repaint + writes.
|
|
416
|
+
const openSkillsPanel = async (ctx: ExtensionContext): Promise<void> => {
|
|
417
|
+
await ctx.ui.custom<null>((tui, theme, _keybindings, done) => {
|
|
418
|
+
const paint: SkillsPaint = { fg: safeThemePaint(theme), bold: safeThemeBold(theme) };
|
|
419
|
+
const state = initialSkillsState();
|
|
420
|
+
let rows = buildSkillsRows(ctx.cwd);
|
|
421
|
+
let notice: string | undefined;
|
|
422
|
+
let cache: { width: number; lines: string[] } | undefined;
|
|
423
|
+
const repaint = () => {
|
|
424
|
+
cache = undefined;
|
|
425
|
+
tui.requestRender();
|
|
426
|
+
};
|
|
427
|
+
return {
|
|
428
|
+
render: (width: number) => {
|
|
429
|
+
if (cache?.width === width) return cache.lines;
|
|
430
|
+
const termRows = (tui as { terminal: { rows: number } }).terminal.rows;
|
|
431
|
+
const height = boundedDockHeight(termRows, SKILLS_PANEL_MAX_HEIGHT);
|
|
432
|
+
const lines = renderSkillsPanel({ state, rows, width, height, notice }, paint).map((line) =>
|
|
433
|
+
truncateLine(line, width),
|
|
434
|
+
);
|
|
435
|
+
cache = { width, lines };
|
|
436
|
+
return lines;
|
|
437
|
+
},
|
|
438
|
+
handleInput: (data: string) => {
|
|
439
|
+
const key = decodeSkillsKey(data);
|
|
440
|
+
if (!key) return;
|
|
441
|
+
const before = visibleRows(rows, state);
|
|
442
|
+
const anchorKey = before[state.cursor]?.key;
|
|
443
|
+
const sortBefore = state.sort;
|
|
444
|
+
const effect = applySkillsKey(state, key, before);
|
|
445
|
+
notice = undefined;
|
|
446
|
+
if (effect?.kind === "close") {
|
|
447
|
+
done(null);
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
if (effect?.kind === "cycle") {
|
|
451
|
+
// The toggle changes only the state; token/scope/name are stable,
|
|
452
|
+
// so patch the one row instead of rescanning every skill from disk.
|
|
453
|
+
const next = nextSkillState(effect.row.state);
|
|
454
|
+
setSkillState(pluginRoot(getAgentDir()), effect.row.key, next);
|
|
455
|
+
rows = rows.map((row) => (row.key === effect.row.key ? { ...row, state: next } : row));
|
|
456
|
+
} else if (effect?.kind === "locked") {
|
|
457
|
+
notice = `${effect.row.name} is a plugin skill — manage it from /plugins.`;
|
|
458
|
+
}
|
|
459
|
+
// A sort toggle reorders the list; keep the same skill selected so a
|
|
460
|
+
// following Space acts on the row the user was looking at, not the one
|
|
461
|
+
// that slid into its index.
|
|
462
|
+
if (state.sort !== sortBefore && anchorKey) {
|
|
463
|
+
const idx = visibleRows(rows, state).findIndex((row) => row.key === anchorKey);
|
|
464
|
+
if (idx >= 0) state.cursor = idx;
|
|
465
|
+
}
|
|
466
|
+
repaint();
|
|
467
|
+
},
|
|
468
|
+
invalidate: () => {
|
|
469
|
+
cache = undefined;
|
|
470
|
+
},
|
|
471
|
+
};
|
|
472
|
+
});
|
|
473
|
+
};
|
|
474
|
+
|
|
164
475
|
pi.registerCommand("skills", {
|
|
165
|
-
description: "
|
|
476
|
+
description: "View and manage skills (on / name-only / user-only / off)",
|
|
166
477
|
handler: async (_args, ctx) => {
|
|
167
|
-
ctx.
|
|
478
|
+
if (ctx.hasUI) {
|
|
479
|
+
await openSkillsPanel(ctx);
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
// Non-interactive fallback: a flat listing with each skill's state.
|
|
483
|
+
const all = index(ctx.cwd);
|
|
484
|
+
if (all.length === 0) {
|
|
485
|
+
ctx.ui.notify("No skills available.", "info");
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
const lines = all.map((skill) => {
|
|
489
|
+
const label = skill.source === "plugin" ? "plugin" : skill.state;
|
|
490
|
+
const desc = skill.description ? `: ${skill.description.split("\n")[0]}` : "";
|
|
491
|
+
return `- ${skill.name} [${label}]${desc}`;
|
|
492
|
+
});
|
|
493
|
+
ctx.ui.notify(`Skills:\n${lines.join("\n")}`, "info");
|
|
168
494
|
},
|
|
169
495
|
});
|
|
170
496
|
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* User-invoked skill commands (pure). pi registers each skill as a `/skill:<name>`
|
|
3
|
+
* slash command and, on invocation, expands it into a `<skill>` block that it
|
|
4
|
+
* submits as a *user message* — so loading a skill shows up in the transcript as
|
|
5
|
+
* a new user turn. One Code intercepts that command in the `input` hook and
|
|
6
|
+
* re-delivers the same block as a hidden message instead (see index.ts), so the
|
|
7
|
+
* model receives byte-identical content but nothing renders.
|
|
8
|
+
*
|
|
9
|
+
* These helpers mirror pi's own `_expandSkillCommand`/`formatSkillInvocation`
|
|
10
|
+
* exactly (same `/skill:` prefix, same block template) so the model sees the same
|
|
11
|
+
* bytes it would have without the interception — only the display changes.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { dirname } from "node:path";
|
|
15
|
+
|
|
16
|
+
const SKILL_PREFIX = "/skill:";
|
|
17
|
+
|
|
18
|
+
/** Parse a `/skill:<name> [args]` command; undefined if it is not one. */
|
|
19
|
+
export function parseSkillCommand(text: string): { name: string; args: string } | undefined {
|
|
20
|
+
if (!text.startsWith(SKILL_PREFIX)) return undefined;
|
|
21
|
+
const rest = text.slice(SKILL_PREFIX.length);
|
|
22
|
+
const space = rest.indexOf(" ");
|
|
23
|
+
if (space === -1) return { name: rest, args: "" };
|
|
24
|
+
return { name: rest.slice(0, space), args: rest.slice(space + 1).trim() };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Skills whose name ends `:<wanted>` — a bare name's plugin-namespaced matches. */
|
|
28
|
+
export function bareSkillMatches<T extends { name: string }>(all: T[], wanted: string): T[] {
|
|
29
|
+
return all.filter((skill) => skill.name.endsWith(`:${wanted}`));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Resolve a bare skill name against the available skills, matching the Skill
|
|
34
|
+
* tool's own resolution: exact, then case-insensitive, then a unique
|
|
35
|
+
* `<plugin>:<name>` suffix. Returns undefined when nothing matches or a bare
|
|
36
|
+
* name is ambiguous across plugins (so the caller falls back to pi's handling).
|
|
37
|
+
*/
|
|
38
|
+
export function resolveSkill<T extends { name: string }>(all: T[], wanted: string): T | undefined {
|
|
39
|
+
const bare = bareSkillMatches(all, wanted);
|
|
40
|
+
return (
|
|
41
|
+
all.find((skill) => skill.name === wanted) ??
|
|
42
|
+
all.find((skill) => skill.name.toLowerCase() === wanted.toLowerCase()) ??
|
|
43
|
+
(bare.length === 1 ? bare[0] : undefined)
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Rebuild the exact `<skill>` block pi's `/skill:` expansion produces. `body` is
|
|
49
|
+
* the SKILL.md with frontmatter already stripped and trimmed; `filePath` is the
|
|
50
|
+
* skill file (its directory is the base for relative references, as pi does).
|
|
51
|
+
*/
|
|
52
|
+
export function buildSkillBlock(skill: { name: string; filePath: string }, body: string, args: string): string {
|
|
53
|
+
const block = `<skill name="${skill.name}" location="${skill.filePath}">
|
|
54
|
+
References are relative to ${dirname(skill.filePath)}.
|
|
55
|
+
|
|
56
|
+
${body}
|
|
57
|
+
</skill>`;
|
|
58
|
+
return args ? `${block}\n\n${args}` : block;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Fast pre-check before the regex walk; also the marker `redactOffSkillText` scans for. */
|
|
62
|
+
const SKILL_BLOCK_START = '<skill name="';
|
|
63
|
+
const SKILL_BLOCK_RE = /<skill name="([^"]+)"[^>]*>[\s\S]*?<\/skill>/g;
|
|
64
|
+
|
|
65
|
+
export const offSkillNotice = (name: string): string =>
|
|
66
|
+
`[Skill "${name}" is turned off — its instructions were removed. The user can re-enable it from /skills or /plugins.]`;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Replace every `<skill>` block belonging to a turned-off skill with a short
|
|
70
|
+
* refusal notice. Returns the rewritten text, or undefined when nothing
|
|
71
|
+
* needed redacting (so callers keep the original object untouched).
|
|
72
|
+
*
|
|
73
|
+
* This is the fail-closed backstop behind the `input`-hook interception:
|
|
74
|
+
* pi's `steer()`/`followUp()` expand `/skill:<name>` natively WITHOUT firing
|
|
75
|
+
* the `input` event (queued interactive messages after the first, RPC
|
|
76
|
+
* steer/followUp), so an off skill's instructions can land in the session
|
|
77
|
+
* history. Redacting on the `context` hook strips them from every outgoing
|
|
78
|
+
* request — the session file keeps the original bytes, and the rewrite is
|
|
79
|
+
* deterministic for a fixed override state, so the request prefix stays
|
|
80
|
+
* byte-stable across turns (prompt-cache friendly).
|
|
81
|
+
*/
|
|
82
|
+
export function redactOffSkillText(text: string, isOff: (name: string) => boolean): string | undefined {
|
|
83
|
+
if (!text.includes(SKILL_BLOCK_START)) return undefined;
|
|
84
|
+
let changed = false;
|
|
85
|
+
const redacted = text.replace(SKILL_BLOCK_RE, (block, name: string) => {
|
|
86
|
+
if (!isOff(name)) return block;
|
|
87
|
+
changed = true;
|
|
88
|
+
return offSkillNotice(name);
|
|
89
|
+
});
|
|
90
|
+
return changed ? redacted : undefined;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Apply redactOffSkillText across a request's messages (user and custom roles,
|
|
95
|
+
* string or text-block content). Returns a rewritten copy, or undefined when
|
|
96
|
+
* no message needed redacting — callers must then keep the original array so
|
|
97
|
+
* untouched requests stay byte-identical.
|
|
98
|
+
*/
|
|
99
|
+
export function redactOffSkillMessages<M extends { role: string; content?: unknown }>(
|
|
100
|
+
messages: readonly M[],
|
|
101
|
+
isOff: (name: string) => boolean,
|
|
102
|
+
): M[] | undefined {
|
|
103
|
+
let changed = false;
|
|
104
|
+
const out = messages.map((message) => {
|
|
105
|
+
if (message.role !== "user" && message.role !== "custom") return message;
|
|
106
|
+
if (typeof message.content === "string") {
|
|
107
|
+
const redacted = redactOffSkillText(message.content, isOff);
|
|
108
|
+
if (redacted === undefined) return message;
|
|
109
|
+
changed = true;
|
|
110
|
+
return { ...message, content: redacted };
|
|
111
|
+
}
|
|
112
|
+
if (!Array.isArray(message.content)) return message;
|
|
113
|
+
let blockChanged = false;
|
|
114
|
+
const blocks = message.content.map((block: { type?: string; text?: string }) => {
|
|
115
|
+
if (block?.type !== "text" || typeof block.text !== "string") return block;
|
|
116
|
+
const redacted = redactOffSkillText(block.text, isOff);
|
|
117
|
+
if (redacted === undefined) return block;
|
|
118
|
+
blockChanged = true;
|
|
119
|
+
return { ...block, text: redacted };
|
|
120
|
+
});
|
|
121
|
+
if (!blockChanged) return message;
|
|
122
|
+
changed = true;
|
|
123
|
+
return { ...message, content: blocks };
|
|
124
|
+
});
|
|
125
|
+
return changed ? out : undefined;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
// Bare `/<skill>` commands (Claude Code's invocation shape)
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* pi's interactive built-ins, matched by literal text before anything reaches
|
|
133
|
+
* an extension — a skill of the same name can never be invoked bare, so no
|
|
134
|
+
* alias is registered for it (its `/skill:<name>` form still works).
|
|
135
|
+
*/
|
|
136
|
+
export const PI_BUILTIN_COMMANDS: ReadonlySet<string> = new Set([
|
|
137
|
+
"changelog",
|
|
138
|
+
"clone",
|
|
139
|
+
"compact",
|
|
140
|
+
"copy",
|
|
141
|
+
"debug",
|
|
142
|
+
"export",
|
|
143
|
+
"fork",
|
|
144
|
+
"hotkeys",
|
|
145
|
+
"import",
|
|
146
|
+
"login",
|
|
147
|
+
"logout",
|
|
148
|
+
"model",
|
|
149
|
+
"name",
|
|
150
|
+
"new",
|
|
151
|
+
"quit",
|
|
152
|
+
"reload",
|
|
153
|
+
"resume",
|
|
154
|
+
"scoped-models",
|
|
155
|
+
"session",
|
|
156
|
+
"settings",
|
|
157
|
+
"share",
|
|
158
|
+
"thinking",
|
|
159
|
+
"tree",
|
|
160
|
+
"trust",
|
|
161
|
+
]);
|
|
162
|
+
|
|
163
|
+
/** A skill name that can be a bare slash command: one token, no `:` (plugin skills stay `<plugin>:<skill>`, as in Claude Code). */
|
|
164
|
+
export function isBareCommandName(name: string): boolean {
|
|
165
|
+
return /^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(name);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Which skills get a bare `/<name>` command, given the command names already
|
|
170
|
+
* taken (other extensions' commands, `.claude/commands` templates, pi's
|
|
171
|
+
* built-ins). Plugin skills are excluded (they keep `<plugin>:<skill>`), a
|
|
172
|
+
* name already taken keeps its owner (first-registered command wins; pi would
|
|
173
|
+
* otherwise rename BOTH to `name:1`/`name:2`), and a duplicate skill name
|
|
174
|
+
* registers once. Pure, so the collision policy is testable.
|
|
175
|
+
*/
|
|
176
|
+
export function skillCommandCandidates<T extends { name: string; source: string }>(skills: T[], taken: Iterable<string>): T[] {
|
|
177
|
+
const used = new Set<string>([...taken, ...PI_BUILTIN_COMMANDS]);
|
|
178
|
+
const out: T[] = [];
|
|
179
|
+
for (const skill of skills) {
|
|
180
|
+
if (skill.source === "plugin" || !isBareCommandName(skill.name) || used.has(skill.name)) continue;
|
|
181
|
+
used.add(skill.name);
|
|
182
|
+
out.push(skill);
|
|
183
|
+
}
|
|
184
|
+
return out;
|
|
185
|
+
}
|