fix(coding-agent): attach source info to resources and commands, fixes #1734

This commit is contained in:
Mario Zechner
2026-03-23 00:52:55 +01:00
parent 0cf18f3cf2
commit d501b9ca26
21 changed files with 425 additions and 321 deletions

View File

@@ -53,7 +53,6 @@ const resourceLoader: ResourceLoader = {
getSystemPrompt: () => `You are a minimal assistant. getSystemPrompt: () => `You are a minimal assistant.
Available: read, bash. Be concise.`, Available: read, bash. Be concise.`,
getAppendSystemPrompt: () => [], getAppendSystemPrompt: () => [],
getPathMetadata: () => new Map(),
extendResources: () => {}, extendResources: () => {},
reload: async () => {}, reload: async () => {},
}; };

View File

@@ -75,7 +75,7 @@ import type { ResourceExtensionPaths, ResourceLoader } from "./resource-loader.j
import type { BranchSummaryEntry, CompactionEntry, SessionManager } from "./session-manager.js"; import type { BranchSummaryEntry, CompactionEntry, SessionManager } from "./session-manager.js";
import { CURRENT_SESSION_VERSION, getLatestCompactionEntry, type SessionHeader } from "./session-manager.js"; import { CURRENT_SESSION_VERSION, getLatestCompactionEntry, type SessionHeader } from "./session-manager.js";
import type { SettingsManager } from "./settings-manager.js"; import type { SettingsManager } from "./settings-manager.js";
import { BUILTIN_SLASH_COMMANDS, type SlashCommandInfo, type SlashCommandLocation } from "./slash-commands.js"; import type { SlashCommandInfo, SlashCommandLocation } from "./slash-commands.js";
import { buildSystemPrompt } from "./system-prompt.js"; import { buildSystemPrompt } from "./system-prompt.js";
import type { BashOperations } from "./tools/bash.js"; import type { BashOperations } from "./tools/bash.js";
import { createAllToolDefinitions } from "./tools/index.js"; import { createAllToolDefinitions } from "./tools/index.js";
@@ -2091,23 +2091,20 @@ export class AgentSession {
return undefined; return undefined;
}; };
const reservedBuiltins = new Set(BUILTIN_SLASH_COMMANDS.map((command) => command.name));
const getCommands = (): SlashCommandInfo[] => { const getCommands = (): SlashCommandInfo[] => {
const extensionCommands: SlashCommandInfo[] = runner const extensionCommands: SlashCommandInfo[] = runner.getRegisteredCommands().map((command) => ({
.getRegisteredCommandsWithPaths() name: command.name,
.filter(({ command }) => !reservedBuiltins.has(command.name)) description: command.description,
.map(({ command, extensionPath }) => ({ source: "extension",
name: command.name, sourceInfo: command.sourceInfo,
description: command.description, path: command.extensionPath,
source: "extension", }));
path: extensionPath,
}));
const templates: SlashCommandInfo[] = this.promptTemplates.map((template) => ({ const templates: SlashCommandInfo[] = this.promptTemplates.map((template) => ({
name: template.name, name: template.name,
description: template.description, description: template.description,
source: "prompt", source: "prompt",
sourceInfo: template.sourceInfo,
location: normalizeLocation(template.source), location: normalizeLocation(template.source),
path: template.filePath, path: template.filePath,
})); }));
@@ -2116,6 +2113,7 @@ export class AgentSession {
name: `skill:${skill.name}`, name: `skill:${skill.name}`,
description: skill.description, description: skill.description,
source: "skill", source: "skill",
sourceInfo: skill.sourceInfo,
location: normalizeLocation(skill.source), location: normalizeLocation(skill.source),
path: skill.filePath, path: skill.filePath,
})); }));

View File

@@ -3,6 +3,7 @@
*/ */
export type { SlashCommandInfo, SlashCommandLocation, SlashCommandSource } from "../slash-commands.js"; export type { SlashCommandInfo, SlashCommandLocation, SlashCommandSource } from "../slash-commands.js";
export type { SourceInfo } from "../source-info.js";
export { export {
createExtensionRuntime, createExtensionRuntime,
discoverAndLoadExtensions, discoverAndLoadExtensions,

View File

@@ -179,8 +179,13 @@ function createExtensionAPI(
runtime.refreshTools(); runtime.refreshTools();
}, },
registerCommand(name: string, options: Omit<RegisteredCommand, "name">): void { registerCommand(name: string, options: Omit<RegisteredCommand, "name" | "extensionPath">): void {
extension.commands.set(name, { name, ...options }); extension.commands.set(name, {
name,
extensionPath: extension.path,
sourceInfo: extension.sourceInfo,
...options,
});
}, },
registerShortcut( registerShortcut(

View File

@@ -467,27 +467,16 @@ export class ExtensionRunner {
return undefined; return undefined;
} }
getRegisteredCommands(reserved?: Set<string>): RegisteredCommand[] { private collectRegisteredCommands(diagnostics?: ResourceDiagnostic[]): RegisteredCommand[] {
this.commandDiagnostics = [];
const commands: RegisteredCommand[] = []; const commands: RegisteredCommand[] = [];
const commandOwners = new Map<string, string>(); const commandOwners = new Map<string, string>();
for (const ext of this.extensions) { for (const ext of this.extensions) {
for (const command of ext.commands.values()) { for (const command of ext.commands.values()) {
if (reserved?.has(command.name)) {
const message = `Extension command '${command.name}' from ${ext.path} conflicts with built-in commands. Skipping.`;
this.commandDiagnostics.push({ type: "warning", message, path: ext.path });
if (!this.hasUI()) {
console.warn(message);
}
continue;
}
const existingOwner = commandOwners.get(command.name); const existingOwner = commandOwners.get(command.name);
if (existingOwner) { if (existingOwner) {
const message = `Extension command '${command.name}' from ${ext.path} conflicts with ${existingOwner}. Skipping.`; const message = `Extension command '${command.name}' from ${ext.path} conflicts with ${existingOwner}. Skipping.`;
this.commandDiagnostics.push({ type: "warning", message, path: ext.path }); diagnostics?.push({ type: "warning", message, path: ext.path });
if (!this.hasUI()) { if (diagnostics && !this.hasUI()) {
console.warn(message); console.warn(message);
} }
continue; continue;
@@ -500,28 +489,19 @@ export class ExtensionRunner {
return commands; return commands;
} }
getRegisteredCommands(): RegisteredCommand[] {
const diagnostics: ResourceDiagnostic[] = [];
const commands = this.collectRegisteredCommands(diagnostics);
this.commandDiagnostics = diagnostics;
return commands;
}
getCommandDiagnostics(): ResourceDiagnostic[] { getCommandDiagnostics(): ResourceDiagnostic[] {
return this.commandDiagnostics; return this.commandDiagnostics;
} }
getRegisteredCommandsWithPaths(): Array<{ command: RegisteredCommand; extensionPath: string }> {
const result: Array<{ command: RegisteredCommand; extensionPath: string }> = [];
for (const ext of this.extensions) {
for (const command of ext.commands.values()) {
result.push({ command, extensionPath: ext.path });
}
}
return result;
}
getCommand(name: string): RegisteredCommand | undefined { getCommand(name: string): RegisteredCommand | undefined {
for (const ext of this.extensions) { return this.collectRegisteredCommands().find((command) => command.name === name);
const command = ext.commands.get(name);
if (command) {
return command;
}
}
return undefined;
} }
/** /**

View File

@@ -55,6 +55,7 @@ import type {
SessionManager, SessionManager,
} from "../session-manager.js"; } from "../session-manager.js";
import type { SlashCommandInfo } from "../slash-commands.js"; import type { SlashCommandInfo } from "../slash-commands.js";
import type { SourceInfo } from "../source-info.js";
import type { BashOperations } from "../tools/bash.js"; import type { BashOperations } from "../tools/bash.js";
import type { EditToolDetails } from "../tools/edit.js"; import type { EditToolDetails } from "../tools/edit.js";
import type { import type {
@@ -960,6 +961,8 @@ export type MessageRenderer<T = unknown> = (
export interface RegisteredCommand { export interface RegisteredCommand {
name: string; name: string;
extensionPath: string;
sourceInfo?: SourceInfo;
description?: string; description?: string;
getArgumentCompletions?: (argumentPrefix: string) => AutocompleteItem[] | null; getArgumentCompletions?: (argumentPrefix: string) => AutocompleteItem[] | null;
handler: (args: string, ctx: ExtensionCommandContext) => Promise<void>; handler: (args: string, ctx: ExtensionCommandContext) => Promise<void>;
@@ -1035,7 +1038,7 @@ export interface ExtensionAPI {
// ========================================================================= // =========================================================================
/** Register a custom command. */ /** Register a custom command. */
registerCommand(name: string, options: Omit<RegisteredCommand, "name">): void; registerCommand(name: string, options: Omit<RegisteredCommand, "name" | "extensionPath">): void;
/** Register a keyboard shortcut. */ /** Register a keyboard shortcut. */
registerShortcut( registerShortcut(
@@ -1414,6 +1417,7 @@ export interface ExtensionRuntime extends ExtensionRuntimeState, ExtensionAction
export interface Extension { export interface Extension {
path: string; path: string;
resolvedPath: string; resolvedPath: string;
sourceInfo?: SourceInfo;
handlers: Map<string, HandlerFn[]>; handlers: Map<string, HandlerFn[]>;
tools: Map<string, RegisteredTool>; tools: Map<string, RegisteredTool>;
messageRenderers: Map<string, MessageRenderer>; messageRenderers: Map<string, MessageRenderer>;

View File

@@ -3,6 +3,7 @@ import { homedir } from "os";
import { basename, isAbsolute, join, resolve, sep } from "path"; import { basename, isAbsolute, join, resolve, sep } from "path";
import { CONFIG_DIR_NAME, getPromptsDir } from "../config.js"; import { CONFIG_DIR_NAME, getPromptsDir } from "../config.js";
import { parseFrontmatter } from "../utils/frontmatter.js"; import { parseFrontmatter } from "../utils/frontmatter.js";
import type { SourceInfo } from "./source-info.js";
/** /**
* Represents a prompt template loaded from a markdown file * Represents a prompt template loaded from a markdown file
@@ -12,6 +13,7 @@ export interface PromptTemplate {
description: string; description: string;
content: string; content: string;
source: string; // "user", "project", or "path" source: string; // "user", "project", or "path"
sourceInfo?: SourceInfo;
filePath: string; // Absolute path to the template file filePath: string; // Absolute path to the template file
} }
@@ -99,7 +101,7 @@ export function substituteArgs(content: string, args: string[]): string {
return result; return result;
} }
function loadTemplateFromFile(filePath: string, source: string, sourceLabel: string): PromptTemplate | null { function loadTemplateFromFile(filePath: string, source: string): PromptTemplate | null {
try { try {
const rawContent = readFileSync(filePath, "utf-8"); const rawContent = readFileSync(filePath, "utf-8");
const { frontmatter, body } = parseFrontmatter<Record<string, string>>(rawContent); const { frontmatter, body } = parseFrontmatter<Record<string, string>>(rawContent);
@@ -117,9 +119,6 @@ function loadTemplateFromFile(filePath: string, source: string, sourceLabel: str
} }
} }
// Append source to description
description = description ? `${description} ${sourceLabel}` : sourceLabel;
return { return {
name, name,
description, description,
@@ -135,7 +134,7 @@ function loadTemplateFromFile(filePath: string, source: string, sourceLabel: str
/** /**
* Scan a directory for .md files (non-recursive) and load them as prompt templates. * Scan a directory for .md files (non-recursive) and load them as prompt templates.
*/ */
function loadTemplatesFromDir(dir: string, source: string, sourceLabel: string): PromptTemplate[] { function loadTemplatesFromDir(dir: string, source: string): PromptTemplate[] {
const templates: PromptTemplate[] = []; const templates: PromptTemplate[] = [];
if (!existsSync(dir)) { if (!existsSync(dir)) {
@@ -161,7 +160,7 @@ function loadTemplatesFromDir(dir: string, source: string, sourceLabel: string):
} }
if (isFile && entry.name.endsWith(".md")) { if (isFile && entry.name.endsWith(".md")) {
const template = loadTemplateFromFile(fullPath, source, sourceLabel); const template = loadTemplateFromFile(fullPath, source);
if (template) { if (template) {
templates.push(template); templates.push(template);
} }
@@ -198,11 +197,6 @@ function resolvePromptPath(p: string, cwd: string): string {
return isAbsolute(normalized) ? normalized : resolve(cwd, normalized); return isAbsolute(normalized) ? normalized : resolve(cwd, normalized);
} }
function buildPathSourceLabel(p: string): string {
const base = basename(p).replace(/\.md$/, "") || "path";
return `(path:${base})`;
}
/** /**
* Load all prompt templates from: * Load all prompt templates from:
* 1. Global: agentDir/prompts/ * 1. Global: agentDir/prompts/
@@ -221,11 +215,11 @@ export function loadPromptTemplates(options: LoadPromptTemplatesOptions = {}): P
// 1. Load global templates from agentDir/prompts/ // 1. Load global templates from agentDir/prompts/
// Note: if agentDir is provided, it should be the agent dir, not the prompts dir // Note: if agentDir is provided, it should be the agent dir, not the prompts dir
const globalPromptsDir = options.agentDir ? join(options.agentDir, "prompts") : resolvedAgentDir; const globalPromptsDir = options.agentDir ? join(options.agentDir, "prompts") : resolvedAgentDir;
templates.push(...loadTemplatesFromDir(globalPromptsDir, "user", "(user)")); templates.push(...loadTemplatesFromDir(globalPromptsDir, "user"));
// 2. Load project templates from cwd/{CONFIG_DIR_NAME}/prompts/ // 2. Load project templates from cwd/{CONFIG_DIR_NAME}/prompts/
const projectPromptsDir = resolve(resolvedCwd, CONFIG_DIR_NAME, "prompts"); const projectPromptsDir = resolve(resolvedCwd, CONFIG_DIR_NAME, "prompts");
templates.push(...loadTemplatesFromDir(projectPromptsDir, "project", "(project)")); templates.push(...loadTemplatesFromDir(projectPromptsDir, "project"));
} }
const userPromptsDir = options.agentDir ? join(options.agentDir, "prompts") : resolvedAgentDir; const userPromptsDir = options.agentDir ? join(options.agentDir, "prompts") : resolvedAgentDir;
@@ -240,16 +234,16 @@ export function loadPromptTemplates(options: LoadPromptTemplatesOptions = {}): P
return target.startsWith(prefix); return target.startsWith(prefix);
}; };
const getSourceInfo = (resolvedPath: string): { source: string; label: string } => { const getSource = (resolvedPath: string): string => {
if (!includeDefaults) { if (!includeDefaults) {
if (isUnderPath(resolvedPath, userPromptsDir)) { if (isUnderPath(resolvedPath, userPromptsDir)) {
return { source: "user", label: "(user)" }; return "user";
} }
if (isUnderPath(resolvedPath, projectPromptsDir)) { if (isUnderPath(resolvedPath, projectPromptsDir)) {
return { source: "project", label: "(project)" }; return "project";
} }
} }
return { source: "path", label: buildPathSourceLabel(resolvedPath) }; return "path";
}; };
// 3. Load explicit prompt paths // 3. Load explicit prompt paths
@@ -261,11 +255,11 @@ export function loadPromptTemplates(options: LoadPromptTemplatesOptions = {}): P
try { try {
const stats = statSync(resolvedPath); const stats = statSync(resolvedPath);
const { source, label } = getSourceInfo(resolvedPath); const source = getSource(resolvedPath);
if (stats.isDirectory()) { if (stats.isDirectory()) {
templates.push(...loadTemplatesFromDir(resolvedPath, source, label)); templates.push(...loadTemplatesFromDir(resolvedPath, source));
} else if (stats.isFile() && resolvedPath.endsWith(".md")) { } else if (stats.isFile() && resolvedPath.endsWith(".md")) {
const template = loadTemplateFromFile(resolvedPath, source, label); const template = loadTemplateFromFile(resolvedPath, source);
if (template) { if (template) {
templates.push(template); templates.push(template);
} }

View File

@@ -17,6 +17,7 @@ import { loadPromptTemplates } from "./prompt-templates.js";
import { SettingsManager } from "./settings-manager.js"; import { SettingsManager } from "./settings-manager.js";
import type { Skill } from "./skills.js"; import type { Skill } from "./skills.js";
import { loadSkills } from "./skills.js"; import { loadSkills } from "./skills.js";
import { createSourceInfo, type SourceInfo } from "./source-info.js";
export interface ResourceExtensionPaths { export interface ResourceExtensionPaths {
skillPaths?: Array<{ path: string; metadata: PathMetadata }>; skillPaths?: Array<{ path: string; metadata: PathMetadata }>;
@@ -32,7 +33,6 @@ export interface ResourceLoader {
getAgentsFiles(): { agentsFiles: Array<{ path: string; content: string }> }; getAgentsFiles(): { agentsFiles: Array<{ path: string; content: string }> };
getSystemPrompt(): string | undefined; getSystemPrompt(): string | undefined;
getAppendSystemPrompt(): string[]; getAppendSystemPrompt(): string[];
getPathMetadata(): Map<string, PathMetadata>;
extendResources(paths: ResourceExtensionPaths): void; extendResources(paths: ResourceExtensionPaths): void;
reload(): Promise<void>; reload(): Promise<void>;
} }
@@ -193,8 +193,10 @@ export class DefaultResourceLoader implements ResourceLoader {
private agentsFiles: Array<{ path: string; content: string }>; private agentsFiles: Array<{ path: string; content: string }>;
private systemPrompt?: string; private systemPrompt?: string;
private appendSystemPrompt: string[]; private appendSystemPrompt: string[];
private pathMetadata: Map<string, PathMetadata>;
private lastSkillPaths: string[]; private lastSkillPaths: string[];
private extensionSkillSourceInfos: Map<string, SourceInfo>;
private extensionPromptSourceInfos: Map<string, SourceInfo>;
private extensionThemeSourceInfos: Map<string, SourceInfo>;
private lastPromptPaths: string[]; private lastPromptPaths: string[];
private lastThemePaths: string[]; private lastThemePaths: string[];
@@ -236,8 +238,10 @@ export class DefaultResourceLoader implements ResourceLoader {
this.themeDiagnostics = []; this.themeDiagnostics = [];
this.agentsFiles = []; this.agentsFiles = [];
this.appendSystemPrompt = []; this.appendSystemPrompt = [];
this.pathMetadata = new Map();
this.lastSkillPaths = []; this.lastSkillPaths = [];
this.extensionSkillSourceInfos = new Map();
this.extensionPromptSourceInfos = new Map();
this.extensionThemeSourceInfos = new Map();
this.lastPromptPaths = []; this.lastPromptPaths = [];
this.lastThemePaths = []; this.lastThemePaths = [];
} }
@@ -270,21 +274,27 @@ export class DefaultResourceLoader implements ResourceLoader {
return this.appendSystemPrompt; return this.appendSystemPrompt;
} }
getPathMetadata(): Map<string, PathMetadata> {
return this.pathMetadata;
}
extendResources(paths: ResourceExtensionPaths): void { extendResources(paths: ResourceExtensionPaths): void {
const skillPaths = this.normalizeExtensionPaths(paths.skillPaths ?? []); const skillPaths = this.normalizeExtensionPaths(paths.skillPaths ?? []);
const promptPaths = this.normalizeExtensionPaths(paths.promptPaths ?? []); const promptPaths = this.normalizeExtensionPaths(paths.promptPaths ?? []);
const themePaths = this.normalizeExtensionPaths(paths.themePaths ?? []); const themePaths = this.normalizeExtensionPaths(paths.themePaths ?? []);
for (const entry of skillPaths) {
this.extensionSkillSourceInfos.set(entry.path, createSourceInfo(entry.path, entry.metadata));
}
for (const entry of promptPaths) {
this.extensionPromptSourceInfos.set(entry.path, createSourceInfo(entry.path, entry.metadata));
}
for (const entry of themePaths) {
this.extensionThemeSourceInfos.set(entry.path, createSourceInfo(entry.path, entry.metadata));
}
if (skillPaths.length > 0) { if (skillPaths.length > 0) {
this.lastSkillPaths = this.mergePaths( this.lastSkillPaths = this.mergePaths(
this.lastSkillPaths, this.lastSkillPaths,
skillPaths.map((entry) => entry.path), skillPaths.map((entry) => entry.path),
); );
this.updateSkillsFromPaths(this.lastSkillPaths, skillPaths); this.updateSkillsFromPaths(this.lastSkillPaths);
} }
if (promptPaths.length > 0) { if (promptPaths.length > 0) {
@@ -292,7 +302,7 @@ export class DefaultResourceLoader implements ResourceLoader {
this.lastPromptPaths, this.lastPromptPaths,
promptPaths.map((entry) => entry.path), promptPaths.map((entry) => entry.path),
); );
this.updatePromptsFromPaths(this.lastPromptPaths, promptPaths); this.updatePromptsFromPaths(this.lastPromptPaths);
} }
if (themePaths.length > 0) { if (themePaths.length > 0) {
@@ -300,7 +310,7 @@ export class DefaultResourceLoader implements ResourceLoader {
this.lastThemePaths, this.lastThemePaths,
themePaths.map((entry) => entry.path), themePaths.map((entry) => entry.path),
); );
this.updateThemesFromPaths(this.lastThemePaths, themePaths); this.updateThemesFromPaths(this.lastThemePaths);
} }
} }
@@ -309,14 +319,19 @@ export class DefaultResourceLoader implements ResourceLoader {
const cliExtensionPaths = await this.packageManager.resolveExtensionSources(this.additionalExtensionPaths, { const cliExtensionPaths = await this.packageManager.resolveExtensionSources(this.additionalExtensionPaths, {
temporary: true, temporary: true,
}); });
const metadataByPath = new Map<string, PathMetadata>();
this.extensionSkillSourceInfos = new Map();
this.extensionPromptSourceInfos = new Map();
this.extensionThemeSourceInfos = new Map();
// Helper to extract enabled paths and store metadata // Helper to extract enabled paths and store metadata
const getEnabledResources = ( const getEnabledResources = (
resources: Array<{ path: string; enabled: boolean; metadata: PathMetadata }>, resources: Array<{ path: string; enabled: boolean; metadata: PathMetadata }>,
): Array<{ path: string; enabled: boolean; metadata: PathMetadata }> => { ): Array<{ path: string; enabled: boolean; metadata: PathMetadata }> => {
for (const r of resources) { for (const r of resources) {
if (!this.pathMetadata.has(r.path)) { if (!metadataByPath.has(r.path)) {
this.pathMetadata.set(r.path, r.metadata); metadataByPath.set(r.path, r.metadata);
} }
} }
return resources.filter((r) => r.enabled); return resources.filter((r) => r.enabled);
@@ -325,9 +340,6 @@ export class DefaultResourceLoader implements ResourceLoader {
const getEnabledPaths = ( const getEnabledPaths = (
resources: Array<{ path: string; enabled: boolean; metadata: PathMetadata }>, resources: Array<{ path: string; enabled: boolean; metadata: PathMetadata }>,
): string[] => getEnabledResources(resources).map((r) => r.path); ): string[] => getEnabledResources(resources).map((r) => r.path);
// Store metadata and get enabled paths
this.pathMetadata = new Map();
const enabledExtensions = getEnabledPaths(resolvedPaths.extensions); const enabledExtensions = getEnabledPaths(resolvedPaths.extensions);
const enabledSkillResources = getEnabledResources(resolvedPaths.skills); const enabledSkillResources = getEnabledResources(resolvedPaths.skills);
const enabledPrompts = getEnabledPaths(resolvedPaths.prompts); const enabledPrompts = getEnabledPaths(resolvedPaths.prompts);
@@ -347,8 +359,8 @@ export class DefaultResourceLoader implements ResourceLoader {
} }
const skillFile = join(resource.path, "SKILL.md"); const skillFile = join(resource.path, "SKILL.md");
if (existsSync(skillFile)) { if (existsSync(skillFile)) {
if (!this.pathMetadata.has(skillFile)) { if (!metadataByPath.has(skillFile)) {
this.pathMetadata.set(skillFile, resource.metadata); metadataByPath.set(skillFile, resource.metadata);
} }
return skillFile; return skillFile;
} }
@@ -359,13 +371,13 @@ export class DefaultResourceLoader implements ResourceLoader {
// Add CLI paths metadata // Add CLI paths metadata
for (const r of cliExtensionPaths.extensions) { for (const r of cliExtensionPaths.extensions) {
if (!this.pathMetadata.has(r.path)) { if (!metadataByPath.has(r.path)) {
this.pathMetadata.set(r.path, { source: "cli", scope: "temporary", origin: "top-level" }); metadataByPath.set(r.path, { source: "cli", scope: "temporary", origin: "top-level" });
} }
} }
for (const r of cliExtensionPaths.skills) { for (const r of cliExtensionPaths.skills) {
if (!this.pathMetadata.has(r.path)) { if (!metadataByPath.has(r.path)) {
this.pathMetadata.set(r.path, { source: "cli", scope: "temporary", origin: "top-level" }); metadataByPath.set(r.path, { source: "cli", scope: "temporary", origin: "top-level" });
} }
} }
@@ -391,31 +403,28 @@ export class DefaultResourceLoader implements ResourceLoader {
} }
this.extensionsResult = this.extensionsOverride ? this.extensionsOverride(extensionsResult) : extensionsResult; this.extensionsResult = this.extensionsOverride ? this.extensionsOverride(extensionsResult) : extensionsResult;
this.applyExtensionSourceInfo(this.extensionsResult.extensions, metadataByPath);
const skillPaths = this.noSkills const skillPaths = this.noSkills
? this.mergePaths(cliEnabledSkills, this.additionalSkillPaths) ? this.mergePaths(cliEnabledSkills, this.additionalSkillPaths)
: this.mergePaths([...enabledSkills, ...cliEnabledSkills], this.additionalSkillPaths); : this.mergePaths([...enabledSkills, ...cliEnabledSkills], this.additionalSkillPaths);
this.lastSkillPaths = skillPaths; this.lastSkillPaths = skillPaths;
this.updateSkillsFromPaths(skillPaths); this.updateSkillsFromPaths(skillPaths, metadataByPath);
const promptPaths = this.noPromptTemplates const promptPaths = this.noPromptTemplates
? this.mergePaths(cliEnabledPrompts, this.additionalPromptTemplatePaths) ? this.mergePaths(cliEnabledPrompts, this.additionalPromptTemplatePaths)
: this.mergePaths([...enabledPrompts, ...cliEnabledPrompts], this.additionalPromptTemplatePaths); : this.mergePaths([...enabledPrompts, ...cliEnabledPrompts], this.additionalPromptTemplatePaths);
this.lastPromptPaths = promptPaths; this.lastPromptPaths = promptPaths;
this.updatePromptsFromPaths(promptPaths); this.updatePromptsFromPaths(promptPaths, metadataByPath);
const themePaths = this.noThemes const themePaths = this.noThemes
? this.mergePaths(cliEnabledThemes, this.additionalThemePaths) ? this.mergePaths(cliEnabledThemes, this.additionalThemePaths)
: this.mergePaths([...enabledThemes, ...cliEnabledThemes], this.additionalThemePaths); : this.mergePaths([...enabledThemes, ...cliEnabledThemes], this.additionalThemePaths);
this.lastThemePaths = themePaths; this.lastThemePaths = themePaths;
this.updateThemesFromPaths(themePaths); this.updateThemesFromPaths(themePaths, metadataByPath);
for (const extension of this.extensionsResult.extensions) {
this.addDefaultMetadataForPath(extension.path);
}
const agentsFiles = { agentsFiles: loadProjectContextFiles({ cwd: this.cwd, agentDir: this.agentDir }) }; const agentsFiles = { agentsFiles: loadProjectContextFiles({ cwd: this.cwd, agentDir: this.agentDir }) };
const resolvedAgentsFiles = this.agentsFilesOverride ? this.agentsFilesOverride(agentsFiles) : agentsFiles; const resolvedAgentsFiles = this.agentsFilesOverride ? this.agentsFilesOverride(agentsFiles) : agentsFiles;
@@ -444,10 +453,7 @@ export class DefaultResourceLoader implements ResourceLoader {
})); }));
} }
private updateSkillsFromPaths( private updateSkillsFromPaths(skillPaths: string[], metadataByPath?: Map<string, PathMetadata>): void {
skillPaths: string[],
extensionPaths: Array<{ path: string; metadata: PathMetadata }> = [],
): void {
let skillsResult: { skills: Skill[]; diagnostics: ResourceDiagnostic[] }; let skillsResult: { skills: Skill[]; diagnostics: ResourceDiagnostic[] };
if (this.noSkills && skillPaths.length === 0) { if (this.noSkills && skillPaths.length === 0) {
skillsResult = { skills: [], diagnostics: [] }; skillsResult = { skills: [], diagnostics: [] };
@@ -460,21 +466,16 @@ export class DefaultResourceLoader implements ResourceLoader {
}); });
} }
const resolvedSkills = this.skillsOverride ? this.skillsOverride(skillsResult) : skillsResult; const resolvedSkills = this.skillsOverride ? this.skillsOverride(skillsResult) : skillsResult;
this.skills = resolvedSkills.skills; this.skills = resolvedSkills.skills.map((skill) => ({
...skill,
sourceInfo:
this.findSourceInfoForPath(skill.filePath, this.extensionSkillSourceInfos, metadataByPath) ??
this.getDefaultSourceInfoForPath(skill.filePath),
}));
this.skillDiagnostics = resolvedSkills.diagnostics; this.skillDiagnostics = resolvedSkills.diagnostics;
this.applyExtensionMetadata(
extensionPaths,
this.skills.map((skill) => skill.filePath),
);
for (const skill of this.skills) {
this.addDefaultMetadataForPath(skill.filePath);
}
} }
private updatePromptsFromPaths( private updatePromptsFromPaths(promptPaths: string[], metadataByPath?: Map<string, PathMetadata>): void {
promptPaths: string[],
extensionPaths: Array<{ path: string; metadata: PathMetadata }> = [],
): void {
let promptsResult: { prompts: PromptTemplate[]; diagnostics: ResourceDiagnostic[] }; let promptsResult: { prompts: PromptTemplate[]; diagnostics: ResourceDiagnostic[] };
if (this.noPromptTemplates && promptPaths.length === 0) { if (this.noPromptTemplates && promptPaths.length === 0) {
promptsResult = { prompts: [], diagnostics: [] }; promptsResult = { prompts: [], diagnostics: [] };
@@ -488,21 +489,16 @@ export class DefaultResourceLoader implements ResourceLoader {
promptsResult = this.dedupePrompts(allPrompts); promptsResult = this.dedupePrompts(allPrompts);
} }
const resolvedPrompts = this.promptsOverride ? this.promptsOverride(promptsResult) : promptsResult; const resolvedPrompts = this.promptsOverride ? this.promptsOverride(promptsResult) : promptsResult;
this.prompts = resolvedPrompts.prompts; this.prompts = resolvedPrompts.prompts.map((prompt) => ({
...prompt,
sourceInfo:
this.findSourceInfoForPath(prompt.filePath, this.extensionPromptSourceInfos, metadataByPath) ??
this.getDefaultSourceInfoForPath(prompt.filePath),
}));
this.promptDiagnostics = resolvedPrompts.diagnostics; this.promptDiagnostics = resolvedPrompts.diagnostics;
this.applyExtensionMetadata(
extensionPaths,
this.prompts.map((prompt) => prompt.filePath),
);
for (const prompt of this.prompts) {
this.addDefaultMetadataForPath(prompt.filePath);
}
} }
private updateThemesFromPaths( private updateThemesFromPaths(themePaths: string[], metadataByPath?: Map<string, PathMetadata>): void {
themePaths: string[],
extensionPaths: Array<{ path: string; metadata: PathMetadata }> = [],
): void {
let themesResult: { themes: Theme[]; diagnostics: ResourceDiagnostic[] }; let themesResult: { themes: Theme[]; diagnostics: ResourceDiagnostic[] };
if (this.noThemes && themePaths.length === 0) { if (this.noThemes && themePaths.length === 0) {
themesResult = { themes: [], diagnostics: [] }; themesResult = { themes: [], diagnostics: [] };
@@ -512,49 +508,115 @@ export class DefaultResourceLoader implements ResourceLoader {
themesResult = { themes: deduped.themes, diagnostics: [...loaded.diagnostics, ...deduped.diagnostics] }; themesResult = { themes: deduped.themes, diagnostics: [...loaded.diagnostics, ...deduped.diagnostics] };
} }
const resolvedThemes = this.themesOverride ? this.themesOverride(themesResult) : themesResult; const resolvedThemes = this.themesOverride ? this.themesOverride(themesResult) : themesResult;
this.themes = resolvedThemes.themes; this.themes = resolvedThemes.themes.map((theme) => {
const sourcePath = theme.sourcePath;
theme.sourceInfo = sourcePath
? (this.findSourceInfoForPath(sourcePath, this.extensionThemeSourceInfos, metadataByPath) ??
this.getDefaultSourceInfoForPath(sourcePath))
: undefined;
return theme;
});
this.themeDiagnostics = resolvedThemes.diagnostics; this.themeDiagnostics = resolvedThemes.diagnostics;
const themePathsWithSource = this.themes.flatMap((theme) => (theme.sourcePath ? [theme.sourcePath] : [])); }
this.applyExtensionMetadata(extensionPaths, themePathsWithSource);
for (const theme of this.themes) { private applyExtensionSourceInfo(extensions: Extension[], metadataByPath: Map<string, PathMetadata>): void {
if (theme.sourcePath) { for (const extension of extensions) {
this.addDefaultMetadataForPath(theme.sourcePath); extension.sourceInfo =
this.findSourceInfoForPath(extension.path, undefined, metadataByPath) ??
this.getDefaultSourceInfoForPath(extension.path);
for (const command of extension.commands.values()) {
command.sourceInfo = extension.sourceInfo;
} }
} }
} }
private applyExtensionMetadata( private findSourceInfoForPath(
extensionPaths: Array<{ path: string; metadata: PathMetadata }>, resourcePath: string,
resourcePaths: string[], extraSourceInfos?: Map<string, SourceInfo>,
): void { metadataByPath?: Map<string, PathMetadata>,
if (extensionPaths.length === 0) { ): SourceInfo | undefined {
return; if (!resourcePath) {
return undefined;
} }
const normalized = extensionPaths.map((entry) => ({ if (resourcePath.startsWith("<")) {
path: resolve(entry.path), return this.getDefaultSourceInfoForPath(resourcePath);
metadata: entry.metadata, }
}));
for (const entry of normalized) { const normalizedResourcePath = resolve(resourcePath);
if (!this.pathMetadata.has(entry.path)) { if (extraSourceInfos) {
this.pathMetadata.set(entry.path, entry.metadata); for (const [sourcePath, sourceInfo] of extraSourceInfos.entries()) {
const normalizedSourcePath = resolve(sourcePath);
if (
normalizedResourcePath === normalizedSourcePath ||
normalizedResourcePath.startsWith(`${normalizedSourcePath}${sep}`)
) {
return { ...sourceInfo, path: resourcePath };
}
} }
} }
for (const resourcePath of resourcePaths) { if (metadataByPath) {
const normalizedResourcePath = resolve(resourcePath); const exact = metadataByPath.get(normalizedResourcePath) ?? metadataByPath.get(resourcePath);
if (this.pathMetadata.has(normalizedResourcePath) || this.pathMetadata.has(resourcePath)) { if (exact) {
continue; return createSourceInfo(resourcePath, exact);
} }
const match = normalized.find(
(entry) => for (const [sourcePath, metadata] of metadataByPath.entries()) {
normalizedResourcePath === entry.path || normalizedResourcePath.startsWith(`${entry.path}${sep}`), const normalizedSourcePath = resolve(sourcePath);
); if (
if (match) { normalizedResourcePath === normalizedSourcePath ||
this.pathMetadata.set(normalizedResourcePath, match.metadata); normalizedResourcePath.startsWith(`${normalizedSourcePath}${sep}`)
) {
return createSourceInfo(resourcePath, metadata);
}
} }
} }
return undefined;
}
private getDefaultSourceInfoForPath(filePath: string): SourceInfo | undefined {
if (!filePath) {
return undefined;
}
if (filePath.startsWith("<") && filePath.endsWith(">")) {
return {
path: filePath,
source: filePath.slice(1, -1).split(":")[0] || "temporary",
scope: "temporary",
origin: "top-level",
};
}
const normalizedPath = resolve(filePath);
const agentRoots = [
join(this.agentDir, "skills"),
join(this.agentDir, "prompts"),
join(this.agentDir, "themes"),
join(this.agentDir, "extensions"),
];
const projectRoots = [
join(this.cwd, CONFIG_DIR_NAME, "skills"),
join(this.cwd, CONFIG_DIR_NAME, "prompts"),
join(this.cwd, CONFIG_DIR_NAME, "themes"),
join(this.cwd, CONFIG_DIR_NAME, "extensions"),
];
for (const root of agentRoots) {
if (this.isUnderPath(normalizedPath, root)) {
return { path: filePath, source: "local", scope: "user", origin: "top-level" };
}
}
for (const root of projectRoots) {
if (this.isUnderPath(normalizedPath, root)) {
return { path: filePath, source: "local", scope: "project", origin: "top-level" };
}
}
return undefined;
} }
private mergePaths(primary: string[], additional: string[]): string[] { private mergePaths(primary: string[], additional: string[]): string[] {
@@ -767,44 +829,6 @@ export class DefaultResourceLoader implements ResourceLoader {
return undefined; return undefined;
} }
private addDefaultMetadataForPath(filePath: string): void {
if (!filePath || filePath.startsWith("<")) {
return;
}
const normalizedPath = resolve(filePath);
if (this.pathMetadata.has(normalizedPath) || this.pathMetadata.has(filePath)) {
return;
}
const agentRoots = [
join(this.agentDir, "skills"),
join(this.agentDir, "prompts"),
join(this.agentDir, "themes"),
join(this.agentDir, "extensions"),
];
const projectRoots = [
join(this.cwd, CONFIG_DIR_NAME, "skills"),
join(this.cwd, CONFIG_DIR_NAME, "prompts"),
join(this.cwd, CONFIG_DIR_NAME, "themes"),
join(this.cwd, CONFIG_DIR_NAME, "extensions"),
];
for (const root of agentRoots) {
if (this.isUnderPath(normalizedPath, root)) {
this.pathMetadata.set(normalizedPath, { source: "local", scope: "user", origin: "top-level" });
return;
}
}
for (const root of projectRoots) {
if (this.isUnderPath(normalizedPath, root)) {
this.pathMetadata.set(normalizedPath, { source: "local", scope: "project", origin: "top-level" });
return;
}
}
}
private isUnderPath(target: string, root: string): boolean { private isUnderPath(target: string, root: string): boolean {
const normalizedRoot = resolve(root); const normalizedRoot = resolve(root);
if (target === normalizedRoot) { if (target === normalizedRoot) {

View File

@@ -5,6 +5,7 @@ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "pat
import { CONFIG_DIR_NAME, getAgentDir } from "../config.js"; import { CONFIG_DIR_NAME, getAgentDir } from "../config.js";
import { parseFrontmatter } from "../utils/frontmatter.js"; import { parseFrontmatter } from "../utils/frontmatter.js";
import type { ResourceDiagnostic } from "./diagnostics.js"; import type { ResourceDiagnostic } from "./diagnostics.js";
import type { SourceInfo } from "./source-info.js";
/** Max name length per spec */ /** Max name length per spec */
const MAX_NAME_LENGTH = 64; const MAX_NAME_LENGTH = 64;
@@ -76,6 +77,7 @@ export interface Skill {
filePath: string; filePath: string;
baseDir: string; baseDir: string;
source: string; source: string;
sourceInfo?: SourceInfo;
disableModelInvocation: boolean; disableModelInvocation: boolean;
} }

View File

@@ -1,3 +1,5 @@
import type { SourceInfo } from "./source-info.js";
export type SlashCommandSource = "extension" | "prompt" | "skill"; export type SlashCommandSource = "extension" | "prompt" | "skill";
export type SlashCommandLocation = "user" | "project" | "path"; export type SlashCommandLocation = "user" | "project" | "path";
@@ -6,6 +8,7 @@ export interface SlashCommandInfo {
name: string; name: string;
description?: string; description?: string;
source: SlashCommandSource; source: SlashCommandSource;
sourceInfo?: SourceInfo;
location?: SlashCommandLocation; location?: SlashCommandLocation;
path?: string; path?: string;
} }

View File

@@ -0,0 +1,19 @@
import type { PathMetadata } from "./package-manager.js";
export interface SourceInfo {
path?: string;
source: string;
scope: "user" | "project" | "temporary";
origin: "package" | "top-level";
baseDir?: string;
}
export function createSourceInfo(path: string | undefined, metadata: PathMetadata): SourceInfo {
return {
path,
source: metadata.source,
scope: metadata.scope,
origin: metadata.origin,
baseDir: metadata.baseDir,
};
}

View File

@@ -110,6 +110,7 @@ export type {
SlashCommandInfo, SlashCommandInfo,
SlashCommandLocation, SlashCommandLocation,
SlashCommandSource, SlashCommandSource,
SourceInfo,
TerminalInputHandler, TerminalInputHandler,
ToolCallEvent, ToolCallEvent,
ToolCallEventResult, ToolCallEventResult,

View File

@@ -63,10 +63,12 @@ import { DefaultPackageManager } from "../../core/package-manager.js";
import type { ResourceDiagnostic } from "../../core/resource-loader.js"; import type { ResourceDiagnostic } from "../../core/resource-loader.js";
import { type SessionContext, SessionManager } from "../../core/session-manager.js"; import { type SessionContext, SessionManager } from "../../core/session-manager.js";
import { BUILTIN_SLASH_COMMANDS } from "../../core/slash-commands.js"; import { BUILTIN_SLASH_COMMANDS } from "../../core/slash-commands.js";
import type { SourceInfo } from "../../core/source-info.js";
import type { TruncationResult } from "../../core/tools/truncate.js"; import type { TruncationResult } from "../../core/tools/truncate.js";
import { getChangelogPath, getNewEntries, parseChangelog } from "../../utils/changelog.js"; import { getChangelogPath, getNewEntries, parseChangelog } from "../../utils/changelog.js";
import { copyToClipboard } from "../../utils/clipboard.js"; import { copyToClipboard } from "../../utils/clipboard.js";
import { extensionForImageMimeType, readClipboardImage } from "../../utils/clipboard-image.js"; import { extensionForImageMimeType, readClipboardImage } from "../../utils/clipboard-image.js";
import { parseGitUrl } from "../../utils/git.js";
import { ensureTool } from "../../utils/tools-manager.js"; import { ensureTool } from "../../utils/tools-manager.js";
import { ArminComponent } from "./components/armin.js"; import { ArminComponent } from "./components/armin.js";
import { AssistantMessageComponent } from "./components/assistant-message.js"; import { AssistantMessageComponent } from "./components/assistant-message.js";
@@ -286,6 +288,55 @@ export class InteractiveMode {
initTheme(this.settingsManager.getTheme(), true); initTheme(this.settingsManager.getTheme(), true);
} }
private getAutocompleteSourceTag(sourceInfo?: SourceInfo): string | undefined {
if (!sourceInfo) {
return undefined;
}
const scopePrefix = sourceInfo.scope === "user" ? "u" : sourceInfo.scope === "project" ? "p" : "t";
const source = sourceInfo.source.trim();
if (source === "auto" || source === "local" || source === "cli") {
return scopePrefix;
}
if (source.startsWith("npm:")) {
return `${scopePrefix}:${source}`;
}
const gitSource = parseGitUrl(source);
if (gitSource) {
const ref = gitSource.ref ? `@${gitSource.ref}` : "";
return `${scopePrefix}:git:${gitSource.host}/${gitSource.path}${ref}`;
}
return scopePrefix;
}
private prefixAutocompleteDescription(description: string | undefined, sourceInfo?: SourceInfo): string | undefined {
const sourceTag = this.getAutocompleteSourceTag(sourceInfo);
if (!sourceTag) {
return description;
}
return description ? `[${sourceTag}] ${description}` : `[${sourceTag}]`;
}
private getBuiltInCommandConflictDiagnostics(extensionRunner: ExtensionRunner | undefined): ResourceDiagnostic[] {
if (!extensionRunner) {
return [];
}
const builtinNames = new Set(BUILTIN_SLASH_COMMANDS.map((command) => command.name));
return extensionRunner
.getRegisteredCommands()
.filter((command) => builtinNames.has(command.name))
.map((command) => ({
type: "warning" as const,
message: `Extension command '/${command.name}' conflicts with built-in interactive command. Skipping in autocomplete.`,
path: command.extensionPath,
}));
}
private setupAutocomplete(fdPath: string | undefined): void { private setupAutocomplete(fdPath: string | undefined): void {
// Define commands for autocomplete // Define commands for autocomplete
const slashCommands: SlashCommand[] = BUILTIN_SLASH_COMMANDS.map((command) => ({ const slashCommands: SlashCommand[] = BUILTIN_SLASH_COMMANDS.map((command) => ({
@@ -327,16 +378,16 @@ export class InteractiveMode {
// Convert prompt templates to SlashCommand format for autocomplete // Convert prompt templates to SlashCommand format for autocomplete
const templateCommands: SlashCommand[] = this.session.promptTemplates.map((cmd) => ({ const templateCommands: SlashCommand[] = this.session.promptTemplates.map((cmd) => ({
name: cmd.name, name: cmd.name,
description: cmd.description, description: this.prefixAutocompleteDescription(cmd.description, cmd.sourceInfo),
})); }));
// Convert extension commands to SlashCommand format // Convert extension commands to SlashCommand format
const builtinCommandNames = new Set(slashCommands.map((c) => c.name)); const builtinCommandNames = new Set(slashCommands.map((c) => c.name));
const extensionCommands: SlashCommand[] = ( const extensionCommands: SlashCommand[] = (
this.session.extensionRunner?.getRegisteredCommands(builtinCommandNames) ?? [] this.session.extensionRunner?.getRegisteredCommands().filter((cmd) => !builtinCommandNames.has(cmd.name)) ?? []
).map((cmd) => ({ ).map((cmd) => ({
name: cmd.name, name: cmd.name,
description: cmd.description ?? "(extension command)", description: this.prefixAutocompleteDescription(cmd.description, cmd.sourceInfo),
getArgumentCompletions: cmd.getArgumentCompletions, getArgumentCompletions: cmd.getArgumentCompletions,
})); }));
@@ -347,7 +398,10 @@ export class InteractiveMode {
for (const skill of this.session.resourceLoader.getSkills().skills) { for (const skill of this.session.resourceLoader.getSkills().skills) {
const commandName = `skill:${skill.name}`; const commandName = `skill:${skill.name}`;
this.skillCommands.set(commandName, skill.filePath); this.skillCommands.set(commandName, skill.filePath);
skillCommandList.push({ name: commandName, description: skill.description }); skillCommandList.push({
name: commandName,
description: this.prefixAutocompleteDescription(skill.description, skill.sourceInfo),
});
} }
} }
@@ -724,27 +778,28 @@ export class InteractiveMode {
/** /**
* Get a short path relative to the package root for display. * Get a short path relative to the package root for display.
*/ */
private getShortPath(fullPath: string, source: string): string { private getShortPath(fullPath: string, sourceInfo?: SourceInfo): string {
// For npm packages, show path relative to node_modules/pkg/ const source = sourceInfo?.source ?? "";
const npmMatch = fullPath.match(/node_modules\/(@?[^/]+(?:\/[^/]+)?)\/(.*)/); const npmMatch = fullPath.match(/node_modules\/(@?[^/]+(?:\/[^/]+)?)\/(.*)/);
if (npmMatch && source.startsWith("npm:")) { if (npmMatch && source.startsWith("npm:")) {
return npmMatch[2]; return npmMatch[2];
} }
// For git packages, show path relative to repo root
const gitMatch = fullPath.match(/git\/[^/]+\/[^/]+\/(.*)/); const gitMatch = fullPath.match(/git\/[^/]+\/[^/]+\/(.*)/);
if (gitMatch && source.startsWith("git:")) { if (gitMatch && source.startsWith("git:")) {
return gitMatch[1]; return gitMatch[1];
} }
// For local/auto, just use formatDisplayPath
return this.formatDisplayPath(fullPath); return this.formatDisplayPath(fullPath);
} }
private getDisplaySourceInfo( private getDisplaySourceInfo(sourceInfo?: SourceInfo): {
source: string, label: string;
scope: string, scopeLabel?: string;
): { label: string; scopeLabel?: string; color: "accent" | "muted" } { color: "accent" | "muted";
} {
const source = sourceInfo?.source ?? "local";
const scope = sourceInfo?.scope ?? "project";
if (source === "local") { if (source === "local") {
if (scope === "user") { if (scope === "user") {
return { label: "user", color: "muted" }; return { label: "user", color: "muted" };
@@ -767,43 +822,49 @@ export class InteractiveMode {
return { label: source, scopeLabel, color: "accent" }; return { label: source, scopeLabel, color: "accent" };
} }
private getScopeGroup(source: string, scope: string): "user" | "project" | "path" { private getScopeGroup(sourceInfo?: SourceInfo): "user" | "project" | "path" {
const source = sourceInfo?.source ?? "local";
const scope = sourceInfo?.scope ?? "project";
if (source === "cli" || scope === "temporary") return "path"; if (source === "cli" || scope === "temporary") return "path";
if (scope === "user") return "user"; if (scope === "user") return "user";
if (scope === "project") return "project"; if (scope === "project") return "project";
return "path"; return "path";
} }
private isPackageSource(source: string): boolean { private isPackageSource(sourceInfo?: SourceInfo): boolean {
const source = sourceInfo?.source ?? "";
return source.startsWith("npm:") || source.startsWith("git:"); return source.startsWith("npm:") || source.startsWith("git:");
} }
private buildScopeGroups( private buildScopeGroups(items: Array<{ path: string; sourceInfo?: SourceInfo }>): Array<{
paths: string[], scope: "user" | "project" | "path";
metadata: Map<string, { source: string; scope: string; origin: string }>, paths: Array<{ path: string; sourceInfo?: SourceInfo }>;
): Array<{ scope: "user" | "project" | "path"; paths: string[]; packages: Map<string, string[]> }> { packages: Map<string, Array<{ path: string; sourceInfo?: SourceInfo }>>;
}> {
const groups: Record< const groups: Record<
"user" | "project" | "path", "user" | "project" | "path",
{ scope: "user" | "project" | "path"; paths: string[]; packages: Map<string, string[]> } {
scope: "user" | "project" | "path";
paths: Array<{ path: string; sourceInfo?: SourceInfo }>;
packages: Map<string, Array<{ path: string; sourceInfo?: SourceInfo }>>;
}
> = { > = {
user: { scope: "user", paths: [], packages: new Map() }, user: { scope: "user", paths: [], packages: new Map() },
project: { scope: "project", paths: [], packages: new Map() }, project: { scope: "project", paths: [], packages: new Map() },
path: { scope: "path", paths: [], packages: new Map() }, path: { scope: "path", paths: [], packages: new Map() },
}; };
for (const p of paths) { for (const item of items) {
const meta = this.findMetadata(p, metadata); const groupKey = this.getScopeGroup(item.sourceInfo);
const source = meta?.source ?? "local";
const scope = meta?.scope ?? "project";
const groupKey = this.getScopeGroup(source, scope);
const group = groups[groupKey]; const group = groups[groupKey];
const source = item.sourceInfo?.source ?? "local";
if (this.isPackageSource(source)) { if (this.isPackageSource(item.sourceInfo)) {
const list = group.packages.get(source) ?? []; const list = group.packages.get(source) ?? [];
list.push(p); list.push(item);
group.packages.set(source, list); group.packages.set(source, list);
} else { } else {
group.paths.push(p); group.paths.push(item);
} }
} }
@@ -813,10 +874,14 @@ export class InteractiveMode {
} }
private formatScopeGroups( private formatScopeGroups(
groups: Array<{ scope: "user" | "project" | "path"; paths: string[]; packages: Map<string, string[]> }>, groups: Array<{
scope: "user" | "project" | "path";
paths: Array<{ path: string; sourceInfo?: SourceInfo }>;
packages: Map<string, Array<{ path: string; sourceInfo?: SourceInfo }>>;
}>,
options: { options: {
formatPath: (p: string) => string; formatPath: (item: { path: string; sourceInfo?: SourceInfo }) => string;
formatPackagePath: (p: string, source: string) => string; formatPackagePath: (item: { path: string; sourceInfo?: SourceInfo }, source: string) => string;
}, },
): string { ): string {
const lines: string[] = []; const lines: string[] = [];
@@ -824,17 +889,17 @@ export class InteractiveMode {
for (const group of groups) { for (const group of groups) {
lines.push(` ${theme.fg("accent", group.scope)}`); lines.push(` ${theme.fg("accent", group.scope)}`);
const sortedPaths = [...group.paths].sort((a, b) => a.localeCompare(b)); const sortedPaths = [...group.paths].sort((a, b) => a.path.localeCompare(b.path));
for (const p of sortedPaths) { for (const item of sortedPaths) {
lines.push(theme.fg("dim", ` ${options.formatPath(p)}`)); lines.push(theme.fg("dim", ` ${options.formatPath(item)}`));
} }
const sortedPackages = Array.from(group.packages.entries()).sort(([a], [b]) => a.localeCompare(b)); const sortedPackages = Array.from(group.packages.entries()).sort(([a], [b]) => a.localeCompare(b));
for (const [source, paths] of sortedPackages) { for (const [source, items] of sortedPackages) {
lines.push(` ${theme.fg("mdLink", source)}`); lines.push(` ${theme.fg("mdLink", source)}`);
const sortedPackagePaths = [...paths].sort((a, b) => a.localeCompare(b)); const sortedPackagePaths = [...items].sort((a, b) => a.path.localeCompare(b.path));
for (const p of sortedPackagePaths) { for (const item of sortedPackagePaths) {
lines.push(theme.fg("dim", ` ${options.formatPackagePath(p, source)}`)); lines.push(theme.fg("dim", ` ${options.formatPackagePath(item, source)}`));
} }
} }
} }
@@ -842,53 +907,31 @@ export class InteractiveMode {
return lines.join("\n"); return lines.join("\n");
} }
/** private findSourceInfoForPath(p: string, sourceInfos: Map<string, SourceInfo>): SourceInfo | undefined {
* Find metadata for a path, checking parent directories if exact match fails. const exact = sourceInfos.get(p);
* Package manager stores metadata for directories, but we display file paths.
*/
private findMetadata(
p: string,
metadata: Map<string, { source: string; scope: string; origin: string }>,
): { source: string; scope: string; origin: string } | undefined {
// Try exact match first
const exact = metadata.get(p);
if (exact) return exact; if (exact) return exact;
// Try parent directories (package manager stores directory paths)
let current = p; let current = p;
while (current.includes("/")) { while (current.includes("/")) {
current = current.substring(0, current.lastIndexOf("/")); current = current.substring(0, current.lastIndexOf("/"));
const parent = metadata.get(current); const parent = sourceInfos.get(current);
if (parent) return parent; if (parent) return parent;
} }
return undefined; return undefined;
} }
/** private formatPathWithSource(p: string, sourceInfo?: SourceInfo): string {
* Format a path with its source/scope info from metadata. if (sourceInfo) {
*/ const shortPath = this.getShortPath(p, sourceInfo);
private formatPathWithSource( const { label, scopeLabel } = this.getDisplaySourceInfo(sourceInfo);
p: string,
metadata: Map<string, { source: string; scope: string; origin: string }>,
): string {
const meta = this.findMetadata(p, metadata);
if (meta) {
const shortPath = this.getShortPath(p, meta.source);
const { label, scopeLabel } = this.getDisplaySourceInfo(meta.source, meta.scope);
const labelText = scopeLabel ? `${label} (${scopeLabel})` : label; const labelText = scopeLabel ? `${label} (${scopeLabel})` : label;
return `${labelText} ${shortPath}`; return `${labelText} ${shortPath}`;
} }
return this.formatDisplayPath(p); return this.formatDisplayPath(p);
} }
/** private formatDiagnostics(diagnostics: readonly ResourceDiagnostic[], sourceInfos: Map<string, SourceInfo>): string {
* Format resource diagnostics with nice collision display using metadata.
*/
private formatDiagnostics(
diagnostics: readonly ResourceDiagnostic[],
metadata: Map<string, { source: string; scope: string; origin: string }>,
): string {
const lines: string[] = []; const lines: string[] = [];
// Group collision diagnostics by name // Group collision diagnostics by name
@@ -910,29 +953,28 @@ export class InteractiveMode {
const first = collisionList[0]?.collision; const first = collisionList[0]?.collision;
if (!first) continue; if (!first) continue;
lines.push(theme.fg("warning", ` "${name}" collision:`)); lines.push(theme.fg("warning", ` "${name}" collision:`));
// Show winner
lines.push( lines.push(
theme.fg("dim", ` ${theme.fg("success", "✓")} ${this.formatPathWithSource(first.winnerPath, metadata)}`), theme.fg(
"dim",
` ${theme.fg("success", "✓")} ${this.formatPathWithSource(first.winnerPath, this.findSourceInfoForPath(first.winnerPath, sourceInfos))}`,
),
); );
// Show all losers
for (const d of collisionList) { for (const d of collisionList) {
if (d.collision) { if (d.collision) {
lines.push( lines.push(
theme.fg( theme.fg(
"dim", "dim",
` ${theme.fg("warning", "✗")} ${this.formatPathWithSource(d.collision.loserPath, metadata)} (skipped)`, ` ${theme.fg("warning", "✗")} ${this.formatPathWithSource(d.collision.loserPath, this.findSourceInfoForPath(d.collision.loserPath, sourceInfos))} (skipped)`,
), ),
); );
} }
} }
} }
// Format other diagnostics (skill name collisions, parse errors, etc.)
for (const d of otherDiagnostics) { for (const d of otherDiagnostics) {
if (d.path) { if (d.path) {
// Use metadata-aware formatting for paths const formattedPath = this.formatPathWithSource(d.path, this.findSourceInfoForPath(d.path, sourceInfos));
const sourceInfo = this.formatPathWithSource(d.path, metadata); lines.push(theme.fg(d.type === "error" ? "error" : "warning", ` ${formattedPath}`));
lines.push(theme.fg(d.type === "error" ? "error" : "warning", ` ${sourceInfo}`));
lines.push(theme.fg(d.type === "error" ? "error" : "warning", ` ${d.message}`)); lines.push(theme.fg(d.type === "error" ? "error" : "warning", ` ${d.message}`));
} else { } else {
lines.push(theme.fg(d.type === "error" ? "error" : "warning", ` ${d.message}`)); lines.push(theme.fg(d.type === "error" ? "error" : "warning", ` ${d.message}`));
@@ -943,7 +985,7 @@ export class InteractiveMode {
} }
private showLoadedResources(options?: { private showLoadedResources(options?: {
extensionPaths?: string[]; extensions?: Array<{ path: string; sourceInfo?: SourceInfo }>;
force?: boolean; force?: boolean;
showDiagnosticsWhenQuiet?: boolean; showDiagnosticsWhenQuiet?: boolean;
}): void { }): void {
@@ -953,12 +995,38 @@ export class InteractiveMode {
return; return;
} }
const metadata = this.session.resourceLoader.getPathMetadata();
const sectionHeader = (name: string, color: ThemeColor = "mdHeading") => theme.fg(color, `[${name}]`); const sectionHeader = (name: string, color: ThemeColor = "mdHeading") => theme.fg(color, `[${name}]`);
const skillsResult = this.session.resourceLoader.getSkills(); const skillsResult = this.session.resourceLoader.getSkills();
const promptsResult = this.session.resourceLoader.getPrompts(); const promptsResult = this.session.resourceLoader.getPrompts();
const themesResult = this.session.resourceLoader.getThemes(); const themesResult = this.session.resourceLoader.getThemes();
const extensions =
options?.extensions ??
this.session.resourceLoader.getExtensions().extensions.map((extension) => ({
path: extension.path,
sourceInfo: extension.sourceInfo,
}));
const sourceInfos = new Map<string, SourceInfo>();
for (const extension of extensions) {
if (extension.sourceInfo) {
sourceInfos.set(extension.path, extension.sourceInfo);
}
}
for (const skill of skillsResult.skills) {
if (skill.sourceInfo) {
sourceInfos.set(skill.filePath, skill.sourceInfo);
}
}
for (const prompt of promptsResult.prompts) {
if (prompt.sourceInfo) {
sourceInfos.set(prompt.filePath, prompt.sourceInfo);
}
}
for (const loadedTheme of themesResult.themes) {
if (loadedTheme.sourcePath && loadedTheme.sourceInfo) {
sourceInfos.set(loadedTheme.sourcePath, loadedTheme.sourceInfo);
}
}
if (showListing) { if (showListing) {
const contextFiles = this.session.resourceLoader.getAgentsFiles().agentsFiles; const contextFiles = this.session.resourceLoader.getAgentsFiles().agentsFiles;
@@ -973,11 +1041,12 @@ export class InteractiveMode {
const skills = skillsResult.skills; const skills = skillsResult.skills;
if (skills.length > 0) { if (skills.length > 0) {
const skillPaths = skills.map((s) => s.filePath); const groups = this.buildScopeGroups(
const groups = this.buildScopeGroups(skillPaths, metadata); skills.map((skill) => ({ path: skill.filePath, sourceInfo: skill.sourceInfo })),
);
const skillList = this.formatScopeGroups(groups, { const skillList = this.formatScopeGroups(groups, {
formatPath: (p) => this.formatDisplayPath(p), formatPath: (item) => this.formatDisplayPath(item.path),
formatPackagePath: (p, source) => this.getShortPath(p, source), formatPackagePath: (item) => this.getShortPath(item.path, item.sourceInfo),
}); });
this.chatContainer.addChild(new Text(`${sectionHeader("Skills")}\n${skillList}`, 0, 0)); this.chatContainer.addChild(new Text(`${sectionHeader("Skills")}\n${skillList}`, 0, 0));
this.chatContainer.addChild(new Spacer(1)); this.chatContainer.addChild(new Spacer(1));
@@ -985,29 +1054,29 @@ export class InteractiveMode {
const templates = this.session.promptTemplates; const templates = this.session.promptTemplates;
if (templates.length > 0) { if (templates.length > 0) {
const templatePaths = templates.map((t) => t.filePath); const groups = this.buildScopeGroups(
const groups = this.buildScopeGroups(templatePaths, metadata); templates.map((template) => ({ path: template.filePath, sourceInfo: template.sourceInfo })),
);
const templateByPath = new Map(templates.map((t) => [t.filePath, t])); const templateByPath = new Map(templates.map((t) => [t.filePath, t]));
const templateList = this.formatScopeGroups(groups, { const templateList = this.formatScopeGroups(groups, {
formatPath: (p) => { formatPath: (item) => {
const template = templateByPath.get(p); const template = templateByPath.get(item.path);
return template ? `/${template.name}` : this.formatDisplayPath(p); return template ? `/${template.name}` : this.formatDisplayPath(item.path);
}, },
formatPackagePath: (p) => { formatPackagePath: (item) => {
const template = templateByPath.get(p); const template = templateByPath.get(item.path);
return template ? `/${template.name}` : this.formatDisplayPath(p); return template ? `/${template.name}` : this.formatDisplayPath(item.path);
}, },
}); });
this.chatContainer.addChild(new Text(`${sectionHeader("Prompts")}\n${templateList}`, 0, 0)); this.chatContainer.addChild(new Text(`${sectionHeader("Prompts")}\n${templateList}`, 0, 0));
this.chatContainer.addChild(new Spacer(1)); this.chatContainer.addChild(new Spacer(1));
} }
const extensionPaths = options?.extensionPaths ?? []; if (extensions.length > 0) {
if (extensionPaths.length > 0) { const groups = this.buildScopeGroups(extensions);
const groups = this.buildScopeGroups(extensionPaths, metadata);
const extList = this.formatScopeGroups(groups, { const extList = this.formatScopeGroups(groups, {
formatPath: (p) => this.formatDisplayPath(p), formatPath: (item) => this.formatDisplayPath(item.path),
formatPackagePath: (p, source) => this.getShortPath(p, source), formatPackagePath: (item) => this.getShortPath(item.path, item.sourceInfo),
}); });
this.chatContainer.addChild(new Text(`${sectionHeader("Extensions", "mdHeading")}\n${extList}`, 0, 0)); this.chatContainer.addChild(new Text(`${sectionHeader("Extensions", "mdHeading")}\n${extList}`, 0, 0));
this.chatContainer.addChild(new Spacer(1)); this.chatContainer.addChild(new Spacer(1));
@@ -1017,11 +1086,15 @@ export class InteractiveMode {
const loadedThemes = themesResult.themes; const loadedThemes = themesResult.themes;
const customThemes = loadedThemes.filter((t) => t.sourcePath); const customThemes = loadedThemes.filter((t) => t.sourcePath);
if (customThemes.length > 0) { if (customThemes.length > 0) {
const themePaths = customThemes.map((t) => t.sourcePath!); const groups = this.buildScopeGroups(
const groups = this.buildScopeGroups(themePaths, metadata); customThemes.map((loadedTheme) => ({
path: loadedTheme.sourcePath!,
sourceInfo: loadedTheme.sourceInfo,
})),
);
const themeList = this.formatScopeGroups(groups, { const themeList = this.formatScopeGroups(groups, {
formatPath: (p) => this.formatDisplayPath(p), formatPath: (item) => this.formatDisplayPath(item.path),
formatPackagePath: (p, source) => this.getShortPath(p, source), formatPackagePath: (item) => this.getShortPath(item.path, item.sourceInfo),
}); });
this.chatContainer.addChild(new Text(`${sectionHeader("Themes")}\n${themeList}`, 0, 0)); this.chatContainer.addChild(new Text(`${sectionHeader("Themes")}\n${themeList}`, 0, 0));
this.chatContainer.addChild(new Spacer(1)); this.chatContainer.addChild(new Spacer(1));
@@ -1031,14 +1104,14 @@ export class InteractiveMode {
if (showDiagnostics) { if (showDiagnostics) {
const skillDiagnostics = skillsResult.diagnostics; const skillDiagnostics = skillsResult.diagnostics;
if (skillDiagnostics.length > 0) { if (skillDiagnostics.length > 0) {
const warningLines = this.formatDiagnostics(skillDiagnostics, metadata); const warningLines = this.formatDiagnostics(skillDiagnostics, sourceInfos);
this.chatContainer.addChild(new Text(`${theme.fg("warning", "[Skill conflicts]")}\n${warningLines}`, 0, 0)); this.chatContainer.addChild(new Text(`${theme.fg("warning", "[Skill conflicts]")}\n${warningLines}`, 0, 0));
this.chatContainer.addChild(new Spacer(1)); this.chatContainer.addChild(new Spacer(1));
} }
const promptDiagnostics = promptsResult.diagnostics; const promptDiagnostics = promptsResult.diagnostics;
if (promptDiagnostics.length > 0) { if (promptDiagnostics.length > 0) {
const warningLines = this.formatDiagnostics(promptDiagnostics, metadata); const warningLines = this.formatDiagnostics(promptDiagnostics, sourceInfos);
this.chatContainer.addChild( this.chatContainer.addChild(
new Text(`${theme.fg("warning", "[Prompt conflicts]")}\n${warningLines}`, 0, 0), new Text(`${theme.fg("warning", "[Prompt conflicts]")}\n${warningLines}`, 0, 0),
); );
@@ -1055,12 +1128,13 @@ export class InteractiveMode {
const commandDiagnostics = this.session.extensionRunner?.getCommandDiagnostics() ?? []; const commandDiagnostics = this.session.extensionRunner?.getCommandDiagnostics() ?? [];
extensionDiagnostics.push(...commandDiagnostics); extensionDiagnostics.push(...commandDiagnostics);
extensionDiagnostics.push(...this.getBuiltInCommandConflictDiagnostics(this.session.extensionRunner));
const shortcutDiagnostics = this.session.extensionRunner?.getShortcutDiagnostics() ?? []; const shortcutDiagnostics = this.session.extensionRunner?.getShortcutDiagnostics() ?? [];
extensionDiagnostics.push(...shortcutDiagnostics); extensionDiagnostics.push(...shortcutDiagnostics);
if (extensionDiagnostics.length > 0) { if (extensionDiagnostics.length > 0) {
const warningLines = this.formatDiagnostics(extensionDiagnostics, metadata); const warningLines = this.formatDiagnostics(extensionDiagnostics, sourceInfos);
this.chatContainer.addChild( this.chatContainer.addChild(
new Text(`${theme.fg("warning", "[Extension issues]")}\n${warningLines}`, 0, 0), new Text(`${theme.fg("warning", "[Extension issues]")}\n${warningLines}`, 0, 0),
); );
@@ -1069,7 +1143,7 @@ export class InteractiveMode {
const themeDiagnostics = themesResult.diagnostics; const themeDiagnostics = themesResult.diagnostics;
if (themeDiagnostics.length > 0) { if (themeDiagnostics.length > 0) {
const warningLines = this.formatDiagnostics(themeDiagnostics, metadata); const warningLines = this.formatDiagnostics(themeDiagnostics, sourceInfos);
this.chatContainer.addChild(new Text(`${theme.fg("warning", "[Theme conflicts]")}\n${warningLines}`, 0, 0)); this.chatContainer.addChild(new Text(`${theme.fg("warning", "[Theme conflicts]")}\n${warningLines}`, 0, 0));
this.chatContainer.addChild(new Spacer(1)); this.chatContainer.addChild(new Spacer(1));
} }
@@ -1169,12 +1243,12 @@ export class InteractiveMode {
const extensionRunner = this.session.extensionRunner; const extensionRunner = this.session.extensionRunner;
if (!extensionRunner) { if (!extensionRunner) {
this.showLoadedResources({ extensionPaths: [], force: false }); this.showLoadedResources({ extensions: [], force: false });
return; return;
} }
this.setupExtensionShortcuts(extensionRunner); this.setupExtensionShortcuts(extensionRunner);
this.showLoadedResources({ extensionPaths: extensionRunner.getExtensionPaths(), force: false }); this.showLoadedResources({ force: false });
} }
/** /**
@@ -3909,7 +3983,6 @@ export class InteractiveMode {
this.rebuildChatFromMessages(); this.rebuildChatFromMessages();
dismissLoader(this.editor as Component); dismissLoader(this.editor as Component);
this.showLoadedResources({ this.showLoadedResources({
extensionPaths: runner?.getExtensionPaths() ?? [],
force: false, force: false,
showDiagnosticsWhenQuiet: true, showDiagnosticsWhenQuiet: true,
}); });

View File

@@ -6,6 +6,7 @@ import { TypeCompiler } from "@sinclair/typebox/compiler";
import chalk from "chalk"; import chalk from "chalk";
import { highlight, supportsLanguage } from "cli-highlight"; import { highlight, supportsLanguage } from "cli-highlight";
import { getCustomThemesDir, getThemesDir } from "../../../config.js"; import { getCustomThemesDir, getThemesDir } from "../../../config.js";
import type { SourceInfo } from "../../../core/source-info.js";
// ============================================================================ // ============================================================================
// Types & Schema // Types & Schema
@@ -341,6 +342,7 @@ function resolveThemeColors<T extends Record<string, ColorValue>>(
export class Theme { export class Theme {
readonly name?: string; readonly name?: string;
readonly sourcePath?: string; readonly sourcePath?: string;
sourceInfo?: SourceInfo;
private fgColors: Map<ThemeColor, string>; private fgColors: Map<ThemeColor, string>;
private bgColors: Map<ThemeBg, string>; private bgColors: Map<ThemeBg, string>;
private mode: ColorMode; private mode: ColorMode;
@@ -349,10 +351,11 @@ export class Theme {
fgColors: Record<ThemeColor, string | number>, fgColors: Record<ThemeColor, string | number>,
bgColors: Record<ThemeBg, string | number>, bgColors: Record<ThemeBg, string | number>,
mode: ColorMode, mode: ColorMode,
options: { name?: string; sourcePath?: string } = {}, options: { name?: string; sourcePath?: string; sourceInfo?: SourceInfo } = {},
) { ) {
this.name = options.name; this.name = options.name;
this.sourcePath = options.sourcePath; this.sourcePath = options.sourcePath;
this.sourceInfo = options.sourceInfo;
this.mode = mode; this.mode = mode;
this.fgColors = new Map(); this.fgColors = new Map();
for (const [key, value] of Object.entries(fgColors) as [ThemeColor, string | number][]) { for (const [key, value] of Object.entries(fgColors) as [ThemeColor, string | number][]) {

View File

@@ -545,12 +545,12 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
const commands: RpcSlashCommand[] = []; const commands: RpcSlashCommand[] = [];
// Extension commands // Extension commands
for (const { command, extensionPath } of session.extensionRunner?.getRegisteredCommandsWithPaths() ?? []) { for (const command of session.extensionRunner?.getRegisteredCommands() ?? []) {
commands.push({ commands.push({
name: command.name, name: command.name,
description: command.description, description: command.description,
source: "extension", source: "extension",
path: extensionPath, path: command.extensionPath,
}); });
} }

View File

@@ -370,32 +370,33 @@ describe("ExtensionRunner", () => {
expect(missing).toBeUndefined(); expect(missing).toBeUndefined();
}); });
it("filters out commands conflict with reseved", async () => { it("filters out duplicate extension commands", async () => {
const cmdCode = (name: string) => ` const cmdCode = (description: string) => `
export default function(pi) { export default function(pi) {
pi.registerCommand("${name}", { pi.registerCommand("shared-cmd", {
description: "Test command", description: "${description}",
handler: async () => {}, handler: async () => {},
}); });
} }
`; `;
fs.writeFileSync(path.join(extensionsDir, "cmd-a.ts"), cmdCode("cmd-a")); fs.writeFileSync(path.join(extensionsDir, "cmd-a.ts"), cmdCode("First command"));
fs.writeFileSync(path.join(extensionsDir, "cmd-b.ts"), cmdCode("cmd-b")); fs.writeFileSync(path.join(extensionsDir, "cmd-b.ts"), cmdCode("Second command"));
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const result = await discoverAndLoadExtensions([], tempDir, tempDir); const result = await discoverAndLoadExtensions([], tempDir, tempDir);
const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry); const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry);
const commands = runner.getRegisteredCommands(new Set(["cmd-a"])); const commands = runner.getRegisteredCommands();
const diagnostics = runner.getCommandDiagnostics(); const diagnostics = runner.getCommandDiagnostics();
expect(commands.length).toBe(1); expect(commands).toHaveLength(1);
expect(commands.map((c) => c.name).sort()).toEqual(["cmd-b"]); expect(commands[0]?.name).toBe("shared-cmd");
expect(commands[0]?.description).toBe("First command");
expect(diagnostics.length).toBe(1); expect(diagnostics).toHaveLength(1);
expect(diagnostics[0].path).toEqual(path.join(extensionsDir, "cmd-a.ts")); expect(diagnostics[0]?.path).toEqual(path.join(extensionsDir, "cmd-b.ts"));
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("conflicts with built-in command")); expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("conflicts with"));
warnSpy.mockRestore(); warnSpy.mockRestore();
}); });
}); });

View File

@@ -346,14 +346,16 @@ Extra prompt content`,
}); });
const { skills } = loader.getSkills(); const { skills } = loader.getSkills();
expect(skills.some((skill) => skill.name === "extra-skill")).toBe(true); const loadedSkill = skills.find((skill) => skill.name === "extra-skill");
expect(loadedSkill).toBeDefined();
expect(loadedSkill?.sourceInfo?.source).toBe("extension:extra");
expect(loadedSkill?.sourceInfo?.path).toBe(skillPath);
const { prompts } = loader.getPrompts(); const { prompts } = loader.getPrompts();
expect(prompts.some((prompt) => prompt.name === "extra")).toBe(true); const loadedPrompt = prompts.find((prompt) => prompt.name === "extra");
expect(loadedPrompt).toBeDefined();
const metadata = loader.getPathMetadata(); expect(loadedPrompt?.sourceInfo?.source).toBe("extension:extra");
expect(metadata.get(skillPath)?.source).toBe("extension:extra"); expect(loadedPrompt?.sourceInfo?.path).toBe(promptPath);
expect(metadata.get(promptPath)?.source).toBe("extension:extra");
}); });
}); });

View File

@@ -156,7 +156,6 @@ function createMinimalResourceLoader(systemPrompt: string): ResourceLoader {
getAgentsFiles: () => ({ agentsFiles: [] }), getAgentsFiles: () => ({ agentsFiles: [] }),
getSystemPrompt: () => systemPrompt, getSystemPrompt: () => systemPrompt,
getAppendSystemPrompt: () => [], getAppendSystemPrompt: () => [],
getPathMetadata: () => new Map(),
extendResources: () => {}, extendResources: () => {},
reload: async () => {}, reload: async () => {},
}; };

View File

@@ -58,7 +58,6 @@ This is a test skill.
getAgentsFiles: () => ({ agentsFiles: [] }), getAgentsFiles: () => ({ agentsFiles: [] }),
getSystemPrompt: () => undefined, getSystemPrompt: () => undefined,
getAppendSystemPrompt: () => [], getAppendSystemPrompt: () => [],
getPathMetadata: () => new Map(),
extendResources: () => {}, extendResources: () => {},
reload: async () => {}, reload: async () => {},
}; };
@@ -92,7 +91,6 @@ This is a test skill.
getAgentsFiles: () => ({ agentsFiles: [] }), getAgentsFiles: () => ({ agentsFiles: [] }),
getSystemPrompt: () => undefined, getSystemPrompt: () => undefined,
getAppendSystemPrompt: () => [], getAppendSystemPrompt: () => [],
getPathMetadata: () => new Map(),
extendResources: () => {}, extendResources: () => {},
reload: async () => {}, reload: async () => {},
}; };

View File

@@ -184,7 +184,6 @@ export function createTestResourceLoader(): ResourceLoader {
getAgentsFiles: () => ({ agentsFiles: [] }), getAgentsFiles: () => ({ agentsFiles: [] }),
getSystemPrompt: () => undefined, getSystemPrompt: () => undefined,
getAppendSystemPrompt: () => [], getAppendSystemPrompt: () => [],
getPathMetadata: () => new Map(),
extendResources: () => {}, extendResources: () => {},
reload: async () => {}, reload: async () => {},
}; };

View File

@@ -458,7 +458,6 @@ function createRunner(sandboxConfig: SandboxConfig, channelId: string, channelDi
getAgentsFiles: () => ({ agentsFiles: [] }), getAgentsFiles: () => ({ agentsFiles: [] }),
getSystemPrompt: () => systemPrompt, getSystemPrompt: () => systemPrompt,
getAppendSystemPrompt: () => [], getAppendSystemPrompt: () => [],
getPathMetadata: () => new Map(),
extendResources: () => {}, extendResources: () => {},
reload: async () => {}, reload: async () => {},
}; };