feat(sdk,plugins): API publique d'accès fichiers/workspace + analyse structure (#124,#129)

This commit is contained in:
2026-08-02 13:34:54 +02:00
parent 033e9a86d5
commit dce61ae1aa
27 changed files with 5895 additions and 94 deletions

View File

@ -42,13 +42,30 @@ import type {
PairingCode,
PermissionSet,
PluginAdmin,
PluginCommandTask,
PluginConfigDocument,
PluginConfigDocumentWriteResult,
PluginContributionSummary,
PluginEventBatch,
PluginEventSubscription,
PluginInstallResult,
PluginLifecycleState,
PluginProjectConvention,
PluginProjectModule,
PluginProjectStructure,
PluginProjectStructureEntry,
PluginReview,
PluginRuntimeContributionCatalog,
PluginToolchainDiagnostic,
PluginUninstallResult,
PluginPublicEvent,
PluginWorkspaceBinaryFile,
PluginWorkspaceDirectoryListing,
PluginWorkspaceDirEntry,
PluginWorkspaceStat,
PluginWorkspaceTextFile,
Project,
JsonValue,
ProjectMcpToolPermissions,
ProjectPermissions,
ProjectWorkState,
@ -104,7 +121,24 @@ import type {
ProfileGateway,
ProjectGateway,
PermissionGateway,
PluginConfigDocumentReadInput,
PluginConfigDocumentUpdateInput,
PluginConfigGateway,
PluginEventGateway,
PluginEventPollInput,
PluginEventSubscribeInput,
PluginEventUnsubscribeInput,
PluginGateway,
PluginProjectStructureQuery,
PluginRunCommandInput,
PluginTaskGateway,
PluginTaskStatusInput,
PluginToolchainDiagnosticRequest,
PluginToolchainGateway,
PluginWorkspaceGateway,
PluginWorkspacePathInput,
PluginWorkspaceWriteBinaryInput,
PluginWorkspaceWriteTextInput,
ReattachResult,
RemoteGateway,
ReviewPluginPackageInput,
@ -3540,6 +3574,448 @@ export class MockPluginGateway implements PluginGateway {
}
}
const PROJECT_MARKERS: Record<string, string> = {
"package.json": "node-package",
"Cargo.toml": "rust-cargo",
"pyproject.toml": "python-project",
"go.mod": "go-module",
Makefile: "makefile",
makefile: "makefile",
".git": "git-repository",
};
function invalidWorkspacePath(path: string): GatewayError {
return {
code: "INVALID",
message: `workspace path must be relative to the project root: ${path}`,
};
}
function normalizeWorkspacePath(path: string): string {
const raw = path.trim();
if (raw === "" || raw === ".") return "";
if (raw.includes("\0") || raw.startsWith("/") || raw.startsWith("\\") || raw.includes(":")) {
throw invalidWorkspacePath(path);
}
const parts = raw.replace(/\\/g, "/").split("/");
if (parts.some((part) => part === "" || part === "." || part === "..")) {
throw invalidWorkspacePath(path);
}
return parts.join("/");
}
function basename(path: string): string {
return path.split("/").pop() ?? path;
}
/**
* In-memory plugin workspace gateway for offline plugin development/tests.
* It mirrors the public contract shape, not the host filesystem.
*/
export class MockPluginWorkspaceGateway implements PluginWorkspaceGateway {
private readonly files = new Map<string, Map<string, Uint8Array>>();
private bucket(projectId: string): Map<string, Uint8Array> {
let files = this.files.get(projectId);
if (!files) {
files = new Map();
this.files.set(projectId, files);
}
return files;
}
_seedText(projectId: string, path: string, content: string): void {
this.bucket(projectId).set(normalizeWorkspacePath(path), new TextEncoder().encode(content));
}
async readText(input: PluginWorkspacePathInput): Promise<PluginWorkspaceTextFile> {
const file = await this.readBinary(input);
return { path: file.path, content: new TextDecoder().decode(file.bytes) };
}
async readBinary(input: PluginWorkspacePathInput): Promise<PluginWorkspaceBinaryFile> {
const path = normalizeWorkspacePath(input.path);
const bytes = this.bucket(input.projectId).get(path);
if (!bytes) {
const err: GatewayError = { code: "NOT_FOUND", message: `workspace file ${path} not found` };
throw err;
}
return { path, bytes: new Uint8Array(bytes) };
}
async writeText(input: PluginWorkspaceWriteTextInput): Promise<void> {
const path = normalizeWorkspacePath(input.path);
this.bucket(input.projectId).set(path, new TextEncoder().encode(input.content));
}
async writeBinary(input: PluginWorkspaceWriteBinaryInput): Promise<void> {
const path = normalizeWorkspacePath(input.path);
this.bucket(input.projectId).set(path, new Uint8Array(input.bytes));
}
async listDir(input: PluginWorkspacePathInput): Promise<PluginWorkspaceDirectoryListing> {
const path = normalizeWorkspacePath(input.path);
const prefix = path === "" ? "" : `${path}/`;
const entries = new Map<string, PluginWorkspaceDirEntry>();
for (const filePath of this.bucket(input.projectId).keys()) {
if (!filePath.startsWith(prefix)) continue;
const rest = filePath.slice(prefix.length);
if (rest === "") continue;
const [name, ...tail] = rest.split("/");
const entryPath = path === "" ? name : `${path}/${name}`;
const existing = entries.get(name);
entries.set(name, {
name,
path: entryPath,
isDir: Boolean(existing?.isDir) || tail.length > 0,
});
}
return { path, entries: [...entries.values()].sort((a, b) => a.name.localeCompare(b.name)) };
}
async stat(input: PluginWorkspacePathInput): Promise<PluginWorkspaceStat> {
const path = normalizeWorkspacePath(input.path);
const files = this.bucket(input.projectId);
const bytes = files.get(path);
if (bytes) {
return { path, exists: true, isFile: true, isDir: false, len: bytes.byteLength };
}
const prefix = path === "" ? "" : `${path}/`;
const isDir = [...files.keys()].some((filePath) => filePath.startsWith(prefix));
return { path, exists: isDir, isFile: false, isDir, len: null };
}
async queryProjectStructure(
input: PluginProjectStructureQuery,
): Promise<PluginProjectStructure> {
const rootPath = normalizeWorkspacePath(input.path ?? "");
const maxDepth = Math.min(input.maxDepth ?? 3, 8);
const maxEntries = Math.min(input.maxEntries ?? 500, 5000);
const prefix = rootPath === "" ? "" : `${rootPath}/`;
const entries = new Map<string, PluginProjectStructureEntry>();
const conventions = new Map<string, PluginProjectConvention>();
const modules = new Map<string, PluginProjectModule>();
for (const filePath of this.bucket(input.projectId).keys()) {
if (!filePath.startsWith(prefix)) continue;
const rest = filePath.slice(prefix.length);
const parts = rest.split("/").filter(Boolean);
for (let i = 0; i < parts.length && i <= maxDepth; i += 1) {
const path = [rootPath, ...parts.slice(0, i + 1)].filter(Boolean).join("/");
const isLeaf = i === parts.length - 1;
entries.set(path, {
path,
name: parts[i],
kind: isLeaf ? "file" : "directory",
});
}
const marker = basename(filePath);
const conventionId = PROJECT_MARKERS[marker];
if (conventionId) {
conventions.set(`${conventionId}:${filePath}`, { id: conventionId, markerPath: filePath });
const modulePath = filePath.slice(0, Math.max(0, filePath.length - marker.length - 1));
modules.set(`${conventionId}:${modulePath}`, {
path: modulePath,
markerPath: filePath,
conventionId,
});
}
}
const sortedEntries = [...entries.values()].sort((a, b) => a.path.localeCompare(b.path));
return {
projectId: input.projectId,
rootPath,
entries: sortedEntries.slice(0, maxEntries),
conventions: [...conventions.values()].sort((a, b) =>
a.markerPath.localeCompare(b.markerPath),
),
modules: [...modules.values()].sort((a, b) => a.path.localeCompare(b.path)),
truncated: sortedEntries.length > maxEntries,
};
}
}
/**
* In-memory command-task gateway for plugin runtime tests/dev. It models host
* task creation and status reads; live output remains owned by WorkStateGateway.
*/
export class MockPluginTaskGateway implements PluginTaskGateway {
private readonly tasks = new Map<string, PluginCommandTask>();
private nextId = 1;
async runCommand(input: PluginRunCommandInput): Promise<PluginCommandTask> {
if (input.command.trim() === "") {
const err: GatewayError = { code: "INVALID", message: "command must not be empty" };
throw err;
}
const now = Date.now();
const task: PluginCommandTask = {
taskId: `mock-plugin-task-${this.nextId++}`,
ownerAgentId: input.ownerAgentId,
projectId: input.projectId,
kind: "command",
state: "running",
exitCode: null,
summary: input.label,
stdoutTail: null,
stderrTail: null,
createdAtMs: now,
updatedAtMs: now,
};
this.tasks.set(task.taskId, task);
return task;
}
async getStatus(input: PluginTaskStatusInput): Promise<PluginCommandTask | null> {
return this.tasks.get(input.taskId) ?? null;
}
}
/**
* In-memory generic toolchain diagnostics for plugin tests/dev. It is
* deterministic and does not inspect the real host environment.
*/
export class MockPluginToolchainGateway implements PluginToolchainGateway {
async diagnose(input: PluginToolchainDiagnosticRequest): Promise<PluginToolchainDiagnostic> {
const tools = (input.tools ?? []).map((tool) => {
const missing = tool.executable.includes("missing");
const ok = !missing;
return {
id: tool.id,
executable: tool.executable,
present: ok,
ok,
status: ok ? ("ok" as const) : ("missing" as const),
required: tool.required ?? false,
exitCode: ok ? 0 : null,
version: ok ? `${tool.executable} mock-version` : null,
stdout: ok ? `${tool.executable} mock-version\n` : null,
stderr: null,
error: ok ? null : `executable not found: ${tool.executable}`,
};
});
const env = (input.env ?? []).map((requirement) => {
const present = !requirement.name.includes("MISSING");
const value = present ? (requirement.equals ?? "mock") : null;
const ok = present && (requirement.equals === undefined || value === requirement.equals);
return {
name: requirement.name,
present,
ok,
required: requirement.required ?? false,
value,
status: ok ? ("ok" as const) : present ? ("mismatch" as const) : ("missing" as const),
};
});
const files = (input.files ?? []).map((requirement) => {
const exists = !requirement.path.includes("missing");
const kind = exists ? (requirement.kind === "directory" ? "directory" : "file") : "missing";
const ok =
exists &&
(requirement.kind === undefined || requirement.kind === "any" || requirement.kind === kind);
return {
path: requirement.path,
exists,
ok,
required: requirement.required ?? false,
kind: kind as "file" | "directory" | "missing",
expectedKind: requirement.kind ?? null,
len: exists && kind === "file" ? 12 : null,
};
});
const messages = [
...tools
.filter((tool) => tool.required && !tool.ok)
.map((tool) => ({ level: "error" as const, message: `${tool.id}: ${tool.error}` })),
...env
.filter((item) => item.required && !item.ok)
.map((item) => ({ level: "error" as const, message: `${item.name}: ${item.status}` })),
...files
.filter((file) => file.required && !file.ok)
.map((file) => ({ level: "error" as const, message: `${file.path}: ${file.kind}` })),
];
return {
projectId: input.projectId,
cwd: input.cwd ?? "",
ok: messages.length === 0,
tools,
env,
files,
messages,
};
}
}
interface MockPluginEventSubscriptionState {
subscription: PluginEventSubscription;
queue: PluginPublicEvent[];
dropped: number;
}
/**
* In-memory public plugin event gateway for offline runtime tests/dev.
*/
export class MockPluginEventGateway implements PluginEventGateway {
private readonly subscriptions = new Map<string, MockPluginEventSubscriptionState>();
private nextId = 1;
async subscribe(input: PluginEventSubscribeInput): Promise<PluginEventSubscription> {
const subscription: PluginEventSubscription = {
subscriptionId: `mock-plugin-events-${this.nextId++}`,
projectId: input.projectId,
eventTypes: input.eventTypes?.length
? input.eventTypes
: ["workspaceFileChanged", "backgroundTaskChanged"],
capacity: Math.min(Math.max(input.capacity ?? 100, 1), 1000),
retention: "bestEffortBounded",
};
this.subscriptions.set(subscription.subscriptionId, {
subscription,
queue: [],
dropped: 0,
});
return subscription;
}
async poll(input: PluginEventPollInput): Promise<PluginEventBatch> {
const state = this.subscriptions.get(input.subscriptionId);
if (!state) {
const err: GatewayError = {
code: "NOT_FOUND",
message: "plugin event subscription not found",
};
throw err;
}
const maxEvents = Math.min(Math.max(input.maxEvents ?? 100, 1), 1000);
const events = state.queue.splice(0, maxEvents);
const dropped = state.dropped;
state.dropped = 0;
return { subscriptionId: input.subscriptionId, events, dropped };
}
async unsubscribe(input: PluginEventUnsubscribeInput): Promise<PluginEventSubscription> {
const state = this.subscriptions.get(input.subscriptionId);
this.subscriptions.delete(input.subscriptionId);
return (
state?.subscription ?? {
subscriptionId: input.subscriptionId,
projectId: "",
eventTypes: [],
capacity: 0,
retention: "disposed",
}
);
}
_emit(event: PluginPublicEvent): void {
for (const state of this.subscriptions.values()) {
if (
state.subscription.projectId !== event.projectId ||
!state.subscription.eventTypes.includes(event.type)
) {
continue;
}
if (state.queue.length >= state.subscription.capacity) {
state.queue.shift();
state.dropped += 1;
}
state.queue.push(event);
}
}
}
function cloneJson<T extends JsonValue>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T;
}
function applyJsonMergePatch(target: JsonValue, patch: JsonValue): JsonValue {
if (patch === null || typeof patch !== "object" || Array.isArray(patch)) return cloneJson(patch);
const base =
target !== null && typeof target === "object" && !Array.isArray(target)
? { ...target }
: {};
for (const [key, value] of Object.entries(patch)) {
if (value === null) {
delete base[key];
} else {
base[key] = applyJsonMergePatch(base[key] ?? null, value);
}
}
return base;
}
/**
* In-memory JSON config-document gateway for plugin tests/dev.
*/
export class MockPluginConfigGateway implements PluginConfigGateway {
private readonly documents = new Map<string, JsonValue>();
_seed(projectId: string, path: string, value: JsonValue): void {
this.documents.set(`${projectId}:${normalizeWorkspacePath(path)}`, cloneJson(value));
}
async readDocument(input: PluginConfigDocumentReadInput): Promise<PluginConfigDocument> {
const path = normalizeWorkspacePath(input.path);
const format = input.format ?? "json";
if (format !== "json") {
const err: GatewayError = {
code: "INVALID",
message: `unsupported structured config format: ${format}; supported formats: json`,
};
throw err;
}
const value = this.documents.get(`${input.projectId}:${path}`);
if (value === undefined) {
const err: GatewayError = { code: "NOT_FOUND", message: `config document ${path} not found` };
throw err;
}
return { projectId: input.projectId, path, format, value: cloneJson(value) };
}
async updateDocument(
input: PluginConfigDocumentUpdateInput,
): Promise<PluginConfigDocumentWriteResult> {
const path = normalizeWorkspacePath(input.path);
const format = input.format ?? "json";
const mode = input.mode ?? "mergePatch";
if (format !== "json") {
const err: GatewayError = {
code: "INVALID",
message: `unsupported structured config format: ${format}; supported formats: json`,
};
throw err;
}
if (mode !== "mergePatch" && mode !== "replace") {
const err: GatewayError = {
code: "INVALID",
message: `unsupported structured config update mode: ${mode}`,
};
throw err;
}
const key = `${input.projectId}:${path}`;
const current = this.documents.get(key);
if (mode === "mergePatch" && current === undefined) {
const err: GatewayError = { code: "NOT_FOUND", message: `config document ${path} not found` };
throw err;
}
const next = mode === "replace" ? cloneJson(input.value) : applyJsonMergePatch(current!, input.value);
this.documents.set(key, next);
return {
projectId: input.projectId,
path,
format,
mode,
bytesWritten: JSON.stringify(next, null, 2).length + 1,
};
}
}
/** Builds the full set of mock gateways. */
export function createMockGateways(): Gateways {
const agentGateway = new MockAgentGateway();
@ -3569,6 +4045,11 @@ export function createMockGateways(): Gateways {
focusedProject: new MockFocusedProjectGateway(),
uiPreferences: new MockUiPreferencesGateway(),
plugin: new MockPluginGateway(),
pluginWorkspace: new MockPluginWorkspaceGateway(),
pluginTask: new MockPluginTaskGateway(),
pluginToolchain: new MockPluginToolchainGateway(),
pluginEvents: new MockPluginEventGateway(),
pluginConfig: new MockPluginConfigGateway(),
};
}