diff --git a/README.md b/README.md index a681450..bc8a456 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,8 @@ Linux, macOS, and Windows License: AGPL-3.0 Node.js 22.12+ + npm downloads + GitHub stars

@@ -31,6 +33,12 @@ Code, Codex, or your own application queries it through MCP, the `rgr` CLI, or a and every passage comes back with a citation the agent can open and check. Your agent plans, reasons, and writes with the model you choose; Ragmir finds the evidence. +```bash +npm install -D @jcode.labs/ragmir +npx rgr setup --agents claude,codex # index the project, connect your agents +npx rgr search "how are permissions checked?" --compact +``` + | Without Ragmir | With Ragmir | | --- | --- | | Code search misses the specs, spreadsheets, and scanned PDFs around the code. | One local index for code and documents, with optional local OCR. | @@ -282,6 +290,14 @@ then run `rgr ingest`. Monorepos can keep separate bases, selected with `rgr bas - [Troubleshooting](./docs/troubleshooting.md) - [Synthetic examples](./packages/ragmir-core/examples/sovereign-rag-demo/README.md) +## Community + +Ask questions, propose ideas, and report bugs in +[GitHub Issues](https://github.com/jcode-works/jcode-ragmir/issues); report vulnerabilities +privately through [SECURITY](./SECURITY.md). Follow releases on +[GitHub](https://github.com/jcode-works/jcode-ragmir/releases), and if Ragmir saves your agents +time, [star the repository](https://github.com/jcode-works/jcode-ragmir) so other teams find it. + ## Contributing Issues and pull requests are welcome; read [CONTRIBUTING](./CONTRIBUTING.md) and the diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 1bf8aec..6c4da57 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -99,8 +99,10 @@ rgr hooks uninstall Each hook gets one marked block that runs `node .ragmir/run.cjs ingest` in the background from the knowledge base folder; existing hook content is kept, and a final `exit` stays last. Incremental ingestion only re-embeds changed files. `hooks install` resolves `core.hooksPath` and linked -worktrees, and refuses a hooks folder tracked in the working tree (for example `.husky/`) unless -`--force` is given. Hooks need Git; on Windows they run in Git's bundled shell. +worktrees. With husky, it edits the `.husky/` scripts that husky runs (`manager=husky`) rather +than the `.husky/_` shims it regenerates; commit those scripts to share the refresh. Another hooks +folder tracked in the working tree (for example `.githooks/`) needs `--force`. Hooks need Git; on +Windows they run in Git's bundled shell. ## Safe upgrades diff --git a/packages/ragmir-core/README.md b/packages/ragmir-core/README.md index 33f700d..cf2a1c7 100644 --- a/packages/ragmir-core/README.md +++ b/packages/ragmir-core/README.md @@ -1,11 +1,23 @@ -# @jcode.labs/ragmir - -[![npm version](https://img.shields.io/npm/v/@jcode.labs/ragmir?color=cb3837&logo=npm)](https://www.npmjs.com/package/@jcode.labs/ragmir) -[![CI](https://github.com/jcode-works/jcode-ragmir/actions/workflows/ci.yml/badge.svg)](https://github.com/jcode-works/jcode-ragmir/actions/workflows/ci.yml) -[![License: AGPL-3.0](https://img.shields.io/badge/license-AGPL--3.0-2f9e44)](https://github.com/jcode-works/jcode-ragmir/blob/main/LICENSE) -[![Node.js 22.12+](https://img.shields.io/badge/node-%E2%89%A522.12-339933?logo=node.js&logoColor=white)](https://nodejs.org) - -**Cited project context for coding agents. Local, fast, and offline by default.** +

+ + Ragmir: local retrieval with exact citations for coding agents + +

+ +

@jcode.labs/ragmir

+ +

+ Cited project context for coding agents. Local, fast, and offline by default. +

+ +

+ npm version + npm downloads + CI + License: AGPL-3.0 + Node.js 22.12+ + GitHub stars +

Ragmir indexes the code, specifications, and office documents you choose, then returns precise passages with checkable citations through a TypeScript library, the `rgr` CLI, or a local MCP @@ -143,9 +155,11 @@ Read the [full documentation](https://github.com/jcode-works/jcode-ragmir#readme [CLI reference](https://github.com/jcode-works/jcode-ragmir/blob/main/docs/cli-reference.md), [migration guide](https://github.com/jcode-works/jcode-ragmir/blob/main/docs/migration.md), and [troubleshooting guide](https://github.com/jcode-works/jcode-ragmir/blob/main/docs/troubleshooting.md). -Issues and contributions are welcome through -[GitHub](https://github.com/jcode-works/jcode-ragmir) and -[CONTRIBUTING](https://github.com/jcode-works/jcode-ragmir/blob/main/CONTRIBUTING.md). +Ask questions and report bugs in +[GitHub Issues](https://github.com/jcode-works/jcode-ragmir/issues); contributions are welcome +through [CONTRIBUTING](https://github.com/jcode-works/jcode-ragmir/blob/main/CONTRIBUTING.md). If +Ragmir saves your agents time, [star the repository](https://github.com/jcode-works/jcode-ragmir) +so other teams find it. Ragmir is open source under [AGPL-3.0-only](https://github.com/jcode-works/jcode-ragmir/blob/main/LICENSE). JCode Works also offers a [commercial license](https://github.com/jcode-works/jcode-ragmir/blob/main/COMMERCIAL-LICENSE.md). diff --git a/packages/ragmir-core/src/cli.ts b/packages/ragmir-core/src/cli.ts index bb190e0..31e9c54 100644 --- a/packages/ragmir-core/src/cli.ts +++ b/packages/ragmir-core/src/cli.ts @@ -1268,7 +1268,10 @@ const hooksCommand = program hooksCommand .command("install") .description("Add a background `rgr ingest` to the Git hooks of this repository.") - .option("--force", "Also edit hooks in a directory tracked by the working tree, such as .husky.") + .option( + "--force", + "Also edit hooks in a directory tracked by the working tree, such as .githooks; husky is supported.", + ) .option("--json", "Print machine-readable JSON.") .action(async (options: { force?: boolean; json?: boolean }, command: Command) => { const report = await installGitHooks({ @@ -1698,7 +1701,11 @@ function printGitHooks( ) } console.log(`hooksDir=${report.hooksDir}`) + console.log(`manager=${report.manager}`) console.log(`installed=${report.installed.join(",") || "none"}`) + if (action === "installed" && report.manager === "husky" && report.changed.length > 0) { + console.log("Commit the changed .husky scripts so the refresh follows the repository.") + } if (report.missing.length > 0) { console.log(`missing=${report.missing.join(",")}`) } diff --git a/packages/ragmir-core/src/git-hooks.test.ts b/packages/ragmir-core/src/git-hooks.test.ts index ffc7a8e..b504c8f 100644 --- a/packages/ragmir-core/src/git-hooks.test.ts +++ b/packages/ragmir-core/src/git-hooks.test.ts @@ -1,6 +1,6 @@ import { execFileSync } from "node:child_process" import { existsSync } from "node:fs" -import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises" +import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises" import os from "node:os" import path from "node:path" import { afterEach, describe, expect, it } from "vitest" @@ -62,12 +62,49 @@ describe("git hooks", () => { it("refuses to edit a hooks directory tracked in the working tree unless forced", async () => { const root = await gitRepository() - execFileSync("git", ["config", "core.hooksPath", ".husky"], { cwd: root }) + execFileSync("git", ["config", "core.hooksPath", ".githooks"], { cwd: root }) await expect(installGitHooks({ cwd: root })).rejects.toThrow(/inside the working tree/u) const forced = await installGitHooks({ cwd: root, force: true }) - expect(forced.trackedHooksDir).toBe(true) + expect(forced).toMatchObject({ manager: "git", trackedHooksDir: true }) + expect(existsSync(path.join(root, ".githooks", "post-merge"))).toBe(true) + }) + + it("adds the block to husky 9 scripts instead of the shims husky regenerates", async () => { + const root = await gitRepository() + const shims = path.join(root, ".husky", "_") + await mkdir(shims, { recursive: true }) + await writeFile(path.join(shims, "h"), "#!/usr/bin/env sh\n", "utf8") + await writeFile(path.join(shims, ".gitignore"), "*", "utf8") + await writeFile(path.join(root, ".husky", "pre-commit"), "pnpm lint\n", "utf8") + execFileSync("git", ["config", "core.hooksPath", ".husky/_"], { cwd: root }) + + const installed = await installGitHooks({ cwd: root }) + const postCommit = await readFile(path.join(root, ".husky", "post-commit"), "utf8") + const removed = await uninstallGitHooks({ cwd: root }) + + expect(installed).toMatchObject({ + manager: "husky", + hooksDir: path.join(await realpath(root), ".husky"), + installed: ["post-checkout", "post-commit", "post-merge", "post-rewrite"], + }) + expect(postCommit).toContain("node .ragmir/run.cjs ingest") + expect(existsSync(path.join(shims, "post-commit"))).toBe(false) + expect(await readFile(path.join(root, ".husky", "pre-commit"), "utf8")).toBe("pnpm lint\n") + expect(removed.changed).toHaveLength(4) + expect(existsSync(path.join(root, ".husky", "post-commit"))).toBe(false) + }) + + it("recognizes husky 8, whose hooks path is the scripts folder itself", async () => { + const root = await gitRepository() + await mkdir(path.join(root, ".husky", "_"), { recursive: true }) + await writeFile(path.join(root, ".husky", "_", "husky.sh"), "#!/usr/bin/env sh\n", "utf8") + execFileSync("git", ["config", "core.hooksPath", ".husky"], { cwd: root }) + + const installed = await installGitHooks({ cwd: root }) + + expect(installed).toMatchObject({ manager: "husky", trackedHooksDir: true }) expect(existsSync(path.join(root, ".husky", "post-merge"))).toBe(true) }) }) diff --git a/packages/ragmir-core/src/git-hooks.ts b/packages/ragmir-core/src/git-hooks.ts index f846695..9248f6d 100644 --- a/packages/ragmir-core/src/git-hooks.ts +++ b/packages/ragmir-core/src/git-hooks.ts @@ -27,6 +27,8 @@ export interface GitHooksOptions { export interface GitHooksReport { hooksDir: string workTree: string + /** `husky` when husky owns the hooks: Ragmir then edits its tracked `.husky/` scripts. */ + manager: GitHooksManager /** The hooks directory lives inside the working tree, so its files are usually committed. */ trackedHooksDir: boolean installed: GitHookName[] @@ -41,7 +43,7 @@ export interface GitHooksReport { */ export async function installGitHooks(options: GitHooksOptions = {}): Promise { const location = await gitHooksLocation(options.cwd) - if (location.trackedHooksDir && options.force !== true) { + if (location.trackedHooksDir && location.manager !== "husky" && options.force !== true) { throw new Error( `Git hooks resolve to ${location.hooksDir}, inside the working tree (for example a husky directory). Add the Ragmir block to that tool's hook files, or rerun with --force to edit them.`, ) @@ -90,9 +92,12 @@ export async function gitHooksStatus(options: GitHooksOptions = {}): Promise ) // Resolves core.hooksPath and linked worktrees the way Git itself does. const hooksPath = (await git(projectRoot, ["rev-parse", "--git-path", "hooks"])).trim() - const hooksDir = path.resolve(projectRoot, hooksPath) + const gitHooksDir = path.resolve(projectRoot, hooksPath) + const huskyDir = huskyScriptsDir(gitHooksDir) + const hooksDir = huskyDir ?? gitHooksDir const gitDir = path.resolve( projectRoot, (await git(projectRoot, ["rev-parse", "--git-common-dir"])).trim(), @@ -116,11 +123,27 @@ async function gitHooksLocation(cwd = process.cwd()): Promise return { hooksDir, workTree, + manager: huskyDir === null ? "git" : "husky", trackedHooksDir: insideWorkTree && !insideGitDir, projectPath: canonicalSourcePath(path.relative(workTree, projectRoot)) || ".", } } +/** + * The folder of husky's hook scripts when husky owns the Git hooks. husky 9 points core.hooksPath at + * `.husky/_`, which it regenerates on every install and whose shims run `.husky/`; husky 8 + * points it at `.husky` itself. Hooks belong in the `.husky/` scripts in both cases. + */ +function huskyScriptsDir(gitHooksDir: string): string | null { + if (path.basename(gitHooksDir) === "_" && path.basename(path.dirname(gitHooksDir)) === ".husky") { + return existsSync(path.join(gitHooksDir, "h")) ? path.dirname(gitHooksDir) : null + } + if (path.basename(gitHooksDir) === ".husky") { + return existsSync(path.join(gitHooksDir, "_", "husky.sh")) ? gitHooksDir : null + } + return null +} + function ragmirHookBlock(projectPath: string): string { const runner = `${RAGMIR_DIR}/${RGR_RUNNER_FILENAME}` return [ @@ -190,6 +213,7 @@ async function reportFor( return { hooksDir: location.hooksDir, workTree: location.workTree, + manager: location.manager, trackedHooksDir: location.trackedHooksDir, installed, missing, diff --git a/packages/ragmir-core/src/mcp-scope.ts b/packages/ragmir-core/src/mcp-scope.ts index 3ec1502..414b28b 100644 --- a/packages/ragmir-core/src/mcp-scope.ts +++ b/packages/ragmir-core/src/mcp-scope.ts @@ -122,7 +122,10 @@ export function workspaceScopeRefusal( name: string, config: Config, ): string { - return `This Ragmir server serves the "${name}" knowledge base only to workspaces under ${config.mcpAllowedRoots.join(", ")} (mcpAllowedRoots). Your workspace is ${verdict.workspaceRoots.join(", ")}. Register a project-scoped Ragmir server for this workspace instead.` + const allowedRoots = config.mcpAllowedRoots.map((root) => + resolveAllowedRoot(config.projectRoot, root), + ) + return `This Ragmir server serves the "${name}" knowledge base only to workspaces under ${allowedRoots.join(", ")} (mcpAllowedRoots). Your workspace is ${verdict.workspaceRoots.join(", ")}. Register a project-scoped Ragmir server for this workspace instead.` } function resolveAllowedRoot(projectRoot: string, value: string): string { diff --git a/packages/ragmir-core/src/mcp.test.ts b/packages/ragmir-core/src/mcp.test.ts index d5a6fbc..2f2386a 100644 --- a/packages/ragmir-core/src/mcp.test.ts +++ b/packages/ragmir-core/src/mcp.test.ts @@ -608,7 +608,7 @@ describe("MCP workspace scope and session", () => { }) expect(refused.isError).toBe(true) - expect(textContent(refused)).toContain("only to workspaces under .") + expect(textContent(refused)).toContain(`only to workspaces under ${root} (mcpAllowedRoots)`) expect(allowed.isError).not.toBe(true) expect(allowed._meta?.["ragmir/workspace"]).toMatchObject({ relation: "related" }) })