diff --git a/README.md b/README.md
index a681450..bc8a456 100644
--- a/README.md
+++ b/README.md
@@ -16,6 +16,8 @@
+
+
@@ -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
-
-[](https://www.npmjs.com/package/@jcode.labs/ragmir)
-[](https://github.com/jcode-works/jcode-ragmir/actions/workflows/ci.yml)
-[](https://github.com/jcode-works/jcode-ragmir/blob/main/LICENSE)
-[](https://nodejs.org)
-
-**Cited project context for coding agents. Local, fast, and offline by default.**
+
+
+
+
+
+
+@jcode.labs/ragmir
+
+
+ Cited project context for coding agents. Local, fast, and offline by default.
+
+
+
+
+
+
+
+
+
+
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" })
})