@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.
Files changed (38) hide show
  1. package/README.md +46 -0
  2. package/node_modules/@mrclrchtr/supi-core/README.md +112 -0
  3. package/node_modules/@mrclrchtr/supi-core/package.json +76 -0
  4. package/node_modules/@mrclrchtr/supi-core/src/api.ts +40 -0
  5. package/node_modules/@mrclrchtr/supi-core/src/config/config.ts +201 -0
  6. package/node_modules/@mrclrchtr/supi-core/src/config/prompt-surface.ts +363 -0
  7. package/node_modules/@mrclrchtr/supi-core/src/config.ts +10 -0
  8. package/node_modules/@mrclrchtr/supi-core/src/context/context-provider-registry.ts +36 -0
  9. package/node_modules/@mrclrchtr/supi-core/src/context/context-tag.ts +31 -0
  10. package/node_modules/@mrclrchtr/supi-core/src/context.ts +8 -0
  11. package/node_modules/@mrclrchtr/supi-core/src/debug-registry.ts +287 -0
  12. package/node_modules/@mrclrchtr/supi-core/src/evidence-badge.ts +41 -0
  13. package/node_modules/@mrclrchtr/supi-core/src/footer-registry.ts +57 -0
  14. package/node_modules/@mrclrchtr/supi-core/src/index.ts +34 -0
  15. package/node_modules/@mrclrchtr/supi-core/src/llm.ts +201 -0
  16. package/node_modules/@mrclrchtr/supi-core/src/model-selection.ts +134 -0
  17. package/node_modules/@mrclrchtr/supi-core/src/path-utils.ts +44 -0
  18. package/node_modules/@mrclrchtr/supi-core/src/path.ts +2 -0
  19. package/node_modules/@mrclrchtr/supi-core/src/project-roots.ts +170 -0
  20. package/node_modules/@mrclrchtr/supi-core/src/project.ts +15 -0
  21. package/node_modules/@mrclrchtr/supi-core/src/prompt-surface.ts +4 -0
  22. package/node_modules/@mrclrchtr/supi-core/src/registry-utils.ts +93 -0
  23. package/node_modules/@mrclrchtr/supi-core/src/report.ts +121 -0
  24. package/node_modules/@mrclrchtr/supi-core/src/session-utils.ts +71 -0
  25. package/node_modules/@mrclrchtr/supi-core/src/session.ts +8 -0
  26. package/node_modules/@mrclrchtr/supi-core/src/settings/settings-registry.ts +102 -0
  27. package/node_modules/@mrclrchtr/supi-core/src/settings/settings-schema.ts +453 -0
  28. package/node_modules/@mrclrchtr/supi-core/src/settings.ts +36 -0
  29. package/node_modules/@mrclrchtr/supi-core/src/spinner-frames.ts +11 -0
  30. package/node_modules/@mrclrchtr/supi-core/src/status-spinner.ts +68 -0
  31. package/node_modules/@mrclrchtr/supi-core/src/terminal.ts +60 -0
  32. package/package.json +64 -0
  33. package/src/extension.ts +9 -0
  34. package/src/skill-catalog.ts +153 -0
  35. package/src/skill-load-settings.ts +305 -0
  36. package/src/skill-model-invocation.ts +134 -0
  37. package/src/skill-settings.ts +400 -0
  38. package/src/skill-shortcut.ts +123 -0
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Shared terminal title formatting and signaling utilities.
3
+ *
4
+ * Centralized place for pi title convention (π prefix), completion (✓)
5
+ * and waiting (●) indicators, and the audible terminal bell.
6
+ */
7
+ import path from "node:path";
8
+
9
+ /** Unicode checkmark shown when the agent finishes a turn. */
10
+ export const DONE_SYMBOL = "\u2713";
11
+ /** Unicode dot shown when waiting for user input. */
12
+ export const WAITING_SYMBOL = "\u25CF";
13
+
14
+ /** Minimal UI surface needed for title operations. */
15
+ export interface TitleTarget {
16
+ ui: {
17
+ setTitle?(title: string): void;
18
+ };
19
+ }
20
+
21
+ /**
22
+ * Format pi's canonical terminal title from session name and cwd.
23
+ * Falls back gracefully when either is missing.
24
+ *
25
+ * @example
26
+ * formatTitle("my-session", "/home/projects/foo") // "π - my-session - foo"
27
+ * formatTitle(undefined, "/home/projects/foo") // "π - foo"
28
+ * formatTitle("my-session") // "π - my-session"
29
+ * formatTitle() // "π"
30
+ */
31
+ export function formatTitle(sessionName?: string, cwd?: string): string {
32
+ const base = cwd ? path.basename(cwd) : undefined;
33
+ if (sessionName && base) return `π - ${sessionName} - ${base}`;
34
+ if (sessionName) return `π - ${sessionName}`;
35
+ if (base) return `π - ${base}`;
36
+ return "π";
37
+ }
38
+
39
+ /** Sound the audible terminal bell (ASCII BEL). */
40
+ export function signalBell(): void {
41
+ process.stdout.write("\x07");
42
+ }
43
+
44
+ /**
45
+ * Set the terminal title to indicate the agent is waiting for user input.
46
+ * Prefixes with ● and sounds the terminal bell.
47
+ */
48
+ export function signalWaiting(ctx: TitleTarget, title: string): void {
49
+ ctx.ui.setTitle?.(`${WAITING_SYMBOL} ${title}`);
50
+ signalBell();
51
+ }
52
+
53
+ /**
54
+ * Set the terminal title to indicate the agent turn has completed.
55
+ * Prefixes with ✓ and sounds the terminal bell.
56
+ */
57
+ export function signalDone(ctx: TitleTarget, title: string): void {
58
+ ctx.ui.setTitle?.(`${DONE_SYMBOL} ${title}`);
59
+ signalBell();
60
+ }
package/package.json ADDED
@@ -0,0 +1,64 @@
1
+ {
2
+ "name": "@mrclrchtr/supi-skills",
3
+ "version": "4.7.0",
4
+ "description": "Scoped skill controls and skill input shortcuts for PI",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/mrclrchtr/supi.git",
9
+ "directory": "packages/supi-skills"
10
+ },
11
+ "homepage": "https://github.com/mrclrchtr/supi/tree/main/packages/supi-skills#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/mrclrchtr/supi/issues"
14
+ },
15
+ "publishConfig": {
16
+ "access": "public"
17
+ },
18
+ "keywords": [
19
+ "pi-package",
20
+ "pi",
21
+ "pi-coding-agent",
22
+ "pi-extension",
23
+ "skills",
24
+ "skill-settings",
25
+ "shortcuts"
26
+ ],
27
+ "type": "module",
28
+ "files": [
29
+ "src/**/*.ts",
30
+ "README.md"
31
+ ],
32
+ "dependencies": {
33
+ "@mrclrchtr/supi-core": "workspace:*"
34
+ },
35
+ "bundledDependencies": [
36
+ "@mrclrchtr/supi-core"
37
+ ],
38
+ "peerDependencies": {
39
+ "@earendil-works/pi-coding-agent": "*",
40
+ "@earendil-works/pi-tui": "*"
41
+ },
42
+ "peerDependenciesMeta": {
43
+ "@earendil-works/pi-coding-agent": {
44
+ "optional": true
45
+ },
46
+ "@earendil-works/pi-tui": {
47
+ "optional": true
48
+ }
49
+ },
50
+ "devDependencies": {
51
+ "@mrclrchtr/supi-test-utils": "workspace:*",
52
+ "vitest": "4.1.10"
53
+ },
54
+ "pi": {
55
+ "extensions": [
56
+ "./src/extension.ts"
57
+ ],
58
+ "image": "https://raw.githubusercontent.com/mrclrchtr/supi/main/packages/supi-skills/assets/social-preview.png"
59
+ },
60
+ "exports": {
61
+ "./extension": "./src/extension.ts",
62
+ "./package.json": "./package.json"
63
+ }
64
+ }
@@ -0,0 +1,9 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import skillSettings from "./skill-settings.ts";
3
+ import skillShortcut from "./skill-shortcut.ts";
4
+
5
+ /** Register scoped skill controls and `$skill-name` input shortcuts. */
6
+ export default function supiSkills(pi: ExtensionAPI): void {
7
+ skillShortcut(pi);
8
+ skillSettings(pi);
9
+ }
@@ -0,0 +1,153 @@
1
+ import { realpathSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import {
4
+ type ExtensionCommandContext,
5
+ type ExtensionContext,
6
+ loadSkills,
7
+ type ResolvedResource,
8
+ type Skill,
9
+ } from "@earendil-works/pi-coding-agent";
10
+ import type { SettingsScope } from "@mrclrchtr/supi-core/settings";
11
+
12
+ export interface SkillSource {
13
+ skill: Skill;
14
+ resource?: ResolvedResource;
15
+ runtime: boolean;
16
+ }
17
+
18
+ export interface SkillRecord {
19
+ name: string;
20
+ description: string;
21
+ sources: SkillSource[];
22
+ activeSkill?: Skill;
23
+ }
24
+
25
+ export type SkillCatalog = Map<string, SkillRecord>;
26
+
27
+ function canonicalPath(path: string): string {
28
+ try {
29
+ return realpathSync(path);
30
+ } catch {
31
+ return resolve(path);
32
+ }
33
+ }
34
+
35
+ export function skillSourceIdentity(skill: Skill): string {
36
+ return [
37
+ canonicalPath(skill.filePath),
38
+ skill.sourceInfo.source,
39
+ skill.sourceInfo.scope,
40
+ skill.sourceInfo.origin,
41
+ ].join("\0");
42
+ }
43
+
44
+ function hasStaticProvenance(skill: Skill, source: SkillSource): boolean {
45
+ const resource = source.resource;
46
+ if (!resource) return false;
47
+ const scope = resource.metadata.scope === "project" ? "project" : "user";
48
+ return (
49
+ skill.sourceInfo.source === resource.metadata.source &&
50
+ skill.sourceInfo.scope === scope &&
51
+ skill.sourceInfo.origin === resource.metadata.origin
52
+ );
53
+ }
54
+
55
+ function addSource(catalog: SkillCatalog, source: SkillSource): void {
56
+ const current = catalog.get(source.skill.name);
57
+ if (current) {
58
+ current.sources.push(source);
59
+ return;
60
+ }
61
+ catalog.set(source.skill.name, {
62
+ name: source.skill.name,
63
+ description: source.skill.description,
64
+ sources: [source],
65
+ });
66
+ }
67
+
68
+ /** Load every resolved resource, including resources that PI currently filters out. */
69
+ export function buildSkillCatalog(
70
+ resources: ResolvedResource[],
71
+ cwd: string,
72
+ agentDir: string,
73
+ ): SkillCatalog {
74
+ const catalog: SkillCatalog = new Map();
75
+ for (const resource of resources) {
76
+ const result = loadSkills({
77
+ cwd,
78
+ agentDir,
79
+ skillPaths: [resource.path],
80
+ includeDefaults: false,
81
+ });
82
+ for (const skill of result.skills) {
83
+ addSource(catalog, { skill, resource, runtime: false });
84
+ }
85
+ }
86
+ return catalog;
87
+ }
88
+
89
+ function cloneCatalog(catalog: SkillCatalog): SkillCatalog {
90
+ return new Map(
91
+ Array.from(catalog, ([name, record]) => [
92
+ name,
93
+ { ...record, sources: record.sources.map((source) => ({ ...source })) },
94
+ ]),
95
+ );
96
+ }
97
+
98
+ function contextSkills(ctx?: ExtensionContext): Skill[] {
99
+ const commandCtx = ctx as Partial<ExtensionCommandContext> | undefined;
100
+ return commandCtx?.getSystemPromptOptions?.().skills ?? [];
101
+ }
102
+
103
+ /**
104
+ * Reconcile PI's active winner with resolved static sources.
105
+ *
106
+ * A pending identity suppresses only the stale pre-reload static winner. A
107
+ * different public provenance remains a runtime source and prevents full disable.
108
+ */
109
+ function mergeRuntimeSkill(
110
+ catalog: SkillCatalog,
111
+ skill: Skill,
112
+ pendingDisabled: ReadonlyMap<string, ReadonlySet<string>>,
113
+ ): void {
114
+ const record = catalog.get(skill.name);
115
+ if (!record) {
116
+ addSource(catalog, { skill, runtime: true });
117
+ const added = catalog.get(skill.name);
118
+ if (added) added.activeSkill = skill;
119
+ return;
120
+ }
121
+ if (pendingDisabled.get(skill.name)?.has(skillSourceIdentity(skill))) return;
122
+ const path = canonicalPath(skill.filePath);
123
+ const staticSource = record.sources.find(
124
+ (source) => source.resource && canonicalPath(source.skill.filePath) === path,
125
+ );
126
+ if (staticSource && hasStaticProvenance(skill, staticSource)) {
127
+ if (staticSource.resource?.enabled) record.activeSkill = skill;
128
+ else {
129
+ record.activeSkill = skill;
130
+ record.sources.push({ skill, runtime: true });
131
+ }
132
+ return;
133
+ }
134
+ // ponytail: PI exposes only winning runtime skills; use an aggregate resource API if PI adds one.
135
+ record.activeSkill = skill;
136
+ record.sources.push({ skill, runtime: true });
137
+ }
138
+
139
+ /** Merge skills that runtime resource events added outside PI's configurable catalog. */
140
+ export function mergeRuntimeSkills(
141
+ base: SkillCatalog,
142
+ ctx: ExtensionContext | undefined,
143
+ scope: SettingsScope,
144
+ pendingDisabled: ReadonlyMap<string, ReadonlySet<string>> = new Map(),
145
+ ): SkillCatalog {
146
+ const catalog = cloneCatalog(base);
147
+ for (const skill of contextSkills(ctx)) {
148
+ if (scope !== "global" || skill.sourceInfo.scope !== "project") {
149
+ mergeRuntimeSkill(catalog, skill, pendingDisabled);
150
+ }
151
+ }
152
+ return catalog;
153
+ }
@@ -0,0 +1,305 @@
1
+ import { homedir } from "node:os";
2
+ import { basename, isAbsolute, join, posix, relative, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import {
5
+ CONFIG_DIR_NAME,
6
+ type PackageSource,
7
+ type ResolvedResource,
8
+ type SettingsManager,
9
+ } from "@earendil-works/pi-coding-agent";
10
+ import type { SettingsScope } from "@mrclrchtr/supi-core/settings";
11
+
12
+ export type SkillLoadOverride = "load" | "unload" | "inherit";
13
+
14
+ type PackageConfig = Exclude<PackageSource, string>;
15
+
16
+ interface SkillLoadContext {
17
+ settingsManager: SettingsManager;
18
+ scope: SettingsScope;
19
+ cwd: string;
20
+ agentDir: string;
21
+ }
22
+
23
+ const FILTER_PREFIXES = new Set(["!", "+", "-"]);
24
+ const PACKAGE_FILTER_KEYS = ["extensions", "skills", "prompts", "themes"] as const;
25
+
26
+ function normalizeExactTarget(target: string): string {
27
+ const normalized = target.replaceAll("\\", "/");
28
+ return normalized.startsWith("./") ? normalized.slice(2) : normalized;
29
+ }
30
+
31
+ function stripFilterPrefix(entry: string): string {
32
+ const target = FILTER_PREFIXES.has(entry[0] ?? "") ? entry.slice(1) : entry;
33
+ return normalizeExactTarget(target);
34
+ }
35
+
36
+ function exactTargets(paths: string[]): Set<string> {
37
+ const targets = new Set(paths.map(normalizeExactTarget));
38
+ for (const path of paths) {
39
+ if (basename(path) === "SKILL.md") targets.add(posix.dirname(normalizeExactTarget(path)));
40
+ }
41
+ return targets;
42
+ }
43
+
44
+ function isExactOverride(entry: string): boolean {
45
+ return entry.startsWith("+") || entry.startsWith("-");
46
+ }
47
+
48
+ function removeExactOverrides(entries: string[], targets: ReadonlySet<string>): string[] {
49
+ return entries.filter(
50
+ (entry) => !(isExactOverride(entry) && targets.has(stripFilterPrefix(entry))),
51
+ );
52
+ }
53
+
54
+ function sourceScope(resource: ResolvedResource): SettingsScope {
55
+ return resource.metadata.scope === "project" ? "project" : "global";
56
+ }
57
+
58
+ function scopeBaseDir(scope: SettingsScope, cwd: string, agentDir: string): string {
59
+ return scope === "project" ? join(cwd, CONFIG_DIR_NAME) : agentDir;
60
+ }
61
+
62
+ function resourcePattern(
63
+ resource: ResolvedResource,
64
+ scope: SettingsScope,
65
+ cwd: string,
66
+ agentDir: string,
67
+ ): string {
68
+ if (scope !== sourceScope(resource)) return resource.path;
69
+ const baseDir = resource.metadata.baseDir ?? scopeBaseDir(sourceScope(resource), cwd, agentDir);
70
+ return relative(baseDir, resource.path);
71
+ }
72
+
73
+ function topLevelTargets(
74
+ resource: ResolvedResource,
75
+ scope: SettingsScope,
76
+ cwd: string,
77
+ agentDir: string,
78
+ ): Set<string> {
79
+ const paths = [
80
+ resourcePattern(resource, scope, cwd, agentDir),
81
+ resource.path,
82
+ relative(scopeBaseDir(scope, cwd, agentDir), resource.path),
83
+ ];
84
+ if (resource.metadata.baseDir) paths.push(relative(resource.metadata.baseDir, resource.path));
85
+ return exactTargets(paths);
86
+ }
87
+
88
+ /**
89
+ * Replace only SuPi-managed exact overrides for one top-level resource.
90
+ * Broad patterns stay unchanged; inherited resources also receive a plain
91
+ * support path because PI applies project filters only to project resources.
92
+ */
93
+ function updateTopLevelEntries(
94
+ entries: string[],
95
+ resource: ResolvedResource,
96
+ state: SkillLoadOverride,
97
+ context: SkillLoadContext,
98
+ ): string[] {
99
+ const { scope, cwd, agentDir } = context;
100
+ const pattern = resourcePattern(resource, scope, cwd, agentDir);
101
+ const targets = topLevelTargets(resource, scope, cwd, agentDir);
102
+ const inherited = scope === "project" && sourceScope(resource) === "global";
103
+ const hadOverride = entries.some(
104
+ (entry) => isExactOverride(entry) && targets.has(stripFilterPrefix(entry)),
105
+ );
106
+ const updated = removeExactOverrides(entries, targets);
107
+
108
+ if (state === "inherit") {
109
+ return inherited && hadOverride
110
+ ? updated.filter((entry) => normalizeExactTarget(entry) !== normalizeExactTarget(pattern))
111
+ : updated;
112
+ }
113
+ if (inherited && !updated.includes(pattern)) updated.push(pattern);
114
+ updated.push(`${state === "load" ? "+" : "-"}${pattern}`);
115
+ return updated;
116
+ }
117
+
118
+ function isLocalPackageSource(source: string): boolean {
119
+ const value = source.trim();
120
+ return !["npm:", "git:", "github:", "http:", "https:", "ssh:"].some((prefix) =>
121
+ value.startsWith(prefix),
122
+ );
123
+ }
124
+
125
+ function resolvePackageSource(source: string, baseDir: string): string {
126
+ const value = source.trim();
127
+ if (value.startsWith("file:")) return fileURLToPath(value);
128
+ if (value === "~") return homedir();
129
+ if (value.startsWith("~/") || value.startsWith("~\\")) {
130
+ return resolve(homedir(), value.slice(2));
131
+ }
132
+ return isAbsolute(value) ? resolve(value) : resolve(baseDir, value);
133
+ }
134
+
135
+ /** Match package identity with the scope-relative local-path rules used by PI. */
136
+ function packageSourcesMatch(options: {
137
+ left: string;
138
+ leftScope: SettingsScope;
139
+ right: string;
140
+ rightScope: SettingsScope;
141
+ cwd: string;
142
+ agentDir: string;
143
+ }): boolean {
144
+ const { left, leftScope, right, rightScope, cwd, agentDir } = options;
145
+ const leftIsLocal = isLocalPackageSource(left);
146
+ const rightIsLocal = isLocalPackageSource(right);
147
+ if (!leftIsLocal || !rightIsLocal) return !leftIsLocal && !rightIsLocal && left === right;
148
+ return (
149
+ resolvePackageSource(left, scopeBaseDir(leftScope, cwd, agentDir)) ===
150
+ resolvePackageSource(right, scopeBaseDir(rightScope, cwd, agentDir))
151
+ );
152
+ }
153
+
154
+ function packagePattern(resource: ResolvedResource): string {
155
+ return relative(resource.metadata.baseDir ?? resource.path, resource.path);
156
+ }
157
+
158
+ function packageTargets(resource: ResolvedResource): Set<string> {
159
+ return exactTargets([packagePattern(resource), resource.path]);
160
+ }
161
+
162
+ function createProjectPackageOverride(
163
+ resource: ResolvedResource,
164
+ cwd: string,
165
+ agentDir: string,
166
+ ): PackageConfig {
167
+ const source = resource.metadata.source;
168
+ if (!isLocalPackageSource(source)) return { source, autoload: false };
169
+ const absolute = resolvePackageSource(source, scopeBaseDir(sourceScope(resource), cwd, agentDir));
170
+ return {
171
+ source: relative(scopeBaseDir("project", cwd, agentDir), absolute) || ".",
172
+ autoload: false,
173
+ };
174
+ }
175
+
176
+ function findPackageIndex(
177
+ packages: PackageSource[],
178
+ resource: ResolvedResource,
179
+ context: SkillLoadContext,
180
+ ): number {
181
+ return packages.findIndex((entry) =>
182
+ packageSourcesMatch({
183
+ left: resource.metadata.source,
184
+ leftScope: sourceScope(resource),
185
+ right: typeof entry === "string" ? entry : entry.source,
186
+ rightScope: context.scope,
187
+ cwd: context.cwd,
188
+ agentDir: context.agentDir,
189
+ }),
190
+ );
191
+ }
192
+
193
+ function cleanPackageEntry(packages: PackageSource[], index: number, scope: SettingsScope): void {
194
+ const entry = packages[index];
195
+ if (!entry || typeof entry === "string") return;
196
+ const hasFilters = PACKAGE_FILTER_KEYS.some((key) => entry[key] !== undefined);
197
+ if (hasFilters) return;
198
+ if (scope === "project" && entry.autoload === false) packages.splice(index, 1);
199
+ else if (entry.autoload !== false) packages[index] = entry.source;
200
+ }
201
+
202
+ /**
203
+ * Apply one exact package-skill delta while preserving broad filters and
204
+ * explicit deny-all semantics. Project autoload:false entries remain deltas.
205
+ */
206
+ function updatePackageEntries(
207
+ packages: PackageSource[],
208
+ resource: ResolvedResource,
209
+ state: SkillLoadOverride,
210
+ context: SkillLoadContext,
211
+ ): void {
212
+ let index = findPackageIndex(packages, resource, context);
213
+ if (index === -1) {
214
+ if (state === "inherit") return;
215
+ packages.push(createProjectPackageOverride(resource, context.cwd, context.agentDir));
216
+ index = packages.length - 1;
217
+ }
218
+
219
+ const current = packages[index];
220
+ if (!current) return;
221
+ const config: PackageConfig = typeof current === "string" ? { source: current } : { ...current };
222
+ packages[index] = config;
223
+ const pattern = packagePattern(resource);
224
+ const targets = packageTargets(resource);
225
+ const explicitDenyAll =
226
+ config.autoload !== false && config.skills !== undefined && config.skills.length === 0;
227
+ const entries = removeExactOverrides([...(config.skills ?? [])], targets);
228
+ if (state !== "inherit") {
229
+ if (explicitDenyAll) entries.push("!**");
230
+ entries.push(`${state === "load" ? "+" : "-"}${pattern}`);
231
+ }
232
+ config.skills = entries.length > 0 ? entries : explicitDenyAll ? [] : undefined;
233
+ cleanPackageEntry(packages, index, context.scope);
234
+ }
235
+
236
+ /** Update only exact PI skill filters and preserve broad user patterns. */
237
+ export function updateSkillLoadOverrides(
238
+ input: SkillLoadContext & { resources: ResolvedResource[]; state: SkillLoadOverride },
239
+ ): void {
240
+ const { settingsManager, resources, scope, state } = input;
241
+ const relevant = resources.filter(
242
+ (resource) => scope === "project" || sourceScope(resource) === "global",
243
+ );
244
+ const settings =
245
+ scope === "project"
246
+ ? settingsManager.getProjectSettings()
247
+ : settingsManager.getGlobalSettings();
248
+
249
+ const topLevelResources = relevant.filter((item) => item.metadata.origin === "top-level");
250
+ if (topLevelResources.length > 0) {
251
+ let skillPaths = [...(settings.skills ?? [])];
252
+ for (const resource of topLevelResources) {
253
+ skillPaths = updateTopLevelEntries(skillPaths, resource, state, input);
254
+ }
255
+ if (scope === "project") settingsManager.setProjectSkillPaths(skillPaths);
256
+ else settingsManager.setSkillPaths(skillPaths);
257
+ }
258
+
259
+ const packageResources = relevant.filter((item) => item.metadata.origin === "package");
260
+ if (packageResources.length > 0) {
261
+ const packages = [...(settings.packages ?? [])];
262
+ for (const resource of packageResources) {
263
+ updatePackageEntries(packages, resource, state, input);
264
+ }
265
+ if (scope === "project") settingsManager.setProjectPackages(packages);
266
+ else settingsManager.setPackages(packages);
267
+ }
268
+ }
269
+
270
+ /** Return true when the selected scope has an exact load override for any source. */
271
+ export function hasExactSkillLoadOverride(
272
+ input: SkillLoadContext & { resources: ResolvedResource[] },
273
+ ): boolean {
274
+ const { settingsManager, resources, scope, cwd, agentDir } = input;
275
+ const settings =
276
+ scope === "project"
277
+ ? settingsManager.getProjectSettings()
278
+ : settingsManager.getGlobalSettings();
279
+ const skillEntries = settings.skills ?? [];
280
+
281
+ for (const resource of resources.filter((item) => item.metadata.origin === "top-level")) {
282
+ const targets = topLevelTargets(resource, scope, cwd, agentDir);
283
+ if (
284
+ skillEntries.some((entry) => isExactOverride(entry) && targets.has(stripFilterPrefix(entry)))
285
+ ) {
286
+ return true;
287
+ }
288
+ }
289
+
290
+ const packages = settings.packages ?? [];
291
+ for (const resource of resources.filter((item) => item.metadata.origin === "package")) {
292
+ const index = findPackageIndex(packages, resource, input);
293
+ const entry = index >= 0 ? packages[index] : undefined;
294
+ if (!entry || typeof entry === "string") continue;
295
+ const targets = packageTargets(resource);
296
+ if (
297
+ (entry.skills ?? []).some(
298
+ (filter) => isExactOverride(filter) && targets.has(stripFilterPrefix(filter)),
299
+ )
300
+ ) {
301
+ return true;
302
+ }
303
+ }
304
+ return false;
305
+ }