@mrclrchtr/supi-skills 4.7.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.
- package/README.md +46 -0
- package/node_modules/@mrclrchtr/supi-core/README.md +112 -0
- package/node_modules/@mrclrchtr/supi-core/package.json +76 -0
- package/node_modules/@mrclrchtr/supi-core/src/api.ts +40 -0
- package/node_modules/@mrclrchtr/supi-core/src/config/config.ts +201 -0
- package/node_modules/@mrclrchtr/supi-core/src/config/prompt-surface.ts +363 -0
- package/node_modules/@mrclrchtr/supi-core/src/config.ts +10 -0
- package/node_modules/@mrclrchtr/supi-core/src/context/context-provider-registry.ts +36 -0
- package/node_modules/@mrclrchtr/supi-core/src/context/context-tag.ts +31 -0
- package/node_modules/@mrclrchtr/supi-core/src/context.ts +8 -0
- package/node_modules/@mrclrchtr/supi-core/src/debug-registry.ts +287 -0
- package/node_modules/@mrclrchtr/supi-core/src/evidence-badge.ts +41 -0
- package/node_modules/@mrclrchtr/supi-core/src/footer-registry.ts +57 -0
- package/node_modules/@mrclrchtr/supi-core/src/index.ts +34 -0
- package/node_modules/@mrclrchtr/supi-core/src/llm.ts +201 -0
- package/node_modules/@mrclrchtr/supi-core/src/model-selection.ts +134 -0
- package/node_modules/@mrclrchtr/supi-core/src/path-utils.ts +44 -0
- package/node_modules/@mrclrchtr/supi-core/src/path.ts +2 -0
- package/node_modules/@mrclrchtr/supi-core/src/project-roots.ts +170 -0
- package/node_modules/@mrclrchtr/supi-core/src/project.ts +15 -0
- package/node_modules/@mrclrchtr/supi-core/src/prompt-surface.ts +4 -0
- package/node_modules/@mrclrchtr/supi-core/src/registry-utils.ts +93 -0
- package/node_modules/@mrclrchtr/supi-core/src/report.ts +121 -0
- package/node_modules/@mrclrchtr/supi-core/src/session-utils.ts +71 -0
- package/node_modules/@mrclrchtr/supi-core/src/session.ts +8 -0
- package/node_modules/@mrclrchtr/supi-core/src/settings/settings-registry.ts +102 -0
- package/node_modules/@mrclrchtr/supi-core/src/settings/settings-schema.ts +453 -0
- package/node_modules/@mrclrchtr/supi-core/src/settings.ts +36 -0
- package/node_modules/@mrclrchtr/supi-core/src/spinner-frames.ts +11 -0
- package/node_modules/@mrclrchtr/supi-core/src/status-spinner.ts +68 -0
- package/node_modules/@mrclrchtr/supi-core/src/terminal.ts +60 -0
- package/package.json +64 -0
- package/src/extension.ts +9 -0
- package/src/skill-catalog.ts +153 -0
- package/src/skill-load-settings.ts +305 -0
- package/src/skill-model-invocation.ts +134 -0
- package/src/skill-settings.ts +400 -0
- package/src/skill-shortcut.ts +123 -0
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type BuildSystemPromptOptions,
|
|
3
|
+
formatSkillsForPrompt,
|
|
4
|
+
} from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import {
|
|
6
|
+
loadSupiConfigSectionForScope,
|
|
7
|
+
removeSupiConfigKey,
|
|
8
|
+
writeSupiConfig,
|
|
9
|
+
} from "@mrclrchtr/supi-core/config";
|
|
10
|
+
import type { SettingsScope, ValueSource } from "@mrclrchtr/supi-core/settings";
|
|
11
|
+
|
|
12
|
+
const CONFIG_SECTION = "skills";
|
|
13
|
+
const MODEL_INVOCATION_KEY = "modelInvocation";
|
|
14
|
+
|
|
15
|
+
export const ENABLED = "Enabled";
|
|
16
|
+
export const MODEL_DISABLED = "Model invocation disabled";
|
|
17
|
+
export const DISABLED = "Disabled";
|
|
18
|
+
|
|
19
|
+
function invocationMap(
|
|
20
|
+
scope: SettingsScope,
|
|
21
|
+
cwd: string,
|
|
22
|
+
homeDir?: string,
|
|
23
|
+
): Record<string, boolean> {
|
|
24
|
+
const section = loadSupiConfigSectionForScope(CONFIG_SECTION, cwd, { scope, homeDir });
|
|
25
|
+
const value = section?.[MODEL_INVOCATION_KEY];
|
|
26
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
|
|
27
|
+
return Object.fromEntries(
|
|
28
|
+
Object.entries(value).filter(
|
|
29
|
+
(entry): entry is [string, boolean] => typeof entry[1] === "boolean",
|
|
30
|
+
),
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface InvocationOptions {
|
|
35
|
+
name: string;
|
|
36
|
+
scope: SettingsScope;
|
|
37
|
+
cwd: string;
|
|
38
|
+
homeDir?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface ResolveInvocationOptions extends InvocationOptions {
|
|
42
|
+
sourceDefault: boolean;
|
|
43
|
+
projectTrusted: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Resolve a scoped model-invocation preference without reading untrusted project config. */
|
|
47
|
+
export function resolveInvocation({
|
|
48
|
+
name,
|
|
49
|
+
sourceDefault,
|
|
50
|
+
scope,
|
|
51
|
+
cwd,
|
|
52
|
+
projectTrusted,
|
|
53
|
+
homeDir,
|
|
54
|
+
}: ResolveInvocationOptions): { disabled: boolean; source: ValueSource } {
|
|
55
|
+
if (scope === "project" && projectTrusted) {
|
|
56
|
+
const project = invocationMap("project", cwd, homeDir);
|
|
57
|
+
if (Object.hasOwn(project, name)) {
|
|
58
|
+
return { disabled: project[name] ?? sourceDefault, source: "project" };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const global = invocationMap("global", cwd, homeDir);
|
|
62
|
+
if (Object.hasOwn(global, name)) {
|
|
63
|
+
return { disabled: global[name] ?? sourceDefault, source: "global" };
|
|
64
|
+
}
|
|
65
|
+
return { disabled: sourceDefault, source: "default" };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Set or remove one scoped model-invocation preference. */
|
|
69
|
+
export function persistInvocation({
|
|
70
|
+
name,
|
|
71
|
+
disabled,
|
|
72
|
+
scope,
|
|
73
|
+
cwd,
|
|
74
|
+
homeDir,
|
|
75
|
+
}: InvocationOptions & { disabled: boolean | undefined }): void {
|
|
76
|
+
const values = invocationMap(scope, cwd, homeDir);
|
|
77
|
+
if (disabled === undefined) delete values[name];
|
|
78
|
+
else {
|
|
79
|
+
Object.defineProperty(values, name, {
|
|
80
|
+
value: disabled,
|
|
81
|
+
enumerable: true,
|
|
82
|
+
configurable: true,
|
|
83
|
+
writable: true,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
if (Object.keys(values).length === 0) {
|
|
87
|
+
removeSupiConfigKey({ section: CONFIG_SECTION, scope, cwd }, MODEL_INVOCATION_KEY, {
|
|
88
|
+
homeDir,
|
|
89
|
+
});
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
writeSupiConfig(
|
|
93
|
+
{ section: CONFIG_SECTION, scope, cwd },
|
|
94
|
+
{ [MODEL_INVOCATION_KEY]: values },
|
|
95
|
+
{ homeDir },
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Replace PI's generated skill block with the effective scoped invocation state. */
|
|
100
|
+
export function applyPromptOverrides({
|
|
101
|
+
options,
|
|
102
|
+
systemPrompt,
|
|
103
|
+
cwd,
|
|
104
|
+
projectTrusted,
|
|
105
|
+
homeDir,
|
|
106
|
+
}: {
|
|
107
|
+
options: BuildSystemPromptOptions;
|
|
108
|
+
systemPrompt: string;
|
|
109
|
+
cwd: string;
|
|
110
|
+
projectTrusted: boolean;
|
|
111
|
+
homeDir?: string;
|
|
112
|
+
}): string | undefined {
|
|
113
|
+
if (options.selectedTools && !options.selectedTools.includes("read")) return undefined;
|
|
114
|
+
const skills = options.skills ?? [];
|
|
115
|
+
const effective = skills.map((skill) => ({
|
|
116
|
+
...skill,
|
|
117
|
+
disableModelInvocation: resolveInvocation({
|
|
118
|
+
name: skill.name,
|
|
119
|
+
sourceDefault: skill.disableModelInvocation,
|
|
120
|
+
scope: "project",
|
|
121
|
+
cwd,
|
|
122
|
+
projectTrusted,
|
|
123
|
+
homeDir,
|
|
124
|
+
}).disabled,
|
|
125
|
+
}));
|
|
126
|
+
const original = formatSkillsForPrompt(skills);
|
|
127
|
+
const replacement = formatSkillsForPrompt(effective);
|
|
128
|
+
if (original === replacement) return undefined;
|
|
129
|
+
if (!original) return `${systemPrompt}${replacement}`;
|
|
130
|
+
if (systemPrompt.includes(original)) return systemPrompt.replace(original, replacement);
|
|
131
|
+
// biome-ignore lint/suspicious/noConsole: prompt mismatch must not fail silently
|
|
132
|
+
console.warn("[supi-skills] Could not apply skill model-invocation overrides");
|
|
133
|
+
return undefined;
|
|
134
|
+
}
|
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DefaultPackageManager,
|
|
3
|
+
type ExtensionAPI,
|
|
4
|
+
type ExtensionContext,
|
|
5
|
+
getAgentDir,
|
|
6
|
+
type ResolvedResource,
|
|
7
|
+
SettingsManager,
|
|
8
|
+
} from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import {
|
|
10
|
+
registerSettings,
|
|
11
|
+
type ScopedFieldValue,
|
|
12
|
+
type SettingsAction,
|
|
13
|
+
type SettingsApplyResult,
|
|
14
|
+
type SettingsContext,
|
|
15
|
+
type SettingsModule,
|
|
16
|
+
type SettingsScope,
|
|
17
|
+
type ValueSource,
|
|
18
|
+
} from "@mrclrchtr/supi-core/settings";
|
|
19
|
+
import {
|
|
20
|
+
buildSkillCatalog,
|
|
21
|
+
mergeRuntimeSkills,
|
|
22
|
+
type SkillCatalog,
|
|
23
|
+
type SkillRecord,
|
|
24
|
+
skillSourceIdentity,
|
|
25
|
+
} from "./skill-catalog.ts";
|
|
26
|
+
import {
|
|
27
|
+
hasExactSkillLoadOverride,
|
|
28
|
+
type SkillLoadOverride,
|
|
29
|
+
updateSkillLoadOverrides,
|
|
30
|
+
} from "./skill-load-settings.ts";
|
|
31
|
+
import {
|
|
32
|
+
applyPromptOverrides,
|
|
33
|
+
DISABLED,
|
|
34
|
+
ENABLED,
|
|
35
|
+
MODEL_DISABLED,
|
|
36
|
+
persistInvocation,
|
|
37
|
+
resolveInvocation,
|
|
38
|
+
} from "./skill-model-invocation.ts";
|
|
39
|
+
|
|
40
|
+
const SETTINGS_SECTION_ID = "skills";
|
|
41
|
+
|
|
42
|
+
interface SkillSettingsOptions {
|
|
43
|
+
agentDir?: string;
|
|
44
|
+
homeDir?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface SkillSettingsControllerOptions {
|
|
48
|
+
cwd: string;
|
|
49
|
+
agentDir: string;
|
|
50
|
+
homeDir?: string;
|
|
51
|
+
projectTrusted: boolean;
|
|
52
|
+
settingsManager: SettingsManager;
|
|
53
|
+
globalSettingsManager: SettingsManager;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function recordResources(record: SkillRecord): ResolvedResource[] {
|
|
57
|
+
return record.sources.flatMap((source) => (source.resource ? [source.resource] : []));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function isLoaded(record: SkillRecord): boolean {
|
|
61
|
+
return (
|
|
62
|
+
record.activeSkill !== undefined ||
|
|
63
|
+
record.sources.some((source) => source.runtime || source.resource?.enabled)
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function canDisable(record: SkillRecord): boolean {
|
|
68
|
+
return record.sources.length > 0 && record.sources.every((source) => !source.runtime);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function sourceDefault(record: SkillRecord): boolean {
|
|
72
|
+
const winner =
|
|
73
|
+
record.activeSkill ??
|
|
74
|
+
record.sources.find((source) => source.runtime || source.resource?.enabled)?.skill ??
|
|
75
|
+
record.sources[0]?.skill;
|
|
76
|
+
return winner?.disableModelInvocation ?? false;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function moreSpecificSource(left: ValueSource, right: ValueSource): ValueSource {
|
|
80
|
+
const rank: Record<ValueSource, number> = { default: 0, global: 1, project: 2 };
|
|
81
|
+
return rank[left] >= rank[right] ? left : right;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function displayValue(value: string, source: ValueSource): string {
|
|
85
|
+
return `${value} (${source})`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function validateAction(record: SkillRecord, action: SettingsAction): void {
|
|
89
|
+
if (action.kind !== "set") return;
|
|
90
|
+
if (![ENABLED, MODEL_DISABLED, DISABLED].includes(action.value)) {
|
|
91
|
+
throw new Error(`Invalid skill state: "${action.value}"`);
|
|
92
|
+
}
|
|
93
|
+
if (action.value === DISABLED && !canDisable(record)) {
|
|
94
|
+
throw new Error(`Full disable is unavailable for skill "${record.name}"`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function loadOverride(
|
|
99
|
+
record: SkillRecord,
|
|
100
|
+
action: SettingsAction,
|
|
101
|
+
hasExactOverride: boolean,
|
|
102
|
+
): SkillLoadOverride | undefined {
|
|
103
|
+
if (action.kind === "unset") return hasExactOverride ? "inherit" : undefined;
|
|
104
|
+
if (action.value === DISABLED) return "unload";
|
|
105
|
+
return isLoaded(record) ? undefined : "load";
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
class SkillSettingsController {
|
|
109
|
+
private readonly cwd: string;
|
|
110
|
+
private readonly agentDir: string;
|
|
111
|
+
private readonly homeDir: string | undefined;
|
|
112
|
+
private readonly projectTrusted: boolean;
|
|
113
|
+
private readonly settingsManager: SettingsManager;
|
|
114
|
+
private readonly globalSettingsManager: SettingsManager;
|
|
115
|
+
private readonly pendingDisabled = new Map<string, Set<string>>();
|
|
116
|
+
private globalCatalog: SkillCatalog = new Map();
|
|
117
|
+
private projectCatalog: SkillCatalog = new Map();
|
|
118
|
+
|
|
119
|
+
private constructor(options: SkillSettingsControllerOptions) {
|
|
120
|
+
this.cwd = options.cwd;
|
|
121
|
+
this.agentDir = options.agentDir;
|
|
122
|
+
this.homeDir = options.homeDir;
|
|
123
|
+
this.projectTrusted = options.projectTrusted;
|
|
124
|
+
this.settingsManager = options.settingsManager;
|
|
125
|
+
this.globalSettingsManager = options.globalSettingsManager;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
static async create(
|
|
129
|
+
cwd: string,
|
|
130
|
+
projectTrusted: boolean,
|
|
131
|
+
options: SkillSettingsOptions,
|
|
132
|
+
): Promise<SkillSettingsController> {
|
|
133
|
+
const agentDir = options.agentDir ?? getAgentDir();
|
|
134
|
+
const controller = new SkillSettingsController({
|
|
135
|
+
cwd,
|
|
136
|
+
agentDir,
|
|
137
|
+
homeDir: options.homeDir,
|
|
138
|
+
projectTrusted,
|
|
139
|
+
settingsManager: SettingsManager.create(cwd, agentDir, { projectTrusted }),
|
|
140
|
+
globalSettingsManager: SettingsManager.create(cwd, agentDir, { projectTrusted: false }),
|
|
141
|
+
});
|
|
142
|
+
await controller.refreshCatalogs();
|
|
143
|
+
return controller;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
private async refreshCatalogs(): Promise<void> {
|
|
147
|
+
await Promise.all([this.settingsManager.reload(), this.globalSettingsManager.reload()]);
|
|
148
|
+
const [globalPaths, projectPaths] = await Promise.all([
|
|
149
|
+
new DefaultPackageManager({
|
|
150
|
+
cwd: this.cwd,
|
|
151
|
+
agentDir: this.agentDir,
|
|
152
|
+
settingsManager: this.globalSettingsManager,
|
|
153
|
+
}).resolve(async () => "skip"),
|
|
154
|
+
new DefaultPackageManager({
|
|
155
|
+
cwd: this.cwd,
|
|
156
|
+
agentDir: this.agentDir,
|
|
157
|
+
settingsManager: this.settingsManager,
|
|
158
|
+
}).resolve(async () => "skip"),
|
|
159
|
+
]);
|
|
160
|
+
this.globalCatalog = buildSkillCatalog(globalPaths.skills, this.cwd, this.agentDir);
|
|
161
|
+
this.projectCatalog = buildSkillCatalog(projectPaths.skills, this.cwd, this.agentDir);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
private records(scope: SettingsScope, ctx?: ExtensionContext): SkillCatalog {
|
|
165
|
+
return mergeRuntimeSkills(
|
|
166
|
+
scope === "project" ? this.projectCatalog : this.globalCatalog,
|
|
167
|
+
ctx,
|
|
168
|
+
scope,
|
|
169
|
+
this.pendingDisabled,
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Prefer original global provenance when a project support path resolves the
|
|
175
|
+
* same file as a project resource. This lets Unset remove both the exact
|
|
176
|
+
* override and the generated support path after a catalog refresh.
|
|
177
|
+
*/
|
|
178
|
+
private actionResources(record: SkillRecord, scope: SettingsScope): ResolvedResource[] {
|
|
179
|
+
const globalResources =
|
|
180
|
+
scope === "project" ? recordResources(this.globalCatalog.get(record.name) ?? record) : [];
|
|
181
|
+
const unique = new Map<string, ResolvedResource>();
|
|
182
|
+
for (const resource of [...globalResources, ...recordResources(record)]) {
|
|
183
|
+
const key = `${resource.path}\0${resource.metadata.origin}\0${resource.metadata.source}`;
|
|
184
|
+
if (!unique.has(key)) unique.set(key, resource);
|
|
185
|
+
}
|
|
186
|
+
return Array.from(unique.values());
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Attribute the effective load value to the narrowest scoped override.
|
|
191
|
+
* Project resources can be support-path surrogates, so attribution compares
|
|
192
|
+
* their effective load state with the original global catalog.
|
|
193
|
+
*/
|
|
194
|
+
private loadSource(record: SkillRecord, scope: SettingsScope): ValueSource {
|
|
195
|
+
const resources = recordResources(record);
|
|
196
|
+
const exact = hasExactSkillLoadOverride({
|
|
197
|
+
settingsManager: this.settingsManager,
|
|
198
|
+
resources,
|
|
199
|
+
scope,
|
|
200
|
+
cwd: this.cwd,
|
|
201
|
+
agentDir: this.agentDir,
|
|
202
|
+
});
|
|
203
|
+
if (exact) return scope;
|
|
204
|
+
if (scope === "global") {
|
|
205
|
+
return resources.some((resource) => !resource.enabled) ? "global" : "default";
|
|
206
|
+
}
|
|
207
|
+
if (resources.some((resource) => resource.metadata.scope === "project" && !resource.enabled)) {
|
|
208
|
+
return "project";
|
|
209
|
+
}
|
|
210
|
+
const globalRecord = this.globalCatalog.get(record.name);
|
|
211
|
+
if (!globalRecord) return "default";
|
|
212
|
+
const globalResources = recordResources(globalRecord);
|
|
213
|
+
const projectLoaded = resources.some((resource) => resource.enabled);
|
|
214
|
+
const globalLoaded = globalResources.some((resource) => resource.enabled);
|
|
215
|
+
if (projectLoaded !== globalLoaded) return "project";
|
|
216
|
+
return this.loadSource(globalRecord, "global");
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
private rowSource(
|
|
220
|
+
record: SkillRecord,
|
|
221
|
+
scope: SettingsScope,
|
|
222
|
+
invocationSource: ValueSource,
|
|
223
|
+
): ValueSource {
|
|
224
|
+
return moreSpecificSource(this.loadSource(record, scope), invocationSource);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
private row(record: SkillRecord, scope: SettingsScope): ScopedFieldValue {
|
|
228
|
+
const invocation = resolveInvocation({
|
|
229
|
+
name: record.name,
|
|
230
|
+
sourceDefault: sourceDefault(record),
|
|
231
|
+
scope,
|
|
232
|
+
cwd: this.cwd,
|
|
233
|
+
projectTrusted: this.projectTrusted,
|
|
234
|
+
homeDir: this.homeDir,
|
|
235
|
+
});
|
|
236
|
+
const value = !isLoaded(record) ? DISABLED : invocation.disabled ? MODEL_DISABLED : ENABLED;
|
|
237
|
+
const source = this.rowSource(record, scope, invocation.source);
|
|
238
|
+
const values = canDisable(record)
|
|
239
|
+
? [ENABLED, MODEL_DISABLED, DISABLED]
|
|
240
|
+
: [ENABLED, MODEL_DISABLED];
|
|
241
|
+
const sourceCount = record.sources.length;
|
|
242
|
+
const limitation = canDisable(record)
|
|
243
|
+
? ""
|
|
244
|
+
: " Full disable is unavailable because PI does not expose a load setting for one or more sources.";
|
|
245
|
+
return {
|
|
246
|
+
field: {
|
|
247
|
+
kind: "enum",
|
|
248
|
+
key: record.name,
|
|
249
|
+
label: record.name,
|
|
250
|
+
values,
|
|
251
|
+
description: `${record.description}${sourceCount > 1 ? ` ${sourceCount} sources.` : ""}${limitation}`,
|
|
252
|
+
},
|
|
253
|
+
displayValue: displayValue(value, source),
|
|
254
|
+
editValue: value,
|
|
255
|
+
source,
|
|
256
|
+
...(scope === "project" && source === "project"
|
|
257
|
+
? {
|
|
258
|
+
inheritanceSource:
|
|
259
|
+
this.globalRowSource(record.name) === "global" ? "global" : "default",
|
|
260
|
+
}
|
|
261
|
+
: {}),
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
private globalRowSource(name: string): ValueSource {
|
|
266
|
+
const record = this.globalCatalog.get(name);
|
|
267
|
+
if (!record) return "default";
|
|
268
|
+
const invocation = resolveInvocation({
|
|
269
|
+
name,
|
|
270
|
+
sourceDefault: sourceDefault(record),
|
|
271
|
+
scope: "global",
|
|
272
|
+
cwd: this.cwd,
|
|
273
|
+
projectTrusted: false,
|
|
274
|
+
homeDir: this.homeDir,
|
|
275
|
+
});
|
|
276
|
+
return this.rowSource(record, "global", invocation.source);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
read(scope: SettingsScope, ctx?: ExtensionContext): ScopedFieldValue[] {
|
|
280
|
+
return Array.from(this.records(scope, ctx).values())
|
|
281
|
+
.sort((left, right) => left.name.localeCompare(right.name))
|
|
282
|
+
.map((record) => this.row(record, scope));
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
private updatePendingDisabled(
|
|
286
|
+
fieldKey: string,
|
|
287
|
+
nextLoadOverride: SkillLoadOverride,
|
|
288
|
+
activeSkillIdentity: string | undefined,
|
|
289
|
+
): void {
|
|
290
|
+
if (nextLoadOverride !== "unload") {
|
|
291
|
+
this.pendingDisabled.delete(fieldKey);
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
if (activeSkillIdentity) {
|
|
295
|
+
this.pendingDisabled.set(fieldKey, new Set([activeSkillIdentity]));
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async apply(
|
|
300
|
+
scope: SettingsScope,
|
|
301
|
+
fieldKey: string,
|
|
302
|
+
action: SettingsAction,
|
|
303
|
+
ctx?: ExtensionContext,
|
|
304
|
+
): Promise<SettingsApplyResult> {
|
|
305
|
+
if (scope === "project" && !this.projectTrusted) {
|
|
306
|
+
throw new Error("Project is not trusted; refusing to write project skill settings");
|
|
307
|
+
}
|
|
308
|
+
const record = this.records(scope, ctx).get(fieldKey);
|
|
309
|
+
if (!record) return {};
|
|
310
|
+
validateAction(record, action);
|
|
311
|
+
const activeSkillIdentity = record.activeSkill
|
|
312
|
+
? skillSourceIdentity(record.activeSkill)
|
|
313
|
+
: undefined;
|
|
314
|
+
|
|
315
|
+
if (action.kind !== "set" || action.value !== DISABLED) {
|
|
316
|
+
persistInvocation({
|
|
317
|
+
name: fieldKey,
|
|
318
|
+
disabled: action.kind === "set" ? action.value === MODEL_DISABLED : undefined,
|
|
319
|
+
scope,
|
|
320
|
+
cwd: this.cwd,
|
|
321
|
+
homeDir: this.homeDir,
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const resources = this.actionResources(record, scope);
|
|
326
|
+
const hasLoadOverride = hasExactSkillLoadOverride({
|
|
327
|
+
settingsManager: this.settingsManager,
|
|
328
|
+
resources,
|
|
329
|
+
scope,
|
|
330
|
+
cwd: this.cwd,
|
|
331
|
+
agentDir: this.agentDir,
|
|
332
|
+
});
|
|
333
|
+
const nextLoadOverride = loadOverride(record, action, hasLoadOverride);
|
|
334
|
+
if (nextLoadOverride && resources.length > 0) {
|
|
335
|
+
updateSkillLoadOverrides({
|
|
336
|
+
settingsManager: this.settingsManager,
|
|
337
|
+
resources,
|
|
338
|
+
scope,
|
|
339
|
+
state: nextLoadOverride,
|
|
340
|
+
cwd: this.cwd,
|
|
341
|
+
agentDir: this.agentDir,
|
|
342
|
+
});
|
|
343
|
+
await this.settingsManager.flush();
|
|
344
|
+
const error = this.settingsManager.drainErrors()[0];
|
|
345
|
+
await this.refreshCatalogs();
|
|
346
|
+
if (error) throw error.error;
|
|
347
|
+
this.updatePendingDisabled(fieldKey, nextLoadOverride, activeSkillIdentity);
|
|
348
|
+
return {
|
|
349
|
+
notice: { message: "Reload required for skill load changes", level: "info" },
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
return {};
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function createSkillSettingsModule(options: SkillSettingsOptions): SettingsModule {
|
|
357
|
+
let controllerKey: string | undefined;
|
|
358
|
+
let controllerPromise: Promise<SkillSettingsController> | undefined;
|
|
359
|
+
|
|
360
|
+
const getController = (context: SettingsContext): Promise<SkillSettingsController> => {
|
|
361
|
+
const projectTrusted = context.ctx?.isProjectTrusted() ?? false;
|
|
362
|
+
const key = `${context.cwd}\0${projectTrusted}`;
|
|
363
|
+
if (!controllerPromise || controllerKey !== key) {
|
|
364
|
+
controllerKey = key;
|
|
365
|
+
controllerPromise = SkillSettingsController.create(context.cwd, projectTrusted, options);
|
|
366
|
+
}
|
|
367
|
+
return controllerPromise;
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
return {
|
|
371
|
+
id: SETTINGS_SECTION_ID,
|
|
372
|
+
label: "Skills",
|
|
373
|
+
read: async (context) => ({
|
|
374
|
+
rows: (await getController(context)).read(context.scope, context.ctx),
|
|
375
|
+
}),
|
|
376
|
+
apply: async (request) =>
|
|
377
|
+
(await getController(request)).apply(
|
|
378
|
+
request.scope,
|
|
379
|
+
request.fieldKey,
|
|
380
|
+
request.action,
|
|
381
|
+
request.ctx,
|
|
382
|
+
),
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** Register scoped skill availability settings and prompt overrides. */
|
|
387
|
+
export default function skillSettings(pi: ExtensionAPI, options: SkillSettingsOptions = {}): void {
|
|
388
|
+
registerSettings(pi, createSkillSettingsModule(options));
|
|
389
|
+
|
|
390
|
+
pi.on("before_agent_start", (event, ctx) => {
|
|
391
|
+
const systemPrompt = applyPromptOverrides({
|
|
392
|
+
options: event.systemPromptOptions,
|
|
393
|
+
systemPrompt: event.systemPrompt,
|
|
394
|
+
cwd: ctx.cwd,
|
|
395
|
+
projectTrusted: ctx.isProjectTrusted(),
|
|
396
|
+
homeDir: options.homeDir,
|
|
397
|
+
});
|
|
398
|
+
return systemPrompt === undefined ? undefined : { systemPrompt };
|
|
399
|
+
});
|
|
400
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { fuzzyFilter } from "@earendil-works/pi-tui";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Extension: `$` as a shortcut prefix for skills.
|
|
6
|
+
*
|
|
7
|
+
* - `$agent-browser` expands to `/skill:agent-browser`
|
|
8
|
+
* - Autocomplete triggers on `$` showing only skill names
|
|
9
|
+
* - Works anywhere in the prompt (after space or at start)
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const DELIMITERS = new Set([" ", "\t", "\n"]);
|
|
13
|
+
|
|
14
|
+
/** Find the `$token` at the cursor, or null if not in one. */
|
|
15
|
+
function extractDollarPrefix(textBeforeCursor: string): string | null {
|
|
16
|
+
// Walk backwards to find the start of the current token
|
|
17
|
+
for (let i = textBeforeCursor.length - 1; i >= 0; i--) {
|
|
18
|
+
const char = textBeforeCursor[i];
|
|
19
|
+
if (char && DELIMITERS.has(char)) {
|
|
20
|
+
// Hit a delimiter — the token starts at i+1
|
|
21
|
+
const token = textBeforeCursor.slice(i + 1);
|
|
22
|
+
return token.startsWith("$") ? token : null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
// Reached start of line
|
|
26
|
+
return textBeforeCursor.startsWith("$") ? textBeforeCursor : null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ── Extension entry point ─────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Register `$skill-name` → `/skill:skill-name` expansion and autocomplete.
|
|
33
|
+
*
|
|
34
|
+
* ## Behavior gotchas
|
|
35
|
+
*
|
|
36
|
+
* - Installed skill names are snapshotted at `session_start` via
|
|
37
|
+
* `pi.getCommands()`; after adding or removing skills, use `/reload` or
|
|
38
|
+
* start a new session before testing expansion behavior.
|
|
39
|
+
* - Outside `$...` tokens, autocomplete must delegate back to the current
|
|
40
|
+
* provider so built-in completion and file completion continue to work.
|
|
41
|
+
*
|
|
42
|
+
* ## Testing
|
|
43
|
+
*
|
|
44
|
+
* If behavior changes, test both:
|
|
45
|
+
* - expansion inside `$...` tokens
|
|
46
|
+
* - normal autocomplete everywhere else
|
|
47
|
+
*/
|
|
48
|
+
export default function (pi: ExtensionAPI) {
|
|
49
|
+
let skillNames: string[] = [];
|
|
50
|
+
let skillCommands: { name: string; description?: string }[] = [];
|
|
51
|
+
|
|
52
|
+
pi.on("session_start", (_event, ctx) => {
|
|
53
|
+
const commands = pi.getCommands();
|
|
54
|
+
skillCommands = commands
|
|
55
|
+
.filter((c) => c.source === "skill")
|
|
56
|
+
.map((c) => ({
|
|
57
|
+
name: c.name.replace(/^skill:/, ""),
|
|
58
|
+
description: c.description,
|
|
59
|
+
}));
|
|
60
|
+
skillNames = skillCommands.map((c) => c.name);
|
|
61
|
+
|
|
62
|
+
// Stack skill autocomplete on top of the built-in provider.
|
|
63
|
+
// addAutocompleteProvider takes a wrapper callback: (current) => provider.
|
|
64
|
+
ctx.ui.addAutocompleteProvider((current) => ({
|
|
65
|
+
triggerCharacters: ["$"],
|
|
66
|
+
async getSuggestions(lines, cursorLine, cursorCol, options) {
|
|
67
|
+
const textBeforeCursor = (lines[cursorLine] || "").slice(0, cursorCol);
|
|
68
|
+
const dollarPrefix = extractDollarPrefix(textBeforeCursor);
|
|
69
|
+
|
|
70
|
+
if (!dollarPrefix || dollarPrefix.includes(" ")) {
|
|
71
|
+
return current.getSuggestions(lines, cursorLine, cursorCol, options);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const query = dollarPrefix.slice(1);
|
|
75
|
+
const items = skillCommands.map((c) => ({
|
|
76
|
+
name: c.name,
|
|
77
|
+
description: c.description,
|
|
78
|
+
}));
|
|
79
|
+
const filtered = fuzzyFilter(items, query, (i) => i.name).map((i) => ({
|
|
80
|
+
value: i.name,
|
|
81
|
+
label: i.name,
|
|
82
|
+
...(i.description && { description: i.description }),
|
|
83
|
+
}));
|
|
84
|
+
return filtered.length
|
|
85
|
+
? { items: filtered, prefix: dollarPrefix }
|
|
86
|
+
: current.getSuggestions(lines, cursorLine, cursorCol, options);
|
|
87
|
+
},
|
|
88
|
+
// biome-ignore lint/complexity/useMaxParams: AutocompleteProvider interface
|
|
89
|
+
applyCompletion(lines, cursorLine, cursorCol, item, prefix) {
|
|
90
|
+
if (prefix.startsWith("$")) {
|
|
91
|
+
const line = lines[cursorLine] || "";
|
|
92
|
+
const before = line.slice(0, cursorCol - prefix.length);
|
|
93
|
+
const after = line.slice(cursorCol);
|
|
94
|
+
const newLine = `${before}$${item.value} ${after}`;
|
|
95
|
+
return {
|
|
96
|
+
lines: [...lines.slice(0, cursorLine), newLine, ...lines.slice(cursorLine + 1)],
|
|
97
|
+
cursorLine,
|
|
98
|
+
cursorCol: before.length + 1 + item.value.length + 1,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
return current.applyCompletion(lines, cursorLine, cursorCol, item, prefix);
|
|
102
|
+
},
|
|
103
|
+
shouldTriggerFileCompletion(lines, cursorLine, cursorCol) {
|
|
104
|
+
return current.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ?? true;
|
|
105
|
+
},
|
|
106
|
+
}));
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// Transform $skill-name → /skill:skill-name before agent processing
|
|
110
|
+
pi.on("input", (event) => {
|
|
111
|
+
const text = event.text.trim();
|
|
112
|
+
|
|
113
|
+
// Find all $skill-name tokens and replace them
|
|
114
|
+
const transformed = text.replace(/(?:^|(?<=\s))\$([a-z0-9][-a-z0-9]*)/g, (_match, name) => {
|
|
115
|
+
return skillNames.includes(name) ? `/skill:${name}` : _match;
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
if (transformed !== text) {
|
|
119
|
+
return { action: "transform" as const, text: transformed };
|
|
120
|
+
}
|
|
121
|
+
return { action: "continue" as const };
|
|
122
|
+
});
|
|
123
|
+
}
|