Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
<img alt="Linux, macOS, and Windows" src="https://img.shields.io/badge/platforms-Linux%20%7C%20macOS%20%7C%20Windows-4c6ef5">
<a href="./LICENSE"><img alt="License: AGPL-3.0" src="https://img.shields.io/badge/license-AGPL--3.0-2f9e44"></a>
<a href="https://nodejs.org"><img alt="Node.js 22.12+" src="https://img.shields.io/badge/node-%E2%89%A522.12-339933?logo=node.js&logoColor=white"></a>
<a href="https://www.npmjs.com/package/@jcode.labs/ragmir"><img alt="npm downloads" src="https://img.shields.io/npm/dm/@jcode.labs/ragmir?color=cb3837"></a>
<a href="https://github.com/jcode-works/jcode-ragmir"><img alt="GitHub stars" src="https://img.shields.io/github/stars/jcode-works/jcode-ragmir?style=social"></a>
</p>

<p align="center">
Expand All @@ -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. |
Expand Down Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<hook>` 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

Expand Down
36 changes: 25 additions & 11 deletions packages/ragmir-core/README.md
Original file line number Diff line number Diff line change
@@ -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.**
<p align="center">
<a href="https://ragmir.com">
<img src="https://ragmir.com/og/ragmir-og.png" width="640" alt="Ragmir: local retrieval with exact citations for coding agents">
</a>
</p>

<h1 align="center">@jcode.labs/ragmir</h1>

<p align="center">
<strong>Cited project context for coding agents. Local, fast, and offline by default.</strong>
</p>

<p align="center">
<a href="https://www.npmjs.com/package/@jcode.labs/ragmir"><img alt="npm version" src="https://img.shields.io/npm/v/@jcode.labs/ragmir?color=cb3837&logo=npm"></a>
<a href="https://www.npmjs.com/package/@jcode.labs/ragmir"><img alt="npm downloads" src="https://img.shields.io/npm/dm/@jcode.labs/ragmir?color=cb3837"></a>
<a href="https://github.com/jcode-works/jcode-ragmir/actions/workflows/ci.yml"><img alt="CI" src="https://github.com/jcode-works/jcode-ragmir/actions/workflows/ci.yml/badge.svg"></a>
<a href="https://github.com/jcode-works/jcode-ragmir/blob/main/LICENSE"><img alt="License: AGPL-3.0" src="https://img.shields.io/badge/license-AGPL--3.0-2f9e44"></a>
<a href="https://nodejs.org"><img alt="Node.js 22.12+" src="https://img.shields.io/badge/node-%E2%89%A522.12-339933?logo=node.js&logoColor=white"></a>
<a href="https://github.com/jcode-works/jcode-ragmir"><img alt="GitHub stars" src="https://img.shields.io/github/stars/jcode-works/jcode-ragmir?style=social"></a>
</p>

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
Expand Down Expand Up @@ -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).
9 changes: 8 additions & 1 deletion packages/ragmir-core/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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(",")}`)
}
Expand Down
43 changes: 40 additions & 3 deletions packages/ragmir-core/src/git-hooks.test.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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)
})
})
28 changes: 26 additions & 2 deletions packages/ragmir-core/src/git-hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/<hook>` scripts. */
manager: GitHooksManager
/** The hooks directory lives inside the working tree, so its files are usually committed. */
trackedHooksDir: boolean
installed: GitHookName[]
Expand All @@ -41,7 +43,7 @@ export interface GitHooksReport {
*/
export async function installGitHooks(options: GitHooksOptions = {}): Promise<GitHooksReport> {
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.`,
)
Expand Down Expand Up @@ -90,9 +92,12 @@ export async function gitHooksStatus(options: GitHooksOptions = {}): Promise<Git
return reportFor(await gitHooksLocation(options.cwd), [])
}

export type GitHooksManager = "git" | "husky"

interface GitHooksLocation {
hooksDir: string
workTree: string
manager: GitHooksManager
trackedHooksDir: boolean
/** Project root relative to the work tree, with forward slashes for the POSIX hook shell. */
projectPath: string
Expand All @@ -106,7 +111,9 @@ async function gitHooksLocation(cwd = process.cwd()): Promise<GitHooksLocation>
)
// 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(),
Expand All @@ -116,11 +123,27 @@ async function gitHooksLocation(cwd = process.cwd()): Promise<GitHooksLocation>
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/<hook>`; husky 8
* points it at `.husky` itself. Hooks belong in the `.husky/<hook>` 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 [
Expand Down Expand Up @@ -190,6 +213,7 @@ async function reportFor(
return {
hooksDir: location.hooksDir,
workTree: location.workTree,
manager: location.manager,
trackedHooksDir: location.trackedHooksDir,
installed,
missing,
Expand Down
5 changes: 4 additions & 1 deletion packages/ragmir-core/src/mcp-scope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion packages/ragmir-core/src/mcp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" })
})
Expand Down
Loading