feat(cli): add gitnexus publish for opt-in understand-quickly registry

Adds a small, opt-in command that fires a single `repository_dispatch`
event at `looptech-ai/understand-quickly` to ask the registry for an
instant resync of the current repo's entry. No graph file is uploaded;
the registry pulls from raw.githubusercontent.com per the protocol at
https://github.com/looptech-ai/understand-quickly/blob/main/docs/integrations/protocol.md.

  - Pure helpers (id parsing, payload construction, validation) live in
    `gitnexus-shared/src/integrations/understand-quickly.ts` so the
    package stays Node-free and the same logic is testable in isolation.
  - The CLI command lives in `gitnexus/src/cli/publish.ts`. Without
    `UNDERSTAND_QUICKLY_TOKEN` it is a no-op (exits 0 with one
    informational line); with the token it POSTs the dispatch and
    surfaces 204 / 401 / 404 / 5xx distinctly.
  - The id defaults to `<owner>/<repo>` parsed from the `origin` remote
    and can be overridden with `--id`.
  - Refuses to publish when no `.gitnexus/` index exists, with a
    `gitnexus analyze` hint.

Tests: a new vitest unit covers the pure helpers (8 + 8 + 2 cases) and
the no-token no-op path with a `fetch` spy that fails the test if the
network is touched. README gets a one-paragraph "Publishing to
understand-quickly" section near the existing CLI docs.
This commit is contained in:
Alex Macdonald-Smith 2026-05-08 00:35:35 -04:00
parent 0824b96d15
commit 3ea22fddff
6 changed files with 404 additions and 0 deletions

View file

@ -214,6 +214,7 @@ gitnexus clean --all --force # Delete all indexes
gitnexus wiki [path] # Generate repository wiki from knowledge graph
gitnexus wiki --model <model> # Wiki with custom LLM model (default: gpt-4o-mini)
gitnexus wiki --base-url <url> # Wiki with custom LLM API base URL
gitnexus publish # Notify the understand-quickly registry (opt-in, see below)
# Repository groups (multi-repo / monorepo service tracking)
gitnexus group create <name> # Create a repository group
@ -228,6 +229,12 @@ gitnexus group status <name> # Check staleness of repos in a group
If `analyze` reports a worker parse timeout on a large or unusual repository, it keeps running and falls back safely. To give slow worker jobs more time, use `gitnexus analyze --worker-timeout 60` or set `GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS=60000`. For very large files, `GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES` controls the worker job byte budget.
#### Publishing to understand-quickly (opt-in)
[`looptech-ai/understand-quickly`](https://github.com/looptech-ai/understand-quickly) is a public registry of code-knowledge graphs that lists `gitnexus@1` as a first-class format. After registering your repo once (`npx @understand-quickly/cli add` or the [wizard](https://looptech-ai.github.io/understand-quickly/add.html)), `gitnexus publish` fires a single `repository_dispatch` event so the registry resyncs your entry on demand instead of waiting for the nightly job.
It is opt-in and a no-op without `UNDERSTAND_QUICKLY_TOKEN` — a fine-grained GitHub PAT with `Repository dispatches: write` on the registry repo. Nothing else happens; no graph file is uploaded. See the [protocol spec](https://github.com/looptech-ai/understand-quickly/blob/main/docs/integrations/protocol.md) for the full contract.
### What Your AI Agent Gets
**16 tools** exposed via MCP (11 per-repo + 5 group):

View file

@ -143,6 +143,17 @@ export type { ScopeTree } from './scope-resolution/scope-tree.js';
export { buildPositionIndex } from './scope-resolution/position-index.js';
export type { PositionIndex } from './scope-resolution/position-index.js';
// Understand-Quickly registry integration (opt-in)
export {
UNDERSTAND_QUICKLY_DISPATCH_URL,
UNDERSTAND_QUICKLY_EVENT_TYPE,
UNDERSTAND_QUICKLY_TOKEN_ENV,
buildUqDispatchPayload,
isValidOwnerRepo,
parseOwnerRepoFromRemote,
} from './integrations/understand-quickly.js';
export type { UqDispatchPayload } from './integrations/understand-quickly.js';
// Shadow-mode diff + aggregation (RFC §6.3; Ring 2 SHARED #918)
export { diffResolutions } from './scope-resolution/shadow/diff.js';
export type {

View file

@ -0,0 +1,105 @@
/**
* Understand-Quickly registry integration helpers.
*
* Pure, runtime-agnostic logic for opting in to publishing a GitNexus
* index to the [`looptech-ai/understand-quickly`](https://github.com/looptech-ai/understand-quickly)
* registry. Lives in `gitnexus-shared` so both the Node CLI and any
* future browser-side surface can construct identical dispatch payloads.
*
* Network I/O lives in the CLI command (`gitnexus/src/cli/publish.ts`)
* to keep this module free of Node-only imports see the comment at
* the top of `gitnexus-shared/src/graph/types.ts`.
*
* The protocol contract (single dispatch event, no graph upload) is
* documented at:
* https://github.com/looptech-ai/understand-quickly/blob/main/docs/integrations/protocol.md
*/
/**
* URL of the registry repo's repository_dispatch endpoint. Hardcoded
* because the registry is the canonical home for this integration
* users who want a private registry can fork and patch.
*/
export const UNDERSTAND_QUICKLY_DISPATCH_URL =
'https://api.github.com/repos/looptech-ai/understand-quickly/dispatches';
/**
* Event type the registry's sync workflow listens for.
* See `looptech-ai/understand-quickly/.github/workflows/sync.yml`.
*/
export const UNDERSTAND_QUICKLY_EVENT_TYPE = 'sync-entry';
/** Environment variable that gates the dispatch. */
export const UNDERSTAND_QUICKLY_TOKEN_ENV = 'UNDERSTAND_QUICKLY_TOKEN';
export interface UqDispatchPayload {
event_type: typeof UNDERSTAND_QUICKLY_EVENT_TYPE;
client_payload: {
/** `<owner>/<repo>` shape — must match the registered entry. */
id: string;
};
}
/**
* Build the JSON body for the `repository_dispatch` ping. Pure no
* env reads, no network. Validates that `id` looks like `owner/repo`
* (one slash, no whitespace, both halves non-empty) so a misconfigured
* caller fails loudly before the round-trip.
*/
export function buildUqDispatchPayload(id: string): UqDispatchPayload {
if (!isValidOwnerRepo(id)) {
throw new Error(
`[understand-quickly] expected id of the form "owner/repo", got "${id}". ` +
`The registry uses this string to look up your entry in registry.json — ` +
`it must match the GitHub owner/repo of the source code, not a local path.`,
);
}
return {
event_type: UNDERSTAND_QUICKLY_EVENT_TYPE,
client_payload: { id },
};
}
/**
* `owner/repo` validation. Conservative on purpose: GitHub's actual
* naming rules are looser, but we want to catch local paths
* (`/Users/...`), bare slugs (`my-repo`), and accidental whitespace.
*/
export function isValidOwnerRepo(id: string): boolean {
return /^[A-Za-z0-9][A-Za-z0-9._-]*\/[A-Za-z0-9._-]+$/.test(id);
}
/**
* Parse `owner/repo` out of a git remote URL. Mirrors the heuristic in
* `gitnexus/src/storage/git.ts:parseRepoNameFromUrl` but keeps both
* halves so we can build a registry id. Returns `null` on shapes we
* don't recognise.
*
* Examples:
* git@github.com:looptech-ai/understand-quickly.git
* https://github.com/looptech-ai/understand-quickly
* ssh://git@github.com/looptech-ai/understand-quickly.git
*/
export function parseOwnerRepoFromRemote(url: string | null | undefined): string | null {
if (!url) return null;
const trimmed = url.trim();
if (!trimmed) return null;
// Strip a trailing `.git` (case-insensitive) and any trailing slashes
// so https://h/o/r and https://h/o/r.git collapse to the same id.
const stripped = trimmed.replace(/\.git\/*$/i, '').replace(/\/+$/, '');
// SCP-form SSH (`git@host:owner/repo`).
const ssh = stripped.match(/^[^@]+@[^:]+:([^/]+)\/([^/]+)$/);
if (ssh) return `${ssh[1]}/${ssh[2]}`;
// URL forms (https://, ssh://, git://, file://) — last two path segments.
const url2 = stripped.match(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^/]+\/(.+)$/);
if (url2) {
const segments = url2[1].split('/').filter(Boolean);
if (segments.length >= 2) {
const [owner, repo] = segments.slice(-2);
return `${owner}/${repo}`;
}
}
return null;
}

View file

@ -160,6 +160,18 @@ program
.description('Augment a search pattern with knowledge graph context (used by hooks)')
.action(createLazyAction(() => import('./augment.js'), 'augmentCommand'));
program
.command('publish [path]')
.description(
'Notify the understand-quickly registry that this repo has a fresh GitNexus index. ' +
'Opt-in: requires UNDERSTAND_QUICKLY_TOKEN (fine-grained PAT with ' +
'`Repository dispatches: write` on looptech-ai/understand-quickly). ' +
'No-op without the token. See https://github.com/looptech-ai/understand-quickly.',
)
.option('--id <owner/repo>', 'Override the registry id (defaults to the origin remote)')
.option('--skip-git', 'Treat cwd as the repo root and skip parent git-root discovery')
.action(createLazyAction(() => import('./publish.js'), 'publishCommand'));
// ─── Direct Tool Commands (no MCP overhead) ────────────────────────
// These invoke LocalBackend directly for use in eval, scripts, and CI.

164
gitnexus/src/cli/publish.ts Normal file
View file

@ -0,0 +1,164 @@
/**
* `gitnexus publish` opt-in ping to the understand-quickly registry.
*
* Fires a single `repository_dispatch` event at
* `looptech-ai/understand-quickly` so the registry knows to refresh its
* entry for the current repo. Does NOT upload anything: per the
* understand-quickly protocol, the registry pulls the graph from a
* raw-GitHub URL the user controls.
*
* https://github.com/looptech-ai/understand-quickly/blob/main/docs/integrations/protocol.md
*
* Defaults:
* - Without `UNDERSTAND_QUICKLY_TOKEN` in the env, this is a no-op
* (prints one informational line, exit 0). Same shape as the
* `--publish` patterns in sibling tools.
* - With the token, fires the dispatch and reports the response code.
*
* The `id` is derived from the repo's `origin` remote unless the caller
* passes `--id <owner/repo>` explicitly. We deliberately do NOT auto-add
* the repo to the registry registration is one-time and uses the
* `npx @understand-quickly/cli add` path documented in the protocol.
*/
import path from 'path';
import {
UNDERSTAND_QUICKLY_DISPATCH_URL,
UNDERSTAND_QUICKLY_TOKEN_ENV,
buildUqDispatchPayload,
isValidOwnerRepo,
parseOwnerRepoFromRemote,
} from 'gitnexus-shared';
import { getGitRoot, getRemoteOriginUrl, getCurrentCommit } from '../storage/git.js';
import { hasIndex } from '../storage/repo-manager.js';
import { cliInfo, cliError } from './cli-message.js';
export interface PublishOptions {
/** Override the auto-derived `owner/repo` id. */
id?: string;
/** Treat the cwd as the repo root (skip git-root walk). */
skipGit?: boolean;
}
const REGISTER_HINT =
'Register your repo once with: npx @understand-quickly/cli add\n' +
'Or use the wizard: https://looptech-ai.github.io/understand-quickly/add.html';
export const publishCommand = async (
inputPath?: string,
options: PublishOptions = {},
): Promise<void> => {
// ── 1. Resolve the repo root (same precedence as `analyze`) ──────────
let repoPath: string;
if (inputPath) {
repoPath = path.resolve(inputPath);
} else if (options.skipGit) {
repoPath = path.resolve(process.cwd());
} else {
const gitRoot = getGitRoot(process.cwd());
if (!gitRoot) {
cliError(
'[understand-quickly] not inside a git repository.\n' +
'Run from a repo, or pass --skip-git to publish from the current directory.',
);
process.exitCode = 1;
return;
}
repoPath = gitRoot;
}
// ── 2. Confirm a GitNexus index exists ───────────────────────────────
// Publishing without an index is almost always a mistake — the
// registry's nightly sync would fetch a stale or missing graph file
// and mark the entry `missing`. Refuse loudly with a fix-it hint.
if (!(await hasIndex(repoPath))) {
cliError(
`[understand-quickly] no GitNexus index found at ${repoPath}/.gitnexus.\n` +
'Run `gitnexus analyze` first, then re-run `gitnexus publish`.',
);
process.exitCode = 1;
return;
}
// ── 3. Derive the registry id ─────────────────────────────────────────
const id =
options.id ?? parseOwnerRepoFromRemote(getRemoteOriginUrl(repoPath) ?? undefined) ?? null;
if (!id || !isValidOwnerRepo(id)) {
cliError(
`[understand-quickly] could not derive a registry id from this repo.\n` +
`Pass --id <owner/repo> explicitly (e.g. --id looptech-ai/${path.basename(repoPath)}).\n` +
REGISTER_HINT,
);
process.exitCode = 1;
return;
}
// ── 4. Token gate: no token → informational no-op (exit 0) ───────────
const token = process.env[UNDERSTAND_QUICKLY_TOKEN_ENV];
if (!token) {
cliInfo(
`[understand-quickly] ${UNDERSTAND_QUICKLY_TOKEN_ENV} is not set — skipping dispatch.\n` +
`Set it to a fine-grained PAT with "Repository dispatches: write" on ` +
`looptech-ai/understand-quickly to enable instant resync.\n` +
`(Without the token, the registry's nightly sync still picks up ${id}.)`,
{ id, skipped: 'no-token' },
);
return;
}
// ── 5. Fire the dispatch ─────────────────────────────────────────────
const payload = buildUqDispatchPayload(id);
const commit = getCurrentCommit(repoPath);
let response: Response;
try {
response = await fetch(UNDERSTAND_QUICKLY_DISPATCH_URL, {
method: 'POST',
headers: {
Accept: 'application/vnd.github+json',
Authorization: `Bearer ${token}`,
'X-GitHub-Api-Version': '2022-11-28',
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
cliError(`[understand-quickly] dispatch network error: ${msg}`, { id });
process.exitCode = 1;
return;
}
// GitHub returns 204 on success, 404 when the token can't reach the
// registry repo, 401 when the token is invalid. Surface these
// distinctly so users debug without checking the docs.
if (response.status === 204) {
cliInfo(
`[understand-quickly] dispatched sync-entry for ${id}` +
(commit ? ` @ ${commit.slice(0, 7)}` : '') +
'.\n' +
`View the workflow run: ` +
`https://github.com/looptech-ai/understand-quickly/actions/workflows/sync.yml`,
{ id, commit, status: response.status },
);
return;
}
if (response.status === 404) {
cliError(
`[understand-quickly] dispatch returned 404 — the token cannot reach ` +
`looptech-ai/understand-quickly. Verify the PAT has Repository access ` +
`to that repo and the "Repository dispatches: write" permission.`,
{ id, status: response.status },
);
process.exitCode = 1;
return;
}
// 401, 403, 422, 5xx → bubble up the body so the user can act.
const body = await response.text().catch(() => '');
cliError(
`[understand-quickly] dispatch failed with HTTP ${response.status}: ${body || '(empty body)'}`,
{ id, status: response.status },
);
process.exitCode = 1;
};

View file

@ -0,0 +1,105 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import fs from 'fs/promises';
import os from 'os';
import path from 'path';
import {
buildUqDispatchPayload,
isValidOwnerRepo,
parseOwnerRepoFromRemote,
UNDERSTAND_QUICKLY_TOKEN_ENV,
} from 'gitnexus-shared';
describe('understand-quickly helpers (gitnexus-shared)', () => {
describe('isValidOwnerRepo', () => {
it.each([
['looptech-ai/understand-quickly', true],
['abhigyanpatwari/GitNexus', true],
['Some_Org/Some.Repo-2', true],
['', false],
['just-a-name', false],
['/Users/me/code/repo', false],
['org/with spaces', false],
['org//double', false],
])('returns %s for %j', (id, expected) => {
expect(isValidOwnerRepo(id as string)).toBe(expected);
});
});
describe('parseOwnerRepoFromRemote', () => {
it.each([
['git@github.com:looptech-ai/understand-quickly.git', 'looptech-ai/understand-quickly'],
['https://github.com/looptech-ai/understand-quickly', 'looptech-ai/understand-quickly'],
['https://github.com/looptech-ai/understand-quickly.git', 'looptech-ai/understand-quickly'],
['ssh://git@github.com/abhigyanpatwari/GitNexus.git', 'abhigyanpatwari/GitNexus'],
['https://gitlab.example.com/group/sub/project.git', 'sub/project'],
])('parses %s -> %s', (url, expected) => {
expect(parseOwnerRepoFromRemote(url)).toBe(expected);
});
it.each([null, undefined, '', ' ', 'not-a-url', 'https://github.com/'])(
'returns null for %j',
(input) => {
expect(parseOwnerRepoFromRemote(input as string | null | undefined)).toBeNull();
},
);
});
describe('buildUqDispatchPayload', () => {
it('wraps the id in the registry-expected event shape', () => {
expect(buildUqDispatchPayload('looptech-ai/understand-quickly')).toEqual({
event_type: 'sync-entry',
client_payload: { id: 'looptech-ai/understand-quickly' },
});
});
it('throws on a malformed id rather than building an invalid payload', () => {
expect(() => buildUqDispatchPayload('just-a-name')).toThrow(/owner\/repo/);
expect(() => buildUqDispatchPayload('/Users/me/repo')).toThrow(/owner\/repo/);
});
});
});
describe('publishCommand (no-token no-op)', () => {
let tempDir: string;
let originalToken: string | undefined;
let exitCodeBefore: number | undefined;
beforeEach(async () => {
vi.resetModules();
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-publish-test-'));
// Simulate an existing index so hasIndex() returns true.
await fs.mkdir(path.join(tempDir, '.gitnexus'), { recursive: true });
await fs.writeFile(
path.join(tempDir, '.gitnexus', 'meta.json'),
JSON.stringify({ repoPath: tempDir, lastCommit: '', indexedAt: '' }),
'utf-8',
);
originalToken = process.env[UNDERSTAND_QUICKLY_TOKEN_ENV];
delete process.env[UNDERSTAND_QUICKLY_TOKEN_ENV];
exitCodeBefore = process.exitCode;
process.exitCode = 0;
});
afterEach(async () => {
if (originalToken !== undefined) {
process.env[UNDERSTAND_QUICKLY_TOKEN_ENV] = originalToken;
} else {
delete process.env[UNDERSTAND_QUICKLY_TOKEN_ENV];
}
process.exitCode = exitCodeBefore;
await fs.rm(tempDir, { recursive: true, force: true });
});
it('exits 0 without firing a network call when the token is unset', async () => {
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(() => {
throw new Error('publishCommand should NOT call fetch when the token is missing');
});
const { publishCommand } = await import('../../src/cli/publish.js');
await publishCommand(tempDir, { id: 'looptech-ai/understand-quickly', skipGit: true });
expect(fetchSpy).not.toHaveBeenCalled();
expect(process.exitCode ?? 0).toBe(0);
fetchSpy.mockRestore();
});
});