Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Large diffs are not rendered by default.

31 changes: 23 additions & 8 deletions cli/src/application/use-cases/plugin/plugin-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import type { Hasher } from "../../../domain/ports/hasher.js";
import type { ManifestRepository } from "../../../domain/ports/manifest-repository.js";
import { getToolConfig, isAiTool } from "../../../domain/tools/registry.js";
import { NoManifestError } from "../../errors.js";
import type { BuiltTreeMaterializationTranslator } from "./translator/built-tree-materialization-translator.js";
import type { PluginTranslator } from "./translator/plugin-translator.js";

export function resolvePluginToolIds(toolIds: AiToolId[] | "all", manifest: Manifest): AiToolId[] {
if (toolIds !== "all") return toolIds;
Expand Down Expand Up @@ -65,6 +65,18 @@ export async function writePluginFiles(
await Promise.all(files.map((f) => fs.writeFile(join(baseDir, f.relativePath), f.content)));
}

/** Deletes exactly the paths a plugin's own manifest entry lists, joined to its base dir.
* Never enumerates the directory or deletes by pattern — only manifest-tracked keys. */
export async function deleteOldFiles(
files: ReadonlyMap<string, string>,
baseDir: string,
fs: FileWriter
): Promise<void> {
for (const relativePath of files.keys()) {
await fs.deleteFile(join(baseDir, relativePath));
}
}

/** Whether the file already on disk matches the content we would write, so a
* caller can skip the write and, more importantly, not count it as restored. */
export async function isPluginFileAtDesiredState(
Expand All @@ -79,18 +91,21 @@ export async function isPluginFileAtDesiredState(
}

/**
* Re-registers a plugin backed by the BUILT tree: drops the existing manifest entry
* and lets the translator re-materialize + re-register it, so update and restore both
* end up with the same single entry an install would have produced.
* Re-registers a marketplace-sourced plugin through its resolved translator: drops the
* existing manifest entry and lets the translator re-add it, so update and restore both
* end up with the same single entry an install would have produced. Works for either
* translation strategy — materializing tools (cursor/opencode) re-copy the BUILT tree;
* Mode A marketplace tools (claude/codex/copilot) register the plugin reference without
* writing any files, matching what install does for them.
*
* Returns how many files the translator actually (re)wrote — not the plugin's total
* file count — so a no-op restore reports zero instead of claiming everything changed.
* `written` is undefined only on the rare fallback path where the translator can't
* resolve the marketplace and delegates to a strategy that doesn't track counts; that
* `written` is undefined for translators that don't track counts (Mode A never writes
* files; the rare built-tree fallback where the marketplace can't be resolved); that
* is reported as 0 rather than guessed.
*/
export async function materializeViaBuiltTree(
translator: BuiltTreeMaterializationTranslator,
export async function materializeViaTranslator(
translator: PluginTranslator,
dist: PluginDistribution,
toolId: AiToolId,
plugin: Plugin,
Expand Down
41 changes: 15 additions & 26 deletions cli/src/application/use-cases/plugin/plugin-update-use-case.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,14 @@ import type { PluginFetcher } from "../../../domain/ports/plugin-fetcher.js";
import { getToolConfig, type ToolConfig } from "../../../domain/tools/registry.js";
import type { BuiltMaterializationDeps } from "../shared/apply-plugin-files-use-case.js";
import {
deleteOldFiles,
loadPluginManifest,
materializeViaBuiltTree,
materializeViaTranslator,
resolvePluginBaseDir,
resolvePluginToolIds,
writePluginFiles,
} from "./plugin-helpers.js";
import { BuiltTreeMaterializationTranslator } from "./translator/built-tree-materialization-translator.js";
import type { PluginTranslator } from "./translator/plugin-translator.js";
import { resolvePluginTranslator } from "./translator/resolve-plugin-translator.js";

export interface PluginUpdateOptions {
Expand Down Expand Up @@ -116,12 +117,12 @@ export class PluginUpdateUseCase {
docsDir: string
): Promise<void> {
const baseDir = resolvePluginBaseDir(toolId, projectRoot, nodeHomedir);
await this.deleteOldFiles(plugin.files, baseDir);
await deleteOldFiles(plugin.files, baseDir, this.fs);
const toolConfig = getToolConfig(toolId);
const builtTree = this.builtTreeTranslator(toolConfig);
if (builtTree !== null && plugin.marketplace !== undefined) {
await materializeViaBuiltTree(
builtTree,
const translator = this.resolveTranslator(toolConfig);
if (translator !== null && plugin.marketplace !== undefined) {
await materializeViaTranslator(
translator,
dist,
toolId,
plugin,
Expand All @@ -134,36 +135,24 @@ export class PluginUpdateUseCase {
const { files: newFiles, componentPaths } = new PluginContentTranslator(
this.hasher
).translateWithComponentPaths(dist, toolConfig, docsDir);
const isLocalMarketplace = plugin.source.kind === "local" && plugin.marketplace !== undefined;
if (!isLocalMarketplace) await writePluginFiles(newFiles, baseDir, this.fs);
await writePluginFiles(newFiles, baseDir, this.fs);
manifest.updatePlugin(
toolId,
Plugin.fromDistribution(
dist,
plugin.source,
isLocalMarketplace ? [] : newFiles,
isLocalMarketplace ? new Map() : componentPaths
)
Plugin.fromDistribution(dist, plugin.source, newFiles, componentPaths)
);
}

private async deleteOldFiles(files: ReadonlyMap<string, string>, baseDir: string): Promise<void> {
for (const relativePath of files.keys()) {
await this.fs.deleteFile(join(baseDir, relativePath));
}
}

// Materializing tools (cursor/opencode) re-materialize from the BUILT tree so an
// update writes the same content install did — not the raw source transform.
private builtTreeTranslator(toolConfig: ToolConfig): BuiltTreeMaterializationTranslator | null {
// Materializing tools (cursor/opencode) re-materialize from the BUILT tree, and Mode A
// marketplace tools (claude/codex/copilot) re-register without writing files, so an
// update matches whatever install would have done for that tool.
private resolveTranslator(toolConfig: ToolConfig): PluginTranslator | null {
if (this.builtDeps === undefined) return null;
const translator = resolvePluginTranslator(toolConfig, {
return resolvePluginTranslator(toolConfig, {
fs: this.fs,
hasher: this.hasher,
homedir: this.builtDeps.homedir,
ensureBuilt: this.builtDeps.ensureBuilt,
marketplaceRegistry: this.builtDeps.marketplaceRegistry,
});
return translator instanceof BuiltTreeMaterializationTranslator ? translator : null;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ export interface PluginTranslator {
* Add a plugin for a specific tool, writing files and/or registering the plugin reference
* in the manifest according to this adapter's strategy.
*
* Returns a skip list — non-empty when the plugin contains components the tool cannot consume.
* Returns a skip list — non-empty when the plugin contains components the tool cannot consume —
* and, for strategies that track it, how many files were actually (re)written to disk.
*
* `previousMcpEntries` — pass the plugin's previous mcpEntries when replacing an existing
* plugin install (--replace path). Used for idempotent re-merge of OpenCode MCP servers.
Expand All @@ -34,5 +35,5 @@ export interface PluginTranslator {
marketplace: string | undefined,
docsDir: string,
previousMcpEntries?: ReadonlyMap<string, string>
): Promise<{ skipped: ReadonlySkipList }>;
): Promise<{ skipped: ReadonlySkipList; written?: number }>;
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,13 @@ import type { MarketplaceRegistry } from "../../../domain/ports/marketplace-regi
import type { PluginDistributionReader } from "../../../domain/ports/plugin-distribution-reader.js";
import type { PluginFetcher } from "../../../domain/ports/plugin-fetcher.js";
import type { ToolConfig } from "../../../domain/tools/registry.js";
import { isPluginFileAtDesiredState, materializeViaBuiltTree } from "../plugin/plugin-helpers.js";
import { BuiltTreeMaterializationTranslator } from "../plugin/translator/built-tree-materialization-translator.js";
import {
deleteOldFiles,
isPluginFileAtDesiredState,
materializeViaTranslator,
resolvePluginBaseDir,
} from "../plugin/plugin-helpers.js";
import type { PluginTranslator } from "../plugin/translator/plugin-translator.js";
import { resolvePluginTranslator } from "../plugin/translator/resolve-plugin-translator.js";
import type { EnsureBuiltMarketplaceUseCase } from "./ensure-built-marketplace-use-case.js";

Expand Down Expand Up @@ -46,34 +51,43 @@ export class ApplyPluginFilesUseCase {
async execute(options: ApplyPluginFilesOptions): Promise<number> {
const localPath = await this.pluginFetcher.fetch(options.plugin.source, options.cacheDir);
const dist = await this.pluginDistributionReader.read(localPath);
const builtTree = this.builtTreeTranslator(options.toolConfig);
if (builtTree !== null && options.plugin.marketplace !== undefined) {
return this.restoreViaBuiltTree(builtTree, dist, options);
const translator = this.resolveTranslator(options.toolConfig);
if (translator !== null && options.plugin.marketplace !== undefined) {
return this.restoreViaTranslator(translator, dist, options);
}
return this.restoreViaTranslate(dist, options);
}

// Materializing tools (cursor/opencode) must re-materialize from the BUILT tree so
// restored content + hashes match what install wrote — not the raw source transform.
private builtTreeTranslator(toolConfig: ToolConfig): BuiltTreeMaterializationTranslator | null {
// restored content + hashes match what install wrote, and Mode A marketplace tools
// (claude/codex/copilot) must re-register without writing files — not the raw source
// transform in either case.
private resolveTranslator(toolConfig: ToolConfig): PluginTranslator | null {
if (this.builtDeps === undefined) return null;
const translator = resolvePluginTranslator(toolConfig, {
return resolvePluginTranslator(toolConfig, {
fs: this.fs,
hasher: this.hasher,
homedir: this.builtDeps.homedir,
ensureBuilt: this.builtDeps.ensureBuilt,
marketplaceRegistry: this.builtDeps.marketplaceRegistry,
});
return translator instanceof BuiltTreeMaterializationTranslator ? translator : null;
}

private async restoreViaBuiltTree(
translator: BuiltTreeMaterializationTranslator,
private async restoreViaTranslator(
translator: PluginTranslator,
dist: PluginDistribution,
options: ApplyPluginFilesOptions
): Promise<number> {
const { toolId, plugin, projectRoot, manifest, docsDir } = options;
return materializeViaBuiltTree(
// Mode A never materializes files, so any manifest-tracked path here is a leftover
// from a run before that was true (see plugin-update-use-case.ts's unconditional
// equivalent). Scoped to the manifest's own keys under the plugin's base dir — never
// a directory scan — so it cannot touch files the plugin never wrote.
if (translator.mode === "marketplace" && this.builtDeps !== undefined) {
const baseDir = resolvePluginBaseDir(toolId, projectRoot, this.builtDeps.homedir);
await deleteOldFiles(plugin.files, baseDir, this.fs);
}
return materializeViaTranslator(
translator,
dist,
toolId,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { PluginAddUseCase } from "../../../../src/application/use-cases/plugin/plugin-add-use-case.js";
import { PluginUpdateUseCase } from "../../../../src/application/use-cases/plugin/plugin-update-use-case.js";
import { Marketplace } from "../../../../src/domain/models/marketplace.js";
import { PluginDistributionReaderAdapter } from "../../../../src/infrastructure/adapters/plugin-distribution-reader-adapter.js";
import { buildUnitDeps, initAndInstall } from "../../../helpers/ports/build-unit-deps.js";
import { fakeEnsureBuiltMarketplace } from "../../../helpers/ports/fake-ensure-built-marketplace.js";
import { InMemoryMarketplaceRegistry } from "../../../helpers/ports/in-memory-marketplace-registry.js";
import { seedFromDirectory } from "../../../helpers/ports/seed-from-directory.js";

const PLUGIN_FIXTURE = join(process.cwd(), "tests/fixtures/plugins/claude-format/sample-plugin");
const PROJECT_ROOT = "/test-project";
const HOME = "/home/u";
const BUILT_OPENCODE_SKILL = "/built/opencode/.opencode/skills/sample-plugin-demo/SKILL.md";

// A plugin whose catalog entry resolves to a github-hosted source (git-subdir), the shape
// PluginInstallFromMarketplaceUseCase produces for any plugin catalogued in a github marketplace.
const GIT_SUBDIR_SOURCE = {
kind: "git-subdir" as const,
url: "https://github.com/ai-driven-dev/framework.git",
path: "plugins/sample-plugin",
};

const PLUGIN_METADATA = { name: "sample-plugin", version: "1.0.0", strict: false };

type Deps = Awaited<ReturnType<typeof buildUnitDeps>>;

async function makeGithubRegistry(): Promise<InMemoryMarketplaceRegistry> {
const registry = new InMemoryMarketplaceRegistry();
await registry.save(
PROJECT_ROOT,
Marketplace.create({
name: "aidd-framework",
source: { kind: "github", repo: "ai-driven-dev/framework" },
scope: "project",
addedAt: "2026-05-01T00:00:00.000Z",
})
);
return registry;
}

function makeUpdateUseCase(deps: Deps, registry: InMemoryMarketplaceRegistry): PluginUpdateUseCase {
return new PluginUpdateUseCase(
deps.fs,
deps.manifestRepo,
deps.pluginFetcher,
new PluginDistributionReaderAdapter(deps.fs),
deps.hasher,
{
ensureBuilt: fakeEnsureBuiltMarketplace(),
marketplaceRegistry: registry,
homedir: () => HOME,
}
);
}

/** Installs a github-sourced marketplace plugin on `toolId`, then lowers its recorded
* version so update sees it as stale and re-fetches. */
async function installStaleGithubPlugin(
deps: Deps,
registry: InMemoryMarketplaceRegistry,
toolId: "claude" | "codex" | "copilot" | "opencode"
): Promise<void> {
await seedFromDirectory(deps.fs, PLUGIN_FIXTURE, { useAbsolutePaths: true });
deps.pluginFetcher.register(GIT_SUBDIR_SOURCE, PLUGIN_FIXTURE);

await new PluginAddUseCase(
deps.fs,
deps.manifestRepo,
deps.pluginFetcher,
new PluginDistributionReaderAdapter(deps.fs),
deps.hasher,
deps.logger,
registry,
fakeEnsureBuiltMarketplace()
).execute({
source: GIT_SUBDIR_SOURCE,
toolIds: [toolId],
projectRoot: PROJECT_ROOT,
marketplace: "aidd-framework",
interactive: false,
pluginMetadata: PLUGIN_METADATA,
});

const manifest = await deps.manifestRepo.load();
if (manifest === null) throw new Error("manifest not found");
const plugin = manifest.getPlugins(toolId).find((p) => p.name === "sample-plugin");
if (plugin === undefined) throw new Error("plugin not found");
manifest.updatePlugin(toolId, plugin.withVersion("0.0.1"));
await deps.manifestRepo.save(manifest);
}

describe("PluginUpdateUseCase — Mode A marketplace tools (claude/codex/copilot)", () => {
it("updates a github-sourced marketplace plugin on claude without materializing any files", async () => {
const deps = await buildUnitDeps(PROJECT_ROOT);
await initAndInstall(deps, PROJECT_ROOT, "claude");
const registry = await makeGithubRegistry();
await installStaleGithubPlugin(deps, registry, "claude");

const beforeManifest = await deps.manifestRepo.load();
const before = beforeManifest?.getPlugins("claude").find((p) => p.name === "sample-plugin");
expect(before?.files.size).toBe(0);
expect(deps.fs.listAll().some((p) => p.includes(".claude/plugins/"))).toBe(false);

const updated = await makeUpdateUseCase(deps, registry).execute({
toolIds: ["claude"],
projectRoot: PROJECT_ROOT,
});

expect(updated).toContain("sample-plugin");
const manifest = await deps.manifestRepo.load();
const plugin = manifest?.getPlugins("claude").find((p) => p.name === "sample-plugin");
expect(plugin?.version).toBe("1.0.0");
// Mode A's whole contract: register the reference, materialize nothing. Update must
// not write plugin files that install never wrote, nor record them in the manifest.
expect(plugin?.files.size).toBe(0);
expect(deps.fs.listAll().some((p) => p.includes(".claude/plugins/"))).toBe(false);
});

it("keeps the manifest's single entry for the plugin after update (no duplicate registration)", async () => {
const deps = await buildUnitDeps(PROJECT_ROOT);
await initAndInstall(deps, PROJECT_ROOT, "claude");
const registry = await makeGithubRegistry();
await installStaleGithubPlugin(deps, registry, "claude");

await makeUpdateUseCase(deps, registry).execute({
toolIds: ["claude"],
projectRoot: PROJECT_ROOT,
});

const manifest = await deps.manifestRepo.load();
const plugins = manifest?.getPlugins("claude").filter((p) => p.name === "sample-plugin") ?? [];
expect(plugins).toHaveLength(1);
expect(plugins[0]?.marketplace).toBe("aidd-framework");
expect(plugins[0]?.source.kind).toBe("git-subdir");
});
});

describe("PluginUpdateUseCase — flat-mode tools are unaffected (regression guard)", () => {
it("still re-materializes opencode's flat files from the built tree on update", async () => {
const deps = await buildUnitDeps(PROJECT_ROOT);
await initAndInstall(deps, PROJECT_ROOT, "opencode");
const registry = await makeGithubRegistry();
await installStaleGithubPlugin(deps, registry, "opencode");
deps.fs.setFile(BUILT_OPENCODE_SKILL, "# Demo skill v2");

const updated = await makeUpdateUseCase(deps, registry).execute({
toolIds: ["opencode"],
projectRoot: PROJECT_ROOT,
});

expect(updated).toContain("sample-plugin");
const manifest = await deps.manifestRepo.load();
const plugin = manifest?.getPlugins("opencode").find((p) => p.name === "sample-plugin");
expect(plugin?.version).toBe("1.0.0");
expect(plugin?.files.size).toBeGreaterThan(0);
expect(
deps.fs.getFile(join(PROJECT_ROOT, ".opencode/skills/sample-plugin-demo/SKILL.md"))
).toBe("# Demo skill v2");
});
});
Loading