diff --git a/README.md b/README.md index acafd38..0a7d772 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,22 @@ rudi integrate all # Add the router to all detected agents This modifies the agent's MCP configuration to include one managed RUDI router; stack discovery and secret injection stay inside RUDI. +Every registry stack declares a primary operator skill. A normal stack install +installs that skill automatically and creates a native wrapper for detected +Codex and Claude hosts without overwriting an existing wrapper. Additional +companion workflows remain optional: + +```bash +rudi install stack:video-editor # operator skill included +rudi install stack:video-editor --with-related-skills # include companions +rudi install stack:video-editor --no-related-skills # operator only +``` + +In Claude Code, invoke the operator as `/skill-name`. In Codex, use `/skills` +to select it or mention it as `$skill-name`. The operator guides the host +through the stack's MCP tools; users do not need to know the individual tool +names. + Each native host has its own skill directory. After installing RUDI skills, sync editable native wrappers when you want them to appear in the host's skill/slash UI: @@ -329,16 +345,17 @@ Each package installs to its own directory. Shims are thin wrappers that set up ## Available Stacks -| Stack | Description | Required Secrets | -|-------|-------------|------------------| -| slack | Channels, messages, reactions | `SLACK_BOT_TOKEN` | -| google-workspace | Gmail, Sheets, Docs, Drive | `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET` | -| notion-workspace | Pages, databases, search | `NOTION_API_KEY` | -| github | Issues, PRs, repos, actions | `GITHUB_TOKEN` | -| postgres | SQL queries | `DATABASE_URL` | -| stripe | Payments, subscriptions | `STRIPE_SECRET_KEY` | -| openai | DALL-E, Whisper, TTS | `OPENAI_API_KEY` | -| google-ai | Gemini, Imagen | `GOOGLE_AI_API_KEY` | +The registry inventory changes independently of the CLI. Discover the current +catalog instead of relying on a checked-in list: + +```bash +rudi search --all --stacks +``` + +When a registry package declares lifecycle metadata, package search, listings, +and `rudi info` show its maturity, support posture, and any deprecation, +replacement, or removal guidance. Packages without lifecycle metadata are +unclassified; the CLI does not infer support from version numbers. ## Available Binaries diff --git a/dist/index.cjs b/dist/index.cjs index 00359a0..c28797c 100755 --- a/dist/index.cjs +++ b/dist/index.cjs @@ -541,6 +541,96 @@ function normalizeSecrets(requires) { }) }; } +function isCalendarDate(value) { + if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return false; + const parsed = /* @__PURE__ */ new Date(`${value}T00:00:00.000Z`); + return !Number.isNaN(parsed.getTime()) && parsed.toISOString().slice(0, 10) === value; +} +function normalizePackageLifecycle(value, packageId) { + if (value === void 0) return void 0; + const lifecycle = asObject(value, `Registry package ${packageId} lifecycle`); + const lifecycleKeys = /* @__PURE__ */ new Set(["maturity", "support", "deprecation"]); + const unknownLifecycleKey = Object.keys(lifecycle).find((key) => !lifecycleKeys.has(key)); + if (unknownLifecycleKey) { + throw new RegistryContractError( + `Registry package ${packageId} lifecycle contains unsupported field: ${unknownLifecycleKey}`, + { packageId } + ); + } + if (!PACKAGE_MATURITY.has(lifecycle.maturity)) { + throw new RegistryContractError( + `Registry package ${packageId} lifecycle.maturity is invalid`, + { packageId } + ); + } + if (!PACKAGE_SUPPORT.has(lifecycle.support)) { + throw new RegistryContractError( + `Registry package ${packageId} lifecycle.support is invalid`, + { packageId } + ); + } + let deprecation; + if (lifecycle.deprecation !== void 0) { + deprecation = asObject( + lifecycle.deprecation, + `Registry package ${packageId} lifecycle.deprecation` + ); + const deprecationKeys = /* @__PURE__ */ new Set([ + "announcedAt", + "message", + "replacementId", + "removalAfter" + ]); + const unknownDeprecationKey = Object.keys(deprecation).find((key) => !deprecationKeys.has(key)); + if (unknownDeprecationKey) { + throw new RegistryContractError( + `Registry package ${packageId} lifecycle.deprecation contains unsupported field: ${unknownDeprecationKey}`, + { packageId } + ); + } + if (!isCalendarDate(deprecation.announcedAt)) { + throw new RegistryContractError( + `Registry package ${packageId} lifecycle.deprecation.announcedAt is invalid`, + { packageId } + ); + } + if (typeof deprecation.message !== "string" || deprecation.message.trim() === "") { + throw new RegistryContractError( + `Registry package ${packageId} lifecycle.deprecation.message is required`, + { packageId } + ); + } + if (deprecation.replacementId !== void 0 && !PACKAGE_ID_PATTERN.test(deprecation.replacementId)) { + throw new RegistryContractError( + `Registry package ${packageId} lifecycle.deprecation.replacementId is invalid`, + { packageId } + ); + } + if (deprecation.removalAfter !== void 0 && !isCalendarDate(deprecation.removalAfter)) { + throw new RegistryContractError( + `Registry package ${packageId} lifecycle.deprecation.removalAfter is invalid`, + { packageId } + ); + } + if (deprecation.removalAfter !== void 0 && deprecation.removalAfter < deprecation.announcedAt) { + throw new RegistryContractError( + `Registry package ${packageId} lifecycle.deprecation.removalAfter precedes announcedAt`, + { packageId } + ); + } + } + if (lifecycle.support === "unsupported" && deprecation === void 0) { + throw new RegistryContractError( + `Registry package ${packageId} with unsupported lifecycle support requires deprecation guidance`, + { packageId } + ); + } + return { + maturity: lifecycle.maturity, + support: lifecycle.support, + ...deprecation ? { deprecation: { ...deprecation } } : {} + }; +} function legacyInstallType(source) { if (source === "download") return "binary"; if (source === "npm" || source === "pip" || source === "system") return source; @@ -563,6 +653,7 @@ function normalizeRegistryPackage(value, kindHint) { path: pkg.path || install.path, description: pkg.description || meta.description, category: pkg.category || meta.category, + ...pkg.lifecycle === void 0 ? {} : { lifecycle: normalizePackageLifecycle(pkg.lifecycle, pkg.id) }, tags: pkg.tags || meta.tags, icon: pkg.icon || meta.icon, author: pkg.author || meta.author, @@ -694,7 +785,7 @@ function getRegistryPackage(value, id, kinds) { } return null; } -var PACKAGE_KINDS2, RegistryContractError; +var PACKAGE_KINDS2, RegistryContractError, PACKAGE_ID_PATTERN, PACKAGE_MATURITY, PACKAGE_SUPPORT; var init_registry_contract = __esm({ "packages/registry-client/src/registry-contract.js"() { PACKAGE_KINDS2 = /* @__PURE__ */ new Set([ @@ -713,6 +804,9 @@ var init_registry_contract = __esm({ this.details = details; } }; + PACKAGE_ID_PATTERN = /^(runtime|binary|agent|stack|skill|prompt):[a-z0-9][a-z0-9-_]*$/; + PACKAGE_MATURITY = /* @__PURE__ */ new Set(["experimental", "stable"]); + PACKAGE_SUPPORT = /* @__PURE__ */ new Set(["supported", "maintenance", "unsupported"]); } }); @@ -1864,6 +1958,16 @@ function normalizeSkillPackageId(id) { } async function resolveRelatedSkills(pkg) { const relatedSkillIds = pkg.related?.skills || []; + const operatorSkillId = normalizeSkillPackageId(pkg.related?.operatorSkill); + if (pkg.kind === "stack" && !operatorSkillId) { + throw new Error(`${pkg.id || "Stack package"} requires related.operatorSkill`); + } + const normalizedRelatedSkillIds = relatedSkillIds.map((id) => normalizeSkillPackageId(id)).filter(Boolean); + if (operatorSkillId && !normalizedRelatedSkillIds.includes(operatorSkillId)) { + throw new Error( + `${pkg.id || "Stack package"} related.operatorSkill must appear in related.skills` + ); + } const relatedSkills = []; const seen = /* @__PURE__ */ new Set(); for (const id of relatedSkillIds) { @@ -1871,13 +1975,19 @@ async function resolveRelatedSkills(pkg) { if (!skillId || seen.has(skillId)) continue; seen.add(skillId); const skillPkg = await getPackage(skillId); - if (!skillPkg) continue; + if (!skillPkg) { + if (skillId === operatorSkillId) { + throw new Error(`operator skill package not found: ${skillId}`); + } + continue; + } relatedSkills.push({ id: skillId, kind: "skill", name: skillPkg.name, version: skillPkg.version, installed: isPackageInstalled(skillId), + isOperator: skillId === operatorSkillId, dependencies: [] }); } @@ -20047,6 +20157,30 @@ EXAMPLES // src/commands/search.js init_src5(); + +// src/commands/package-lifecycle.js +function formatPackageLifecycleLines(pkg) { + const lifecycle = pkg?.lifecycle; + if (!lifecycle) return []; + const lines = [`Lifecycle: ${lifecycle.maturity} \xB7 ${lifecycle.support}`]; + const deprecation = lifecycle.deprecation; + if (!deprecation) return lines; + lines.push(`Deprecated since ${deprecation.announcedAt}: ${deprecation.message}`); + if (deprecation.replacementId) { + lines.push(`Replacement: ${deprecation.replacementId}`); + } + if (deprecation.removalAfter) { + lines.push(`Removal after: ${deprecation.removalAfter}`); + } + return lines; +} +function printPackageLifecycle(pkg, indent = "") { + for (const line of formatPackageLifecycleLines(pkg)) { + console.log(`${indent}${line}`); + } +} + +// src/commands/search.js function pluralizeKind(kind) { if (!kind) return "packages"; if (kind === "binary") return "binaries"; @@ -20118,6 +20252,7 @@ Found ${results.length} package(s): if (pkg.version) { console.log(` v${pkg.version}`); } + printPackageLifecycle(pkg, " "); console.log(); } } @@ -20158,6 +20293,7 @@ ${headingForKind(k)} (${packages.length}):`); const runtime = pkg.runtime ? ` [${pkg.runtime.replace("runtime:", "")}]` : ""; console.log(` ${id}${runtime}`); console.log(` ${pkg.description || "No description"}`); + printPackageLifecycle(pkg, " "); } } console.log(` @@ -20657,15 +20793,27 @@ init_src(); init_src5(); // src/commands/related-skills.js +function normalizeSkillId(value) { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + if (!trimmed) return null; + return trimmed.startsWith("skill:") ? trimmed : trimmed.startsWith("prompt:") ? trimmed.replace(/^prompt:/, "skill:") : trimmed.includes(":") ? null : `skill:${trimmed}`; +} +function getOperatorSkillId(pkg) { + return normalizeSkillId(pkg?.related?.operatorSkill); +} +function formatOperatorSkillLine(pkg, options = {}) { + const { label = "Operator skill" } = options; + const id = getOperatorSkillId(pkg); + if (!id) return null; + return `${label}: ${id}`; +} function getRelatedSkillIds(pkg) { const skills = Array.isArray(pkg?.related?.skills) ? pkg.related.skills : []; const ids = []; const seen = /* @__PURE__ */ new Set(); for (const value of skills) { - if (typeof value !== "string") continue; - const trimmed = value.trim(); - if (!trimmed) continue; - const id = trimmed.startsWith("skill:") ? trimmed : trimmed.startsWith("prompt:") ? trimmed.replace(/^prompt:/, "skill:") : trimmed.includes(":") ? null : `skill:${trimmed}`; + const id = normalizeSkillId(value); if (!id || seen.has(id)) continue; seen.add(id); ids.push(id); @@ -20674,7 +20822,8 @@ function getRelatedSkillIds(pkg) { } function formatRelatedSkillsLine(pkg, options = {}) { const { label = "Related skills" } = options; - const ids = getRelatedSkillIds(pkg); + const operatorSkill = getOperatorSkillId(pkg); + const ids = getRelatedSkillIds(pkg).filter((id) => id !== operatorSkill); if (ids.length === 0) return null; return `${label}: ${ids.join(", ")}`; } @@ -20822,6 +20971,7 @@ SKILLS (${packages.length}):`); if (pkg.description) { console.log(` ${pkg.description}`); } + printPackageLifecycle(pkg, " "); if (pkg.requires && pkg.requires.stacks && pkg.requires.stacks.length > 0) { console.log(` Requires: ${pkg.requires.stacks.join(", ")}`); } @@ -20858,12 +21008,17 @@ ${headingForKind2(pkgKind)} (${pkgs.length}):`); if (pkg.description) { console.log(` ${pkg.description}`); } + printPackageLifecycle(pkg, " "); if (pkg.category) { console.log(` Category: ${pkg.category}`); } if (pkg.tags && pkg.tags.length > 0) { console.log(` Tags: ${pkg.tags.join(", ")}`); } + const operatorSkillLine = formatOperatorSkillLine(pkg); + if (operatorSkillLine) { + console.log(` ${operatorSkillLine}`); + } const relatedSkillsLine = formatRelatedSkillsLine(pkg); if (relatedSkillsLine) { console.log(` ${relatedSkillsLine}`); @@ -21009,7 +21164,7 @@ function buildClaudeSkillFiles(pkg, sourceContent) { const body = parsed.body || `Use the installed RUDI skill \`skill:${skillName}\` as the source of truth.`; const skillMd = [ "---", - `name: ${yamlString(displayName)}`, + `name: ${yamlString(skillName)}`, `description: ${yamlString(description)}`, "---", "", @@ -21401,14 +21556,33 @@ function getRelatedSkillInstallMode(flags = {}) { function buildRelatedSkillInstallPlan(resolved, flags = {}) { const mode = getRelatedSkillInstallMode(flags); const relatedSkills = Array.isArray(resolved?.relatedSkills) ? resolved.relatedSkills : []; - const missing = relatedSkills.filter((skill) => !skill.installed); + const operatorSkill = relatedSkills.find((skill) => skill.isOperator) || null; + const companionSkills = relatedSkills.filter((skill) => !skill.isOperator); + const missingOperator = operatorSkill && !operatorSkill.installed ? [operatorSkill] : []; + const missingCompanions = companionSkills.filter((skill) => !skill.installed); + const missing = [...missingOperator, ...missingCompanions]; return { mode, relatedSkills, + operatorSkill, + companionSkills, + missingOperator, + missingCompanions, missing, - toInstall: mode === "include" ? missing : [] + toInstall: [ + ...missingOperator, + ...mode === "include" ? missingCompanions : [] + ] }; } +function selectRelatedSkillsForInstall(plan, includeCompanions = false) { + if (!plan) return []; + const selected = [...plan.missingOperator || []]; + if (plan.mode === "include" || plan.mode === "offer" && includeCompanions) { + selected.push(...plan.missingCompanions || []); + } + return selected; +} async function activateInstalledStack(stackId, options = {}, dependencies = {}) { const missingSecrets = Array.isArray(options.missingSecrets) ? [...new Set(options.missingSecrets.filter(Boolean))] : []; if (missingSecrets.length > 0) { @@ -21467,36 +21641,51 @@ async function syncRelatedSkillWrappers(relatedSkills, installResults, installed function printRelatedSkillSummary(plan) { if (!plan || plan.relatedSkills.length === 0) return; console.log(` -Related skills:`); - for (const skill of plan.relatedSkills) { +Operator skill:`); + if (plan.operatorSkill) { + const status = plan.operatorSkill.installed ? "(installed)" : "(will install with stack)"; + console.log(` - ${plan.operatorSkill.id} ${status}`); + } else { + console.log(` - missing from registry metadata`); + } + if (plan.companionSkills.length === 0) return; + console.log(` +Companion skills:`); + for (const skill of plan.companionSkills) { const status = skill.installed ? "(installed)" : "(available)"; console.log(` - ${skill.id} ${status}`); } - if (plan.missing.length === 0) { - console.log(` All related skills are already installed.`); + if (plan.missingCompanions.length === 0) { + console.log(` All companion skills are already installed.`); } else if (plan.mode === "include") { - console.log(` Missing related skills will be installed after the stack.`); + console.log(` Missing companion skills will be installed after the stack.`); } else if (plan.mode === "skip") { - console.log(` Skipping related skills because --no-related-skills was set.`); + console.log(` Skipping companion skills because --no-related-skills was set.`); } else { - console.log(` Related skills are editable workflow playbooks installed into ~/.rudi/skills.`); + console.log(` Companion skills are editable workflow playbooks installed into ~/.rudi/skills.`); } } async function promptForRelatedSkills(plan) { if (!plan || plan.missing.length === 0) return []; - if (plan.mode === "include") return plan.toInstall; - if (plan.mode === "skip") return []; - if (!process.stdin.isTTY || !process.stdout.isTTY) return []; + if (plan.mode === "include" || plan.mode === "skip") { + return selectRelatedSkillsForInstall(plan); + } + if (plan.missingCompanions.length === 0) { + return selectRelatedSkillsForInstall(plan); + } + if (!process.stdin.isTTY || !process.stdout.isTTY) { + return selectRelatedSkillsForInstall(plan); + } const { createInterface: createInterface2 } = await import("node:readline/promises"); const readline3 = createInterface2({ input: process.stdin, output: process.stdout }); try { - const label = plan.missing.length === 1 ? plan.missing[0].id : `${plan.missing.length} related skills`; + const label = plan.missingCompanions.length === 1 ? plan.missingCompanions[0].id : `${plan.missingCompanions.length} companion skills`; const answer = await readline3.question(` Install ${label} now? [y/N] `); - return /^(y|yes)$/i.test(answer.trim()) ? plan.missing : []; + return selectRelatedSkillsForInstall(plan, /^(y|yes)$/i.test(answer.trim())); } finally { readline3.close(); } @@ -21526,6 +21715,40 @@ async function installRelatedSkills(skills, options = {}) { } return results; } +async function installAndSyncStackSkills(plan, options = {}) { + const { + allowScripts = false, + withShims = false, + installedAgents = getInstalledAgents() + } = options; + const selectedSkills = await promptForRelatedSkills(plan); + const installResults = selectedSkills.length > 0 ? await installRelatedSkills(selectedSkills, { allowScripts, withShims }) : []; + if (installResults.length > 0) { + console.log(` + Installed skills:`); + for (const result of installResults) { + if (result.success) { + console.log(` - ${result.id} installed`); + } else { + console.log(` - ${result.id} failed: ${result.error}`); + } + } + } + const wrapperSync = await syncRelatedSkillWrappers( + plan.relatedSkills, + installResults, + installedAgents + ); + for (const target of wrapperSync.targets) { + if (wrapperSync.errors[target]) { + console.log(` - ${target} native skill sync failed: ${wrapperSync.errors[target]}`); + console.log(` Retry with: rudi skills sync ${target}`); + } else { + console.log(` - ${target} native skill wrapper synced`); + } + } + return { selectedSkills, installResults, wrapperSync }; +} function getStackEntryPoint(stackPath, manifest) { const command = getStackCommand(manifest); if (!command || command.length === 0) { @@ -21711,6 +21934,12 @@ Package: ${resolved.name} (${resolved.id})`); console.log(`Description: ${resolved.description}`); } if (resolved.installed && !force) { + if (resolved.kind === "stack" && relatedSkillPlan.missing.length > 0) { + console.log(` +Stack already installed. Installing missing operator or companion skills.`); + await installAndSyncStackSkills(relatedSkillPlan, { allowScripts, withShims }); + return; + } console.log(` Already installed. Use --force to reinstall.`); return; @@ -21856,32 +22085,10 @@ ${depResult.error}`); console.log(` - ${id}`); } } - const selectedRelatedSkills = await promptForRelatedSkills(relatedSkillPlan); - const relatedSkillResults = selectedRelatedSkills.length > 0 ? await installRelatedSkills(selectedRelatedSkills, { allowScripts, withShims }) : []; - if (relatedSkillResults.length > 0) { - console.log(` - Related skills:`); - for (const relatedResult of relatedSkillResults) { - if (relatedResult.success) { - console.log(` - ${relatedResult.id} installed`); - } else { - console.log(` - ${relatedResult.id} failed: ${relatedResult.error}`); - } - } - } - const wrapperSync = await syncRelatedSkillWrappers( - relatedSkillPlan.relatedSkills, - relatedSkillResults, - getInstalledAgents() + const { installResults: relatedSkillResults } = await installAndSyncStackSkills( + relatedSkillPlan, + { allowScripts, withShims } ); - for (const target of wrapperSync.targets) { - if (wrapperSync.errors[target]) { - console.log(` - ${target} native skill sync failed: ${wrapperSync.errors[target]}`); - console.log(` Retry with: rudi skills sync ${target}`); - } else { - console.log(` - ${target} native skill wrapper synced`); - } - } const { found, missing } = await checkSecrets(manifest); const envExampleKeys = await parseEnvExample(result.path); for (const key of envExampleKeys) { @@ -23914,8 +24121,8 @@ function buildRudiInstructionBlock(agent = "generic") { "- Router binary: `~/.rudi/bins/rudi-router`.", "- Tool index cache: `~/.rudi/cache/tool-index.json`.", "- Installed stacks: `rudi list stacks --json`.", - "- Stack manifests may declare related skills; inspect package details with `rudi which ` when workflow behavior matters.", - "- Install a stack with its missing related skills: `rudi install --with-related-skills`.", + "- Every stack declares a primary operator skill; inspect it and any optional companions with `rudi which `.", + "- The operator is installed automatically with the stack. Add all optional companions with `rudi install --with-related-skills`.", "- Rebuild router cache: `rudi index --json`.", "- Daemon status: `rudi daemon status --json`.", "", @@ -24598,6 +24805,10 @@ Installed stacks:`); if (stack.description) { console.log(`About: ${stack.description}`); } + const operatorSkillLine = formatOperatorSkillLine(stack); + if (operatorSkillLine) { + console.log(operatorSkillLine); + } const relatedSkillsLine = formatRelatedSkillsLine(stack); if (relatedSkillsLine) { console.log(relatedSkillsLine); @@ -24627,9 +24838,9 @@ Installed stacks:`); console.log("Commands:"); console.log(` rudi run ${stack.id} Test the stack`); console.log(` rudi secrets ${stack.id} Configure secrets`); - if (getRelatedSkillIds(stack).length > 0) { + if (relatedSkillsLine) { console.log(` rudi install ${stack.id} --with-related-skills`); - console.log(` Install editable related skills`); + console.log(` Install optional companion skills`); } if (runtimeInfo.entry) { console.log(""); @@ -27622,6 +27833,7 @@ Package: ${pkgId}`); console.log(` Install Dir: ${installPath}`); const installType = manifest?.installType || (manifest?.npmPackage ? "npm" : manifest?.pipPackage ? "pip" : kind); console.log(` Install Type: ${installType}`); + printPackageLifecycle(manifest, " "); if (manifest?.source) { if (typeof manifest.source === "string") { console.log(` Source: ${manifest.source}`); diff --git a/packages/core/src/__tests__/unit/installer-state-preservation.test.js b/packages/core/src/__tests__/unit/installer-state-preservation.test.js index 56c1366..e5c5606 100644 --- a/packages/core/src/__tests__/unit/installer-state-preservation.test.js +++ b/packages/core/src/__tests__/unit/installer-state-preservation.test.js @@ -67,7 +67,10 @@ test('updatePackage migrates install-local stack state unless preservation is ex const rudiHome = path.join(root, '.rudi'); const registryRoot = path.join(root, 'registry'); const stackSource = path.join(registryRoot, 'catalog/stacks/state-demo'); + const skillSource = path.join(registryRoot, 'catalog/skills/state-demo.md'); fs.mkdirSync(stackSource, { recursive: true }); + fs.mkdirSync(path.dirname(skillSource), { recursive: true }); + fs.writeFileSync(skillSource, '# State Demo Operator\n'); fs.writeFileSync(path.join(registryRoot, 'index.json'), JSON.stringify({ schemaVersion: '2', packages: { @@ -81,6 +84,19 @@ test('updatePackage migrates install-local stack state unless preservation is ex runtime: 'node', provides: { tools: ['state_demo'] }, mcp: { transport: 'stdio', command: 'node', args: ['src/index.js'] }, + related: { + operatorSkill: 'skill:state-demo', + skills: ['skill:state-demo'], + }, + }, + 'skill:state-demo': { + id: 'skill:state-demo', + kind: 'skill', + name: 'State Demo Operator', + version: '1.0.0', + delivery: 'remote', + install: { source: 'catalog', path: 'catalog/skills/state-demo.md' }, + requires: { stacks: ['stack:state-demo'] }, }, }, }, null, 2)); @@ -94,6 +110,10 @@ test('updatePackage migrates install-local stack state unless preservation is ex runtime: 'node', provides: { tools: ['state_demo'] }, mcp: { transport: 'stdio', command: 'node', args: ['src/index.js'] }, + related: { + operatorSkill: 'skill:state-demo', + skills: ['skill:state-demo'], + }, }, null, 2)); try { diff --git a/packages/core/src/__tests__/unit/resolver-related-skills.test.js b/packages/core/src/__tests__/unit/resolver-related-skills.test.js index 1ec92d2..1abd148 100644 --- a/packages/core/src/__tests__/unit/resolver-related-skills.test.js +++ b/packages/core/src/__tests__/unit/resolver-related-skills.test.js @@ -31,7 +31,10 @@ test('resolvePackage surfaces related skills without adding them to dependency i runtime: 'node', provides: { tools: ['video_render'] }, mcp: { transport: 'stdio', command: 'node', args: ['src/index.js'] }, - related: { skills: ['skill:shortform-your-words-script'] }, + related: { + operatorSkill: 'skill:shortform-your-words-script', + skills: ['skill:shortform-your-words-script'], + }, }, 'skill:shortform-your-words-script': { id: 'skill:shortform-your-words-script', @@ -49,6 +52,7 @@ test('resolvePackage surfaces related skills without adding them to dependency i name: 'Video Editor', version: '1.0.0', related: { + operatorSkill: 'skill:shortform-your-words-script', skills: ['skill:shortform-your-words-script'] } }); @@ -63,6 +67,7 @@ test('resolvePackage surfaces related skills without adding them to dependency i kind: skill.kind, name: skill.name, installed: skill.installed, + isOperator: skill.isOperator, })), [ { @@ -70,6 +75,7 @@ test('resolvePackage surfaces related skills without adding them to dependency i kind: 'skill', name: 'Shortform Your Words Script', installed: false, + isOperator: true, }, ] ); @@ -78,6 +84,131 @@ test('resolvePackage surfaces related skills without adding them to dependency i fs.rmSync(root, { recursive: true, force: true }); }); +test('resolvePackage rejects a stack whose primary operator skill is missing', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'rudi-missing-operator-skill-')); + const registryRoot = path.join(root, 'registry'); + const rudiHome = path.join(root, '.rudi'); + + process.env.RUDI_HOME = rudiHome; + process.env.USE_LOCAL_REGISTRY = 'true'; + process.env.RUDI_REGISTRY_ROOT = registryRoot; + + writeJson(path.join(registryRoot, 'index.json'), { + schemaVersion: '2', + packages: { + 'stack:demo': { + id: 'stack:demo', + kind: 'stack', + name: 'Demo', + version: '1.0.0', + delivery: 'remote', + install: { source: 'catalog', path: 'catalog/stacks/demo' }, + runtime: 'node', + provides: { tools: ['demo_run'] }, + mcp: { transport: 'stdio', command: 'node', args: ['src/index.js'] }, + related: { skills: [] }, + }, + }, + }); + + const { resolvePackage } = await import('../../resolver.js'); + + await assert.rejects( + resolvePackage('stack:demo'), + /stack:demo requires related\.operatorSkill/ + ); + + fs.rmSync(root, { recursive: true, force: true }); +}); + +test('resolvePackage rejects an operator skill omitted from related.skills', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'rudi-unrelated-operator-skill-')); + const registryRoot = path.join(root, 'registry'); + const rudiHome = path.join(root, '.rudi'); + + process.env.RUDI_HOME = rudiHome; + process.env.USE_LOCAL_REGISTRY = 'true'; + process.env.RUDI_REGISTRY_ROOT = registryRoot; + + writeJson(path.join(registryRoot, 'index.json'), { + schemaVersion: '2', + packages: { + 'stack:demo': { + id: 'stack:demo', + kind: 'stack', + name: 'Demo', + version: '1.0.0', + delivery: 'remote', + install: { source: 'catalog', path: 'catalog/stacks/demo' }, + runtime: 'node', + provides: { tools: ['demo_run'] }, + mcp: { transport: 'stdio', command: 'node', args: ['src/index.js'] }, + related: { + operatorSkill: 'skill:demo', + skills: [], + }, + }, + 'skill:demo': { + id: 'skill:demo', + kind: 'skill', + name: 'Demo Operator', + version: '1.0.0', + delivery: 'remote', + install: { source: 'catalog', path: 'catalog/skills/demo.md' }, + }, + }, + }); + + const { resolvePackage } = await import('../../resolver.js'); + + await assert.rejects( + resolvePackage('stack:demo'), + /related\.operatorSkill must appear in related\.skills/ + ); + + fs.rmSync(root, { recursive: true, force: true }); +}); + +test('resolvePackage rejects an unknown primary operator skill package', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'rudi-unknown-operator-skill-')); + const registryRoot = path.join(root, 'registry'); + const rudiHome = path.join(root, '.rudi'); + + process.env.RUDI_HOME = rudiHome; + process.env.USE_LOCAL_REGISTRY = 'true'; + process.env.RUDI_REGISTRY_ROOT = registryRoot; + + writeJson(path.join(registryRoot, 'index.json'), { + schemaVersion: '2', + packages: { + 'stack:demo': { + id: 'stack:demo', + kind: 'stack', + name: 'Demo', + version: '1.0.0', + delivery: 'remote', + install: { source: 'catalog', path: 'catalog/stacks/demo' }, + runtime: 'node', + provides: { tools: ['demo_run'] }, + mcp: { transport: 'stdio', command: 'node', args: ['src/index.js'] }, + related: { + operatorSkill: 'skill:missing', + skills: ['skill:missing'], + }, + }, + }, + }); + + const { resolvePackage } = await import('../../resolver.js'); + + await assert.rejects( + resolvePackage('stack:demo'), + /operator skill package not found: skill:missing/ + ); + + fs.rmSync(root, { recursive: true, force: true }); +}); + test('resolvePackage installs workflow-required skills as dependencies', async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'rudi-workflow-skills-')); const registryRoot = path.join(root, 'registry'); diff --git a/packages/core/src/resolver.js b/packages/core/src/resolver.js index bbaa58a..2febb4f 100644 --- a/packages/core/src/resolver.js +++ b/packages/core/src/resolver.js @@ -106,6 +106,18 @@ function normalizeSkillPackageId(id) { async function resolveRelatedSkills(pkg) { const relatedSkillIds = pkg.related?.skills || []; + const operatorSkillId = normalizeSkillPackageId(pkg.related?.operatorSkill); + if (pkg.kind === 'stack' && !operatorSkillId) { + throw new Error(`${pkg.id || 'Stack package'} requires related.operatorSkill`); + } + const normalizedRelatedSkillIds = relatedSkillIds + .map((id) => normalizeSkillPackageId(id)) + .filter(Boolean); + if (operatorSkillId && !normalizedRelatedSkillIds.includes(operatorSkillId)) { + throw new Error( + `${pkg.id || 'Stack package'} related.operatorSkill must appear in related.skills` + ); + } const relatedSkills = []; const seen = new Set(); @@ -115,7 +127,12 @@ async function resolveRelatedSkills(pkg) { seen.add(skillId); const skillPkg = await getPackage(skillId); - if (!skillPkg) continue; + if (!skillPkg) { + if (skillId === operatorSkillId) { + throw new Error(`operator skill package not found: ${skillId}`); + } + continue; + } relatedSkills.push({ id: skillId, @@ -123,6 +140,7 @@ async function resolveRelatedSkills(pkg) { name: skillPkg.name, version: skillPkg.version, installed: isPackageInstalled(skillId), + isOperator: skillId === operatorSkillId, dependencies: [] }); } diff --git a/packages/registry-client/src/__tests__/unit/registry-contract.test.js b/packages/registry-client/src/__tests__/unit/registry-contract.test.js index afc7cac..1badcbc 100644 --- a/packages/registry-client/src/__tests__/unit/registry-contract.test.js +++ b/packages/registry-client/src/__tests__/unit/registry-contract.test.js @@ -48,6 +48,16 @@ const v2Index = { description: 'Demo package', category: 'testing', }, + lifecycle: { + maturity: 'stable', + support: 'maintenance', + deprecation: { + announcedAt: '2026-08-02', + message: 'Use stack:replacement for new installs.', + replacementId: 'stack:replacement', + removalAfter: '2026-11-01', + }, + }, }, }, }; @@ -80,13 +90,39 @@ test('registry contract: enumerates canonical v2 packages', () => { description: v2Packages[0].description, category: v2Packages[0].category, command: v2Packages[0].command, + lifecycle: v2Packages[0].lifecycle, }, { description: 'Demo package', category: 'testing', command: ['node', 'src/index.js'], + lifecycle: { + maturity: 'stable', + support: 'maintenance', + deprecation: { + announcedAt: '2026-08-02', + message: 'Use stack:replacement for new installs.', + replacementId: 'stack:replacement', + removalAfter: '2026-11-01', + }, + }, }); }); +test('registry contract: rejects invalid lifecycle metadata at the client boundary', () => { + assert.throws( + () => listRegistryPackages({ + ...v2Index, + packages: { + 'stack:demo': { + ...v2Index.packages['stack:demo'], + lifecycle: { maturity: 'beta', support: 'supported' }, + }, + }, + }, 'stack'), + /Registry package stack:demo lifecycle.maturity is invalid/ + ); +}); + test('registry contract: rejects unsupported explicit schema versions', () => { assert.throws( () => detectRegistrySchema({ schemaVersion: '3', packages: {} }), diff --git a/packages/registry-client/src/registry-contract.js b/packages/registry-client/src/registry-contract.js index ae5f4c0..3f17a15 100644 --- a/packages/registry-client/src/registry-contract.js +++ b/packages/registry-client/src/registry-contract.js @@ -71,6 +71,114 @@ function normalizeSecrets(requires) { }; } +const PACKAGE_ID_PATTERN = /^(runtime|binary|agent|stack|skill|prompt):[a-z0-9][a-z0-9-_]*$/; +const PACKAGE_MATURITY = new Set(['experimental', 'stable']); +const PACKAGE_SUPPORT = new Set(['supported', 'maintenance', 'unsupported']); + +function isCalendarDate(value) { + if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return false; + const parsed = new Date(`${value}T00:00:00.000Z`); + return !Number.isNaN(parsed.getTime()) && parsed.toISOString().slice(0, 10) === value; +} + +function normalizePackageLifecycle(value, packageId) { + if (value === undefined) return undefined; + const lifecycle = asObject(value, `Registry package ${packageId} lifecycle`); + const lifecycleKeys = new Set(['maturity', 'support', 'deprecation']); + const unknownLifecycleKey = Object.keys(lifecycle).find((key) => !lifecycleKeys.has(key)); + if (unknownLifecycleKey) { + throw new RegistryContractError( + `Registry package ${packageId} lifecycle contains unsupported field: ${unknownLifecycleKey}`, + { packageId } + ); + } + if (!PACKAGE_MATURITY.has(lifecycle.maturity)) { + throw new RegistryContractError( + `Registry package ${packageId} lifecycle.maturity is invalid`, + { packageId } + ); + } + if (!PACKAGE_SUPPORT.has(lifecycle.support)) { + throw new RegistryContractError( + `Registry package ${packageId} lifecycle.support is invalid`, + { packageId } + ); + } + + let deprecation; + if (lifecycle.deprecation !== undefined) { + deprecation = asObject( + lifecycle.deprecation, + `Registry package ${packageId} lifecycle.deprecation` + ); + const deprecationKeys = new Set([ + 'announcedAt', + 'message', + 'replacementId', + 'removalAfter', + ]); + const unknownDeprecationKey = Object.keys(deprecation) + .find((key) => !deprecationKeys.has(key)); + if (unknownDeprecationKey) { + throw new RegistryContractError( + `Registry package ${packageId} lifecycle.deprecation contains unsupported field: ${unknownDeprecationKey}`, + { packageId } + ); + } + if (!isCalendarDate(deprecation.announcedAt)) { + throw new RegistryContractError( + `Registry package ${packageId} lifecycle.deprecation.announcedAt is invalid`, + { packageId } + ); + } + if (typeof deprecation.message !== 'string' || deprecation.message.trim() === '') { + throw new RegistryContractError( + `Registry package ${packageId} lifecycle.deprecation.message is required`, + { packageId } + ); + } + if ( + deprecation.replacementId !== undefined && + !PACKAGE_ID_PATTERN.test(deprecation.replacementId) + ) { + throw new RegistryContractError( + `Registry package ${packageId} lifecycle.deprecation.replacementId is invalid`, + { packageId } + ); + } + if ( + deprecation.removalAfter !== undefined && + !isCalendarDate(deprecation.removalAfter) + ) { + throw new RegistryContractError( + `Registry package ${packageId} lifecycle.deprecation.removalAfter is invalid`, + { packageId } + ); + } + if ( + deprecation.removalAfter !== undefined && + deprecation.removalAfter < deprecation.announcedAt + ) { + throw new RegistryContractError( + `Registry package ${packageId} lifecycle.deprecation.removalAfter precedes announcedAt`, + { packageId } + ); + } + } + if (lifecycle.support === 'unsupported' && deprecation === undefined) { + throw new RegistryContractError( + `Registry package ${packageId} with unsupported lifecycle support requires deprecation guidance`, + { packageId } + ); + } + + return { + maturity: lifecycle.maturity, + support: lifecycle.support, + ...(deprecation ? { deprecation: { ...deprecation } } : {}), + }; +} + function legacyInstallType(source) { if (source === 'download') return 'binary'; if (source === 'npm' || source === 'pip' || source === 'system') return source; @@ -103,6 +211,9 @@ export function normalizeRegistryPackage(value, kindHint) { path: pkg.path || install.path, description: pkg.description || meta.description, category: pkg.category || meta.category, + ...(pkg.lifecycle === undefined + ? {} + : { lifecycle: normalizePackageLifecycle(pkg.lifecycle, pkg.id) }), tags: pkg.tags || meta.tags, icon: pkg.icon || meta.icon, author: pkg.author || meta.author, diff --git a/src/__tests__/unit/agent-host-boundaries.test.js b/src/__tests__/unit/agent-host-boundaries.test.js index a46de70..07c6884 100644 --- a/src/__tests__/unit/agent-host-boundaries.test.js +++ b/src/__tests__/unit/agent-host-boundaries.test.js @@ -1,10 +1,12 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; +import { fileURLToPath } from 'node:url'; import path from 'node:path'; import test from 'node:test'; -const AGENT_HOST_ROOT = path.resolve(import.meta.dirname, '../../agent-host'); -const SOURCE_ROOT = path.resolve(import.meta.dirname, '../..'); +const TEST_DIR = path.dirname(fileURLToPath(import.meta.url)); +const AGENT_HOST_ROOT = path.resolve(TEST_DIR, '../../agent-host'); +const SOURCE_ROOT = path.resolve(TEST_DIR, '../..'); function listJavaScriptFiles(directory) { return fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { diff --git a/src/__tests__/unit/daemon-process-smoke.test.js b/src/__tests__/unit/daemon-process-smoke.test.js index b665b1a..bf18cef 100644 --- a/src/__tests__/unit/daemon-process-smoke.test.js +++ b/src/__tests__/unit/daemon-process-smoke.test.js @@ -5,8 +5,10 @@ import os from 'node:os'; import path from 'node:path'; import { spawn } from 'node:child_process'; import test from 'node:test'; +import { fileURLToPath } from 'node:url'; -const CLI_ENTRYPOINT = path.resolve(import.meta.dirname, '../../index.js'); +const TEST_DIR = path.dirname(fileURLToPath(import.meta.url)); +const CLI_ENTRYPOINT = path.resolve(TEST_DIR, '../../index.js'); async function waitForFile(filePath, child, timeoutMs = 5_000) { const deadline = Date.now() + timeoutMs; diff --git a/src/__tests__/unit/install-related-skills.test.js b/src/__tests__/unit/install-related-skills.test.js index b6373f8..d698ba4 100644 --- a/src/__tests__/unit/install-related-skills.test.js +++ b/src/__tests__/unit/install-related-skills.test.js @@ -5,6 +5,7 @@ import { activateInstalledStack, buildRelatedSkillInstallPlan, getRelatedSkillInstallMode, + selectRelatedSkillsForInstall, syncRelatedSkillWrappers, } from '../../commands/install.js'; @@ -17,12 +18,14 @@ const resolvedStack = { kind: 'skill', name: 'Shortform Your Words Script', installed: false, + isOperator: true, }, { id: 'skill:shortform-render-qa', kind: 'skill', name: 'Shortform Render QA', - installed: true, + installed: false, + isOperator: false, }, ], }; @@ -35,18 +38,39 @@ test('getRelatedSkillInstallMode maps explicit related-skill flags', () => { assert.equal(getRelatedSkillInstallMode({}), 'offer'); }); -test('buildRelatedSkillInstallPlan only installs missing related skills when explicitly requested', () => { +test('buildRelatedSkillInstallPlan always installs the operator and gates companion skills by mode', () => { const include = buildRelatedSkillInstallPlan(resolvedStack, { 'with-related-skills': true }); - assert.deepEqual(include.missing.map((skill) => skill.id), ['skill:shortform-your-words-script']); - assert.deepEqual(include.toInstall.map((skill) => skill.id), ['skill:shortform-your-words-script']); + assert.equal(include.operatorSkill.id, 'skill:shortform-your-words-script'); + assert.deepEqual(include.missingCompanions.map((skill) => skill.id), ['skill:shortform-render-qa']); + assert.deepEqual(include.toInstall.map((skill) => skill.id), [ + 'skill:shortform-your-words-script', + 'skill:shortform-render-qa', + ]); const skip = buildRelatedSkillInstallPlan(resolvedStack, { 'no-related-skills': true }); - assert.deepEqual(skip.missing.map((skill) => skill.id), ['skill:shortform-your-words-script']); - assert.deepEqual(skip.toInstall, []); + assert.deepEqual(skip.toInstall.map((skill) => skill.id), ['skill:shortform-your-words-script']); const offer = buildRelatedSkillInstallPlan(resolvedStack, {}); assert.equal(offer.mode, 'offer'); - assert.deepEqual(offer.toInstall, []); + assert.deepEqual(offer.toInstall.map((skill) => skill.id), ['skill:shortform-your-words-script']); +}); + +test('selectRelatedSkillsForInstall keeps the operator mandatory and companions optional', () => { + const offer = buildRelatedSkillInstallPlan(resolvedStack, {}); + assert.deepEqual( + selectRelatedSkillsForInstall(offer, false).map((skill) => skill.id), + ['skill:shortform-your-words-script'] + ); + assert.deepEqual( + selectRelatedSkillsForInstall(offer, true).map((skill) => skill.id), + ['skill:shortform-your-words-script', 'skill:shortform-render-qa'] + ); + + const skip = buildRelatedSkillInstallPlan(resolvedStack, { 'no-related-skills': true }); + assert.deepEqual( + selectRelatedSkillsForInstall(skip, true).map((skill) => skill.id), + ['skill:shortform-your-words-script'] + ); }); test('activateInstalledStack indexes immediately when configured and defers when secrets are missing', async () => { diff --git a/src/__tests__/unit/instructions-command.test.js b/src/__tests__/unit/instructions-command.test.js index 4094ecb..1e955b5 100644 --- a/src/__tests__/unit/instructions-command.test.js +++ b/src/__tests__/unit/instructions-command.test.js @@ -29,7 +29,8 @@ test('buildRudiInstructionBlock emits a bounded discover-first block', () => { assert.match(block, /~\/\.rudi\/skills/); assert.match(block, /single RUDI MCP router/); assert.match(block, /rudi list stacks --json/); - assert.match(block, /Stack manifests may declare related skills/); + assert.match(block, /Every stack declares a primary operator skill/); + assert.match(block, /operator is installed automatically/); assert.match(block, /--with-related-skills/); assert.match(block, /rudi integrate codex/); assert.doesNotMatch(block, /rudi mcp --list/); diff --git a/src/__tests__/unit/package-lifecycle.test.js b/src/__tests__/unit/package-lifecycle.test.js new file mode 100644 index 0000000..05dfd81 --- /dev/null +++ b/src/__tests__/unit/package-lifecycle.test.js @@ -0,0 +1,29 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { formatPackageLifecycleLines } from '../../commands/package-lifecycle.js'; + +test('package lifecycle formatter exposes maturity, support, and deprecation guidance', () => { + assert.deepEqual(formatPackageLifecycleLines({ + id: 'stack:demo', + lifecycle: { + maturity: 'stable', + support: 'maintenance', + deprecation: { + announcedAt: '2026-08-02', + message: 'Use the replacement for new installs.', + replacementId: 'stack:replacement', + removalAfter: '2026-11-01', + }, + }, + }), [ + 'Lifecycle: stable · maintenance', + 'Deprecated since 2026-08-02: Use the replacement for new installs.', + 'Replacement: stack:replacement', + 'Removal after: 2026-11-01', + ]); +}); + +test('package lifecycle formatter treats omitted metadata as unclassified', () => { + assert.deepEqual(formatPackageLifecycleLines({ id: 'stack:demo' }), []); +}); diff --git a/src/__tests__/unit/quality-workflow-contract.test.js b/src/__tests__/unit/quality-workflow-contract.test.js index b6ca019..842ce2e 100644 --- a/src/__tests__/unit/quality-workflow-contract.test.js +++ b/src/__tests__/unit/quality-workflow-contract.test.js @@ -2,8 +2,10 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; import test from 'node:test'; +import { fileURLToPath } from 'node:url'; -const REPO_ROOT = path.resolve(import.meta.dirname, '../../..'); +const TEST_DIR = path.dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = path.resolve(TEST_DIR, '../../..'); function read(relativePath) { return fs.readFileSync(path.join(REPO_ROOT, relativePath), 'utf8'); diff --git a/src/__tests__/unit/related-skills-visibility.test.js b/src/__tests__/unit/related-skills-visibility.test.js index ab37eee..f917386 100644 --- a/src/__tests__/unit/related-skills-visibility.test.js +++ b/src/__tests__/unit/related-skills-visibility.test.js @@ -2,10 +2,28 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { + getOperatorSkillId, getRelatedSkillIds, + formatOperatorSkillLine, formatRelatedSkillsLine, } from '../../commands/related-skills.js'; +test('getOperatorSkillId normalizes the primary operator skill', () => { + assert.equal( + getOperatorSkillId({ related: { operatorSkill: 'video-editor' } }), + 'skill:video-editor' + ); + assert.equal(getOperatorSkillId({}), null); +}); + +test('formatOperatorSkillLine identifies the primary invokable workflow', () => { + assert.equal( + formatOperatorSkillLine({ related: { operatorSkill: 'skill:video-editor' } }), + 'Operator skill: skill:video-editor' + ); + assert.equal(formatOperatorSkillLine({}), null); +}); + test('getRelatedSkillIds normalizes related skill ids from stack metadata', () => { assert.deepEqual( getRelatedSkillIds({ @@ -26,5 +44,14 @@ test('formatRelatedSkillsLine returns a display line only when related skills ex }), 'Related skills: skill:shortform-your-words-script' ); + assert.equal( + formatRelatedSkillsLine({ + related: { + operatorSkill: 'skill:video-editor', + skills: ['skill:video-editor', 'skill:render-qa'], + }, + }), + 'Related skills: skill:render-qa' + ); assert.equal(formatRelatedSkillsLine({}), null); }); diff --git a/src/__tests__/unit/skills-sync.test.js b/src/__tests__/unit/skills-sync.test.js index d3ecb70..4fd77f8 100644 --- a/src/__tests__/unit/skills-sync.test.js +++ b/src/__tests__/unit/skills-sync.test.js @@ -37,7 +37,7 @@ test('buildCodexSkillFiles normalizes RUDI skill metadata for Codex', () => { ); assert.equal(files.skillName, 'grill-with-docs'); - assert.match(files.skillMd, /^name: "?Grill With Docs"?$/m); + assert.match(files.skillMd, /^name: "?grill-with-docs"?$/m); assert.match(files.skillMd, /^description: "Stress-test a plan against the existing domain model"$/m); assert.match(files.skillMd, /Ask questions one at a time\./); assert.match(files.openaiYaml, /display_name: "Grill With Docs"/); @@ -93,7 +93,7 @@ test('syncCodexSkills creates native Codex skill wrappers for RUDI skills', asyn assert.equal(result.results[0].action, 'created'); assert.equal(fs.existsSync(skillPath), true); assert.equal(fs.existsSync(openaiPath), true); - assert.match(fs.readFileSync(skillPath, 'utf-8'), /name: "?Grill With Docs"?/); + assert.match(fs.readFileSync(skillPath, 'utf-8'), /name: "?grill-with-docs"?/); } finally { fs.rmSync(root, { recursive: true, force: true }); } @@ -169,7 +169,7 @@ test('syncClaudeSkills creates native Claude skill wrappers for RUDI skills', as assert.equal(result.results[0].action, 'created'); assert.equal(fs.existsSync(skillPath), true); assert.equal(fs.existsSync(openaiPath), false); - assert.match(fs.readFileSync(skillPath, 'utf-8'), /name: "?Grill With Docs"?/); + assert.match(fs.readFileSync(skillPath, 'utf-8'), /name: "?grill-with-docs"?/); } finally { fs.rmSync(root, { recursive: true, force: true }); } @@ -303,7 +303,7 @@ test('buildClaudeSkillFiles emits a Claude SKILL.md without Codex metadata', () ); assert.equal(files.skillName, 'grill-with-docs'); - assert.match(files.skillMd, /^name: "?Grill With Docs"?$/m); + assert.match(files.skillMd, /^name: "?grill-with-docs"?$/m); assert.match(files.skillMd, /Ask questions one at a time\./); assert.equal(Object.hasOwn(files, 'openaiYaml'), false); }); diff --git a/src/commands/info.js b/src/commands/info.js index 378bc2a..4469eb4 100644 --- a/src/commands/info.js +++ b/src/commands/info.js @@ -14,6 +14,7 @@ import fs from 'fs'; import path from 'path'; import { getPackagePath, parsePackageId, PATHS } from '@learnrudi/env'; import { getShimOwner, validateShim } from '@learnrudi/core'; +import { printPackageLifecycle } from './package-lifecycle.js'; export async function cmdInfo(args, flags) { const pkgId = args[0]; @@ -58,6 +59,7 @@ export async function cmdInfo(args, flags) { const installType = manifest?.installType || (manifest?.npmPackage ? 'npm' : manifest?.pipPackage ? 'pip' : kind); console.log(` Install Type: ${installType}`); + printPackageLifecycle(manifest, ' '); // Source if (manifest?.source) { diff --git a/src/commands/install.js b/src/commands/install.js index 8e0d107..d427c6b 100644 --- a/src/commands/install.js +++ b/src/commands/install.js @@ -225,16 +225,41 @@ export function buildRelatedSkillInstallPlan(resolved, flags = {}) { const relatedSkills = Array.isArray(resolved?.relatedSkills) ? resolved.relatedSkills : []; - const missing = relatedSkills.filter((skill) => !skill.installed); + const operatorSkill = relatedSkills.find((skill) => skill.isOperator) || null; + const companionSkills = relatedSkills.filter((skill) => !skill.isOperator); + const missingOperator = operatorSkill && !operatorSkill.installed + ? [operatorSkill] + : []; + const missingCompanions = companionSkills.filter((skill) => !skill.installed); + const missing = [...missingOperator, ...missingCompanions]; return { mode, relatedSkills, + operatorSkill, + companionSkills, + missingOperator, + missingCompanions, missing, - toInstall: mode === 'include' ? missing : [], + toInstall: [ + ...missingOperator, + ...(mode === 'include' ? missingCompanions : []), + ], }; } +export function selectRelatedSkillsForInstall(plan, includeCompanions = false) { + if (!plan) return []; + const selected = [...(plan.missingOperator || [])]; + if ( + plan.mode === 'include' || + (plan.mode === 'offer' && includeCompanions) + ) { + selected.push(...(plan.missingCompanions || [])); + } + return selected; +} + export async function activateInstalledStack(stackId, options = {}, dependencies = {}) { const missingSecrets = Array.isArray(options.missingSecrets) ? [...new Set(options.missingSecrets.filter(Boolean))] @@ -309,28 +334,44 @@ export async function syncRelatedSkillWrappers( function printRelatedSkillSummary(plan) { if (!plan || plan.relatedSkills.length === 0) return; - console.log(`\nRelated skills:`); - for (const skill of plan.relatedSkills) { + console.log(`\nOperator skill:`); + if (plan.operatorSkill) { + const status = plan.operatorSkill.installed ? '(installed)' : '(will install with stack)'; + console.log(` - ${plan.operatorSkill.id} ${status}`); + } else { + console.log(` - missing from registry metadata`); + } + + if (plan.companionSkills.length === 0) return; + + console.log(`\nCompanion skills:`); + for (const skill of plan.companionSkills) { const status = skill.installed ? '(installed)' : '(available)'; console.log(` - ${skill.id} ${status}`); } - if (plan.missing.length === 0) { - console.log(` All related skills are already installed.`); + if (plan.missingCompanions.length === 0) { + console.log(` All companion skills are already installed.`); } else if (plan.mode === 'include') { - console.log(` Missing related skills will be installed after the stack.`); + console.log(` Missing companion skills will be installed after the stack.`); } else if (plan.mode === 'skip') { - console.log(` Skipping related skills because --no-related-skills was set.`); + console.log(` Skipping companion skills because --no-related-skills was set.`); } else { - console.log(` Related skills are editable workflow playbooks installed into ~/.rudi/skills.`); + console.log(` Companion skills are editable workflow playbooks installed into ~/.rudi/skills.`); } } async function promptForRelatedSkills(plan) { if (!plan || plan.missing.length === 0) return []; - if (plan.mode === 'include') return plan.toInstall; - if (plan.mode === 'skip') return []; - if (!process.stdin.isTTY || !process.stdout.isTTY) return []; + if (plan.mode === 'include' || plan.mode === 'skip') { + return selectRelatedSkillsForInstall(plan); + } + if (plan.missingCompanions.length === 0) { + return selectRelatedSkillsForInstall(plan); + } + if (!process.stdin.isTTY || !process.stdout.isTTY) { + return selectRelatedSkillsForInstall(plan); + } const { createInterface } = await import('node:readline/promises'); const readline = createInterface({ @@ -339,9 +380,11 @@ async function promptForRelatedSkills(plan) { }); try { - const label = plan.missing.length === 1 ? plan.missing[0].id : `${plan.missing.length} related skills`; + const label = plan.missingCompanions.length === 1 + ? plan.missingCompanions[0].id + : `${plan.missingCompanions.length} companion skills`; const answer = await readline.question(`\nInstall ${label} now? [y/N] `); - return /^(y|yes)$/i.test(answer.trim()) ? plan.missing : []; + return selectRelatedSkillsForInstall(plan, /^(y|yes)$/i.test(answer.trim())); } finally { readline.close(); } @@ -376,6 +419,45 @@ async function installRelatedSkills(skills, options = {}) { return results; } +async function installAndSyncStackSkills(plan, options = {}) { + const { + allowScripts = false, + withShims = false, + installedAgents = getInstalledAgents(), + } = options; + const selectedSkills = await promptForRelatedSkills(plan); + const installResults = selectedSkills.length > 0 + ? await installRelatedSkills(selectedSkills, { allowScripts, withShims }) + : []; + + if (installResults.length > 0) { + console.log(`\n Installed skills:`); + for (const result of installResults) { + if (result.success) { + console.log(` - ${result.id} installed`); + } else { + console.log(` - ${result.id} failed: ${result.error}`); + } + } + } + + const wrapperSync = await syncRelatedSkillWrappers( + plan.relatedSkills, + installResults, + installedAgents + ); + for (const target of wrapperSync.targets) { + if (wrapperSync.errors[target]) { + console.log(` - ${target} native skill sync failed: ${wrapperSync.errors[target]}`); + console.log(` Retry with: rudi skills sync ${target}`); + } else { + console.log(` - ${target} native skill wrapper synced`); + } + } + + return { selectedSkills, installResults, wrapperSync }; +} + /** * Find a stack entry point from its command * @returns {{ entryArg: string|null, entryPath: string|null, error?: string }} @@ -615,6 +697,11 @@ export async function cmdInstall(args, flags) { } if (resolved.installed && !force) { + if (resolved.kind === 'stack' && relatedSkillPlan.missing.length > 0) { + console.log(`\nStack already installed. Installing missing operator or companion skills.`); + await installAndSyncStackSkills(relatedSkillPlan, { allowScripts, withShims }); + return; + } console.log(`\nAlready installed. Use --force to reinstall.`); return; } @@ -779,35 +866,10 @@ export async function cmdInstall(args, flags) { } } - const selectedRelatedSkills = await promptForRelatedSkills(relatedSkillPlan); - const relatedSkillResults = selectedRelatedSkills.length > 0 - ? await installRelatedSkills(selectedRelatedSkills, { allowScripts, withShims }) - : []; - - if (relatedSkillResults.length > 0) { - console.log(`\n Related skills:`); - for (const relatedResult of relatedSkillResults) { - if (relatedResult.success) { - console.log(` - ${relatedResult.id} installed`); - } else { - console.log(` - ${relatedResult.id} failed: ${relatedResult.error}`); - } - } - } - - const wrapperSync = await syncRelatedSkillWrappers( - relatedSkillPlan.relatedSkills, - relatedSkillResults, - getInstalledAgents() + const { installResults: relatedSkillResults } = await installAndSyncStackSkills( + relatedSkillPlan, + { allowScripts, withShims } ); - for (const target of wrapperSync.targets) { - if (wrapperSync.errors[target]) { - console.log(` - ${target} native skill sync failed: ${wrapperSync.errors[target]}`); - console.log(` Retry with: rudi skills sync ${target}`); - } else { - console.log(` - ${target} native skill wrapper synced`); - } - } // Check secrets status const { found, missing } = await checkSecrets(manifest); diff --git a/src/commands/instructions.js b/src/commands/instructions.js index bd78e6d..d2554ac 100644 --- a/src/commands/instructions.js +++ b/src/commands/instructions.js @@ -75,8 +75,8 @@ export function buildRudiInstructionBlock(agent = 'generic') { '- Router binary: `~/.rudi/bins/rudi-router`.', '- Tool index cache: `~/.rudi/cache/tool-index.json`.', '- Installed stacks: `rudi list stacks --json`.', - '- Stack manifests may declare related skills; inspect package details with `rudi which ` when workflow behavior matters.', - '- Install a stack with its missing related skills: `rudi install --with-related-skills`.', + '- Every stack declares a primary operator skill; inspect it and any optional companions with `rudi which `.', + '- The operator is installed automatically with the stack. Add all optional companions with `rudi install --with-related-skills`.', '- Rebuild router cache: `rudi index --json`.', '- Daemon status: `rudi daemon status --json`.', '', diff --git a/src/commands/list.js b/src/commands/list.js index 8618d49..7950c26 100644 --- a/src/commands/list.js +++ b/src/commands/list.js @@ -12,7 +12,8 @@ import { listInstalled } from '@learnrudi/core'; import { detectAllMcpServers, getInstalledAgents, getMcpServerSummary, AGENT_CONFIGS } from '@learnrudi/mcp'; -import { formatRelatedSkillsLine } from './related-skills.js'; +import { formatOperatorSkillLine, formatRelatedSkillsLine } from './related-skills.js'; +import { printPackageLifecycle } from './package-lifecycle.js'; function pluralizeKind(kind) { if (!kind) return 'packages'; @@ -183,6 +184,7 @@ export async function cmdList(args, flags) { if (pkg.description) { console.log(` ${pkg.description}`); } + printPackageLifecycle(pkg, ' '); if (pkg.requires && pkg.requires.stacks && pkg.requires.stacks.length > 0) { console.log(` Requires: ${pkg.requires.stacks.join(', ')}`); } @@ -223,12 +225,17 @@ export async function cmdList(args, flags) { if (pkg.description) { console.log(` ${pkg.description}`); } + printPackageLifecycle(pkg, ' '); if (pkg.category) { console.log(` Category: ${pkg.category}`); } if (pkg.tags && pkg.tags.length > 0) { console.log(` Tags: ${pkg.tags.join(', ')}`); } + const operatorSkillLine = formatOperatorSkillLine(pkg); + if (operatorSkillLine) { + console.log(` ${operatorSkillLine}`); + } const relatedSkillsLine = formatRelatedSkillsLine(pkg); if (relatedSkillsLine) { console.log(` ${relatedSkillsLine}`); diff --git a/src/commands/package-lifecycle.js b/src/commands/package-lifecycle.js new file mode 100644 index 0000000..3756db6 --- /dev/null +++ b/src/commands/package-lifecycle.js @@ -0,0 +1,23 @@ +export function formatPackageLifecycleLines(pkg) { + const lifecycle = pkg?.lifecycle; + if (!lifecycle) return []; + + const lines = [`Lifecycle: ${lifecycle.maturity} · ${lifecycle.support}`]; + const deprecation = lifecycle.deprecation; + if (!deprecation) return lines; + + lines.push(`Deprecated since ${deprecation.announcedAt}: ${deprecation.message}`); + if (deprecation.replacementId) { + lines.push(`Replacement: ${deprecation.replacementId}`); + } + if (deprecation.removalAfter) { + lines.push(`Removal after: ${deprecation.removalAfter}`); + } + return lines; +} + +export function printPackageLifecycle(pkg, indent = '') { + for (const line of formatPackageLifecycleLines(pkg)) { + console.log(`${indent}${line}`); + } +} diff --git a/src/commands/related-skills.js b/src/commands/related-skills.js index 37d9733..f07c808 100644 --- a/src/commands/related-skills.js +++ b/src/commands/related-skills.js @@ -1,20 +1,34 @@ +function normalizeSkillId(value) { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + if (!trimmed) return null; + return trimmed.startsWith('skill:') + ? trimmed + : trimmed.startsWith('prompt:') + ? trimmed.replace(/^prompt:/, 'skill:') + : trimmed.includes(':') + ? null + : `skill:${trimmed}`; +} + +export function getOperatorSkillId(pkg) { + return normalizeSkillId(pkg?.related?.operatorSkill); +} + +export function formatOperatorSkillLine(pkg, options = {}) { + const { label = 'Operator skill' } = options; + const id = getOperatorSkillId(pkg); + if (!id) return null; + return `${label}: ${id}`; +} + export function getRelatedSkillIds(pkg) { const skills = Array.isArray(pkg?.related?.skills) ? pkg.related.skills : []; const ids = []; const seen = new Set(); for (const value of skills) { - if (typeof value !== 'string') continue; - const trimmed = value.trim(); - if (!trimmed) continue; - - const id = trimmed.startsWith('skill:') - ? trimmed - : trimmed.startsWith('prompt:') - ? trimmed.replace(/^prompt:/, 'skill:') - : trimmed.includes(':') - ? null - : `skill:${trimmed}`; + const id = normalizeSkillId(value); if (!id || seen.has(id)) continue; seen.add(id); @@ -26,7 +40,8 @@ export function getRelatedSkillIds(pkg) { export function formatRelatedSkillsLine(pkg, options = {}) { const { label = 'Related skills' } = options; - const ids = getRelatedSkillIds(pkg); + const operatorSkill = getOperatorSkillId(pkg); + const ids = getRelatedSkillIds(pkg).filter((id) => id !== operatorSkill); if (ids.length === 0) return null; return `${label}: ${ids.join(', ')}`; } diff --git a/src/commands/search.js b/src/commands/search.js index ed34ccb..0f21a95 100644 --- a/src/commands/search.js +++ b/src/commands/search.js @@ -3,6 +3,7 @@ */ import { fetchIndex, searchPackages, listPackages } from '@learnrudi/core'; +import { printPackageLifecycle } from './package-lifecycle.js'; function pluralizeKind(kind) { if (!kind) return 'packages'; @@ -103,6 +104,7 @@ export async function cmdSearch(args, flags) { if (pkg.version) { console.log(` v${pkg.version}`); } + printPackageLifecycle(pkg, ' '); console.log(); } } @@ -170,6 +172,7 @@ async function listAllPackages(flags) { const runtime = pkg.runtime ? ` [${pkg.runtime.replace('runtime:', '')}]` : ''; console.log(` ${id}${runtime}`); console.log(` ${pkg.description || 'No description'}`); + printPackageLifecycle(pkg, ' '); } } diff --git a/src/commands/skills.js b/src/commands/skills.js index 508e278..29bd782 100644 --- a/src/commands/skills.js +++ b/src/commands/skills.js @@ -185,7 +185,7 @@ export function buildClaudeSkillFiles(pkg, sourceContent) { const skillMd = [ '---', - `name: ${yamlString(displayName)}`, + `name: ${yamlString(skillName)}`, `description: ${yamlString(description)}`, '---', '', diff --git a/src/commands/which.js b/src/commands/which.js index 7fa91d2..6586b6f 100644 --- a/src/commands/which.js +++ b/src/commands/which.js @@ -11,7 +11,7 @@ import * as fs from 'fs/promises'; import * as path from 'path'; import { listInstalled } from '@learnrudi/core'; import { PATHS } from '@learnrudi/env'; -import { formatRelatedSkillsLine, getRelatedSkillIds } from './related-skills.js'; +import { formatOperatorSkillLine, formatRelatedSkillsLine } from './related-skills.js'; import { runCommand as defaultRunCommand } from '../utils/subprocess.js'; export async function cmdWhich(args, flags) { @@ -69,6 +69,10 @@ export async function cmdWhich(args, flags) { if (stack.description) { console.log(`About: ${stack.description}`); } + const operatorSkillLine = formatOperatorSkillLine(stack); + if (operatorSkillLine) { + console.log(operatorSkillLine); + } const relatedSkillsLine = formatRelatedSkillsLine(stack); if (relatedSkillsLine) { console.log(relatedSkillsLine); @@ -105,9 +109,9 @@ export async function cmdWhich(args, flags) { console.log('Commands:'); console.log(` rudi run ${stack.id} Test the stack`); console.log(` rudi secrets ${stack.id} Configure secrets`); - if (getRelatedSkillIds(stack).length > 0) { + if (relatedSkillsLine) { console.log(` rudi install ${stack.id} --with-related-skills`); - console.log(` Install editable related skills`); + console.log(` Install optional companion skills`); } if (runtimeInfo.entry) { console.log('');