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
12 changes: 9 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,16 @@ name: Release

# Publish to npm + cut a GitHub Release when a version tag is pushed.
# Flow: bump package.json → merge to main → `git tag v0.1.5 && git push --tags`.
# Requires repo secret NPM_TOKEN (a granular/automation token with publish + bypass-2FA).
# Auth via OIDC trusted publishing (no NPM_TOKEN); requires id-token: write
# and the GitHub Actions trusted publisher registered on the npm package.
on:
push:
tags: ['v*.*.*']
workflow_dispatch: {}

permissions:
contents: write # cut the GitHub Release
id-token: write # mint the OIDC token for npm trusted publishing

jobs:
publish:
Expand All @@ -32,6 +34,10 @@ jobs:
- name: Install
run: bun install --frozen-lockfile

# Trusted publishing needs npm >= 11.5.1; Node 20 ships npm 10.x.
- name: Upgrade npm for trusted publishing
run: npm install -g npm@latest

# The prepublishOnly gate runs the full test suite, which includes
# pre-commit-hook.test.ts — that asserts the hook is installed.
- name: Install git hooks
Expand All @@ -49,10 +55,10 @@ jobs:

# `npm publish` runs prepublishOnly (build + tests + gen:check + compat +
# migrations) before uploading, so the gate is enforced here too.
# Auth is via OIDC (id-token: write above) — no token env needed.
# Provenance is attached automatically when publishing over OIDC.
- name: Publish to npm
run: npm publish --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

- name: Create GitHub Release
if: github.event_name == 'push'
Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@ All notable changes to Rig are documented here. The format follows

## [Unreleased]

## [0.1.6] - 2026-05-26

### Added
- `rig <unknown>` now suggests the closest command ("Did you mean `rig serve`?"), and maps agent names to `install` (`rig claude` → `rig install claude`).

### Changed
- Release workflow publishes to npm via **OIDC trusted publishing** (signed provenance, no `NPM_TOKEN` secret). Requires a trusted publisher registered on the npm package.

## [0.1.5] - 2026-05-26

### Added
Expand Down
32 changes: 32 additions & 0 deletions __tests__/suggest.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// SPDX-License-Identifier: Apache-2.0
import { describe, expect, test } from 'bun:test';
import { suggestCommand } from '../packages/cli/src/suggest.ts';

const COMMANDS = [
'start', 'init', 'status', 'index', 'sync', 'query', 'files',
'install', 'serve', 'doctor', 'bench', 'export-docs', 'update',
];

describe('rig — did-you-mean command suggestions', () => {
test('common typos map to the right command', () => {
expect(suggestCommand('server', COMMANDS)).toBe('serve');
expect(suggestCommand('statuss', COMMANDS)).toBe('status');
expect(suggestCommand('udpate', COMMANDS)).toBe('update');
expect(suggestCommand('exprt-docs', COMMANDS)).toBe('export-docs');
});

test('agent names route to `install <agent>`', () => {
expect(suggestCommand('claude', COMMANDS)).toBe('install claude');
expect(suggestCommand('cursor', COMMANDS)).toBe('install cursor');
expect(suggestCommand('openrouter', COMMANDS)).toBe('install openrouter');
});

test('far-off gibberish suggests nothing', () => {
expect(suggestCommand('xyzzy', COMMANDS)).toBeNull();
expect(suggestCommand('frobnicate', COMMANDS)).toBeNull();
});

test('an exact command is its own nearest match (distance 0)', () => {
expect(suggestCommand('serve', COMMANDS)).toBe('serve');
});
});
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "rig-constellation",
"version": "0.1.5",
"version": "0.1.6",
"private": false,
"type": "module",
"description": "Local-first semantic knowledge graph",
Expand Down
6 changes: 5 additions & 1 deletion packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { runStart } from './start.ts';
import { runStatus } from './status.ts';
import { runSync } from './sync-cmd.ts';
import { runUpdate } from './update-cmd.ts';
import { suggestCommand } from './suggest.ts';

const COMMANDS: Record<string, (cwd: string) => void | Promise<void>> = {
start: runStart,
Expand Down Expand Up @@ -74,7 +75,10 @@ if (!cmd) {

const handler = COMMANDS[cmd];
if (!handler) {
console.error(`rig: unknown command '${cmd}'. Try \`rig --help\`.`);
const hint = suggestCommand(cmd, Object.keys(COMMANDS));
console.error(
`rig: unknown command '${cmd}'.${hint ? ` Did you mean \`rig ${hint}\`?` : ''} Try \`rig --help\`.`,
);
process.exit(2);
}
await handler(process.cwd());
42 changes: 42 additions & 0 deletions packages/cli/src/suggest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// SPDX-License-Identifier: Apache-2.0

// Agent names are valid arguments to `install`, not commands — `rig claude`
// almost always means `rig install claude`.
const AGENTS = ['claude', 'cursor', 'codex', 'opencode', 'openrouter'];

// Levenshtein edit distance — small inputs, so the simple O(n·m) table is fine.
const editDistance = (a: string, b: string): number => {
const m = a.length;
const n = b.length;
const row = Array.from({ length: n + 1 }, (_, i) => i);
for (let i = 1; i <= m; i++) {
let prev = row[0];
row[0] = i;
for (let j = 1; j <= n; j++) {
const tmp = row[j];
row[j] = a[i - 1] === b[j - 1] ? prev : 1 + Math.min(prev, row[j], row[j - 1]);
prev = tmp;
}
}
return row[n];
};

// Suggest the closest real command for an unknown one. Returns the suggestion
// as the user would type it (e.g. `serve`, or `install claude`), or null when
// nothing is close enough to be worth showing.
export const suggestCommand = (input: string, commands: string[]): string | null => {
if (AGENTS.includes(input)) return `install ${input}`;
let best: string | null = null;
let bestDist = Number.POSITIVE_INFINITY;
for (const c of commands) {
const d = editDistance(input, c);
if (d < bestDist) {
bestDist = d;
best = c;
}
}
// Only suggest when the typo is plausibly that command: within 2 edits, or
// within half the input length for longer words (e.g. "exprt-docs").
const threshold = Math.max(2, Math.floor(input.length / 2));
return best !== null && bestDist <= threshold ? best : null;
};
Loading