From 75520c11a9a09a585192409e3a61452c812949b3 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste THERY Date: Mon, 13 Jul 2026 13:39:05 +0700 Subject: [PATCH] feat(core): harden native agent integration (#96) --- README.md | 5 +- docs/agent-integration.md | 15 +- docs/api-reference.md | 5 +- docs/cli-reference.md | 4 +- packages/ragmir-core/README.md | 3 +- packages/ragmir-core/src/cli.ts | 40 +- packages/ragmir-core/src/doctor.test.ts | 16 +- packages/ragmir-core/src/doctor.ts | 23 +- packages/ragmir-core/src/gitignore.ts | 8 +- packages/ragmir-core/src/index.ts | 3 + .../ragmir-core/src/package-manager.test.ts | 16 +- packages/ragmir-core/src/package-manager.ts | 20 +- packages/ragmir-core/src/setup.test.ts | 27 +- packages/ragmir-core/src/setup.ts | 21 +- packages/ragmir-core/src/skill.test.ts | 62 ++- packages/ragmir-core/src/skill.ts | 370 +++++++++++++++++- packages/ragmir-core/src/types.ts | 2 + scripts/smoke.mjs | 8 +- 18 files changed, 586 insertions(+), 62 deletions(-) diff --git a/README.md b/README.md index 4a5e07d..44505ac 100644 --- a/README.md +++ b/README.md @@ -76,8 +76,9 @@ pnpm exec rgr setup --agents claude,codex,kimi,opencode,cline pnpm exec rgr doctor ``` -Setup writes local helper files for the selected agents. The MCP surface is intentionally bounded -and read-focused. Agents can request compact evidence first, then expand one returned citation +Setup links skills into the selected agents' native project folders and writes local MCP helpers +backed by a generated project runner. The MCP surface is intentionally bounded and read-focused. +Agents can request compact evidence first, then expand one returned citation without opening a second index or reading arbitrary files. MCP clients can read `ragmir://context` for a compact base, readiness, freshness, and capability overview before choosing a tool. diff --git a/docs/agent-integration.md b/docs/agent-integration.md index d756d1c..5782155 100644 --- a/docs/agent-integration.md +++ b/docs/agent-integration.md @@ -6,7 +6,9 @@ Ragmir gives agents cited local passages through one stdio MCP server. Prepare t rgr setup --agents claude,codex,kimi,opencode,cline ``` -The generated files live under ignored `.ragmir/`. They reference `rgr serve-mcp` in the target repository, never a hosted document service. +The canonical files live under ignored `.ragmir/`. Setup also links the selected skills into each +agent's native project directory and generates a local `.ragmir/run.cjs` MCP runner. The runner uses +the installed project binary first, then the current package installation, with a pinned npm fallback. ## Native helpers @@ -18,13 +20,17 @@ The generated files live under ignored `.ragmir/`. They reference `rgr serve-mcp | OpenCode | `.ragmir/opencode.jsonc` | | Cline | `.ragmir/cline-mcp.json` | -Install native skill discovery when the agent supports it: +Setup installs project-scoped native skill discovery by default. Re-run installation when you want a +different scope or copy mode: ```bash rgr install-agent --agents codex,claude ``` -Use `--scope user` only when you intentionally want a user-wide installation. Project scope is the default. `--mode copy` is a fallback for filesystems that cannot follow symlinks. +Use `--scope user` only when you intentionally want a user-wide installation. Project scope is the +default. Codex skills use `.agents/skills/` for both project and user discovery. `--mode copy` is a +fallback for filesystems that cannot follow symlinks. Ragmir refuses to overwrite an unmanaged +same-name skill unless you explicitly pass `--force` after reviewing it. ## Monorepo bases @@ -76,4 +82,7 @@ rgr bases --json rgr search "known phrase" --compact ``` +Doctor reports runner verification, native agents discovered, and integration warnings separately +from retrieval readiness. + If the client cannot set a working directory, launch the server with `RAGMIR_PROJECT_ROOT=/absolute/path/to/project`. diff --git a/docs/api-reference.md b/docs/api-reference.md index 3e45a14..2cdb750 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -80,10 +80,11 @@ evaluate one heading or structured-data branch. | Export | Use | | --- | --- | | `serveMcp(cwd?)` | Start the local stdio MCP server. | -| `installSkill(options?)` / `installAgentSkills(options?)` | Install bundled helper files. | +| `installSkill(options?)` / `installAgentSkills(options?)` | Install the canonical kit and ownership-safe native skill links. | +| `inspectAgentIntegration(cwd?)` | Verify the local runner and native skill discovery targets. | | `parseAgentTargets(value)` / `SUPPORTED_AGENT_TARGETS` | Validate supported agent targets. | | `detectPackageManager(cwd?)` | Detect the target project package manager. | -| `rgrCommand(cwd, args)` | Build the canonical local `rgr` command. | +| `rgrCommand(cwd, args)` | Prefer the generated project runner, then build the package-manager command. | `kbCommand` and `ragmirCommand` remain compatibility helpers. New integration code should use `rgrCommand` and the `rgr` CLI name. diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 1646f31..2941ca8 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -89,7 +89,9 @@ rgr usage-report --days 30 rgr destroy-index --yes ``` -- `install-skill` and `install-agent` install the bundled project helpers. +- `setup` installs canonical skills, native project links, a local runner, and selected MCP helpers. +- `install-skill` refreshes only the canonical kit; `install-agent` changes native scope or link mode. +- `install-agent --force` replaces a conflicting same-name skill only when explicitly requested. - `serve-mcp` starts the local stdio MCP server. - `route-prompt` classifies whether a prompt should use Ragmir without storing it. - `evaluate` measures retrieval against a local golden-query file. diff --git a/packages/ragmir-core/README.md b/packages/ragmir-core/README.md index 79e12f7..2260252 100644 --- a/packages/ragmir-core/README.md +++ b/packages/ragmir-core/README.md @@ -110,7 +110,8 @@ npx rgr setup --agents claude,codex,kimi,opencode,cline npx rgr doctor ``` -Ragmir writes helper files for the selected clients and points them at the current project. MCP +Ragmir links the canonical skills into each selected client's native project folder, writes a local +runner plus MCP helpers, and points them at the current project. MCP exposes status, search, ask, research, exact citation expansion, audit, evaluation, usage, and security tools. Retrieval responses have a global byte ceiling and expose metadata-only output metrics. The server does not expose index deletion. diff --git a/packages/ragmir-core/src/cli.ts b/packages/ragmir-core/src/cli.ts index 32db6fa..461c2e7 100644 --- a/packages/ragmir-core/src/cli.ts +++ b/packages/ragmir-core/src/cli.ts @@ -225,7 +225,9 @@ program program .command("setup") - .description("Initialize Ragmir, install the agent kit, run doctor, and ingest when safe.") + .description( + "Initialize Ragmir, expose native agent skills, generate MCP helpers, and ingest when safe.", + ) .option( "--target-dir ", "Directory where the skill folder should be copied.", @@ -233,7 +235,7 @@ program ) .option( "--agents ", - `Agent MCP helpers to generate: all, ${SUPPORTED_AGENT_TARGETS.join(", ")}.`, + `Native agent skills and MCP helpers to install: all, ${SUPPORTED_AGENT_TARGETS.join(", ")}.`, "all", ) .option( @@ -252,6 +254,10 @@ program "Download the configured Transformers.js embedding model and enable higher-quality semantic retrieval.", ) .option("--no-ingest", "Skip automatic indexing even when supported files are present.") + .option( + "--force-agent-skills", + "Replace same-name native skills after reviewing that they can be overwritten.", + ) .option("--json", "Print machine-readable JSON.") .action( async ( @@ -263,6 +269,7 @@ program mcpArg: string[] semantic?: boolean ingest?: boolean + forceAgentSkills?: boolean json?: boolean }, command: Command, @@ -277,6 +284,7 @@ program addOption(setupOptions, "semantic", options.semantic) addOption(setupOptions, "ingest", options.ingest) addOption(setupOptions, "mcpCommand", options.mcpCommand) + addOption(setupOptions, "forceAgentSkills", options.forceAgentSkills) if (options.mcpArg.length > 0) { setupOptions.mcpArgs = options.mcpArg } @@ -1327,17 +1335,27 @@ program ) .option("--scope ", "Install scope: project or user.", "project") .option("--mode ", "Expose skills as links or physical copies: link or copy.", "link") + .option( + "--force", + "Replace same-name native skills after reviewing that they can be overwritten.", + ) .option("--json", "Print machine-readable JSON.") .action( async ( - options: { agents: string; scope: string; mode: string; json?: boolean }, + options: { agents: string; scope: string; mode: string; force?: boolean; json?: boolean }, command: Command, ) => { const cwd = projectRoot(command) const scope = parseAgentInstallScope(options.scope) const mode = parseAgentInstallMode(options.mode) const agents = parseAgentTargets(options.agents) - const result = await installAgentSkills({ cwd, agents, scope, mode }) + const result = await installAgentSkills({ + cwd, + agents, + scope, + mode, + force: options.force ?? false, + }) if (options.json) { console.log(JSON.stringify(result, null, 2)) return @@ -1651,6 +1669,15 @@ function printDoctor(report: Awaited>): void { console.log(`packageManager=${report.packageManager}`) console.log(`runCommand=${report.runCommand}`) console.log(`agentKitInstalled=${report.agentKitInstalled}`) + console.log(`agentIntegration.ready=${report.agentIntegration.ready}`) + console.log(`agentIntegration.runnerReady=${report.agentIntegration.runnerReady}`) + console.log(`agentIntegration.runnerMode=${report.agentIntegration.runnerMode ?? "none"}`) + console.log(`agentIntegration.projectAgents=${report.agentIntegration.projectAgents.join(",")}`) + console.log(`agentIntegration.userAgents=${report.agentIntegration.userAgents.join(",")}`) + console.log(`agentIntegration.nativeAgents=${report.agentIntegration.nativeAgents.join(",")}`) + for (const warning of report.agentIntegration.warnings) { + console.log(pc.yellow(`agentIntegration.warning: ${warning}`)) + } console.log(`embeddingProvider=${report.embeddingProvider}`) console.log(`transformersAllowRemoteModels=${report.transformersAllowRemoteModels}`) console.log(`redactionEnabled=${report.redactionEnabled}`) @@ -1808,6 +1835,11 @@ function printSetup(result: Awaited>, title: str console.log(` - ${helper.label} MCP helper: ${helper.path}`) } console.log(` - agent setup guide: ${result.agentKit.agentSetupPath}`) + for (const installation of result.agentInstallations) { + console.log( + ` - ${installation.label} skills: ${installation.targetDir} (${installation.mode})`, + ) + } console.log("") if (result.semantic) { console.log(pc.cyan("Semantic retrieval:")) diff --git a/packages/ragmir-core/src/doctor.test.ts b/packages/ragmir-core/src/doctor.test.ts index 8b6a9ff..448f592 100644 --- a/packages/ragmir-core/src/doctor.test.ts +++ b/packages/ragmir-core/src/doctor.test.ts @@ -5,7 +5,7 @@ import { afterEach, describe, expect, it } from "vitest" import { doctor } from "./doctor.js" import { ingest } from "./ingest.js" import { initProject } from "./init.js" -import { installSkill } from "./skill.js" +import { installAgentSkills, installSkill } from "./skill.js" const tempDirs: string[] = [] @@ -25,6 +25,7 @@ describe("doctor", () => { expect(uninitialized.ready).toBe(false) expect(uninitialized.packageManager).toBe("pnpm") expect(uninitialized.agentKitInstalled).toBe(false) + expect(uninitialized.agentIntegration.ready).toBe(false) expect(uninitialized.nextSteps).toEqual([ "Run `pnpm exec rgr setup` to initialize Ragmir and install the agent kit.", ]) @@ -80,7 +81,18 @@ describe("doctor", () => { await installSkill({ cwd: root }) - expect((await doctor(root)).agentKitInstalled).toBe(true) + const kitOnly = await doctor(root) + expect(kitOnly.agentKitInstalled).toBe(true) + expect(["installed-package", "npm-cache"]).toContain(kitOnly.agentIntegration.runnerMode) + expect(kitOnly.agentIntegration.projectAgents).toEqual([]) + expect(kitOnly.agentIntegration.ready).toBe( + kitOnly.agentIntegration.runnerReady && kitOnly.agentIntegration.nativeAgents.length > 0, + ) + + await installAgentSkills({ cwd: root, agents: ["codex"] }) + const integrated = await doctor(root) + expect(integrated.agentIntegration.projectAgents).toContain("codex") + expect(integrated.agentIntegration.ready).toBe(integrated.agentIntegration.runnerReady) }) it("does not report complete coverage when a supported file yields no text", async () => { diff --git a/packages/ragmir-core/src/doctor.ts b/packages/ragmir-core/src/doctor.ts index f0e2324..5077682 100644 --- a/packages/ragmir-core/src/doctor.ts +++ b/packages/ragmir-core/src/doctor.ts @@ -5,11 +5,12 @@ import { RAGMIR_DIR } from "./defaults.js" import { countSkippedByReason } from "./files.js" import { getIndexFreshnessWarning, getLexicalScanWarning } from "./index-diagnostics.js" import { audit } from "./ingest.js" -import { rgrCommand } from "./package-manager.js" +import { RGR_RUNNER_FILENAME, rgrCommand } from "./package-manager.js" import { securityAudit } from "./security.js" import { AGENT_HELPER_CONFIG_FILENAMES, AGENT_SETUP_FILENAME, + inspectAgentIntegration, MCP_CONFIG_FILENAME, SKILL_NAMES, } from "./skill.js" @@ -22,6 +23,7 @@ export async function doctor(cwd = process.cwd()): Promise { const config = await loadConfig(cwd) const command = await rgrCommand(config.projectRoot, []) const agentKitInstalled = isAgentKitInstalled(config.projectRoot) + const agentIntegration = inspectAgentIntegration(config.projectRoot) const [auditReport, securityReport, chunksIndexed, manifest, freshnessWarning] = await Promise.all([ audit(config.projectRoot), @@ -63,6 +65,8 @@ export async function doctor(cwd = process.cwd()): Promise { warnings: securityReport.warnings.length, embeddingProvider: config.embeddingProvider, agentKitInstalled, + agentRunnerReady: agentIntegration.runnerReady, + nativeAgentCount: agentIntegration.nativeAgents.length, freshnessWarning, lexicalScanWarning, run: (args) => command.display + (args.length > 0 ? ` ${args.join(" ")}` : ""), @@ -74,6 +78,7 @@ export async function doctor(cwd = process.cwd()): Promise { packageManager: command.packageManager, runCommand: command.display, agentKitInstalled, + agentIntegration, rawDir: config.rawDir, storageDir: config.storageDir, embeddingProvider: config.embeddingProvider, @@ -126,6 +131,8 @@ interface NextActionInput { warnings: number embeddingProvider: string agentKitInstalled: boolean + agentRunnerReady: boolean + nativeAgentCount: number freshnessWarning: string | null lexicalScanWarning: string | null run: (args: string[]) => string @@ -205,13 +212,22 @@ function nextActions(input: NextActionInput): string[] { steps.push( `Run \`${input.run(["research", '"your topic"'])}\` for audit-backed multi-query evidence.`, ) - if (input.agentKitInstalled) { + if (input.agentKitInstalled && input.nativeAgentCount > 0) { steps.push( - "Run `rgr install-agent --agents claude` or another targeted agent list for native skill discovery.", + "Restart or reload the selected agents so they discover the installed Ragmir skills.", ) + if (!input.agentRunnerReady) { + steps.push( + "Install @jcode.labs/ragmir in this project or rebuild the workspace package, then rerun `rgr doctor` to verify the local runner.", + ) + } steps.push( "Wire the matching MCP helper from .ragmir/ when the agent should call Ragmir tools directly.", ) + } else if (input.agentKitInstalled) { + steps.push( + "Run `rgr install-agent --agents claude` or another targeted agent list, then rerun `rgr doctor`.", + ) } else { steps.push( `Run \`${input.run(["install-skill"])}\` if an AI agent should use the local knowledge base.`, @@ -226,6 +242,7 @@ function isAgentKitInstalled(projectRoot: string): boolean { const ragmirDir = path.join(projectRoot, RAGMIR_DIR) const requiredPaths = [ ...SKILL_NAMES.map((skillName) => path.join(ragmirDir, "skills", skillName, "SKILL.md")), + path.join(ragmirDir, RGR_RUNNER_FILENAME), path.join(ragmirDir, MCP_CONFIG_FILENAME), path.join(ragmirDir, AGENT_SETUP_FILENAME), ] diff --git a/packages/ragmir-core/src/gitignore.ts b/packages/ragmir-core/src/gitignore.ts index bd27623..8d4df22 100644 --- a/packages/ragmir-core/src/gitignore.ts +++ b/packages/ragmir-core/src/gitignore.ts @@ -5,7 +5,10 @@ import { RAGMIR_GITIGNORE_ENTRY } from "./defaults.js" export const RAGMIR_GITIGNORE_ENTRIES = [RAGMIR_GITIGNORE_ENTRY] -export async function ensureRagmirGitignore(cwd = process.cwd()): Promise { +export async function ensureRagmirGitignore( + cwd = process.cwd(), + additionalEntries: readonly string[] = [], +): Promise { const root = path.resolve(cwd) const gitignorePath = path.join(root, ".gitignore") const current = existsSync(gitignorePath) ? await readFile(gitignorePath, "utf8") : "" @@ -15,7 +18,8 @@ export async function ensureRagmirGitignore(cwd = process.cwd()): Promise line.trim()) .filter(Boolean), ) - const missingEntries = RAGMIR_GITIGNORE_ENTRIES.filter((entry) => !currentLines.has(entry)) + const desiredEntries = [...new Set([...RAGMIR_GITIGNORE_ENTRIES, ...additionalEntries])] + const missingEntries = desiredEntries.filter((entry) => !currentLines.has(entry)) if (missingEntries.length === 0) { return false diff --git a/packages/ragmir-core/src/index.ts b/packages/ragmir-core/src/index.ts index f41a988..f43a086 100644 --- a/packages/ragmir-core/src/index.ts +++ b/packages/ragmir-core/src/index.ts @@ -44,15 +44,18 @@ export type { AgentHelperFile, AgentInstallMode, AgentInstallScope, + AgentIntegrationReport, AgentSkillInstallation, AgentTarget, InstallAgentSkillsOptions, InstallAgentSkillsResult, InstallSkillOptions, InstallSkillResult, + RagmirRunnerMode, } from "./skill.js" export { bundledSkillPath, + inspectAgentIntegration, installAgentSkills, installSkill, parseAgentTargets, diff --git a/packages/ragmir-core/src/package-manager.test.ts b/packages/ragmir-core/src/package-manager.test.ts index 31ebb60..3a49d39 100644 --- a/packages/ragmir-core/src/package-manager.test.ts +++ b/packages/ragmir-core/src/package-manager.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, rm, writeFile } from "node:fs/promises" +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises" import os from "node:os" import path from "node:path" import { afterEach, describe, expect, it } from "vitest" @@ -50,6 +50,20 @@ describe("package manager detection", () => { expect(await detectPackageManager(yarnRoot)).toBe("yarn") }) + it("prefers the generated project runner when it exists", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "ragmir-pm-")) + tempDirs.push(root) + const runnerPath = path.join(root, ".ragmir", "run.cjs") + await mkdir(path.dirname(runnerPath), { recursive: true }) + await writeFile(runnerPath, "", "utf8") + + await expect(rgrCommand(root, ["doctor"])).resolves.toMatchObject({ + command: "node", + args: [runnerPath, "doctor"], + display: "node .ragmir/run.cjs doctor", + }) + }) + it("keeps existing command helpers as compatibility aliases", () => { expect(ragmirCommand).toBe(rgrCommand) expect(kbCommand).toBe(rgrCommand) diff --git a/packages/ragmir-core/src/package-manager.ts b/packages/ragmir-core/src/package-manager.ts index 9cf40af..d636076 100644 --- a/packages/ragmir-core/src/package-manager.ts +++ b/packages/ragmir-core/src/package-manager.ts @@ -4,6 +4,8 @@ import path from "node:path" export type PackageManager = "pnpm" | "npm" | "yarn" | "bun" const RGR_CLI_BIN = "rgr" +export const RGR_RUNNER_FILENAME = "run.cjs" +export const RGR_RUNNER_PROBE_ARG = "--ragmir-runner-probe" export interface RagmirCommand { packageManager: PackageManager @@ -37,7 +39,17 @@ export async function detectPackageManager(cwd = process.cwd()): Promise { - const packageManager = await detectPackageManager(cwd) + const root = path.resolve(cwd) + const packageManager = await detectPackageManager(root) + const runnerPath = path.join(root, ".ragmir", RGR_RUNNER_FILENAME) + if (existsSync(runnerPath)) { + return { + packageManager, + command: "node", + args: [runnerPath, ...args], + display: displayRunnerCommand(root, runnerPath, args), + } + } const commandArgs = commandArgsFor(packageManager, args) return { packageManager, @@ -47,6 +59,12 @@ export async function rgrCommand(cwd: string, args: string[]): Promise { expect(result.configurationPrompt).toContain("You are helping configure Ragmir") expect(result.configurationPrompt).toContain("rgr sources add") expect(result.configurationPrompt).toContain("Do not add every locale by default") - expect(mcpConfig.mcpServers.ragmir.command).toBe("pnpm") - expect(mcpConfig.mcpServers.ragmir.args).toEqual(["exec", "rgr", "serve-mcp"]) + expect(mcpConfig.mcpServers.ragmir.command).toBe("node") + expect(mcpConfig.mcpServers.ragmir.args).toEqual([result.agentKit.runnerPath, "serve-mcp"]) + expect(result.agentInstallations.map((installation) => installation.agent)).toEqual([ + "claude", + "codex", + "kimi", + "opencode", + "cline", + ]) + expect(existsSync(path.join(root, ".agents", "skills", "ragmir", "SKILL.md"))).toBe(true) + expect(result.doctor.agentIntegration.ready).toBe(result.doctor.agentIntegration.runnerReady) + expect(["installed-package", "npm-cache"]).toContain(result.doctor.agentIntegration.runnerMode) + expect(result.doctor.agentIntegration.projectAgents).toEqual([ + "claude", + "codex", + "kimi", + "opencode", + "cline", + ]) }) it("can preload and enable semantic embeddings during setup", async () => { @@ -86,10 +103,10 @@ describe("setupProject", () => { expect(second.ingested?.indexedFiles).toBe(1) expect(second.doctor.ready).toBe(true) expect(second.doctor.nextSteps).toContain( - "Run `rgr install-agent --agents claude` or another targeted agent list for native skill discovery.", + "Restart or reload the selected agents so they discover the installed Ragmir skills.", ) expect(second.nextSteps).toContain( - "Run `rgr install-agent --agents claude` or another targeted agent list for native skill discovery.", + "Restart or reload the selected agents so they discover the installed Ragmir skills.", ) }) @@ -111,6 +128,8 @@ describe("setupProject", () => { expect(mcpConfig.mcpServers["local-docs"]?.command).toBe("./scripts/serve-mcp.sh") expect(mcpConfig.mcpServers["local-docs"]?.args).toEqual([]) expect(result.agentKit.agentHelpers.map((helper) => helper.agent)).toEqual(["claude"]) + expect(result.agentInstallations.map((installation) => installation.agent)).toEqual(["claude"]) + expect(existsSync(path.join(root, ".claude", "skills", "ragmir", "SKILL.md"))).toBe(true) expect(existsSync(path.join(root, ".ragmir", "codex-mcp.toml"))).toBe(false) expect(existsSync(path.join(root, ".ragmir", "kimi-mcp.json"))).toBe(false) }) diff --git a/packages/ragmir-core/src/setup.ts b/packages/ragmir-core/src/setup.ts index 0ea836e..b197198 100644 --- a/packages/ragmir-core/src/setup.ts +++ b/packages/ragmir-core/src/setup.ts @@ -6,7 +6,13 @@ import { ingest } from "./ingest.js" import { initProject } from "./init.js" import { type PackageManager, rgrCommand } from "./package-manager.js" import { type EnableSemanticEmbeddingsResult, enableSemanticEmbeddings } from "./semantic-config.js" -import { type AgentTarget, type InstallSkillResult, installSkill } from "./skill.js" +import { + type AgentSkillInstallation, + type AgentTarget, + type InstallAgentSkillsOptions, + type InstallSkillResult, + installAgentSkills, +} from "./skill.js" import type { DoctorReport, IngestResult } from "./types.js" export interface SetupOptions { @@ -18,6 +24,7 @@ export interface SetupOptions { mcpServerName?: string mcpCommand?: string mcpArgs?: readonly string[] + forceAgentSkills?: boolean } export interface SetupSemanticResult { @@ -31,6 +38,7 @@ export interface SetupResult { runCommand: string created: string[] agentKit: InstallSkillResult + agentInstallations: AgentSkillInstallation[] semantic: SetupSemanticResult | null ingested: IngestResult | null doctor: DoctorReport @@ -56,7 +64,7 @@ Keep all proposed paths relative to the repository root. Do not add secrets or p export async function setupProject(options: SetupOptions = {}): Promise { const cwd = path.resolve(options.cwd ?? process.cwd()) const created = await initProject(cwd) - const installOptions: Parameters[0] = { cwd } + const installOptions: InstallAgentSkillsOptions = { cwd, scope: "project", mode: "link" } if (options.targetDir !== undefined) { installOptions.targetDir = options.targetDir } @@ -72,7 +80,11 @@ export async function setupProject(options: SetupOptions = {}): Promise { expect(audioSkill).toContain("name: ragmir-audio-summary") expect(reportSkill).toContain("name: ragmir-markdown-report") expect(legalSkill).toContain("name: ragmir-legal-dossier") - expect(mcpConfig.mcpServers.ragmir.command).toBe("pnpm") - expect(mcpConfig.mcpServers.ragmir.args).toEqual(["exec", "rgr", "serve-mcp"]) + expect(mcpConfig.mcpServers.ragmir.command).toBe("node") + expect(mcpConfig.mcpServers.ragmir.args).toEqual([result.runnerPath, "serve-mcp"]) expect(mcpConfig.mcpServers.ragmir.cwd).toBe(root) expect(mcpConfig.mcpServers.ragmir.env.RAGMIR_PROJECT_ROOT).toBe(root) expect(claudeConfig).toEqual({ type: "stdio", - command: "pnpm", - args: ["exec", "rgr", "serve-mcp"], + command: "node", + args: [result.runnerPath, "serve-mcp"], env: { RAGMIR_PROJECT_ROOT: root }, }) expect(codexConfig).toContain("[mcp_servers.ragmir]") - expect(codexConfig).toContain('command = "pnpm"') - expect(codexConfig).toContain('args = ["exec", "rgr", "serve-mcp"]') + expect(codexConfig).toContain('command = "node"') + expect(codexConfig).toContain(`args = [${JSON.stringify(result.runnerPath)}, "serve-mcp"]`) expect(codexConfig).toContain(`cwd = ${JSON.stringify(root)}`) expect(codexConfig).toContain("[[skills.config]]") expect(codexConfig).toContain(path.join(root, ".ragmir", "skills", "ragmir")) @@ -82,7 +82,7 @@ describe("installSkill", () => { expect(kimiConfig.mcpServers.ragmir.env.RAGMIR_PROJECT_ROOT).toBe(root) expect(opencodeConfig.mcp.ragmir).toEqual({ type: "local", - command: ["pnpm", "exec", "rgr", "serve-mcp"], + command: ["node", result.runnerPath, "serve-mcp"], enabled: true, environment: { RAGMIR_PROJECT_ROOT: root }, }) @@ -92,6 +92,8 @@ describe("installSkill", () => { expect(agentSetup).toContain("Kimi Code CLI") expect(agentSetup).toContain("OpenCode") expect(agentSetup).toContain("Cline") + expect(agentSetup).toContain(".agents/skills/") + expect(await readFile(result.runnerPath, "utf8")).toContain("@jcode.labs/ragmir@") }) it("should generate unique rooted MCP helpers for a nested monorepo base", async () => { @@ -135,6 +137,7 @@ describe("installSkill", () => { expect(first.written).toContain(path.join(".ragmir", "skills", "ragmir-audio-summary")) expect(first.written).toContain(path.join(".ragmir", "skills", "ragmir-markdown-report")) expect(first.written).toContain(path.join(".ragmir", "skills", "ragmir-legal-dossier")) + expect(first.written).toContain(path.join(".ragmir", "run.cjs")) expect(first.written).toContain(path.join(".ragmir", "claude-mcp-server.json")) expect(first.written).toContain(path.join(".ragmir", "codex-mcp.toml")) expect(first.written).toContain(path.join(".ragmir", "kimi-mcp.json")) @@ -147,7 +150,7 @@ describe("installSkill", () => { expect(gitignore).not.toContain("!private/") }) - it("uses the target repository package manager in generated MCP config", async () => { + it("uses the generated local runner independently of the target package manager", async () => { const root = await mkdtemp(path.join(os.tmpdir(), "ragmir-skill-")) tempDirs.push(root) await writeFile(path.join(root, "package-lock.json"), "{}\n", "utf8") @@ -159,11 +162,11 @@ describe("installSkill", () => { const codexConfig = await readFile(result.codexConfigPath, "utf8") const readme = await readFile(result.readmePath, "utf8") - expect(mcpConfig.mcpServers.ragmir.command).toBe("npx") - expect(mcpConfig.mcpServers.ragmir.args).toEqual(["rgr", "serve-mcp"]) - expect(codexConfig).toContain('command = "npx"') - expect(codexConfig).toContain('args = ["rgr", "serve-mcp"]') - expect(readme).toContain("npx rgr serve-mcp") + expect(mcpConfig.mcpServers.ragmir.command).toBe("node") + expect(mcpConfig.mcpServers.ragmir.args).toEqual([result.runnerPath, "serve-mcp"]) + expect(codexConfig).toContain('command = "node"') + expect(codexConfig).toContain(`args = [${JSON.stringify(result.runnerPath)}, "serve-mcp"]`) + expect(readme).toContain("node .ragmir/run.cjs serve-mcp") }) it("can generate selected agent helpers with a custom MCP command", async () => { @@ -228,28 +231,40 @@ describe("installAgentSkills", () => { const result = await installAgentSkills({ cwd: root, - agents: parseAgentTargets("claude,kimi"), + agents: parseAgentTargets("claude,codex,kimi"), scope: "project", }) expect(result.installations.map((installation) => installation.agent)).toEqual([ "claude", + "codex", "kimi", ]) - expect(result.installations.map((installation) => installation.mode)).toEqual(["link", "link"]) + expect(result.installations.map((installation) => installation.mode)).toEqual([ + "link", + "link", + "link", + ]) const claudeSkillDir = path.join(root, ".claude", "skills", "ragmir") + const codexSkillDir = path.join(root, ".agents", "skills", "ragmir") const kimiSkillDir = path.join(root, ".kimi", "skills", "ragmir") expect(existsSync(path.join(claudeSkillDir, "SKILL.md"))).toBe(true) + expect(existsSync(path.join(codexSkillDir, "SKILL.md"))).toBe(true) expect(existsSync(path.join(kimiSkillDir, "SKILL.md"))).toBe(true) expect((await lstat(claudeSkillDir)).isSymbolicLink()).toBe(true) + expect((await lstat(codexSkillDir)).isSymbolicLink()).toBe(true) expect((await lstat(kimiSkillDir)).isSymbolicLink()).toBe(true) const canonicalSkillDir = await realpath(path.join(root, ".ragmir", "skills", "ragmir")) expect(await realpath(claudeSkillDir)).toBe(canonicalSkillDir) + expect(await realpath(codexSkillDir)).toBe(canonicalSkillDir) expect(await realpath(kimiSkillDir)).toBe(canonicalSkillDir) expect(existsSync(path.join(root, ".codex", "skills", "ragmir", "SKILL.md"))).toBe(false) expect(result.written).toContain(path.join(".claude", "skills", "ragmir")) expect(result.written).toContain(path.join(".kimi", "skills", "ragmir-markdown-report")) expect(result.written).toContain(path.join(".kimi", "skills", "ragmir-legal-dossier")) + const gitignore = await readFile(path.join(root, ".gitignore"), "utf8") + expect(gitignore).toContain(".agents/skills/ragmir") + expect(gitignore).toContain(".claude/skills/ragmir") }) it("can copy skills when symlinks are not wanted", async () => { @@ -267,6 +282,23 @@ describe("installAgentSkills", () => { expect(result.installations[0]?.mode).toBe("copy") expect(existsSync(path.join(clineSkillDir, "SKILL.md"))).toBe(true) expect((await lstat(clineSkillDir)).isSymbolicLink()).toBe(false) + expect(existsSync(path.join(clineSkillDir, ".ragmir-managed.json"))).toBe(true) + }) + + it("refuses to replace unmanaged skills unless force is explicit", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "ragmir-agent-")) + tempDirs.push(root) + const customSkillDir = path.join(root, ".claude", "skills", "ragmir") + await mkdir(customSkillDir, { recursive: true }) + await writeFile(path.join(customSkillDir, "SKILL.md"), "custom skill\n", "utf8") + + await expect( + installAgentSkills({ cwd: root, agents: ["claude"], scope: "project" }), + ).rejects.toThrow("Refusing to replace unmanaged agent skill") + expect(await readFile(path.join(customSkillDir, "SKILL.md"), "utf8")).toBe("custom skill\n") + + await installAgentSkills({ cwd: root, agents: ["claude"], scope: "project", force: true }) + expect((await lstat(customSkillDir)).isSymbolicLink()).toBe(true) }) it("uses user-scope directories and environment overrides", async () => { diff --git a/packages/ragmir-core/src/skill.ts b/packages/ragmir-core/src/skill.ts index 412a8e2..b8c71b1 100644 --- a/packages/ragmir-core/src/skill.ts +++ b/packages/ragmir-core/src/skill.ts @@ -1,10 +1,18 @@ -import { cp, mkdir, rm, symlink, writeFile } from "node:fs/promises" +import { spawnSync } from "node:child_process" +import { existsSync } from "node:fs" +import { cp, lstat, mkdir, readFile, realpath, rm, symlink, writeFile } from "node:fs/promises" import path from "node:path" import { fileURLToPath } from "node:url" import { DEFAULT_SKILL_TARGET_DIR, RAGMIR_DIR, RAGMIR_PROJECT_ROOT_ENV } from "./defaults.js" import { ensureRagmirGitignore } from "./gitignore.js" import { knowledgeBaseIdentity } from "./knowledge-bases.js" -import { type RagmirCommand, rgrCommand } from "./package-manager.js" +import { + type RagmirCommand, + RGR_RUNNER_FILENAME, + RGR_RUNNER_PROBE_ARG, + rgrCommand, +} from "./package-manager.js" +import { VERSION } from "./version.js" export type AgentTarget = "claude" | "codex" | "kimi" | "opencode" | "cline" export type AgentInstallScope = "project" | "user" @@ -32,6 +40,7 @@ export interface InstallSkillResult { clineConfigPath: string agentSetupPath: string readmePath: string + runnerPath: string agentHelpers: AgentHelperFile[] mcpServerName: string mcpCommand: string @@ -39,13 +48,12 @@ export interface InstallSkillResult { written: string[] } -export interface InstallAgentSkillsOptions { - cwd?: string - agents?: readonly AgentTarget[] +export interface InstallAgentSkillsOptions extends InstallSkillOptions { scope?: AgentInstallScope mode?: AgentInstallMode homeDir?: string env?: Record + force?: boolean } export interface AgentSkillInstallation { @@ -69,6 +77,20 @@ export interface AgentHelperFile { path: string } +export type RagmirRunnerMode = "local-bin" | "workspace" | "installed-package" | "npm-cache" + +export interface AgentIntegrationReport { + runnerPath: string + runnerReady: boolean + runnerMode: RagmirRunnerMode | null + runnerRequiresDownload: boolean + projectAgents: AgentTarget[] + userAgents: AgentTarget[] + nativeAgents: AgentTarget[] + ready: boolean + warnings: string[] +} + const PACKAGE_ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url))) const PRIMARY_SKILL_NAME = "ragmir" const AUDIO_SKILL_NAME = "ragmir-audio-summary" @@ -76,6 +98,7 @@ const REPORT_SKILL_NAME = "ragmir-markdown-report" const LEGAL_SKILL_NAME = "ragmir-legal-dossier" const DEFAULT_MCP_SERVER_NAME = "ragmir" const MCP_SERVER_NAME_PATTERN = /^[A-Za-z0-9_-]+$/u +const MANAGED_SKILL_METADATA_FILENAME = ".ragmir-managed.json" export const SKILL_NAMES = [ PRIMARY_SKILL_NAME, AUDIO_SKILL_NAME, @@ -131,8 +154,8 @@ const AGENT_DESTINATIONS: Record< codex: { label: "Codex", env: "CODEX_SKILLS_DIR", - projectDir: path.join(".codex", "skills"), - userDir: (homeDir) => path.join(homeDir, ".codex", "skills"), + projectDir: path.join(".agents", "skills"), + userDir: (homeDir) => path.join(homeDir, ".agents", "skills"), }, kimi: { label: "Kimi Code CLI", @@ -213,10 +236,16 @@ export async function installSkill(options: InstallSkillOptions = {}): Promise path.relative(cwd, helper.path)), path.relative(cwd, agentSetupPath), @@ -328,6 +358,7 @@ export async function installSkill(options: InstallSkillOptions = {}): Promise displayPath(cwd, skillPath))) installations.push({ @@ -368,6 +409,18 @@ export async function installAgentSkills( }) } + if (scope === "project") { + const gitignoreEntries = installations.flatMap((installation) => + installation.skillPaths.map((skillPath) => posixRelativePath(cwd, skillPath)), + ) + if (await ensureRagmirGitignore(cwd, gitignoreEntries)) { + if (!projectKit.written.includes(".gitignore")) { + projectKit.written.push(".gitignore") + } + written.push(".gitignore") + } + } + return { projectKit, installations, @@ -518,27 +571,29 @@ async function exposeAgentSkills( sourceDir: string, targetDir: string, requestedMode: AgentInstallMode, + force: boolean, ): Promise<{ mode: AgentInstallMode; skillPaths: string[] }> { if (requestedMode === "copy") { - return copyAgentSkills(sourceDir, targetDir) + return copyAgentSkills(sourceDir, targetDir, force) } try { - return await linkAgentSkills(sourceDir, targetDir) + return await linkAgentSkills(sourceDir, targetDir, force) } catch { - return copyAgentSkills(sourceDir, targetDir) + return copyAgentSkills(sourceDir, targetDir, force) } } async function linkAgentSkills( sourceDir: string, targetDir: string, + force: boolean, ): Promise<{ mode: AgentInstallMode; skillPaths: string[] }> { const skillPaths: string[] = [] for (const skillName of SKILL_NAMES) { const source = path.join(sourceDir, skillName) const target = path.join(targetDir, skillName) - await replaceWithDirectorySymlink(source, target) + await replaceWithDirectorySymlink(source, target, skillName, force) skillPaths.push(target) } return { mode: "link", skillPaths } @@ -547,26 +602,98 @@ async function linkAgentSkills( async function copyAgentSkills( sourceDir: string, targetDir: string, + force: boolean, ): Promise<{ mode: AgentInstallMode; skillPaths: string[] }> { const skillPaths: string[] = [] for (const skillName of SKILL_NAMES) { const source = path.join(sourceDir, skillName) const target = path.join(targetDir, skillName) + await assertManagedSkillTarget(source, target, skillName, force) await rm(target, { recursive: true, force: true }) await cp(source, target, { recursive: true, force: true }) + await writeFile( + path.join(target, MANAGED_SKILL_METADATA_FILENAME), + `${JSON.stringify({ managedBy: "ragmir", skillName }, null, 2)}\n`, + "utf8", + ) skillPaths.push(target) } return { mode: "copy", skillPaths } } -async function replaceWithDirectorySymlink(source: string, target: string): Promise { +async function replaceWithDirectorySymlink( + source: string, + target: string, + skillName: string, + force: boolean, +): Promise { if (path.resolve(source) === path.resolve(target)) { return } + await assertManagedSkillTarget(source, target, skillName, force) await rm(target, { recursive: true, force: true }) await symlink(source, target, process.platform === "win32" ? "junction" : "dir") } +async function assertManagedSkillTarget( + source: string, + target: string, + skillName: string, + force: boolean, +): Promise { + let targetStats: Awaited> + try { + targetStats = await lstat(target) + } catch (error) { + if (hasErrorCode(error, "ENOENT")) return + throw error + } + + if (force) return + + if (targetStats.isSymbolicLink()) { + try { + if ((await realpath(target)) === (await realpath(source))) return + } catch { + // A broken or unreadable link is not safe to replace implicitly. + } + } else if (targetStats.isDirectory()) { + const metadata = await readManagedSkillMetadata(target) + if (metadata?.managedBy === "ragmir" && metadata.skillName === skillName) return + } + + throw new Error( + `Refusing to replace unmanaged agent skill at ${target}. Move it, or rerun with --force after reviewing its contents.`, + ) +} + +async function readManagedSkillMetadata( + target: string, +): Promise<{ managedBy: string; skillName: string } | null> { + try { + const value: unknown = JSON.parse( + await readFile(path.join(target, MANAGED_SKILL_METADATA_FILENAME), "utf8"), + ) + if ( + typeof value === "object" && + value !== null && + "managedBy" in value && + typeof value.managedBy === "string" && + "skillName" in value && + typeof value.skillName === "string" + ) { + return { managedBy: value.managedBy, skillName: value.skillName } + } + } catch { + return null + } + return null +} + +function hasErrorCode(error: unknown, code: string): boolean { + return typeof error === "object" && error !== null && "code" in error && error.code === code +} + function agentTargetDir( agent: AgentTarget, scope: AgentInstallScope, @@ -603,6 +730,216 @@ function displayPath(cwd: string, filePath: string): string { return filePath } +function posixRelativePath(cwd: string, filePath: string): string { + return path.relative(cwd, filePath).split(path.sep).join("/") +} + +export function inspectAgentIntegration( + cwd = process.cwd(), + homeDir = process.env.HOME ?? process.cwd(), + env: Record = process.env, +): AgentIntegrationReport { + const projectRoot = path.resolve(cwd) + const resolvedHome = path.resolve(homeDir) + const runnerPath = path.join(projectRoot, RAGMIR_DIR, RGR_RUNNER_FILENAME) + const probe = probeRagmirRunner(projectRoot, runnerPath) + const projectAgents = detectedAgentTargets("project", projectRoot, resolvedHome, env) + const userAgents = detectedAgentTargets("user", projectRoot, resolvedHome, env) + const nativeAgents = [...new Set([...projectAgents, ...userAgents])] + const warnings: string[] = [] + + if (!existsSync(runnerPath)) { + warnings.push("The generated Ragmir runner is missing. Run `rgr setup` or `rgr doctor --fix`.") + } else if (!probe.runnerReady) { + warnings.push( + "The generated Ragmir runner could not verify a local CLI. Install @jcode.labs/ragmir in the project or rebuild the workspace package.", + ) + } + if (probe.runnerRequiresDownload) { + warnings.push( + "The runner will fall back to the pinned npm package and may need a network download before first use.", + ) + } + if (nativeAgents.length === 0) { + warnings.push( + "No native agent skill exposure was detected. Run `rgr install-agent --agents `.", + ) + } + + return { + runnerPath, + runnerReady: probe.runnerReady, + runnerMode: probe.runnerMode, + runnerRequiresDownload: probe.runnerRequiresDownload, + projectAgents, + userAgents, + nativeAgents, + ready: probe.runnerReady && nativeAgents.length > 0, + warnings, + } +} + +function detectedAgentTargets( + scope: AgentInstallScope, + projectRoot: string, + homeDir: string, + env: Record, +): AgentTarget[] { + return SUPPORTED_AGENT_TARGETS.filter((agent) => { + const targetDir = agentTargetDir(agent, scope, projectRoot, homeDir, env) + return SKILL_NAMES.every((skillName) => existsSync(path.join(targetDir, skillName, "SKILL.md"))) + }) +} + +function probeRagmirRunner( + projectRoot: string, + runnerPath: string, +): Pick { + if (!existsSync(runnerPath)) { + return { runnerReady: false, runnerMode: null, runnerRequiresDownload: false } + } + + const result = spawnSync(process.execPath, [runnerPath, RGR_RUNNER_PROBE_ARG], { + cwd: projectRoot, + encoding: "utf8", + timeout: 5_000, + env: { ...process.env, [RAGMIR_PROJECT_ROOT_ENV]: projectRoot }, + }) + if (result.status !== 0) { + return { runnerReady: false, runnerMode: null, runnerRequiresDownload: false } + } + + try { + const value: unknown = JSON.parse(result.stdout.trim()) + if ( + typeof value === "object" && + value !== null && + "verified" in value && + typeof value.verified === "boolean" && + "mode" in value && + isRagmirRunnerMode(value.mode) && + "requiresDownload" in value && + typeof value.requiresDownload === "boolean" + ) { + return { + runnerReady: value.verified, + runnerMode: value.mode, + runnerRequiresDownload: value.requiresDownload, + } + } + } catch { + return { runnerReady: false, runnerMode: null, runnerRequiresDownload: false } + } + + return { runnerReady: false, runnerMode: null, runnerRequiresDownload: false } +} + +function isRagmirRunnerMode(value: unknown): value is RagmirRunnerMode { + return ( + value === "local-bin" || + value === "workspace" || + value === "installed-package" || + value === "npm-cache" + ) +} + +function ragmirRunnerSource(version: string, installedCliPath: string): string { + const packageSpec = `@jcode.labs/ragmir@${version}` + return `#!/usr/bin/env node +const { spawnSync } = require("node:child_process") +const { existsSync, readFileSync } = require("node:fs") +const path = require("node:path") + +const PACKAGE_SPEC = ${JSON.stringify(packageSpec)} +const INSTALLED_CLI = ${JSON.stringify(installedCliPath)} +const PROBE_ARG = ${JSON.stringify(RGR_RUNNER_PROBE_ARG)} +const projectRoot = path.resolve(process.env.${RAGMIR_PROJECT_ROOT_ENV} || process.cwd()) + +function isRagmirWorkspaceCli(cliPath) { + if (!existsSync(cliPath)) return false + try { + const manifestPath = path.join(path.dirname(path.dirname(cliPath)), "package.json") + const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) + return manifest.name === "@jcode.labs/ragmir" + } catch { + return false + } +} + +function resolveCommand() { + const localBin = path.join( + projectRoot, + "node_modules", + ".bin", + process.platform === "win32" ? "rgr.cmd" : "rgr", + ) + if (existsSync(localBin)) { + return { mode: "local-bin", command: localBin, args: [], requiresDownload: false } + } + + const workspaceCli = path.join(projectRoot, "packages", "ragmir-core", "dist", "cli.js") + if (isRagmirWorkspaceCli(workspaceCli)) { + return { + mode: "workspace", + command: process.execPath, + args: [workspaceCli], + requiresDownload: false, + } + } + + if (isRagmirWorkspaceCli(INSTALLED_CLI)) { + return { + mode: "installed-package", + command: process.execPath, + args: [INSTALLED_CLI], + requiresDownload: false, + } + } + + return { + mode: "npm-cache", + command: process.platform === "win32" ? "npx.cmd" : "npx", + args: ["--yes", "--package", PACKAGE_SPEC, "rgr"], + requiresDownload: true, + } +} + +const selected = resolveCommand() +const commandArgs = process.argv.slice(2) + +if (commandArgs[0] === PROBE_ARG) { + const probeArgs = selected.mode === "npm-cache" ? ["--version"] : [...selected.args, "--version"] + const probe = spawnSync(selected.command, probeArgs, { + cwd: projectRoot, + stdio: "ignore", + env: { ...process.env, ${RAGMIR_PROJECT_ROOT_ENV}: projectRoot }, + }) + const available = probe.status === 0 + console.log( + JSON.stringify({ + available, + verified: available && selected.mode !== "npm-cache", + mode: selected.mode, + requiresDownload: selected.requiresDownload, + }), + ) + process.exitCode = available ? 0 : 1 +} else { + const result = spawnSync(selected.command, [...selected.args, ...commandArgs], { + cwd: projectRoot, + stdio: "inherit", + env: { ...process.env, ${RAGMIR_PROJECT_ROOT_ENV}: projectRoot }, + }) + if (result.error) { + console.error(result.error.message) + process.exitCode = 1 + } else { + process.exitCode = result.status ?? 1 + } +} +` +} + function mcpConfig( cwd: string, serveCommand: McpCommand, @@ -955,7 +1292,7 @@ Default project-scope targets: | Agent | Project skill directory | User skill directory | | --- | --- | --- | | Claude Code | \`.claude/skills/\` | \`~/.claude/skills/\` | -| Codex | \`.codex/skills/\` | \`~/.codex/skills/\` | +| Codex | \`.agents/skills/\` | \`~/.agents/skills/\` | | Kimi Code CLI | \`.kimi/skills/\` | \`~/.kimi/skills/\` | | OpenCode | \`.opencode/skills/\` | \`~/.config/opencode/skills/\` | | Cline | \`.cline/skills/\` | \`~/.cline/skills/\` | @@ -964,7 +1301,8 @@ Override paths with \`CLAUDE_SKILLS_DIR\`, \`CODEX_SKILLS_DIR\`, \`KIMI_SKILLS_D \`OPENCODE_SKILLS_DIR\`, or \`CLINE_SKILLS_DIR\`. Use \`--mode copy\` only when an agent runtime does not follow symlinked skill directories. When using -copy mode, rerun \`install-agent\` after refreshing \`.ragmir/skills/\`. +copy mode, rerun \`install-agent\` after refreshing \`.ragmir/skills/\`. Ragmir refuses to replace +an unmanaged same-name skill by default; use \`--force\` only after reviewing that existing folder. ## Skill Folders diff --git a/packages/ragmir-core/src/types.ts b/packages/ragmir-core/src/types.ts index b8ecad7..cfa9d68 100644 --- a/packages/ragmir-core/src/types.ts +++ b/packages/ragmir-core/src/types.ts @@ -1,5 +1,6 @@ import type { PathLike } from "node:fs" import type { PackageManager } from "./package-manager.js" +import type { AgentIntegrationReport } from "./skill.js" export interface Config { projectRoot: string @@ -606,6 +607,7 @@ export interface DoctorReport { packageManager: PackageManager runCommand: string agentKitInstalled: boolean + agentIntegration: AgentIntegrationReport rawDir: string storageDir: string embeddingProvider: EmbeddingProvider diff --git a/scripts/smoke.mjs b/scripts/smoke.mjs index 12d08e0..34423fd 100644 --- a/scripts/smoke.mjs +++ b/scripts/smoke.mjs @@ -181,6 +181,11 @@ try { "agentKitInstalled=true", "setup should leave the agent kit installed", ) + assertIncludes( + initialDoctor.stdout, + "agentIntegration.ready=true", + "setup should expose native skills through a verified local runner", + ) const ocrDoctorJson = parseJson( (await runKb(["ocr", "doctor", "--json"], tempRoot)).stdout, @@ -199,7 +204,8 @@ try { } const mcpConfig = await readFile(path.join(tempRoot, ".ragmir", "mcp.json"), "utf8") - assertIncludes(mcpConfig, '"command": "pnpm"', "default generated MCP config should use pnpm") + assertIncludes(mcpConfig, '"command": "node"', "default MCP config should use the local runner") + assertIncludes(mcpConfig, ".ragmir/run.cjs", "default MCP config should pin the generated runner") assertIncludes( mcpConfig, '"serve-mcp"',