diff --git a/.project-docs/30-worklog/tasks/20260827-plugin-ml01-package-selection-8d3c7a21.md b/.project-docs/30-worklog/tasks/20260827-plugin-ml01-package-selection-8d3c7a21.md new file mode 100644 index 0000000..bbf48f1 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260827-plugin-ml01-package-selection-8d3c7a21.md @@ -0,0 +1,74 @@ +# Task: Implement ML-01 package selection and bundled plugin registry + +## Identity + +- Task ID: 20260827-plugin-ml01-package-selection-8d3c7a21 +- Mode: Feature +- Branch: codex/20260827-plugin-ml01-package-selection-8d3c7a21-plugin-ml01-package-selection +- Worktree: D:\Datas\OthersProjects\makelore-plugin-ml01-package-selection-8d3c7a21 +- Base commit: 2ab1c51a2404086cbd688ac80154765d7c5d4662 +- Owner: plugin_ml01_package_selection +- Status: Ready for integration + +## Scope + +- Implement ML-01 in the isolated MakeLore client worktree only. +- Add immutable shared plugin/tool definitions for the bundled Data Service package. +- Add exact Agent Plugins root/capability manifest parsing, fixed bundled-root resolution, + allowlist/path/reference/uniqueness validation, and unsupported component rejection. +- Add atomic project plugin selection persistence and legacy `data-service` Skill projection, + including deterministic IDs, unknown-ID preservation, idempotent state changes, managed-input + revision callbacks, and adapter deactivation callbacks. +- Move the Data Service Skill and SDK assets into the bundled plugin package without changing + their content or Skill ID; project the package Skill through the existing Skill registry. +- Add ML-01-focused manifest, project selection, and Skill display tests. + +## Intent And Constraints + +- Base is the exact accepted ML-00 client frontier `2ab1c51a2404086cbd688ac80154765d7c5d4662`. +- Work is isolated at this task's linked worktree; do not alter the occupied client coordinator + or any other task's files. +- Preserve the frozen P0 scope: bundled Data Service only, no policy HTTP, capability invoke, + Pi runtime/CLI, Host/Renderer, generic execution, pricing, or P1 marketplace behavior. +- Keep `shared/coding-skills.ts` limited to core Skill IDs; package-owned Skills come from the + validated package projection. +- The SDK asset test is part of this correction because the ML-01 resource move owns its target + path; update assertions only to the new package location without changing SDK behavior. + +## Outcome + +- Added `shared/coding-plugins.ts` with deeply immutable Data Service definitions and all ten + stable parent-only tool declarations/input schemas. +- Added `electron/coding-plugins/manifest.ts` with exact root/capability parsing, fixed bundle + roots, code-owned allowlists, path containment, unique/reference checks, destructive confirmation + checks, and unsupported MCP/hooks/executables handling. +- Added `electron/coding-plugins/project-service.ts` with atomic `.niancode/plugins.json` state, + unknown-ID retention, legacy Skill projection, deterministic output, idempotent mutations, and + lifecycle callback seams. +- Moved `data-service/SKILL.md`, `makelore-data.ts`, and `makelore-data.js` under + `resources/coding-plugins/data-service/skills/data-service/` byte-for-byte, and added the root + and capability manifests. +- Updated `electron/coding-projects/skill-registry.ts` to merge core Skills with the fixed package + Skill projection while preserving existing display/command behavior. + +## Verification + +- `corepack pnpm exec vitest run tests/unit/data-service-sdk-assets.test.ts tests/unit/coding-plugin-manifest.test.ts tests/unit/project-plugin-service.test.ts tests/unit/skill-display.test.ts tests/unit/pi-product-tools.test.ts --maxWorkers=1` — passed, 5 files / 45 tests. +- `corepack pnpm run typecheck` — passed. +- `corepack pnpm run lint:check` — passed with five pre-existing warnings in `src/pages/Home/index.tsx` and `src/pages/Makelore/index.tsx`, zero errors. +- Scoped ESLint for all ML-01-owned source/tests — passed. +- Git blob comparison confirmed moved Skill/SDK assets retain the exact base contents. +- `tests/unit/data-service-sdk-assets.test.ts` now verifies the canonical package Skill/asset path + under `resources/coding-plugins/data-service/skills/data-service/`; its SDK behavior assertions + pass unchanged. + +## Follow-ups + +- ML-02/ML-03 must consume the exported definition and ProjectPluginService callback interfaces + when wiring registry, adapter, worker materialization, and lifecycle invalidation. +- Main composition remains intentionally unwired in ML-01. + +## Promotion Candidates + +- None. This feature task implements the already accepted package/selection design and introduces + no canonical project-memory or decision change. diff --git a/electron/coding-plugins/manifest.ts b/electron/coding-plugins/manifest.ts new file mode 100644 index 0000000..7adfbe4 --- /dev/null +++ b/electron/coding-plugins/manifest.ts @@ -0,0 +1,664 @@ +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { + AGENT_PLUGINS_SCHEMA_URL, + BUNDLED_CODING_PLUGIN_ADAPTER_IDS, + BUNDLED_CODING_PLUGIN_PREVIEW_SURFACES, + BUNDLED_CODING_PLUGIN_SETTINGS_SURFACES, + CODE_OWNED_PLUGIN_PERMISSION_IDS, + DATA_SERVICE_CAPABILITY_IDS, + DATA_SERVICE_PLUGIN_ID, + DATA_SERVICE_TOOL_NAMES, + type AgentPluginsRootManifest, + type CodingPluginDefinition, + type CodingPluginSkillDefinition, + type CodingPluginToolDefinition, + type PluginToolMutation, +} from '../../shared/coding-plugins'; + +/** + * P0 deliberately has one statically enumerated package root. Keeping this + * list relative makes it impossible for an environment variable or a project + * file to add a package to the trusted catalog. + */ +export const BUNDLED_CODING_PLUGIN_ROOTS = Object.freeze([ + 'data-service', +] as const); + +const CAPABILITY_MANIFEST_RELATIVE_PATH = 'com.makelore/capability.json'; +const PACKAGE_MANIFEST_FILE = 'plugin.json'; +const SKILL_ID_PATTERN = /^[a-z][a-z0-9._-]{0,63}$/u; +const CAPABILITY_ID_PATTERN = /^[a-z][a-z0-9.-]{0,63}$/u; +const OPERATION_ID_PATTERN = /^[a-z][a-z0-9._-]{0,63}$/u; +const TOOL_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9._:-]{0,63}$/u; +const SEMVER_PATTERN = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/u; +const MAX_DISPLAY_TEXT = 256; + +const UNSUPPORTED_COMPONENT_KEYS = new Set([ + 'commands', + 'executables', + 'hooks', + 'mcp', + 'mcpServers', + 'scripts', +]); + +const FORBIDDEN_FIELD_PATTERN = /^(?:price|prices|points|balance|owner|owners|token|tokens|url|urls|entrypoint|entryPoint|react)$/iu; + +const DATA_SERVICE_OPERATION_CAPABILITIES: Readonly> = Object.freeze({ + configure: 'data-service.control', + inspect: 'data-service.control', + list_projects: 'data-service.control', + remove_collection: 'data-service.control', + reset: 'data-service.control', + remove_project: 'data-service.control', + get_document: 'data-service.documents', + list_documents: 'data-service.documents', + put_document: 'data-service.documents', + delete_document: 'data-service.documents', +}); + +const ROOT_KEYS = new Set([ + '$schema', + 'name', + 'version', + 'description', + 'author', + 'extensions', + ...UNSUPPORTED_COMPONENT_KEYS, +]); + +const CAPABILITY_KEYS = new Set([ + 'schemaVersion', + 'pluginId', + 'contractVersion', + 'scope', + 'adapterId', + 'requiresBackend', + 'display', + 'skills', + 'tools', + 'surfaces', + ...UNSUPPORTED_COMPONENT_KEYS, +]); + +const AUTHOR_KEYS = new Set(['name']); +const EXTENSIONS_KEYS = new Set(['com.makelore']); +const MAKELore_EXTENSION_KEYS = new Set(['capabilityManifest']); +const DISPLAY_KEYS = new Set(['name', 'description']); +const SKILL_KEYS = new Set(['id', 'entry', 'grants']); +const TOOL_KEYS = new Set([ + 'name', + 'label', + 'description', + 'capabilityId', + 'operation', + 'roles', + 'mutation', + 'projectWriteLease', + 'permissions', + 'inputSchema', +]); +const SURFACE_KEYS = new Set(['projectSettings', 'previewRuntime']); + +type UnknownRecord = Record; + +export class CodingPluginManifestError extends Error { + readonly code = 'plugin_manifest_invalid' as const; + + constructor( + readonly filePath: string, + readonly field: string, + message: string, + ) { + super(`Invalid plugin manifest (${filePath}, ${field}): ${message}`); + this.name = 'CodingPluginManifestError'; + } +} + +function isRecord(value: unknown): value is UnknownRecord { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function freezeDeep(value: T): T { + if (value && typeof value === 'object' && !Object.isFrozen(value)) { + for (const child of Object.values(value as UnknownRecord)) freezeDeep(child); + Object.freeze(value); + } + return value; +} + +function fail(filePath: string, field: string, message: string): never { + throw new CodingPluginManifestError(filePath, field, message); +} + +function assertRecord( + value: unknown, + filePath: string, + field: string, +): UnknownRecord { + if (!isRecord(value)) fail(filePath, field, 'expected an object'); + return value; +} + +function assertExactKeys( + value: UnknownRecord, + allowed: ReadonlySet, + filePath: string, + field: string, +): void { + for (const key of Object.keys(value)) { + if (FORBIDDEN_FIELD_PATTERN.test(key)) { + fail(filePath, `${field}.${key}`, 'field is not allowed in a plugin manifest'); + } + if (!allowed.has(key)) { + fail(filePath, `${field}.${key}`, 'field is not part of the exact P0 schema'); + } + } +} + +function unsupportedComponentIsEmpty(value: unknown): boolean { + if (value === undefined || value === null) return true; + if (Array.isArray(value)) return value.length === 0; + if (isRecord(value)) return Object.keys(value).length === 0; + return false; +} + +function rejectUnsupportedComponents(value: UnknownRecord, filePath: string, field: string): void { + for (const key of UNSUPPORTED_COMPONENT_KEYS) { + if (key in value && !unsupportedComponentIsEmpty(value[key])) { + fail(filePath, `${field}.${key}`, 'non-empty component is unsupported in P0'); + } + } +} + +function text(value: unknown, filePath: string, field: string, maximum = MAX_DISPLAY_TEXT): string { + if (typeof value !== 'string' || value.trim().length === 0 || value.length > maximum) { + fail(filePath, field, `expected non-empty text of at most ${maximum} characters`); + } + return value; +} + +function stableId( + value: unknown, + filePath: string, + field: string, + pattern: RegExp, +): string { + const result = text(value, filePath, field, 64); + if (!pattern.test(result)) fail(filePath, field, 'identifier has an invalid shape'); + return result; +} + +function bool(value: unknown, filePath: string, field: string): boolean { + if (typeof value !== 'boolean') fail(filePath, field, 'expected a boolean'); + return value; +} + +function positiveInteger(value: unknown, filePath: string, field: string): number { + if (!Number.isSafeInteger(value) || (value as number) < 1) { + fail(filePath, field, 'expected a positive safe integer'); + } + return value as number; +} + +function uniqueStrings( + value: unknown, + filePath: string, + field: string, + pattern: RegExp, + allowEmpty = false, +): string[] { + if (!Array.isArray(value)) fail(filePath, field, 'expected an array'); + const result: string[] = []; + const seen = new Set(); + for (const [index, candidate] of value.entries()) { + if (typeof candidate !== 'string' || (!allowEmpty && candidate.trim().length === 0)) { + fail(filePath, `${field}[${index}]`, 'expected a non-empty string'); + } + if (!pattern.test(candidate)) fail(filePath, `${field}[${index}]`, 'identifier has an invalid shape'); + if (seen.has(candidate)) fail(filePath, `${field}[${index}]`, 'duplicate identifier'); + seen.add(candidate); + result.push(candidate); + } + return result; +} + +function normalizeCandidatePath( + packageRoot: string, + baseDirectory: string, + candidate: unknown, + filePath: string, + field: string, +): string { + const value = text(candidate, filePath, field, 512); + const portable = value.replaceAll('\\', '/'); + if (portable.startsWith('/') || /^[A-Za-z]:\//u.test(portable) + || /^[A-Za-z][A-Za-z0-9+.-]*:/u.test(portable)) { + fail(filePath, field, 'path must be relative to the package root'); + } + const root = path.resolve(packageRoot); + const resolved = path.resolve(baseDirectory, value); + const relative = path.relative(root, resolved); + if (!relative || relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) { + fail(filePath, field, 'path escapes the package root'); + } + return relative.split(path.sep).join('/'); +} + +function validateSchemaNode(value: unknown, filePath: string, field: string, root = false): void { + const schema = assertRecord(value, filePath, field); + const allowed = root + ? new Set(['type', 'additionalProperties', 'required', 'properties']) + : new Set([ + 'type', + 'additionalProperties', + 'required', + 'properties', + 'items', + 'pattern', + 'minLength', + 'maxLength', + 'minimum', + 'maximum', + 'maxItems', + 'const', + ]); + assertExactKeys(schema, allowed, filePath, field); + if (typeof schema.type !== 'string' || !['array', 'boolean', 'integer', 'object', 'string'].includes(schema.type)) { + fail(filePath, `${field}.type`, 'unsupported input schema type'); + } + if ('additionalProperties' in schema && typeof schema.additionalProperties !== 'boolean') { + fail(filePath, `${field}.additionalProperties`, 'must be a boolean'); + } + if ('required' in schema) { + uniqueStrings(schema.required, filePath, `${field}.required`, /^[A-Za-z_][A-Za-z0-9_]*$/u); + } + if ('properties' in schema) { + const properties = assertRecord(schema.properties, filePath, `${field}.properties`); + for (const [name, propertySchema] of Object.entries(properties)) { + if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(name)) { + fail(filePath, `${field}.properties.${name}`, 'input property name is invalid'); + } + validateSchemaNode(propertySchema, filePath, `${field}.properties.${name}`); + } + if ('required' in schema) { + for (const required of schema.required as string[]) { + if (!(required in properties)) { + fail(filePath, `${field}.required`, `required property is not declared: ${required}`); + } + } + } + } else if ('required' in schema && (schema.required as unknown[]).length > 0) { + fail(filePath, `${field}.required`, 'required properties need a properties object'); + } + if ('items' in schema) validateSchemaNode(schema.items, filePath, `${field}.items`); + for (const numericKey of ['minLength', 'maxLength', 'minimum', 'maximum', 'maxItems']) { + if (numericKey in schema && (!Number.isSafeInteger(schema[numericKey]) || (schema[numericKey] as number) < 0)) { + fail(filePath, `${field}.${numericKey}`, 'must be a non-negative safe integer'); + } + } + if ('const' in schema && schema.const !== true && schema.const !== false) { + fail(filePath, `${field}.const`, 'only boolean literal constants are supported'); + } +} + +function validateDestructiveConfirmation( + schema: UnknownRecord, + filePath: string, + field: string, +): void { + const required = Array.isArray(schema.required) ? schema.required : []; + const properties = isRecord(schema.properties) ? schema.properties : {}; + const confirmation = properties.confirmed; + if (!required.includes('confirmed') || !isRecord(confirmation) + || confirmation.type !== 'boolean' || confirmation.const !== true) { + fail(filePath, field, 'destructive tools must require literal confirmed: true'); + } +} + +export function parseAgentPluginsRootManifest( + value: unknown, + filePath = PACKAGE_MANIFEST_FILE, +): AgentPluginsRootManifest { + const root = assertRecord(value, filePath, 'root'); + assertExactKeys(root, ROOT_KEYS, filePath, 'root'); + rejectUnsupportedComponents(root, filePath, 'root'); + if (root.$schema !== AGENT_PLUGINS_SCHEMA_URL) { + fail(filePath, '$schema', `must equal ${AGENT_PLUGINS_SCHEMA_URL}`); + } + const name = stableId(root.name, filePath, 'name', /^[a-z][a-z0-9.-]{0,127}$/u); + const version = text(root.version, filePath, 'version', 128); + if (!SEMVER_PATTERN.test(version)) fail(filePath, 'version', 'must be a valid package semver'); + const description = text(root.description, filePath, 'description'); + const author = assertRecord(root.author, filePath, 'author'); + assertExactKeys(author, AUTHOR_KEYS, filePath, 'author'); + const authorName = text(author.name, filePath, 'author.name', 128); + const extensions = assertRecord(root.extensions, filePath, 'extensions'); + assertExactKeys(extensions, EXTENSIONS_KEYS, filePath, 'extensions'); + const makeLore = assertRecord(extensions['com.makelore'], filePath, 'extensions.com.makelore'); + assertExactKeys(makeLore, MAKELore_EXTENSION_KEYS, filePath, 'extensions.com.makelore'); + const capabilityManifest = text( + makeLore.capabilityManifest, + filePath, + 'extensions.com.makelore.capabilityManifest', + 512, + ); + return freezeDeep({ + $schema: AGENT_PLUGINS_SCHEMA_URL, + name, + version, + description, + author: { name: authorName }, + extensions: { 'com.makelore': { capabilityManifest } }, + }); +} + +function parseSkill( + value: unknown, + index: number, + packageRoot: string, + capabilityDirectory: string, + filePath: string, +): CodingPluginSkillDefinition { + const skill = assertRecord(value, filePath, `skills[${index}]`); + assertExactKeys(skill, SKILL_KEYS, filePath, `skills[${index}]`); + const id = stableId(skill.id, filePath, `skills[${index}].id`, SKILL_ID_PATTERN); + const entryPath = normalizeCandidatePath( + packageRoot, + capabilityDirectory, + skill.entry, + filePath, + `skills[${index}].entry`, + ); + const grants = uniqueStrings( + skill.grants, + filePath, + `skills[${index}].grants`, + CAPABILITY_ID_PATTERN, + ); + for (const grant of grants) { + if (!DATA_SERVICE_CAPABILITY_IDS.includes(grant as (typeof DATA_SERVICE_CAPABILITY_IDS)[number])) { + fail(filePath, `skills[${index}].grants`, `unknown capability grant: ${grant}`); + } + } + return { id, entryPath, grants }; +} + +function parseTool( + value: unknown, + index: number, + filePath: string, +): CodingPluginToolDefinition { + const tool = assertRecord(value, filePath, `tools[${index}]`); + assertExactKeys(tool, TOOL_KEYS, filePath, `tools[${index}]`); + const name = stableId(tool.name, filePath, `tools[${index}].name`, TOOL_NAME_PATTERN); + const label = text(tool.label, filePath, `tools[${index}].label`); + const description = text(tool.description, filePath, `tools[${index}].description`); + const capabilityId = stableId(tool.capabilityId, filePath, `tools[${index}].capabilityId`, CAPABILITY_ID_PATTERN); + if (!DATA_SERVICE_CAPABILITY_IDS.includes(capabilityId as (typeof DATA_SERVICE_CAPABILITY_IDS)[number])) { + fail(filePath, `tools[${index}].capabilityId`, `unknown capability: ${capabilityId}`); + } + const operation = stableId(tool.operation, filePath, `tools[${index}].operation`, OPERATION_ID_PATTERN); + const roles = uniqueStrings(tool.roles, filePath, `tools[${index}].roles`, /^[a-z]+$/u); + if (roles.length !== 1 || roles[0] !== 'parent') { + fail(filePath, `tools[${index}].roles`, 'P0 tools must be exactly parent-only'); + } + const mutation = tool.mutation; + if (mutation !== 'read' && mutation !== 'write' && mutation !== 'destructive') { + fail(filePath, `tools[${index}].mutation`, 'unknown tool mutation'); + } + const projectWriteLease = bool(tool.projectWriteLease, filePath, `tools[${index}].projectWriteLease`); + const permissions = uniqueStrings( + tool.permissions, + filePath, + `tools[${index}].permissions`, + /^[a-z][a-z0-9._-]{0,63}$/u, + ); + for (const permission of permissions) { + if (!CODE_OWNED_PLUGIN_PERMISSION_IDS.includes(permission as (typeof CODE_OWNED_PLUGIN_PERMISSION_IDS)[number])) { + fail(filePath, `tools[${index}].permissions`, `unknown code-owned permission: ${permission}`); + } + } + validateSchemaNode(tool.inputSchema, filePath, `tools[${index}].inputSchema`, true); + const schema = tool.inputSchema as UnknownRecord; + if (schema.type !== 'object' || schema.additionalProperties !== false) { + fail(filePath, `tools[${index}].inputSchema`, 'tool input schema must be an exact object'); + } + if (mutation === 'destructive') { + validateDestructiveConfirmation(schema, filePath, `tools[${index}].inputSchema`); + } + return { + name, + label, + description, + capabilityId, + operation, + roles: ['parent'], + mutation: mutation as PluginToolMutation, + projectWriteLease, + permissions, + inputSchema: freezeDeep(structuredClone(schema)), + }; +} + +function validateDataServiceDefinition( + definition: CodingPluginDefinition, + filePath: string, +): void { + const expectedNames = new Set(DATA_SERVICE_TOOL_NAMES); + if (definition.id !== DATA_SERVICE_PLUGIN_ID) return; + if (definition.tools.length !== DATA_SERVICE_TOOL_NAMES.length) { + fail(filePath, 'tools', `Data Service must declare exactly ${DATA_SERVICE_TOOL_NAMES.length} tools`); + } + for (const tool of definition.tools) { + if (!expectedNames.has(tool.name)) fail(filePath, `tools.${tool.name}`, 'unknown Data Service tool'); + const expectedCapability = DATA_SERVICE_OPERATION_CAPABILITIES[tool.operation]; + if (!expectedCapability || expectedCapability !== tool.capabilityId) { + fail(filePath, `tools.${tool.name}.operation`, 'operation does not reference its capability'); + } + } + const operationNames = definition.tools.map((tool) => tool.operation); + if (new Set(operationNames).size !== operationNames.length) { + fail(filePath, 'tools.operation', 'duplicate operation identifier'); + } +} + +export function parseCodingPluginManifest( + rootValue: unknown, + capabilityValue: unknown, + options: { + packageRoot?: string; + rootManifestPath?: string; + capabilityManifestPath?: string; + } = {}, +): CodingPluginDefinition { + const packageRoot = path.resolve(options.packageRoot ?? path.dirname(options.capabilityManifestPath ?? '.')); + const rootManifestPath = path.resolve( + options.rootManifestPath ?? path.join(packageRoot, PACKAGE_MANIFEST_FILE), + ); + const root = parseAgentPluginsRootManifest(rootValue, rootManifestPath); + const capabilityManifestPath = path.resolve( + options.capabilityManifestPath ?? path.join(packageRoot, CAPABILITY_MANIFEST_RELATIVE_PATH), + ); + const capabilityRelativePath = path.relative(packageRoot, capabilityManifestPath); + if (!capabilityRelativePath || capabilityRelativePath === '..' + || capabilityRelativePath.startsWith(`..${path.sep}`) || path.isAbsolute(capabilityRelativePath)) { + fail(capabilityManifestPath, 'root', 'capability manifest must be inside the package root'); + } + const capability = assertRecord(capabilityValue, capabilityManifestPath, 'root'); + assertExactKeys(capability, CAPABILITY_KEYS, capabilityManifestPath, 'root'); + rejectUnsupportedComponents(capability, capabilityManifestPath, 'root'); + if (capability.schemaVersion !== 1) fail(capabilityManifestPath, 'schemaVersion', 'must equal 1'); + const pluginId = stableId(capability.pluginId, capabilityManifestPath, 'pluginId', /^[a-z][a-z0-9.-]{0,127}$/u); + if (pluginId !== root.name) fail(capabilityManifestPath, 'pluginId', 'must match plugin.json name'); + const contractVersion = positiveInteger(capability.contractVersion, capabilityManifestPath, 'contractVersion'); + if (capability.scope !== 'project') fail(capabilityManifestPath, 'scope', 'P0 plugins must be project-scoped'); + const adapterId = text(capability.adapterId, capabilityManifestPath, 'adapterId', 64); + if (!BUNDLED_CODING_PLUGIN_ADAPTER_IDS.includes(adapterId as (typeof BUNDLED_CODING_PLUGIN_ADAPTER_IDS)[number])) { + fail(capabilityManifestPath, 'adapterId', `adapter is not code-owned: ${adapterId}`); + } + const requiresBackend = bool(capability.requiresBackend, capabilityManifestPath, 'requiresBackend'); + const display = assertRecord(capability.display, capabilityManifestPath, 'display'); + assertExactKeys(display, DISPLAY_KEYS, capabilityManifestPath, 'display'); + const displayName = text(display.name, capabilityManifestPath, 'display.name'); + const description = text(display.description, capabilityManifestPath, 'display.description'); + const capabilityDirectory = path.dirname(capabilityManifestPath); + const skillsValue = capability.skills; + if (!Array.isArray(skillsValue) || skillsValue.length === 0) { + fail(capabilityManifestPath, 'skills', 'must contain at least one Skill'); + } + const skills = skillsValue.map((value, index) => parseSkill( + value, + index, + packageRoot, + capabilityDirectory, + capabilityManifestPath, + )); + if (new Set(skills.map((skill) => skill.id)).size !== skills.length) { + fail(capabilityManifestPath, 'skills', 'duplicate Skill identifier'); + } + if (!Array.isArray(capability.tools) || capability.tools.length === 0) { + fail(capabilityManifestPath, 'tools', 'must contain at least one tool'); + } + const tools = capability.tools.map((value, index) => parseTool(value, index, capabilityManifestPath)); + if (new Set(tools.map((tool) => tool.name)).size !== tools.length) { + fail(capabilityManifestPath, 'tools.name', 'duplicate tool identifier'); + } + if (new Set(tools.map((tool) => tool.operation)).size !== tools.length) { + fail(capabilityManifestPath, 'tools.operation', 'duplicate operation identifier'); + } + const grants = new Set(skills.flatMap((skill) => skill.grants)); + for (const tool of tools) { + if (!grants.has(tool.capabilityId)) { + fail(capabilityManifestPath, `tools.${tool.name}.capabilityId`, 'tool capability has no Skill grant'); + } + } + const surfacesValue = capability.surfaces; + const surfaces = assertRecord(surfacesValue, capabilityManifestPath, 'surfaces'); + assertExactKeys(surfaces, SURFACE_KEYS, capabilityManifestPath, 'surfaces'); + const normalizedSurfaces: { projectSettings?: string; previewRuntime?: string } = {}; + if (surfaces.projectSettings !== undefined) { + const projectSettings = text(surfaces.projectSettings, capabilityManifestPath, 'surfaces.projectSettings', 64); + if (!BUNDLED_CODING_PLUGIN_SETTINGS_SURFACES.includes(projectSettings as (typeof BUNDLED_CODING_PLUGIN_SETTINGS_SURFACES)[number])) { + fail(capabilityManifestPath, 'surfaces.projectSettings', `surface is not code-owned: ${projectSettings}`); + } + normalizedSurfaces.projectSettings = projectSettings; + } + if (surfaces.previewRuntime !== undefined) { + const previewRuntime = text(surfaces.previewRuntime, capabilityManifestPath, 'surfaces.previewRuntime', 64); + if (!BUNDLED_CODING_PLUGIN_PREVIEW_SURFACES.includes(previewRuntime as (typeof BUNDLED_CODING_PLUGIN_PREVIEW_SURFACES)[number])) { + fail(capabilityManifestPath, 'surfaces.previewRuntime', `surface is not code-owned: ${previewRuntime}`); + } + normalizedSurfaces.previewRuntime = previewRuntime; + } + const definition = freezeDeep({ + id: pluginId, + version: root.version, + contractVersion, + displayName, + description, + scope: 'project' as const, + adapterId, + requiresBackend, + skills, + tools, + surfaces: normalizedSurfaces, + }); + validateDataServiceDefinition(definition, capabilityManifestPath); + return definition; +} + +function readJson(source: string, filePath: string): unknown { + try { + return JSON.parse(source) as unknown; + } catch (error) { + fail(filePath, 'root', `invalid JSON: ${error instanceof Error ? error.message : String(error)}`); + } +} + +export async function loadCodingPluginDefinition(packageRoot: string): Promise { + const root = path.resolve(packageRoot); + const pluginManifestPath = path.join(root, PACKAGE_MANIFEST_FILE); + const rootValue = readJson(await readFile(pluginManifestPath, 'utf8'), pluginManifestPath); + const rootManifest = parseAgentPluginsRootManifest(rootValue, pluginManifestPath); + const capabilityManifest = normalizeCandidatePath( + root, + root, + rootManifest.extensions['com.makelore'].capabilityManifest, + pluginManifestPath, + 'extensions.com.makelore.capabilityManifest', + ); + if (capabilityManifest !== CAPABILITY_MANIFEST_RELATIVE_PATH) { + fail(pluginManifestPath, 'extensions.com.makelore.capabilityManifest', `must point to ${CAPABILITY_MANIFEST_RELATIVE_PATH}`); + } + const capabilityManifestPath = path.join(root, capabilityManifest); + const capabilityValue = readJson(await readFile(capabilityManifestPath, 'utf8'), capabilityManifestPath); + const definition = parseCodingPluginManifest(rootValue, capabilityValue, { + packageRoot: root, + rootManifestPath: pluginManifestPath, + capabilityManifestPath, + }); + for (const skill of definition.skills) { + try { + await readFile(path.join(root, skill.entryPath), 'utf8'); + } catch (error) { + fail( + capabilityManifestPath, + `skills.${skill.id}.entry`, + `Skill entry could not be read: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + return definition; +} + +export function resolveBundledCodingPluginRootPaths(resourcesRoot: string): string[] { + const root = path.resolve(resourcesRoot); + return BUNDLED_CODING_PLUGIN_ROOTS.map((relativeRoot) => path.join(root, relativeRoot)); +} + +export const resolveBundledPluginRoots = resolveBundledCodingPluginRootPaths; + +export async function loadBundledCodingPluginDefinitions( + resourcesRoot: string, +): Promise { + const roots = resolveBundledCodingPluginRootPaths(resourcesRoot); + const definitions = await Promise.all(roots.map(async (root, index) => { + const definition = await loadCodingPluginDefinition(root); + const expectedRoot = BUNDLED_CODING_PLUGIN_ROOTS[index]; + if (definition.id !== `makelore.${expectedRoot}`) { + throw new CodingPluginManifestError( + path.join(root, PACKAGE_MANIFEST_FILE), + 'name', + `fixed bundle root ${expectedRoot} contains ${definition.id}`, + ); + } + return definition; + })); + const pluginIds = new Set(); + const capabilityIds = new Set(); + const skillIds = new Set(); + const operationIds = new Set(); + const toolIds = new Set(); + for (const definition of definitions) { + if (pluginIds.has(definition.id)) throw new Error(`Duplicate bundled plugin identifier: ${definition.id}`); + pluginIds.add(definition.id); + for (const skill of definition.skills) { + if (skillIds.has(skill.id)) throw new Error(`Duplicate bundled Skill identifier: ${skill.id}`); + skillIds.add(skill.id); + for (const grant of skill.grants) capabilityIds.add(grant); + } + for (const tool of definition.tools) { + if (toolIds.has(tool.name)) throw new Error(`Duplicate bundled tool identifier: ${tool.name}`); + if (operationIds.has(tool.operation)) throw new Error(`Duplicate bundled operation identifier: ${tool.operation}`); + toolIds.add(tool.name); + operationIds.add(tool.operation); + capabilityIds.add(tool.capabilityId); + } + } + return definitions; +} + +export const parseBundledCodingPlugins = loadBundledCodingPluginDefinitions; +export const loadBundledPluginDefinitions = loadBundledCodingPluginDefinitions; diff --git a/electron/coding-plugins/project-service.ts b/electron/coding-plugins/project-service.ts new file mode 100644 index 0000000..b263f52 --- /dev/null +++ b/electron/coding-plugins/project-service.ts @@ -0,0 +1,390 @@ +import path from 'node:path'; +import { atomicWriteJson, readJsonFile, type JsonFileWriter } from '../coding-projects/atomic-json'; +import { readCodingProjectConfigV2 } from '../coding-projects/project-config'; +import { + BUNDLED_CODING_PLUGIN_DEFINITIONS, + DATA_SERVICE_PLUGIN_ID, +} from '../../shared/coding-plugins'; + +export const PROJECT_PLUGIN_SELECTION_PATH = '.niancode/plugins.json'; +export const PROJECT_PLUGIN_SELECTION_SCHEMA_VERSION = 1 as const; + +const PLUGIN_ID_PATTERN = /^[a-z][a-z0-9.-]{0,127}$/u; +const ISO_UTC_TIMESTAMP_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/u; + +export interface ProjectPluginSelectionFile { + schemaVersion: typeof PROJECT_PLUGIN_SELECTION_SCHEMA_VERSION; + enabledPluginIds: string[]; + updatedAt: string; +} + +export type ProjectPluginSelectionSource = 'none' | 'file' | 'legacy'; + +/** + * Effective project selection. A missing file is deliberately represented as + * `source: 'none'`; the legacy projection is read-only until a user mutation + * persists the new file. + */ +export interface ProjectPluginSelection { + projectPath: string; + status: 'missing' | 'present'; + source: ProjectPluginSelectionSource; + schemaVersion: typeof PROJECT_PLUGIN_SELECTION_SCHEMA_VERSION | null; + enabledPluginIds: readonly string[]; + unknownPluginIds: readonly string[]; + updatedAt: string | null; + legacyProjectedPluginIds: readonly string[]; + persisted: boolean; +} + +export interface ProjectPluginManagedInputsChangedEvent { + projectPath: string; + pluginId: string; + enabled: boolean; + revision: number; +} + +export interface ProjectPluginAdapterDeactivatedEvent { + projectPath: string; + pluginId: string; +} + +export interface ProjectPluginServiceOptions { + /** Defaults to the code-owned bundled catalog. */ + knownPluginIds?: readonly string[] | (() => readonly string[]); + now?: () => string; + /** Test seam; production writes use the existing atomic JSON writer. */ + writer?: JsonFileWriter; + writeJson?: JsonFileWriter; + onManagedInputsChanged?( + event: ProjectPluginManagedInputsChangedEvent, + ): Promise | void; + onAdapterDeactivated?( + event: ProjectPluginAdapterDeactivatedEvent, + ): Promise | void; +} + +export class ProjectPluginServiceError extends Error { + constructor( + readonly code: + | 'CODING_PLUGIN_PROJECT_PATH_INVALID' + | 'CODING_PLUGIN_SELECTION_INVALID' + | 'CODING_PLUGIN_UNKNOWN' + | 'CODING_PLUGIN_SELECTION_WRITE_FAILED', + message: string, + ) { + super(message); + this.name = 'ProjectPluginServiceError'; + } +} + +function projectSelectionPath(projectPath: string): string { + const candidate = projectPath.trim(); + if (!candidate || !path.isAbsolute(candidate)) { + throw new ProjectPluginServiceError( + 'CODING_PLUGIN_PROJECT_PATH_INVALID', + 'Project path must be an absolute path', + ); + } + return path.join(path.resolve(candidate), PROJECT_PLUGIN_SELECTION_PATH); +} + +function projectPathFromSelectionPath(selectionPath: string): string { + return path.dirname(path.dirname(path.resolve(selectionPath))); +} + +function knownPluginIdSet( + value: readonly string[] | (() => readonly string[]) | undefined, +): Set { + const source = typeof value === 'function' + ? value() + : value ?? BUNDLED_CODING_PLUGIN_DEFINITIONS.map(({ id }) => id); + return new Set(source.map((id) => id.trim()).filter(Boolean)); +} + +function normalizePluginId(value: unknown, field: string): string { + if (typeof value !== 'string') { + throw new ProjectPluginServiceError( + 'CODING_PLUGIN_SELECTION_INVALID', + `${field} must contain strings`, + ); + } + const id = value.trim(); + if (!PLUGIN_ID_PATTERN.test(id)) { + throw new ProjectPluginServiceError( + 'CODING_PLUGIN_SELECTION_INVALID', + `${field} contains an invalid plugin id`, + ); + } + return id; +} + +function normalizePluginIds(value: unknown, field: string): string[] { + if (!Array.isArray(value)) { + throw new ProjectPluginServiceError( + 'CODING_PLUGIN_SELECTION_INVALID', + `${field} must be an array`, + ); + } + const ids = value.map((candidate, index) => normalizePluginId(candidate, `${field}[${index}]`)); + return [...new Set(ids)].sort(); +} + +function normalizeUpdatedAt(value: unknown): string { + const timestamp = typeof value === 'string' ? value.trim() : ''; + if (!ISO_UTC_TIMESTAMP_PATTERN.test(timestamp) || Number.isNaN(Date.parse(timestamp))) { + throw new ProjectPluginServiceError( + 'CODING_PLUGIN_SELECTION_INVALID', + 'updatedAt must be an ISO UTC timestamp', + ); + } + return timestamp; +} + +function normalizeSelectionFile(value: unknown): ProjectPluginSelectionFile { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new ProjectPluginServiceError( + 'CODING_PLUGIN_SELECTION_INVALID', + 'Plugin selection must be an object', + ); + } + const record = value as Record; + const keys = Object.keys(record).sort(); + if (keys.length !== 3 || keys.some((key, index) => key !== ['enabledPluginIds', 'schemaVersion', 'updatedAt'][index])) { + throw new ProjectPluginServiceError( + 'CODING_PLUGIN_SELECTION_INVALID', + 'Plugin selection has unexpected fields', + ); + } + if (record.schemaVersion !== PROJECT_PLUGIN_SELECTION_SCHEMA_VERSION) { + throw new ProjectPluginServiceError( + 'CODING_PLUGIN_SELECTION_INVALID', + 'Unsupported plugin selection schema', + ); + } + return { + schemaVersion: PROJECT_PLUGIN_SELECTION_SCHEMA_VERSION, + enabledPluginIds: normalizePluginIds(record.enabledPluginIds, 'enabledPluginIds'), + updatedAt: normalizeUpdatedAt(record.updatedAt), + }; +} + +function currentTime(now: (() => string) | undefined): string { + const value = now?.() ?? new Date().toISOString(); + if (!ISO_UTC_TIMESTAMP_PATTERN.test(typeof value === 'string' ? value.trim() : '') + || Number.isNaN(Date.parse(value))) { + throw new ProjectPluginServiceError( + 'CODING_PLUGIN_SELECTION_WRITE_FAILED', + 'Plugin selection timestamp must be an ISO UTC timestamp', + ); + } + return value.trim(); +} + +function isMissing(error: unknown): boolean { + return (error as NodeJS.ErrnoException)?.code === 'ENOENT'; +} + +interface ReadSelectionResult { + file: ProjectPluginSelectionFile | null; + filePath: string; +} + +export class ProjectPluginService { + private readonly mutationTails = new Map>(); + private readonly managedInputRevisions = new Map(); + + constructor(private readonly options: ProjectPluginServiceOptions = {}) {} + + selectionPath(projectPath: string): string { + return projectSelectionPath(projectPath); + } + + getManagedInputRevision(projectPath: string): number { + const normalizedProjectPath = path.resolve(projectPath); + return this.managedInputRevisions.get(normalizedProjectPath) ?? 0; + } + + async readSelection(projectPath: string): Promise { + const filePath = projectSelectionPath(projectPath); + const project = path.dirname(path.dirname(filePath)); + const result = await this.readFile(filePath); + const knownIds = knownPluginIdSet(this.options.knownPluginIds); + if (result.file) { + const enabledPluginIds = result.file.enabledPluginIds; + return { + projectPath: project, + status: 'present', + source: 'file', + schemaVersion: PROJECT_PLUGIN_SELECTION_SCHEMA_VERSION, + enabledPluginIds, + unknownPluginIds: enabledPluginIds.filter((id) => !knownIds.has(id)), + updatedAt: result.file.updatedAt, + legacyProjectedPluginIds: [], + persisted: true, + }; + } + + const legacyProjectedPluginIds = await this.legacyProjectedPluginIds(project); + const enabledPluginIds = [...legacyProjectedPluginIds]; + return { + projectPath: project, + status: 'missing', + source: enabledPluginIds.length > 0 ? 'legacy' : 'none', + schemaVersion: null, + enabledPluginIds, + unknownPluginIds: enabledPluginIds.filter((id) => !knownIds.has(id)), + updatedAt: null, + legacyProjectedPluginIds, + persisted: false, + }; + } + + getSelection(projectPath: string): Promise { + return this.readSelection(projectPath); + } + + readProjectSelection(projectPath: string): Promise { + return this.readSelection(projectPath); + } + + async getEnabledPluginIds(projectPath: string): Promise { + return (await this.readSelection(projectPath)).enabledPluginIds; + } + + async isEnabled(projectPath: string, pluginId: string): Promise { + const id = normalizePluginId(pluginId, 'pluginId'); + return (await this.readSelection(projectPath)).enabledPluginIds.includes(id); + } + + enable(projectPath: string, pluginId: string): Promise { + return this.setEnabled(projectPath, pluginId, true); + } + + disable(projectPath: string, pluginId: string): Promise { + return this.setEnabled(projectPath, pluginId, false); + } + + setEnabled( + projectPath: string, + pluginId: string, + enabled: boolean, + ): Promise { + const selectionPath = projectSelectionPath(projectPath); + const normalizedProjectPath = projectPathFromSelectionPath(selectionPath); + return this.enqueue(normalizedProjectPath, async () => { + const id = normalizePluginId(pluginId, 'pluginId'); + if (typeof enabled !== 'boolean') { + throw new ProjectPluginServiceError( + 'CODING_PLUGIN_SELECTION_INVALID', + 'enabled must be a boolean', + ); + } + const knownIds = knownPluginIdSet(this.options.knownPluginIds); + if (!knownIds.has(id)) { + throw new ProjectPluginServiceError( + 'CODING_PLUGIN_UNKNOWN', + `Unknown coding plugin: ${id}`, + ); + } + const current = await this.readSelection(normalizedProjectPath); + const wasEnabled = current.enabledPluginIds.includes(id); + // A missing read is intentionally side-effect free. A real state + // change (or the legacy projection) is the user mutation that creates + // the selection file. + const needsPersist = current.source === 'legacy' || wasEnabled !== enabled; + if (!needsPersist) return current; + + const ids = new Set(current.enabledPluginIds); + if (enabled) ids.add(id); + else ids.delete(id); + const nextFile: ProjectPluginSelectionFile = { + schemaVersion: PROJECT_PLUGIN_SELECTION_SCHEMA_VERSION, + enabledPluginIds: [...ids].sort(), + updatedAt: currentTime(this.options.now), + }; + try { + await (this.options.writer ?? this.options.writeJson ?? atomicWriteJson)(selectionPath, nextFile); + } catch (error) { + throw new ProjectPluginServiceError( + 'CODING_PLUGIN_SELECTION_WRITE_FAILED', + `Plugin selection could not be persisted: ${error instanceof Error ? error.message : String(error)}`, + ); + } + + const revision = this.nextRevision(normalizedProjectPath); + const event: ProjectPluginManagedInputsChangedEvent = { + projectPath: normalizedProjectPath, + pluginId: id, + enabled, + revision, + }; + await this.options.onManagedInputsChanged?.(event); + if (!enabled && wasEnabled) { + await this.options.onAdapterDeactivated?.({ + projectPath: normalizedProjectPath, + pluginId: id, + }); + } + return { + projectPath: normalizedProjectPath, + status: 'present', + source: 'file', + schemaVersion: PROJECT_PLUGIN_SELECTION_SCHEMA_VERSION, + enabledPluginIds: nextFile.enabledPluginIds, + unknownPluginIds: nextFile.enabledPluginIds.filter((candidate) => !knownIds.has(candidate)), + updatedAt: nextFile.updatedAt, + legacyProjectedPluginIds: [], + persisted: true, + }; + }); + } + + private async readFile(filePath: string): Promise { + try { + return { + file: normalizeSelectionFile(await readJsonFile(filePath)), + filePath, + }; + } catch (error) { + if (isMissing(error)) return { file: null, filePath }; + if (error instanceof ProjectPluginServiceError) throw error; + throw new ProjectPluginServiceError( + 'CODING_PLUGIN_SELECTION_INVALID', + `Plugin selection could not be read: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + private async legacyProjectedPluginIds(projectPath: string): Promise { + const result = await readCodingProjectConfigV2(projectPath); + if (result.status !== 'valid') return []; + const hasDataServiceSkill = result.config.agents.some((agent) => agent.skillIds.includes('data-service')); + return hasDataServiceSkill ? [DATA_SERVICE_PLUGIN_ID] : []; + } + + private nextRevision(projectPath: string): number { + const revision = (this.managedInputRevisions.get(projectPath) ?? 0) + 1; + this.managedInputRevisions.set(projectPath, revision); + return revision; + } + + private enqueue(projectPath: string, operation: () => Promise): Promise { + const previous = this.mutationTails.get(projectPath) ?? Promise.resolve(); + const result = previous.then(operation, operation); + this.mutationTails.set(projectPath, result); + void result.then( + () => { + if (this.mutationTails.get(projectPath) === result) this.mutationTails.delete(projectPath); + }, + () => { + if (this.mutationTails.get(projectPath) === result) this.mutationTails.delete(projectPath); + }, + ); + return result; + } +} + +export const createProjectPluginService = ( + options: ProjectPluginServiceOptions = {}, +): ProjectPluginService => new ProjectPluginService(options); diff --git a/electron/coding-projects/skill-registry.ts b/electron/coding-projects/skill-registry.ts index eee71db..26446b7 100644 --- a/electron/coding-projects/skill-registry.ts +++ b/electron/coding-projects/skill-registry.ts @@ -1,5 +1,6 @@ import { readFile, readdir } from 'node:fs/promises'; import path from 'node:path'; +import { DATA_SERVICE_PLUGIN_DEFINITION } from '../../shared/coding-plugins'; import { BUNDLED_CODING_SKILL_IDS, type BundledCodingSkillId, @@ -26,6 +27,13 @@ const MAKELORE_COMMANDS: readonly ProductCodingCommand[] = [ const COMMAND_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$/; +export interface ProductCodingPluginSkillSource { + id: string; + directory: string; + /** Package-relative Skill entry; defaults to `SKILL.md`. */ + entryPath?: string; +} + function frontmatterScalar(content: string, key: string): string | undefined { const block = content.match(/^---\s*\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/)?.[1]; if (!block) return undefined; @@ -39,17 +47,41 @@ function frontmatterScalar(content: string, key: string): string | undefined { return value || undefined; } -function selectedSkillIds(value: readonly string[]): Set { - const allowed = new Set(BUNDLED_CODING_SKILL_IDS); - const selected = new Set(); +function defaultPluginSkillSources(bundledSkillsDir: string): ProductCodingPluginSkillSource[] { + const skillId = DATA_SERVICE_PLUGIN_DEFINITION.skills[0]?.id ?? 'data-service'; + return [{ + id: skillId, + directory: path.join( + path.dirname(path.resolve(bundledSkillsDir)), + 'coding-plugins', + 'data-service', + 'skills', + 'data-service', + ), + }]; +} + +function selectedSkillIds( + value: readonly string[], + allIds: readonly string[], +): Set { + const allowed = new Set(allIds); + const selected = new Set(); for (const raw of value) { const id = raw.trim(); if (!allowed.has(id)) throw new Error(`Unknown bundled coding skill: ${id}`); - selected.add(id as BundledCodingSkillId); + selected.add(id); } return selected; } +function productSkillId(id: string): BundledCodingSkillId { + // ProductCodingSkill predates package-owned Skill ids. The registry is + // the trusted projection boundary, so the runtime value may contain a + // package Skill while the shared contract is migrated by the caller. + return id as BundledCodingSkillId; +} + async function listSkillEntries( directory: string, relative = '', @@ -71,13 +103,26 @@ async function listSkillEntries( export async function listProductCodingSkills( bundledSkillsDir: string, selectedIds: readonly string[] = [], + pluginSkillSources: readonly ProductCodingPluginSkillSource[] = defaultPluginSkillSources(bundledSkillsDir), ): Promise { - const selected = selectedSkillIds(selectedIds); - return await Promise.all(BUNDLED_CODING_SKILL_IDS.map(async (id) => { - const location = path.join(bundledSkillsDir, id); - const content = await readFile(path.join(location, 'SKILL.md'), 'utf8'); - return { + const pluginIds = pluginSkillSources.map(({ id }) => id); + const allIds = [...BUNDLED_CODING_SKILL_IDS, ...pluginIds]; + if (new Set(allIds).size !== allIds.length) { + throw new Error('Duplicate coding skill identifier'); + } + const selected = selectedSkillIds(selectedIds, allIds); + const sources: ProductCodingPluginSkillSource[] = [ + ...BUNDLED_CODING_SKILL_IDS.map((id) => ({ id, + directory: path.join(bundledSkillsDir, id), + })), + ...pluginSkillSources, + ]; + return await Promise.all(sources.map(async ({ id, directory, entryPath }) => { + const location = path.resolve(directory); + const content = await readFile(path.join(location, entryPath ?? 'SKILL.md'), 'utf8'); + return { + id: productSkillId(id), name: frontmatterScalar(content, 'name') ?? id, description: frontmatterScalar(content, 'description') ?? '', selected: selected.has(id), diff --git a/resources/coding-plugins/data-service/com.makelore/capability.json b/resources/coding-plugins/data-service/com.makelore/capability.json new file mode 100644 index 0000000..2c911e3 --- /dev/null +++ b/resources/coding-plugins/data-service/com.makelore/capability.json @@ -0,0 +1,281 @@ +{ + "schemaVersion": 1, + "pluginId": "makelore.data-service", + "contractVersion": 1, + "scope": "project", + "adapterId": "data-service", + "requiresBackend": true, + "display": { + "name": "开发数据服务", + "description": "为当前项目提供受控的开发期 JSON 数据存储。" + }, + "skills": [ + { + "id": "data-service", + "entry": "../skills/data-service/SKILL.md", + "grants": [ + "data-service.control", + "data-service.documents" + ] + } + ], + "tools": [ + { + "name": "data_service_configure", + "label": "Data Service configure", + "description": "Configure collections for the active project.", + "capabilityId": "data-service.control", + "operation": "configure", + "roles": ["parent"], + "mutation": "write", + "projectWriteLease": true, + "permissions": ["project.data.configure"], + "inputSchema": { + "type": "object", + "additionalProperties": false, + "required": ["collections"], + "properties": { + "collections": { + "type": "array", + "maxItems": 20, + "items": { + "type": "string", + "pattern": "^[a-z][a-z0-9_-]{0,47}$" + } + } + } + } + }, + { + "name": "data_service_inspect", + "label": "Data Service inspect", + "description": "Inspect the active project data instance.", + "capabilityId": "data-service.control", + "operation": "inspect", + "roles": ["parent"], + "mutation": "read", + "projectWriteLease": false, + "permissions": ["project.data.read"], + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } + }, + { + "name": "data_service_list_projects", + "label": "Data Service list projects", + "description": "List the authenticated projects with Data Service instances.", + "capabilityId": "data-service.control", + "operation": "list_projects", + "roles": ["parent"], + "mutation": "read", + "projectWriteLease": false, + "permissions": ["project.data.read"], + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } + }, + { + "name": "data_service_get_document", + "label": "Data Service get document", + "description": "Read a document from the active project.", + "capabilityId": "data-service.documents", + "operation": "get_document", + "roles": ["parent"], + "mutation": "read", + "projectWriteLease": false, + "permissions": ["project.data.read"], + "inputSchema": { + "type": "object", + "additionalProperties": false, + "required": ["collection", "document_id"], + "properties": { + "collection": { + "type": "string", + "pattern": "^[a-z][a-z0-9_-]{0,47}$" + }, + "document_id": { + "type": "string", + "pattern": "^[A-Za-z0-9._~-]{1,128}$" + } + } + } + }, + { + "name": "data_service_list_documents", + "label": "Data Service list documents", + "description": "List documents from a collection in the active project.", + "capabilityId": "data-service.documents", + "operation": "list_documents", + "roles": ["parent"], + "mutation": "read", + "projectWriteLease": false, + "permissions": ["project.data.read"], + "inputSchema": { + "type": "object", + "additionalProperties": false, + "required": ["collection"], + "properties": { + "collection": { + "type": "string", + "pattern": "^[a-z][a-z0-9_-]{0,47}$" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 100 + }, + "cursor": { + "type": "string", + "minLength": 1, + "maxLength": 1024 + } + } + } + }, + { + "name": "data_service_put_document", + "label": "Data Service put document", + "description": "Create or replace a document in the active project.", + "capabilityId": "data-service.documents", + "operation": "put_document", + "roles": ["parent"], + "mutation": "write", + "projectWriteLease": true, + "permissions": ["project.data.write"], + "inputSchema": { + "type": "object", + "additionalProperties": false, + "required": ["collection", "document_id", "data"], + "properties": { + "collection": { + "type": "string", + "pattern": "^[a-z][a-z0-9_-]{0,47}$" + }, + "document_id": { + "type": "string", + "pattern": "^[A-Za-z0-9._~-]{1,128}$" + }, + "data": { + "type": "object" + }, + "if_revision": { + "type": "integer", + "minimum": 1 + } + } + } + }, + { + "name": "data_service_delete_document", + "label": "Data Service delete document", + "description": "Delete a document after explicit confirmation.", + "capabilityId": "data-service.documents", + "operation": "delete_document", + "roles": ["parent"], + "mutation": "destructive", + "projectWriteLease": true, + "permissions": ["project.data.write"], + "inputSchema": { + "type": "object", + "additionalProperties": false, + "required": ["collection", "document_id", "confirmed"], + "properties": { + "collection": { + "type": "string", + "pattern": "^[a-z][a-z0-9_-]{0,47}$" + }, + "document_id": { + "type": "string", + "pattern": "^[A-Za-z0-9._~-]{1,128}$" + }, + "if_revision": { + "type": "integer", + "minimum": 1 + }, + "confirmed": { + "type": "boolean", + "const": true + } + } + } + }, + { + "name": "data_service_remove_collection", + "label": "Data Service remove collection", + "description": "Remove a collection after explicit confirmation.", + "capabilityId": "data-service.control", + "operation": "remove_collection", + "roles": ["parent"], + "mutation": "destructive", + "projectWriteLease": true, + "permissions": ["project.data.admin"], + "inputSchema": { + "type": "object", + "additionalProperties": false, + "required": ["collection", "confirmed"], + "properties": { + "collection": { + "type": "string", + "pattern": "^[a-z][a-z0-9_-]{0,47}$" + }, + "confirmed": { + "type": "boolean", + "const": true + } + } + } + }, + { + "name": "data_service_reset", + "label": "Data Service reset", + "description": "Reset all active project data after explicit confirmation.", + "capabilityId": "data-service.control", + "operation": "reset", + "roles": ["parent"], + "mutation": "destructive", + "projectWriteLease": true, + "permissions": ["project.data.admin"], + "inputSchema": { + "type": "object", + "additionalProperties": false, + "required": ["confirmed"], + "properties": { + "confirmed": { + "type": "boolean", + "const": true + } + } + } + }, + { + "name": "data_service_remove_project", + "label": "Data Service remove project", + "description": "Remove the active project data instance after explicit confirmation.", + "capabilityId": "data-service.control", + "operation": "remove_project", + "roles": ["parent"], + "mutation": "destructive", + "projectWriteLease": true, + "permissions": ["project.data.admin"], + "inputSchema": { + "type": "object", + "additionalProperties": false, + "required": ["confirmed"], + "properties": { + "confirmed": { + "type": "boolean", + "const": true + } + } + } + } + ], + "surfaces": { + "projectSettings": "data-service", + "previewRuntime": "data-service-v1" + } +} diff --git a/resources/coding-plugins/data-service/plugin.json b/resources/coding-plugins/data-service/plugin.json new file mode 100644 index 0000000..1633a91 --- /dev/null +++ b/resources/coding-plugins/data-service/plugin.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + "name": "makelore.data-service", + "version": "1.0.0", + "description": "Project-scoped development data storage", + "author": { + "name": "MakeLore" + }, + "extensions": { + "com.makelore": { + "capabilityManifest": "./com.makelore/capability.json" + } + } +} diff --git a/resources/coding-skills/data-service/SKILL.md b/resources/coding-plugins/data-service/skills/data-service/SKILL.md similarity index 100% rename from resources/coding-skills/data-service/SKILL.md rename to resources/coding-plugins/data-service/skills/data-service/SKILL.md diff --git a/resources/coding-skills/data-service/assets/makelore-data.js b/resources/coding-plugins/data-service/skills/data-service/assets/makelore-data.js similarity index 100% rename from resources/coding-skills/data-service/assets/makelore-data.js rename to resources/coding-plugins/data-service/skills/data-service/assets/makelore-data.js diff --git a/resources/coding-skills/data-service/assets/makelore-data.ts b/resources/coding-plugins/data-service/skills/data-service/assets/makelore-data.ts similarity index 100% rename from resources/coding-skills/data-service/assets/makelore-data.ts rename to resources/coding-plugins/data-service/skills/data-service/assets/makelore-data.ts diff --git a/shared/coding-plugins.ts b/shared/coding-plugins.ts new file mode 100644 index 0000000..7dbea3c --- /dev/null +++ b/shared/coding-plugins.ts @@ -0,0 +1,407 @@ +/** + * The small, code-owned vocabulary shared by the Main plugin registry and the + * renderer-facing product contracts. Package manifests are untrusted input; + * the values below are the only first-party adapter, surface, permission and + * Data Service identifiers that the P0 registry accepts. + */ + +export type PluginBillingMode = + | 'included' + | 'platform_metered' + | 'external_account'; + +export type PluginToolMutation = 'read' | 'write' | 'destructive'; + +export interface CodingPluginSkillDefinition { + id: string; + entryPath: string; + grants: readonly string[]; +} + +export interface CodingPluginToolDefinition { + name: string; + label: string; + description: string; + capabilityId: string; + operation: string; + roles: readonly ['parent']; + mutation: PluginToolMutation; + projectWriteLease: boolean; + permissions: readonly string[]; + inputSchema: Readonly>; +} + +export interface CodingPluginDefinition { + id: string; + version: string; + contractVersion: number; + displayName: string; + description: string; + scope: 'project'; + adapterId: string; + requiresBackend: boolean; + skills: readonly CodingPluginSkillDefinition[]; + tools: readonly CodingPluginToolDefinition[]; + surfaces: Readonly<{ + projectSettings?: string; + previewRuntime?: string; + }>; +} + +export interface AgentPluginsAuthorManifest { + name: string; +} + +export interface AgentPluginsRootManifest { + $schema: string; + name: string; + version: string; + description: string; + author: AgentPluginsAuthorManifest; + extensions: Readonly<{ + 'com.makelore': Readonly<{ capabilityManifest: string }>; + }>; +} + +export const AGENT_PLUGINS_SCHEMA_URL = + 'https://agent-plugins.org/schemas/1.0.0/plugin.schema.json' as const; + +export const DATA_SERVICE_PLUGIN_ID = 'makelore.data-service' as const; +export const DATA_SERVICE_ADAPTER_ID = 'data-service' as const; +export const DATA_SERVICE_PROJECT_SETTINGS_SURFACE = 'data-service' as const; +export const DATA_SERVICE_PREVIEW_RUNTIME_SURFACE = 'data-service-v1' as const; + +export const DATA_SERVICE_CAPABILITY_IDS = Object.freeze([ + 'data-service.control', + 'data-service.documents', + 'data-service.preview', +] as const); + +export const DATA_SERVICE_TOOL_NAMES = Object.freeze([ + 'data_service_configure', + 'data_service_inspect', + 'data_service_list_projects', + 'data_service_get_document', + 'data_service_list_documents', + 'data_service_put_document', + 'data_service_delete_document', + 'data_service_remove_collection', + 'data_service_reset', + 'data_service_remove_project', +] as const); + +export const CODE_OWNED_PLUGIN_ADAPTER_IDS = Object.freeze([ + DATA_SERVICE_ADAPTER_ID, +] as const); + +export const CODE_OWNED_PLUGIN_SETTINGS_SURFACES = Object.freeze([ + DATA_SERVICE_PROJECT_SETTINGS_SURFACE, +] as const); + +export const CODE_OWNED_PLUGIN_PREVIEW_SURFACES = Object.freeze([ + DATA_SERVICE_PREVIEW_RUNTIME_SURFACE, +] as const); + +export const CODE_OWNED_PLUGIN_PERMISSION_IDS = Object.freeze([ + 'project.data.admin', + 'project.data.configure', + 'project.data.read', + 'project.data.write', +] as const); + +// Descriptive aliases keep the allowlist vocabulary discoverable to callers +// without creating a second source of truth. +export const BUNDLED_CODING_PLUGIN_ADAPTER_IDS = CODE_OWNED_PLUGIN_ADAPTER_IDS; +export const BUNDLED_CODING_PLUGIN_SETTINGS_SURFACES = CODE_OWNED_PLUGIN_SETTINGS_SURFACES; +export const BUNDLED_CODING_PLUGIN_PREVIEW_SURFACES = CODE_OWNED_PLUGIN_PREVIEW_SURFACES; +export const BUNDLED_CODING_PLUGIN_PERMISSION_IDS = CODE_OWNED_PLUGIN_PERMISSION_IDS; + +const EMPTY_OBJECT_SCHEMA = Object.freeze({ + type: 'object', + additionalProperties: false, + properties: {}, +} as const); + +const COLLECTION_SCHEMA = Object.freeze({ + type: 'string', + pattern: '^[a-z][a-z0-9_-]{0,47}$', +} as const); + +const DOCUMENT_ID_SCHEMA = Object.freeze({ + type: 'string', + pattern: '^[A-Za-z0-9._~-]{1,128}$', +} as const); + +const REVISION_SCHEMA = Object.freeze({ + type: 'integer', + minimum: 1, +} as const); + +const DATA_SCHEMA = Object.freeze({ + type: 'object', +} as const); + +const LIST_LIMIT_SCHEMA = Object.freeze({ + type: 'integer', + minimum: 1, + maximum: 100, +} as const); + +const CURSOR_SCHEMA = Object.freeze({ + type: 'string', + minLength: 1, + maxLength: 1024, +} as const); + +const CONFIRMED_SCHEMA = Object.freeze({ + type: 'boolean', + const: true, +} as const); + +const CONFIGURE_SCHEMA = Object.freeze({ + type: 'object', + additionalProperties: false, + required: ['collections'], + properties: { + collections: { + type: 'array', + maxItems: 20, + items: COLLECTION_SCHEMA, + }, + }, +} as const); + +const GET_DOCUMENT_SCHEMA = Object.freeze({ + type: 'object', + additionalProperties: false, + required: ['collection', 'document_id'], + properties: { + collection: COLLECTION_SCHEMA, + document_id: DOCUMENT_ID_SCHEMA, + }, +} as const); + +const LIST_DOCUMENTS_SCHEMA = Object.freeze({ + type: 'object', + additionalProperties: false, + required: ['collection'], + properties: { + collection: COLLECTION_SCHEMA, + limit: LIST_LIMIT_SCHEMA, + cursor: CURSOR_SCHEMA, + }, +} as const); + +const PUT_DOCUMENT_SCHEMA = Object.freeze({ + type: 'object', + additionalProperties: false, + required: ['collection', 'document_id', 'data'], + properties: { + collection: COLLECTION_SCHEMA, + document_id: DOCUMENT_ID_SCHEMA, + data: DATA_SCHEMA, + if_revision: REVISION_SCHEMA, + }, +} as const); + +const DELETE_DOCUMENT_SCHEMA = Object.freeze({ + type: 'object', + additionalProperties: false, + required: ['collection', 'document_id', 'confirmed'], + properties: { + collection: COLLECTION_SCHEMA, + document_id: DOCUMENT_ID_SCHEMA, + if_revision: REVISION_SCHEMA, + confirmed: CONFIRMED_SCHEMA, + }, +} as const); + +const REMOVE_COLLECTION_SCHEMA = Object.freeze({ + type: 'object', + additionalProperties: false, + required: ['collection', 'confirmed'], + properties: { + collection: COLLECTION_SCHEMA, + confirmed: CONFIRMED_SCHEMA, + }, +} as const); + +const CONFIRMATION_SCHEMA = Object.freeze({ + type: 'object', + additionalProperties: false, + required: ['confirmed'], + properties: { confirmed: CONFIRMED_SCHEMA }, +} as const); + +function freezeDeep(value: T): T { + if (value && typeof value === 'object' && !Object.isFrozen(value)) { + for (const child of Object.values(value as Record)) freezeDeep(child); + Object.freeze(value); + } + return value; +} + +function tool( + name: (typeof DATA_SERVICE_TOOL_NAMES)[number], + label: string, + description: string, + capabilityId: string, + operation: string, + mutation: PluginToolMutation, + projectWriteLease: boolean, + permissions: readonly string[], + inputSchema: Readonly>, +): CodingPluginToolDefinition { + return { + name, + label, + description, + capabilityId, + operation, + roles: ['parent'], + mutation, + projectWriteLease, + permissions, + inputSchema, + }; +} + +export const DATA_SERVICE_TOOL_DEFINITIONS = freezeDeep([ + tool( + 'data_service_configure', + 'Data Service configure', + 'Configure collections for the active project.', + 'data-service.control', + 'configure', + 'write', + true, + ['project.data.configure'], + CONFIGURE_SCHEMA, + ), + tool( + 'data_service_inspect', + 'Data Service inspect', + 'Inspect the active project data instance.', + 'data-service.control', + 'inspect', + 'read', + false, + ['project.data.read'], + EMPTY_OBJECT_SCHEMA, + ), + tool( + 'data_service_list_projects', + 'Data Service list projects', + 'List the authenticated projects with Data Service instances.', + 'data-service.control', + 'list_projects', + 'read', + false, + ['project.data.read'], + EMPTY_OBJECT_SCHEMA, + ), + tool( + 'data_service_get_document', + 'Data Service get document', + 'Read a document from the active project.', + 'data-service.documents', + 'get_document', + 'read', + false, + ['project.data.read'], + GET_DOCUMENT_SCHEMA, + ), + tool( + 'data_service_list_documents', + 'Data Service list documents', + 'List documents from a collection in the active project.', + 'data-service.documents', + 'list_documents', + 'read', + false, + ['project.data.read'], + LIST_DOCUMENTS_SCHEMA, + ), + tool( + 'data_service_put_document', + 'Data Service put document', + 'Create or replace a document in the active project.', + 'data-service.documents', + 'put_document', + 'write', + true, + ['project.data.write'], + PUT_DOCUMENT_SCHEMA, + ), + tool( + 'data_service_delete_document', + 'Data Service delete document', + 'Delete a document after explicit confirmation.', + 'data-service.documents', + 'delete_document', + 'destructive', + true, + ['project.data.write'], + DELETE_DOCUMENT_SCHEMA, + ), + tool( + 'data_service_remove_collection', + 'Data Service remove collection', + 'Remove a collection after explicit confirmation.', + 'data-service.control', + 'remove_collection', + 'destructive', + true, + ['project.data.admin'], + REMOVE_COLLECTION_SCHEMA, + ), + tool( + 'data_service_reset', + 'Data Service reset', + 'Reset all active project data after explicit confirmation.', + 'data-service.control', + 'reset', + 'destructive', + true, + ['project.data.admin'], + CONFIRMATION_SCHEMA, + ), + tool( + 'data_service_remove_project', + 'Data Service remove project', + 'Remove the active project data instance after explicit confirmation.', + 'data-service.control', + 'remove_project', + 'destructive', + true, + ['project.data.admin'], + CONFIRMATION_SCHEMA, + ), +] as const satisfies readonly CodingPluginToolDefinition[]); + +export const DATA_SERVICE_PLUGIN_DEFINITION = freezeDeep({ + id: DATA_SERVICE_PLUGIN_ID, + version: '1.0.0', + contractVersion: 1, + displayName: '开发数据服务', + description: '为当前项目提供受控的开发期 JSON 数据存储。', + scope: 'project', + adapterId: DATA_SERVICE_ADAPTER_ID, + requiresBackend: true, + skills: [ + { + id: 'data-service', + entryPath: 'skills/data-service/SKILL.md', + grants: ['data-service.control', 'data-service.documents'], + }, + ], + tools: DATA_SERVICE_TOOL_DEFINITIONS, + surfaces: { + projectSettings: DATA_SERVICE_PROJECT_SETTINGS_SURFACE, + previewRuntime: DATA_SERVICE_PREVIEW_RUNTIME_SURFACE, + }, +} as const satisfies CodingPluginDefinition); + +export const BUNDLED_CODING_PLUGIN_DEFINITIONS = freezeDeep([ + DATA_SERVICE_PLUGIN_DEFINITION, +] as const satisfies readonly CodingPluginDefinition[]); diff --git a/shared/coding-skills.ts b/shared/coding-skills.ts index 14e5595..2626ed6 100644 --- a/shared/coding-skills.ts +++ b/shared/coding-skills.ts @@ -1,12 +1,21 @@ -export const BUNDLED_CODING_SKILL_IDS = [ +/** Skills that are part of Makelore's core coding runtime. */ +export const CORE_CODING_SKILL_IDS = [ 'agent-browser', - 'data-service', 'frontend-slides', 'grilling', 'planning-with-files', ] as const; +/** + * Compatibility name for callers that enumerate skills built into the app. + * Plugin skills intentionally do not appear here; their package definitions + * are the source of truth. + */ +export const BUNDLED_CODING_SKILL_IDS = CORE_CODING_SKILL_IDS; + export type BundledCodingSkillId = (typeof BUNDLED_CODING_SKILL_IDS)[number]; +export type PluginCodingSkillId = string; +export type CodingSkillId = BundledCodingSkillId | PluginCodingSkillId; export const DEFAULT_PROJECT_AGENT_SKILL_IDS = [ 'agent-browser', diff --git a/tests/unit/coding-plugin-manifest.test.ts b/tests/unit/coding-plugin-manifest.test.ts new file mode 100644 index 0000000..2041325 --- /dev/null +++ b/tests/unit/coding-plugin-manifest.test.ts @@ -0,0 +1,122 @@ +// @vitest-environment node + +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { + BUNDLED_CODING_PLUGIN_ROOTS, + CodingPluginManifestError, + loadBundledCodingPluginDefinitions, + loadCodingPluginDefinition, + parseCodingPluginManifest, + parseAgentPluginsRootManifest, + resolveBundledCodingPluginRootPaths, +} from '../../electron/coding-plugins/manifest'; +import { + AGENT_PLUGINS_SCHEMA_URL, + DATA_SERVICE_PLUGIN_DEFINITION, + DATA_SERVICE_TOOL_NAMES, +} from '../../shared/coding-plugins'; + +const PACKAGE_ROOT = path.resolve('resources/coding-plugins/data-service'); + +async function packageManifests(): Promise<{ root: Record; capability: Record }> { + return { + root: JSON.parse(await readFile(path.join(PACKAGE_ROOT, 'plugin.json'), 'utf8')) as Record, + capability: JSON.parse(await readFile(path.join(PACKAGE_ROOT, 'com.makelore/capability.json'), 'utf8')) as Record, + }; +} + +describe('bundled coding plugin manifests', () => { + it('loads the fixed Data Service package and immutable declarations', async () => { + const definitions = await loadBundledCodingPluginDefinitions(path.resolve('resources/coding-plugins')); + expect(BUNDLED_CODING_PLUGIN_ROOTS).toEqual(['data-service']); + expect(resolveBundledCodingPluginRootPaths(path.resolve('resources/coding-plugins'))).toEqual([PACKAGE_ROOT]); + expect(definitions).toHaveLength(1); + expect(definitions[0]).toMatchObject({ + id: 'makelore.data-service', + adapterId: 'data-service', + contractVersion: 1, + skills: [{ id: 'data-service', entryPath: 'skills/data-service/SKILL.md' }], + }); + expect(definitions[0]?.tools.map(({ name }) => name)).toEqual(DATA_SERVICE_TOOL_NAMES); + expect(Object.isFrozen(definitions[0])).toBe(true); + expect(Object.isFrozen(definitions[0]?.tools)).toBe(true); + expect(DATA_SERVICE_PLUGIN_DEFINITION.tools).toHaveLength(10); + }); + + it('accepts exact root and capability manifests and freezes the projection', async () => { + const { root, capability } = await packageManifests(); + expect(parseAgentPluginsRootManifest(root, 'plugin.json').$schema).toBe(AGENT_PLUGINS_SCHEMA_URL); + const parsed = parseCodingPluginManifest(root, capability, { + packageRoot: PACKAGE_ROOT, + capabilityManifestPath: path.join(PACKAGE_ROOT, 'com.makelore/capability.json'), + }); + expect(parsed.id).toBe('makelore.data-service'); + expect(Object.isFrozen(parsed)).toBe(true); + expect(Object.isFrozen(parsed.tools[0]?.inputSchema)).toBe(true); + }); + + it.each([ + ['price field', (root: Record) => { root.price = 1; }], + ['non-empty MCP', (root: Record) => { root.mcp = [{ name: 'unsupported' }]; }], + ['non-empty scripts', (root: Record) => { root.scripts = ['run.js']; }], + ['unknown adapter', (_root: Record, capability: Record) => { capability.adapterId = 'other'; }], + ['unknown surface', (_root: Record, capability: Record) => { + capability.surfaces = { projectSettings: 'other' }; + }], + ])('rejects %s', async (_label, mutate) => { + const { root, capability } = await packageManifests(); + mutate(root, capability); + expect(() => parseCodingPluginManifest(root, capability, { + packageRoot: PACKAGE_ROOT, + capabilityManifestPath: path.join(PACKAGE_ROOT, 'com.makelore/capability.json'), + })).toThrow(CodingPluginManifestError); + }); + + it('rejects duplicate tools, escaping Skill entries and unconfirmed destructive tools', async () => { + const { root, capability } = await packageManifests(); + const duplicate = structuredClone(capability) as Record; + duplicate.tools = [...(capability.tools as unknown[]), (capability.tools as unknown[])[0]]; + expect(() => parseCodingPluginManifest(root, duplicate, { + packageRoot: PACKAGE_ROOT, + capabilityManifestPath: path.join(PACKAGE_ROOT, 'com.makelore/capability.json'), + })).toThrow('duplicate tool identifier'); + + const escaping = structuredClone(capability) as Record; + const skill = (escaping.skills as Array>)[0]; + skill.entry = '../../outside/SKILL.md'; + expect(() => parseCodingPluginManifest(root, escaping, { + packageRoot: PACKAGE_ROOT, + capabilityManifestPath: path.join(PACKAGE_ROOT, 'com.makelore/capability.json'), + })).toThrow('escapes the package root'); + + const unconfirmed = structuredClone(capability) as Record; + const tools = unconfirmed.tools as Array>; + const destructive = tools.find((tool) => tool.mutation === 'destructive'); + expect(destructive).toBeDefined(); + const schema = destructive?.inputSchema as Record; + const properties = schema.properties as Record; + delete properties.confirmed; + schema.required = (schema.required as string[]).filter((required) => required !== 'confirmed'); + expect(() => parseCodingPluginManifest(root, unconfirmed, { + packageRoot: PACKAGE_ROOT, + capabilityManifestPath: path.join(PACKAGE_ROOT, 'com.makelore/capability.json'), + })).toThrow('confirmed: true'); + }); + + it('does not infer package roots from arbitrary directories', async () => { + expect(resolveBundledCodingPluginRootPaths(path.resolve('tmp'))).toEqual([ + path.resolve('tmp/data-service'), + ]); + }); + + it('loads a single package directly through its exact capability path', async () => { + await expect(loadCodingPluginDefinition(PACKAGE_ROOT)).resolves.toMatchObject({ + id: 'makelore.data-service', + tools: expect.arrayContaining([ + expect.objectContaining({ name: 'data_service_configure' }), + ]), + }); + }); +}); diff --git a/tests/unit/data-service-sdk-assets.test.ts b/tests/unit/data-service-sdk-assets.test.ts index 274d616..d33e239 100644 --- a/tests/unit/data-service-sdk-assets.test.ts +++ b/tests/unit/data-service-sdk-assets.test.ts @@ -8,7 +8,8 @@ import ts from 'typescript'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { listProductCodingSkills } from '@electron/coding-projects/skill-registry'; -const ASSET_ROOT = path.resolve('resources/coding-skills/data-service/assets'); +const PLUGIN_SKILL_ROOT = path.resolve('resources/coding-plugins/data-service/skills/data-service'); +const ASSET_ROOT = path.join(PLUGIN_SKILL_ROOT, 'assets'); const ASSETS = ['makelore-data.ts', 'makelore-data.js'] as const; const temporaryRoots: string[] = []; let moduleCounter = 0; @@ -236,7 +237,7 @@ describe.each(ASSETS)('generated Data Service SDK (%s)', (assetName) => { describe('bundled Data Service Skill packaging', () => { it('ships both canonical assets and an ordered workflow with explicit completion criteria', async () => { - const skill = await readFile(path.resolve('resources/coding-skills/data-service/SKILL.md'), 'utf8'); + const skill = await readFile(path.join(PLUGIN_SKILL_ROOT, 'SKILL.md'), 'utf8'); const readme = await readFile(path.resolve('README.md'), 'utf8'); const tsAsset = await readFile(path.join(ASSET_ROOT, 'makelore-data.ts'), 'utf8'); const jsAsset = await readFile(path.join(ASSET_ROOT, 'makelore-data.js'), 'utf8'); @@ -263,9 +264,17 @@ describe('bundled Data Service Skill packaging', () => { expect(readme).toContain('data-service'); expect(readme).toContain('显式请求'); expect(readme).toContain('预览'); - const packaged = await listProductCodingSkills(path.resolve('resources/coding-skills')); + const packaged = await listProductCodingSkills( + path.resolve('resources/coding-skills'), + [], + [{ id: 'data-service', directory: PLUGIN_SKILL_ROOT }], + ); const dataService = packaged.find(({ id }) => id === 'data-service'); - expect(dataService).toMatchObject({ id: 'data-service', name: 'data-service' }); + expect(dataService).toMatchObject({ + id: 'data-service', + name: 'data-service', + location: PLUGIN_SKILL_ROOT, + }); expect(dataService?.entries).toEqual(expect.arrayContaining([ { path: 'SKILL.md', type: 'file' }, { path: 'assets', type: 'directory' }, diff --git a/tests/unit/project-plugin-service.test.ts b/tests/unit/project-plugin-service.test.ts new file mode 100644 index 0000000..f2c2f45 --- /dev/null +++ b/tests/unit/project-plugin-service.test.ts @@ -0,0 +1,158 @@ +// @vitest-environment node + +import { readFile, stat } from 'node:fs/promises'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + createCodingProjectAgent, + createCodingProjectMetadata, +} from '../../electron/coding-projects/project-config'; +import { + PROJECT_PLUGIN_SELECTION_PATH, + ProjectPluginService, + ProjectPluginServiceError, +} from '../../electron/coding-plugins/project-service'; + +const roots: string[] = []; + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); + +async function project(): Promise { + const root = await mkdtemp(path.join(tmpdir(), 'makelore-plugin-selection-')); + roots.push(root); + await createCodingProjectMetadata(root, { now: '2026-08-27T00:00:00.000Z' }); + return root; +} + +async function legacyProject(): Promise { + const root = await project(); + await createCodingProjectAgent(root, { + id: 'builder', + avatarId: 'avatar-01', + roleName: 'Builder', + name: 'Builder', + model: { accountId: 'account', modelId: 'model', thinkingLevel: 'medium' }, + modelResolution: 'resolved', + skillIds: ['data-service'], + responsibility: { + mission: 'Build the project', + owns: [], + boundaries: [], + collaborators: [], + principles: [], + }, + }); + return root; +} + +function selectionPath(root: string): string { + return path.join(root, PROJECT_PLUGIN_SELECTION_PATH); +} + +describe('ProjectPluginService', () => { + it('does not create plugins.json during an ordinary missing read', async () => { + const root = await project(); + const service = new ProjectPluginService(); + await expect(service.readSelection(root)).resolves.toMatchObject({ + status: 'missing', + source: 'none', + enabledPluginIds: [], + persisted: false, + }); + await expect(stat(selectionPath(root))).rejects.toMatchObject({ code: 'ENOENT' }); + await service.disable(root, 'makelore.data-service'); + await expect(stat(selectionPath(root))).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('projects the legacy Data Service Skill once without eager persistence', async () => { + const root = await legacyProject(); + const service = new ProjectPluginService(); + await expect(service.readSelection(root)).resolves.toMatchObject({ + status: 'missing', + source: 'legacy', + enabledPluginIds: ['makelore.data-service'], + legacyProjectedPluginIds: ['makelore.data-service'], + persisted: false, + }); + await expect(stat(selectionPath(root))).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('writes deterministic, atomic selection and preserves unknown IDs', async () => { + const root = await project(); + await writeFile(selectionPath(root), JSON.stringify({ + schemaVersion: 1, + enabledPluginIds: ['z.future-plugin', 'makelore.data-service', 'z.future-plugin'], + updatedAt: '2026-08-27T01:00:00.000Z', + }), 'utf8'); + const service = new ProjectPluginService({ now: () => '2026-08-27T02:00:00.000Z' }); + await expect(service.readSelection(root)).resolves.toMatchObject({ + source: 'file', + enabledPluginIds: ['makelore.data-service', 'z.future-plugin'], + unknownPluginIds: ['z.future-plugin'], + }); + const next = await service.disable(root, 'makelore.data-service'); + expect(next.enabledPluginIds).toEqual(['z.future-plugin']); + expect(JSON.parse(await readFile(selectionPath(root), 'utf8'))).toEqual({ + schemaVersion: 1, + enabledPluginIds: ['z.future-plugin'], + updatedAt: '2026-08-27T02:00:00.000Z', + }); + }); + + it('enables and disables idempotently while invoking lifecycle callbacks once', async () => { + const root = await project(); + const managed: unknown[] = []; + const deactivated: unknown[] = []; + let writes = 0; + const service = new ProjectPluginService({ + now: () => '2026-08-27T03:00:00.000Z', + writer: async (filePath, value) => { + writes += 1; + const { atomicWriteJson } = await import('../../electron/coding-projects/atomic-json'); + await atomicWriteJson(filePath, value); + }, + onManagedInputsChanged: (event) => { managed.push(event); }, + onAdapterDeactivated: (event) => { deactivated.push(event); }, + }); + await service.enable(root, 'makelore.data-service'); + await service.enable(root, 'makelore.data-service'); + await service.disable(root, 'makelore.data-service'); + await service.disable(root, 'makelore.data-service'); + expect(writes).toBe(2); + expect(managed).toHaveLength(2); + expect(deactivated).toEqual([{ projectPath: root, pluginId: 'makelore.data-service' }]); + expect(service.getManagedInputRevision(root)).toBe(2); + }); + + it('keeps selection unchanged when atomic persistence fails', async () => { + const root = await project(); + const service = new ProjectPluginService({ + writer: async () => { throw new Error('disk full'); }, + }); + await expect(service.enable(root, 'makelore.data-service')).rejects.toMatchObject({ + code: 'CODING_PLUGIN_SELECTION_WRITE_FAILED', + } satisfies Partial); + await expect(stat(selectionPath(root))).rejects.toMatchObject({ code: 'ENOENT' }); + await expect(service.readSelection(root)).resolves.toMatchObject({ enabledPluginIds: [] }); + }); + + it('rejects unknown plugin mutations but retains unknown IDs already in the file', async () => { + const root = await project(); + await writeFile(selectionPath(root), JSON.stringify({ + schemaVersion: 1, + enabledPluginIds: ['future.plugin'], + updatedAt: '2026-08-27T04:00:00.000Z', + }), 'utf8'); + const service = new ProjectPluginService(); + await expect(service.enable(root, 'future.plugin')).rejects.toMatchObject({ + code: 'CODING_PLUGIN_UNKNOWN', + }); + await expect(service.enable(root, 'makelore.data-service')).resolves.toMatchObject({ + enabledPluginIds: ['future.plugin', 'makelore.data-service'], + }); + }); +});