feat(coding-agent): progress callbacks, conflict detection, URL parsing, tests (#645)

- Add progress callbacks to PackageManager for TUI status during install/remove/update
- Add extension conflict detection (tools, commands, flags with same names)
- Accept raw GitHub/GitLab URLs without git: prefix
- Add tests for package-manager.ts and resource-loader.ts
- Add empty fixture directories for skills tests
This commit is contained in:
Mario Zechner 2026-01-20 23:44:49 +01:00
parent b846a4bfcf
commit 4058680d22
8 changed files with 548 additions and 25 deletions

View file

@ -15,6 +15,15 @@ export interface ResolvedPaths {
export type MissingSourceAction = "install" | "skip" | "error";
export interface ProgressEvent {
type: "start" | "progress" | "complete" | "error";
action: "install" | "remove" | "update" | "clone" | "pull";
source: string;
message?: string;
}
export type ProgressCallback = (event: ProgressEvent) => void;
export interface PackageManager {
resolve(onMissing?: (source: string) => Promise<MissingSourceAction>): Promise<ResolvedPaths>;
install(source: string, options?: { local?: boolean }): Promise<void>;
@ -24,6 +33,7 @@ export interface PackageManager {
sources: string[],
options?: { local?: boolean; temporary?: boolean },
): Promise<ResolvedPaths>;
setProgressCallback(callback: ProgressCallback | undefined): void;
}
interface PackageManagerOptions {
@ -76,6 +86,7 @@ export class DefaultPackageManager implements PackageManager {
private agentDir: string;
private settingsManager: SettingsManager;
private globalNpmRoot: string | undefined;
private progressCallback: ProgressCallback | undefined;
constructor(options: PackageManagerOptions) {
this.cwd = options.cwd;
@ -83,6 +94,14 @@ export class DefaultPackageManager implements PackageManager {
this.settingsManager = options.settingsManager;
}
setProgressCallback(callback: ProgressCallback | undefined): void {
this.progressCallback = callback;
}
private emitProgress(event: ProgressEvent): void {
this.progressCallback?.(event);
}
async resolve(onMissing?: (source: string) => Promise<MissingSourceAction>): Promise<ResolvedPaths> {
const accumulator = this.createAccumulator();
const globalSettings = this.settingsManager.getGlobalSettings();
@ -134,29 +153,47 @@ export class DefaultPackageManager implements PackageManager {
async install(source: string, options?: { local?: boolean }): Promise<void> {
const parsed = this.parseSource(source);
const scope: SourceScope = options?.local ? "project" : "global";
if (parsed.type === "npm") {
await this.installNpm(parsed, scope, false);
return;
this.emitProgress({ type: "start", action: "install", source, message: `Installing ${source}...` });
try {
if (parsed.type === "npm") {
await this.installNpm(parsed, scope, false);
this.emitProgress({ type: "complete", action: "install", source });
return;
}
if (parsed.type === "git") {
await this.installGit(parsed, scope, false);
this.emitProgress({ type: "complete", action: "install", source });
return;
}
throw new Error(`Unsupported install source: ${source}`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.emitProgress({ type: "error", action: "install", source, message });
throw error;
}
if (parsed.type === "git") {
await this.installGit(parsed, scope, false);
return;
}
throw new Error(`Unsupported install source: ${source}`);
}
async remove(source: string, options?: { local?: boolean }): Promise<void> {
const parsed = this.parseSource(source);
const scope: SourceScope = options?.local ? "project" : "global";
if (parsed.type === "npm") {
await this.uninstallNpm(parsed, scope);
return;
this.emitProgress({ type: "start", action: "remove", source, message: `Removing ${source}...` });
try {
if (parsed.type === "npm") {
await this.uninstallNpm(parsed, scope);
this.emitProgress({ type: "complete", action: "remove", source });
return;
}
if (parsed.type === "git") {
await this.removeGit(parsed, scope, false);
this.emitProgress({ type: "complete", action: "remove", source });
return;
}
throw new Error(`Unsupported remove source: ${source}`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.emitProgress({ type: "error", action: "remove", source, message });
throw error;
}
if (parsed.type === "git") {
await this.removeGit(parsed, scope, false);
return;
}
throw new Error(`Unsupported remove source: ${source}`);
}
async update(source?: string): Promise<void> {
@ -180,12 +217,28 @@ export class DefaultPackageManager implements PackageManager {
const parsed = this.parseSource(source);
if (parsed.type === "npm") {
if (parsed.pinned) return;
await this.installNpm(parsed, scope, false);
this.emitProgress({ type: "start", action: "update", source, message: `Updating ${source}...` });
try {
await this.installNpm(parsed, scope, false);
this.emitProgress({ type: "complete", action: "update", source });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.emitProgress({ type: "error", action: "update", source, message });
throw error;
}
return;
}
if (parsed.type === "git") {
if (parsed.pinned) return;
await this.updateGit(parsed, scope, false);
this.emitProgress({ type: "start", action: "update", source, message: `Updating ${source}...` });
try {
await this.updateGit(parsed, scope, false);
this.emitProgress({ type: "complete", action: "update", source });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.emitProgress({ type: "error", action: "update", source, message });
throw error;
}
return;
}
}
@ -281,10 +334,11 @@ export class DefaultPackageManager implements PackageManager {
};
}
if (source.startsWith("git:")) {
const repoSpec = source.slice("git:".length).trim();
// Accept git: prefix or raw URLs (https://github.com/..., github.com/...)
if (source.startsWith("git:") || this.looksLikeGitUrl(source)) {
const repoSpec = source.startsWith("git:") ? source.slice("git:".length).trim() : source;
const [repo, ref] = repoSpec.split("@");
const normalized = repo.replace(/^https?:\/\//, "");
const normalized = repo.replace(/^https?:\/\//, "").replace(/\.git$/, "");
const parts = normalized.split("/");
const host = parts.shift() ?? "";
const repoPath = parts.join("/");
@ -301,6 +355,13 @@ export class DefaultPackageManager implements PackageManager {
return { type: "local", path: source };
}
private looksLikeGitUrl(source: string): boolean {
// Match URLs like https://github.com/..., github.com/..., gitlab.com/...
const gitHosts = ["github.com", "gitlab.com", "bitbucket.org", "codeberg.org"];
const normalized = source.replace(/^https?:\/\//, "");
return gitHosts.some((host) => normalized.startsWith(`${host}/`));
}
private parseNpmSpec(spec: string): { name: string; version?: string } {
const match = spec.match(/^(@?[^@]+(?:\/[^@]+)?)(?:@(.+))?$/);
if (!match) {

View file

@ -280,6 +280,13 @@ export class DefaultResourceLoader implements ResourceLoader {
const inlineExtensions = await this.loadExtensionFactories(extensionsResult.runtime);
extensionsResult.extensions.push(...inlineExtensions.extensions);
extensionsResult.errors.push(...inlineExtensions.errors);
// Detect extension conflicts (tools, commands, flags with same names from different extensions)
const conflicts = this.detectExtensionConflicts(extensionsResult.extensions);
for (const conflict of conflicts) {
extensionsResult.errors.push({ path: conflict.path, error: conflict.message });
}
this.extensionsResult = this.extensionsOverride ? this.extensionsOverride(extensionsResult) : extensionsResult;
const skillPaths = this.noSkills
@ -513,4 +520,56 @@ export class DefaultResourceLoader implements ResourceLoader {
return undefined;
}
private detectExtensionConflicts(extensions: Extension[]): Array<{ path: string; message: string }> {
const conflicts: Array<{ path: string; message: string }> = [];
// Track which extension registered each tool, command, and flag
const toolOwners = new Map<string, string>();
const commandOwners = new Map<string, string>();
const flagOwners = new Map<string, string>();
for (const ext of extensions) {
// Check tools
for (const toolName of ext.tools.keys()) {
const existingOwner = toolOwners.get(toolName);
if (existingOwner && existingOwner !== ext.path) {
conflicts.push({
path: ext.path,
message: `Tool "${toolName}" conflicts with ${existingOwner}`,
});
} else {
toolOwners.set(toolName, ext.path);
}
}
// Check commands
for (const commandName of ext.commands.keys()) {
const existingOwner = commandOwners.get(commandName);
if (existingOwner && existingOwner !== ext.path) {
conflicts.push({
path: ext.path,
message: `Command "/${commandName}" conflicts with ${existingOwner}`,
});
} else {
commandOwners.set(commandName, ext.path);
}
}
// Check flags
for (const flagName of ext.flags.keys()) {
const existingOwner = flagOwners.get(flagName);
if (existingOwner && existingOwner !== ext.path) {
conflicts.push({
path: ext.path,
message: `Flag "--${flagName}" conflicts with ${existingOwner}`,
});
} else {
flagOwners.set(flagName, ext.path);
}
}
}
return conflicts;
}
}

View file

@ -116,6 +116,8 @@ export {
export type { ReadonlyFooterDataProvider } from "./core/footer-data-provider.js";
export { convertToLlm } from "./core/messages.js";
export { ModelRegistry } from "./core/model-registry.js";
export type { PackageManager, ProgressCallback, ProgressEvent } from "./core/package-manager.js";
export { DefaultPackageManager } from "./core/package-manager.js";
export type { ResourceDiagnostic, ResourceLoader } from "./core/resource-loader.js";
export { DefaultResourceLoader } from "./core/resource-loader.js";
// SDK for programmatic usage

View file

@ -134,6 +134,15 @@ async function handlePackageCommand(args: string[]): Promise<boolean> {
const settingsManager = SettingsManager.create(cwd, agentDir);
const packageManager = new DefaultPackageManager({ cwd, agentDir, settingsManager });
// Set up progress callback for CLI feedback
packageManager.setProgressCallback((event) => {
if (event.type === "start") {
process.stdout.write(chalk.dim(`${event.message}\n`));
} else if (event.type === "error") {
console.error(chalk.red(`Error: ${event.message}`));
}
});
if (options.command === "install") {
if (!options.source) {
console.error(chalk.red("Missing install source."));
@ -141,7 +150,7 @@ async function handlePackageCommand(args: string[]): Promise<boolean> {
}
await packageManager.install(options.source, { local: options.local });
updateExtensionSources(settingsManager, options.source, options.local, "add");
console.log(`Installed ${options.source}`);
console.log(chalk.green(`Installed ${options.source}`));
return true;
}
@ -152,15 +161,15 @@ async function handlePackageCommand(args: string[]): Promise<boolean> {
}
await packageManager.remove(options.source, { local: options.local });
updateExtensionSources(settingsManager, options.source, options.local, "remove");
console.log(`Removed ${options.source}`);
console.log(chalk.green(`Removed ${options.source}`));
return true;
}
await packageManager.update(options.source);
if (options.source) {
console.log(`Updated ${options.source}`);
console.log(chalk.green(`Updated ${options.source}`));
} else {
console.log("Updated extensions");
console.log(chalk.green("Updated extensions"));
}
return true;
}