one-code-extension 0.1.4 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (300) hide show
  1. package/LICENSE +20 -0
  2. package/README.md +200 -172
  3. package/agents/explore.md +9 -6
  4. package/agents/general-purpose.md +1 -1
  5. package/agents/plan.md +6 -4
  6. package/docs/guide/README.md +52 -0
  7. package/docs/guide/bring-your-claude-code-setup.md +126 -0
  8. package/docs/guide/configuration.md +171 -0
  9. package/docs/guide/differences-from-claude-code.md +150 -0
  10. package/docs/guide/doctor.md +86 -0
  11. package/docs/guide/hooks.md +109 -0
  12. package/docs/guide/installation.md +124 -0
  13. package/docs/guide/permissions-modes-and-auto-mode.md +450 -0
  14. package/docs/guide/providers-and-models.md +194 -0
  15. package/docs/guide/reference.md +224 -0
  16. package/docs/guide/sessions-and-context.md +190 -0
  17. package/docs/guide/skills-plugins-and-mcp.md +176 -0
  18. package/docs/guide/subagents-and-workflows.md +193 -0
  19. package/docs/guide/tasks-and-background-work.md +111 -0
  20. package/docs/guide/terminal-interface.md +155 -0
  21. package/docs/guide/tools.md +175 -0
  22. package/docs/guide/troubleshooting.md +175 -0
  23. package/extensions/ask-user/index.ts +65 -71
  24. package/extensions/ask-user/questions.ts +15 -28
  25. package/extensions/ask-user/widget.ts +533 -0
  26. package/extensions/auto-mode/actions.ts +9 -26
  27. package/extensions/auto-mode/classifier-prompt.ts +127 -0
  28. package/extensions/auto-mode/classifier.ts +305 -227
  29. package/extensions/auto-mode/config.ts +279 -77
  30. package/extensions/auto-mode/defaults.ts +17 -60
  31. package/extensions/auto-mode/instructions.ts +2 -1
  32. package/extensions/auto-mode/model-picker.ts +53 -31
  33. package/extensions/auto-mode/model-select.ts +153 -82
  34. package/extensions/auto-mode/paths.ts +13 -1
  35. package/extensions/auto-mode/pause.ts +8 -0
  36. package/extensions/auto-mode/prompt.ts +205 -238
  37. package/extensions/auto-mode/recoverability.ts +229 -0
  38. package/extensions/auto-mode/rules.ts +92 -75
  39. package/extensions/auto-mode/safety-floor.ts +43 -12
  40. package/extensions/auto-mode/sensitive.ts +11 -0
  41. package/extensions/auto-mode/setup-run.ts +245 -0
  42. package/extensions/auto-mode/setup.ts +235 -0
  43. package/extensions/auto-mode/shell-analysis.ts +332 -26
  44. package/extensions/auto-mode/transcript.ts +102 -0
  45. package/extensions/background/index.ts +256 -43
  46. package/extensions/background/monitor-batch.ts +58 -0
  47. package/extensions/background/registry.ts +9 -0
  48. package/extensions/background/wakeup.ts +45 -0
  49. package/extensions/bash/background.ts +47 -22
  50. package/extensions/bash/guards.ts +229 -0
  51. package/extensions/bash/index.ts +81 -19
  52. package/extensions/branding/index.ts +44 -18
  53. package/extensions/branding/startup.ts +23 -8
  54. package/extensions/claude-compat/index.ts +17 -2
  55. package/extensions/claude-context/index.ts +41 -12
  56. package/extensions/clear/index.ts +27 -0
  57. package/extensions/compaction/fit.ts +121 -0
  58. package/extensions/compaction/index.ts +176 -71
  59. package/extensions/compaction/prompt.ts +21 -1
  60. package/extensions/context-budget/budget.ts +61 -0
  61. package/extensions/context-budget/index.ts +66 -0
  62. package/extensions/context-management/index.ts +40 -16
  63. package/extensions/doctor/build.ts +226 -0
  64. package/extensions/doctor/cli.ts +99 -0
  65. package/extensions/doctor/compat.ts +532 -0
  66. package/extensions/doctor/dependencies.ts +186 -0
  67. package/extensions/doctor/fix-prompt.ts +125 -0
  68. package/extensions/doctor/index.ts +308 -0
  69. package/extensions/doctor/models.ts +231 -0
  70. package/extensions/doctor/presets.ts +208 -0
  71. package/extensions/doctor/report.ts +189 -0
  72. package/extensions/doctor/update-lookup.ts +46 -0
  73. package/extensions/doctor/viewer.ts +124 -0
  74. package/extensions/effort/index.ts +41 -4
  75. package/extensions/effort/slider.ts +33 -2
  76. package/extensions/file-tracker/index.ts +143 -20
  77. package/extensions/file-tracker/replay.ts +46 -0
  78. package/extensions/file-tracker/tracker.ts +51 -4
  79. package/extensions/footer/footer-line.ts +207 -0
  80. package/extensions/footer/index.ts +152 -0
  81. package/extensions/footer/pr.ts +53 -0
  82. package/extensions/hooks/executor.ts +57 -20
  83. package/extensions/hooks/formatter-notice.ts +76 -0
  84. package/extensions/hooks/index.ts +314 -73
  85. package/extensions/hooks/matcher.ts +35 -1
  86. package/extensions/hooks/plugin-hooks.ts +3 -3
  87. package/extensions/hooks/protocol.ts +58 -0
  88. package/extensions/hooks/subagent-bridge.ts +77 -0
  89. package/extensions/hooks/trust.ts +15 -4
  90. package/extensions/init/index.ts +26 -0
  91. package/extensions/init/prompt.ts +40 -0
  92. package/extensions/interrupted/index.ts +35 -0
  93. package/extensions/interrupted/line.ts +9 -0
  94. package/extensions/lib/abort.ts +15 -0
  95. package/extensions/lib/agent-loader.ts +183 -0
  96. package/extensions/lib/anthropic-payload.ts +15 -0
  97. package/extensions/lib/atomic-write.ts +56 -0
  98. package/extensions/lib/bridge-watch.ts +20 -0
  99. package/extensions/lib/cache-retention.ts +60 -0
  100. package/extensions/lib/capability-index.ts +466 -0
  101. package/extensions/lib/claude-context.ts +318 -27
  102. package/extensions/lib/claude-settings.ts +64 -0
  103. package/extensions/lib/consent-preview.ts +30 -0
  104. package/extensions/lib/deferred.ts +144 -13
  105. package/extensions/lib/favorites.ts +43 -0
  106. package/extensions/lib/fetch-timeout.ts +15 -0
  107. package/extensions/lib/frontmatter.ts +31 -0
  108. package/extensions/lib/git.ts +44 -2
  109. package/extensions/lib/hook-gate.ts +90 -0
  110. package/extensions/lib/interrupt.ts +94 -0
  111. package/extensions/lib/mcp-overrides.ts +61 -0
  112. package/extensions/lib/mcp-share.ts +25 -0
  113. package/extensions/lib/mcp-status.ts +34 -0
  114. package/extensions/lib/memory.ts +41 -6
  115. package/extensions/lib/model-facts.generated.json +2480 -0
  116. package/extensions/lib/model-facts.ts +162 -0
  117. package/extensions/lib/model-policy.ts +145 -189
  118. package/extensions/lib/model-tier.ts +480 -64
  119. package/extensions/lib/model-unusable.ts +37 -0
  120. package/extensions/lib/notifications.ts +320 -1
  121. package/extensions/lib/one-code-settings.ts +63 -0
  122. package/extensions/lib/original-command.ts +71 -0
  123. package/extensions/lib/package-version.ts +31 -0
  124. package/extensions/lib/paths.ts +39 -4
  125. package/extensions/lib/permission-gate.ts +192 -0
  126. package/extensions/lib/persisted-output.ts +18 -0
  127. package/extensions/lib/pi-ai-estimate.ts +160 -0
  128. package/extensions/lib/pi-version.ts +1 -1
  129. package/extensions/lib/plan-mode-channels.ts +11 -0
  130. package/extensions/lib/plugin-overrides.ts +24 -0
  131. package/extensions/lib/plugin-root.ts +44 -0
  132. package/extensions/lib/plugins.ts +233 -54
  133. package/extensions/lib/prefix-warm-gate.ts +157 -0
  134. package/extensions/lib/process-tree.ts +56 -0
  135. package/extensions/lib/reminders.ts +321 -68
  136. package/extensions/lib/scratchpad.ts +11 -8
  137. package/extensions/lib/session-lifecycle.ts +28 -0
  138. package/extensions/lib/settings-channels.ts +14 -0
  139. package/extensions/lib/shell-tasks.ts +54 -0
  140. package/extensions/lib/skill-overrides.ts +86 -0
  141. package/extensions/lib/skill-scan.ts +110 -0
  142. package/extensions/lib/text-width.ts +267 -0
  143. package/extensions/lib/tui-render.ts +281 -54
  144. package/extensions/lib/usage-bus.ts +75 -0
  145. package/extensions/lib/usage-tracker.ts +67 -0
  146. package/extensions/lib/worktree-isolation.ts +170 -0
  147. package/extensions/lsp/client.ts +69 -12
  148. package/extensions/lsp/format.ts +12 -2
  149. package/extensions/lsp/index.ts +273 -84
  150. package/extensions/lsp/install-hints.ts +43 -0
  151. package/extensions/lsp/keep-alive.ts +19 -0
  152. package/extensions/lsp/plugin-servers.ts +331 -0
  153. package/extensions/lsp/watcher.ts +157 -0
  154. package/extensions/mcp/announce.ts +99 -0
  155. package/extensions/mcp/client.ts +181 -22
  156. package/extensions/mcp/config.ts +55 -24
  157. package/extensions/mcp/index.ts +586 -86
  158. package/extensions/mcp/oauth/browser.ts +26 -0
  159. package/extensions/mcp/oauth/callback.ts +104 -0
  160. package/extensions/mcp/oauth/flow.ts +68 -0
  161. package/extensions/mcp/oauth/provider.ts +147 -0
  162. package/extensions/mcp/oauth/store.ts +86 -0
  163. package/extensions/mcp/panel/keys.ts +34 -0
  164. package/extensions/mcp/panel/model.ts +62 -0
  165. package/extensions/mcp/panel/render.ts +141 -0
  166. package/extensions/mcp/panel/state.ts +108 -0
  167. package/extensions/mcp/schema.ts +83 -6
  168. package/extensions/mcp/trust.ts +340 -0
  169. package/extensions/memory/entries.ts +163 -0
  170. package/extensions/memory/index.ts +119 -2
  171. package/extensions/memory/open-external.ts +97 -0
  172. package/extensions/memory/panel.ts +124 -0
  173. package/extensions/model-default/index.ts +74 -0
  174. package/extensions/notebook/index.ts +36 -18
  175. package/extensions/notebook/notebook.ts +110 -17
  176. package/extensions/permissions/index.ts +1012 -247
  177. package/extensions/permissions/matcher.ts +488 -57
  178. package/extensions/permissions/modes.ts +55 -7
  179. package/extensions/permissions/project-trust.ts +81 -0
  180. package/extensions/permissions/protected-paths.ts +16 -3
  181. package/extensions/permissions/rule-prose.ts +79 -0
  182. package/extensions/permissions/session-grant.ts +91 -0
  183. package/extensions/permissions/settings.ts +112 -33
  184. package/extensions/permissions/subagent-gate.ts +57 -0
  185. package/extensions/plan-mode/index.ts +143 -33
  186. package/extensions/plan-mode/reminder.ts +16 -19
  187. package/extensions/plan-mode/viewer.ts +40 -31
  188. package/extensions/plugins/counts.ts +68 -0
  189. package/extensions/plugins/index.ts +496 -32
  190. package/extensions/plugins/install/install.ts +167 -0
  191. package/extensions/plugins/install/paths.ts +23 -0
  192. package/extensions/plugins/install/registry.ts +72 -0
  193. package/extensions/plugins/marketplace/git.ts +103 -0
  194. package/extensions/plugins/marketplace/official.ts +36 -0
  195. package/extensions/plugins/marketplace/parse.ts +94 -0
  196. package/extensions/plugins/marketplace/registry.ts +60 -0
  197. package/extensions/plugins/marketplace/sync.ts +146 -0
  198. package/extensions/plugins/marketplace/types.ts +128 -0
  199. package/extensions/plugins/panel/keys.ts +52 -0
  200. package/extensions/plugins/panel/render.ts +315 -0
  201. package/extensions/plugins/panel/rows.ts +230 -0
  202. package/extensions/plugins/panel/state.ts +333 -0
  203. package/extensions/recap/index.ts +215 -0
  204. package/extensions/recap/prompt.ts +44 -0
  205. package/extensions/recap/scheduler.ts +87 -0
  206. package/extensions/search-tools/index.ts +0 -0
  207. package/extensions/search-tools/tools.ts +7 -5
  208. package/extensions/skill/index.ts +360 -34
  209. package/extensions/skill/invoke.ts +185 -0
  210. package/extensions/skill/listing.ts +21 -0
  211. package/extensions/skill/panel/keys.ts +41 -0
  212. package/extensions/skill/panel/render.ts +91 -0
  213. package/extensions/skill/panel/state.ts +115 -0
  214. package/extensions/spinner/index.ts +110 -0
  215. package/extensions/spinner/line.ts +51 -0
  216. package/extensions/spinner/verbs.ts +201 -0
  217. package/extensions/subagents/agents.ts +88 -44
  218. package/extensions/subagents/default-model.ts +62 -32
  219. package/extensions/subagents/delegation-steer.ts +23 -0
  220. package/extensions/subagents/hand-back-review.ts +67 -0
  221. package/extensions/subagents/index.ts +1545 -346
  222. package/extensions/subagents/live-runs.ts +293 -0
  223. package/extensions/subagents/model-select.ts +252 -82
  224. package/extensions/subagents/outcome.ts +90 -0
  225. package/extensions/subagents/panel-keys.ts +41 -0
  226. package/extensions/subagents/panel-render.ts +357 -0
  227. package/extensions/subagents/panel-widget.ts +330 -0
  228. package/extensions/subagents/pending-claim.ts +45 -0
  229. package/extensions/subagents/prose.ts +105 -0
  230. package/extensions/subagents/runner.ts +674 -0
  231. package/extensions/subagents/runs.ts +68 -1
  232. package/extensions/subagents/send-to-main-tool.ts +55 -0
  233. package/extensions/subagents/session-turns.ts +138 -0
  234. package/extensions/subagents/shell-panel.ts +198 -0
  235. package/extensions/subagents/usage.ts +16 -4
  236. package/extensions/subagents/worktree.ts +13 -0
  237. package/extensions/system-prompt/environment.ts +5 -3
  238. package/extensions/system-prompt/git-status.ts +93 -0
  239. package/extensions/system-prompt/index.ts +23 -1
  240. package/extensions/system-prompt/template.ts +34 -4
  241. package/extensions/system-prompt/tiers/common.ts +31 -6
  242. package/extensions/system-prompt/tiers/low.ts +13 -3
  243. package/extensions/system-prompt/tiers/mid.ts +21 -8
  244. package/extensions/system-reminder/index.ts +53 -9
  245. package/extensions/tasks/index.ts +65 -16
  246. package/extensions/tasks/store.ts +76 -3
  247. package/extensions/tool-search/announce.ts +42 -0
  248. package/extensions/tool-search/index.ts +136 -13
  249. package/extensions/turn-duration/index.ts +60 -0
  250. package/extensions/turn-duration/line.ts +22 -0
  251. package/extensions/turn-duration/span.ts +41 -0
  252. package/extensions/turn-duration/verbs.ts +24 -0
  253. package/extensions/web/backends.ts +421 -0
  254. package/extensions/web/index.ts +178 -21
  255. package/extensions/web-fetch/index.ts +74 -18
  256. package/extensions/web-fetch/summarize.ts +1 -1
  257. package/extensions/workflow/agent-session.ts +182 -112
  258. package/extensions/workflow/globals.ts +129 -106
  259. package/extensions/workflow/index.ts +343 -28
  260. package/extensions/workflow/records.ts +118 -0
  261. package/extensions/workflow/run-manager.ts +80 -31
  262. package/extensions/workflow/saved-workflows.ts +2 -1
  263. package/extensions/workflow/script-worker.mjs +110 -0
  264. package/extensions/workflow/types.ts +74 -8
  265. package/extensions/workflow/viewer.ts +562 -0
  266. package/extensions/workflow/vm-runtime.ts +118 -22
  267. package/extensions/workflow/widget.ts +126 -18
  268. package/extensions/worktree/guards.ts +240 -0
  269. package/extensions/worktree/index.ts +30 -5
  270. package/extensions/worktree/rewrite.ts +32 -16
  271. package/package.json +20 -10
  272. package/skills/code-review/SKILL.md +165 -0
  273. package/skills/fewer-permission-prompts/SKILL.md +61 -0
  274. package/skills/security-review/SKILL.md +178 -0
  275. package/skills/simplify/SKILL.md +70 -0
  276. package/themes/{one-code-light.json → onecode-light.json} +6 -1
  277. package/themes/{one-code.json → onecode.json} +6 -1
  278. package/types/app.d.ts +8 -1
  279. package/types/community-packages.d.ts +45 -2
  280. package/docs/decisions/auto-mode.md +0 -526
  281. package/docs/decisions/branding.md +0 -127
  282. package/docs/decisions/code-review-remediation.md +0 -106
  283. package/docs/decisions/compaction.md +0 -140
  284. package/docs/decisions/distribution.md +0 -168
  285. package/docs/decisions/hooks.md +0 -64
  286. package/docs/decisions/lsp.md +0 -58
  287. package/docs/decisions/mcp.md +0 -104
  288. package/docs/decisions/memory-state.md +0 -170
  289. package/docs/decisions/model-policy.md +0 -126
  290. package/docs/decisions/modes.md +0 -178
  291. package/docs/decisions/skills-plugins.md +0 -56
  292. package/docs/decisions/subagents-workflows.md +0 -225
  293. package/docs/decisions/system-prompt.md +0 -98
  294. package/docs/decisions/tools.md +0 -407
  295. package/docs/decisions/tui.md +0 -217
  296. package/docs/decisions.md +0 -148
  297. package/extensions/subagents/child.ts +0 -414
  298. package/extensions/subagents/rpc-turns.ts +0 -123
  299. package/extensions/todo/index.ts +0 -133
  300. 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 { join } from "node:path";
20
- import { discoverPlugins } from "../lib/plugins.ts";
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 { ccToolRenderers } from "../lib/tui-render.ts";
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: record.path ?? record.filePath ?? "",
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
- const index = (): IndexedSkill[] => [
61
- ...piSkills.filter((skill) => skill.path),
62
- ...discoverPlugins(join(os.homedir(), ".claude")).skills.map((skill) => ({
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
- const describe = () => {
70
- const all = index();
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: a packaged set of instructions for a particular kind of task. Call this when the task at hand matches an available skill, or when the user asks for one by name (including `/<name>`). Returns the skill's instructions to follow. Plugin skills are named `<plugin>:<skill>`. Use `list` to see what is available.",
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${describe()}` }],
102
- details: { skills: index().map((s) => s.name) } as Record<string, unknown>,
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${describe()}`,
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
- const all = index();
120
- const found =
121
- all.find((skill) => skill.name === wanted) ??
122
- all.find((skill) => skill.name.toLowerCase() === wanted.toLowerCase()) ??
123
- // A bare name matches a plugin skill when it is unambiguous.
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: `No skill named "${params.skill}".\n\nAvailable skills:\n${describe()}` }],
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 = parseFrontmatter(readFileSync(found.path, "utf-8")) as { body: string };
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: "List available skills",
476
+ description: "View and manage skills (on / name-only / user-only / off)",
166
477
  handler: async (_args, ctx) => {
167
- ctx.ui.notify(`Available skills:\n${describe()}`, "info");
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
+ }