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
13 changes: 7 additions & 6 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,15 @@ OPENROUTER_API_KEY=

# ── AI provider ─────────────────────────────────────────────────────────────
# openrouter (default): hosted models through OPENROUTER_API_KEY.
# claude-code | antigravity: run on an AI CLI you have installed and signed in
# to (`claude` or `agy`) instead — no API key, no quota. `start:local` and
# `npx muktiai` set this themselves (pick with --provider); set it here only
# when running the API directly in local mode. Antigravity costs roughly 15k
# input tokens and 30–45 seconds per reply — see the README before choosing it.
# claude | agy: run on the AI CLI of that name, installed and signed in, instead
# — no API key, no quota. Each provider is named after its command; the older
# names claude-code and antigravity still work. `start:local` and `npx muktiai`
# set this themselves (pick with --provider); set it here only when running the
# API directly in local mode. Antigravity costs roughly 15k input tokens and
# 30–45 seconds per reply — see the README before choosing it.
# AI_PROVIDER=openrouter
#
# antigravity only: the directory agy runs from, which also names the agy
# agy only: the directory agy runs from, which also names the agy
# project Mukti's replies are filed under. Defaults to ~/.mukti/mukti-socratic.
# Never point it inside a repository: agy would pick up that repo's own rules.
# MUKTI_AGY_WORKSPACE=
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ To override:

```bash
npx muktiai --choose # ask again
npx muktiai --provider antigravity --save # set the default, no prompt
npx muktiai --provider agy --save # set the default, no prompt
```

| | Claude Code | Antigravity |
Expand Down
22 changes: 13 additions & 9 deletions docs/local-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,15 @@ Both share the same launcher, preflight checks, and saved settings.

## Supported AI CLIs

| CLI | Provider id | Status |
| ------------------------------------------------------------------------------ | ------------- | ----------- |
| [Claude Code](https://docs.claude.com/en/docs/claude-code/overview) (`claude`) | `claude-code` | Supported |
| [Antigravity](https://antigravity.google/docs/cli/reference) (`agy`) | `antigravity` | Supported |
| Codex, OpenCode, Pi, Grok, and more | — | Coming soon |
| CLI | Provider id | Status |
| ------------------------------------------------------------------------------ | ----------- | ----------- |
| [Claude Code](https://docs.claude.com/en/docs/claude-code/overview) (`claude`) | `claude` | Supported |
| [Antigravity](https://antigravity.google/docs/cli/reference) (`agy`) | `agy` | Supported |
| Codex, OpenCode, Pi, Grok, and more | — | Coming soon |

Each provider is named after the command it runs, so `--provider claude` and `--provider agy` are
all you have to remember. The older names `claude-code` and `antigravity` still work everywhere a
provider can be named.

Sign in first: `claude login`, or run `agy` once and follow the prompts.

Expand All @@ -41,8 +45,8 @@ so. To change it, or to decide without being asked:

```bash
npx muktiai --choose # ask again, and offer to remember
npx muktiai --provider antigravity # just this once
npx muktiai --provider antigravity --save # set the default, no prompt
npx muktiai --provider agy # just this once
npx muktiai --provider agy --save # set the default, no prompt
```

From a checkout, pass the same flags after `--`: `bun run start:local -- --choose`.
Expand All @@ -60,7 +64,7 @@ you how to choose another.

The two are not equivalent, and the difference is worth knowing before you choose:

| | Claude Code (`claude-code`) | Antigravity (`antigravity`) |
| | Claude Code (`claude`) | Antigravity (`agy`) |
| ------------------------- | --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Time per reply (measured) | about 13–20 seconds | about 30–60 seconds |
| Prompt size per reply | Mukti's own prompt and your conversation | about 15–25k input tokens: the same, plus agy's own ~14k-token coding-agent prompt, which cannot be replaced |
Expand Down Expand Up @@ -108,7 +112,7 @@ npx muktiai [options]
--port, --web-port <n> Port for the web app (default 3001)
--api-port <n> Port for the API (default 3000)
--data-dir <path> Where to keep the database and logs (default ~/.mukti, or $MUKTI_HOME)
--provider <name> AI CLI to run on: claude-code or antigravity
--provider <name> AI CLI to run on: claude or agy
--save Save --provider as your default for later runs
--choose Ask which AI CLI to use, and offer to save it
-h, --help Show help
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,18 @@ describe('AiProviderRegistry', () => {
expect(r.isLocalCliProvider()).toBe(true);
});

// The launcher asks for these CLIs by the command they run and passes that
// name straight through, so both spellings have to reach the same adapter.
it.each([
['claude', ClaudeCliAdapter],
['agy', AntigravityCliAdapter],
])('resolves %s, the command name, to its adapter', (provider, adapter) => {
const r = registry({ AI_PROVIDER: provider });

expect(r.getActiveLocalCliAdapter()).toBeInstanceOf(adapter);
expect(r.isLocalCliProvider()).toBe(true);
});

it('runs agy from the workspace named by MUKTI_AGY_WORKSPACE', () => {
const workspace = join(root, 'custom-workspace');
const adapter = registry({
Expand All @@ -74,7 +86,7 @@ describe('AiProviderRegistry', () => {
const r = registry({ AI_PROVIDER: 'gemini-cli' });

await expect(r.onModuleInit()).rejects.toThrow(
'Unsupported AI_PROVIDER "gemini-cli". Supported values: openrouter, claude-code, antigravity.',
'Unsupported AI_PROVIDER "gemini-cli". Supported values: openrouter, claude-code, antigravity, agy, claude.',
);
expect(r.isLocalCliProvider()).toBe(false);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,18 @@ const OPENROUTER_PROVIDER = 'openrouter';

const DEFAULT_PROVIDER = OPENROUTER_PROVIDER;

/**
* Local CLIs named after the commands they run. The launcher asks for them
* this way (`--provider claude`, `--provider agy`) and passes the name
* straight through as `AI_PROVIDER`, while the adapters were first registered
* under longer names. Both are accepted so neither a saved launcher default
* nor an older `.env` has to be rewritten.
*/
const PROVIDER_ALIASES: Readonly<Record<string, string>> = {
agy: 'antigravity',
claude: 'claude-code',
};

@Injectable()
export class AiProviderRegistry implements OnModuleInit {
private readonly adapters: readonly LocalCliAdapter[];
Expand Down Expand Up @@ -55,16 +67,19 @@ export class AiProviderRegistry implements OnModuleInit {
return this.adapters.find((a) => a.providerId === provider);
}

/** Raw `AI_PROVIDER` value, defaulting to `openrouter` when unset. */
/**
* The configured `AI_PROVIDER` with any alias resolved, defaulting to
* `openrouter` when unset.
*/
getProviderId(): string {
// `||` (not `??`) is deliberate: an empty or whitespace-only AI_PROVIDER is
// an unset one, and must fall through to the default rather than being
// treated as a configured provider name.
/* eslint-disable @typescript-eslint/prefer-nullish-coalescing */
return (
this.configService.get<string>('AI_PROVIDER')?.trim() || DEFAULT_PROVIDER
);
const configured =
this.configService.get<string>('AI_PROVIDER')?.trim() || DEFAULT_PROVIDER;
/* eslint-enable @typescript-eslint/prefer-nullish-coalescing */
return PROVIDER_ALIASES[configured] ?? configured;
}

/** Whether completions route through a local, user-authenticated CLI. */
Expand Down Expand Up @@ -103,7 +118,10 @@ export class AiProviderRegistry implements OnModuleInit {

if (!supported.includes(provider)) {
throw new Error(
`Unsupported AI_PROVIDER "${provider}". Supported values: ${supported.join(', ')}.`,
`Unsupported AI_PROVIDER "${provider}". Supported values: ${[
...supported,
...Object.keys(PROVIDER_ALIASES),
].join(', ')}.`,
);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -394,7 +394,7 @@ export class DialogueAIService {
}

throw new Error(
'No AI API key is configured. Add your OpenRouter key in Settings, set OPENROUTER_API_KEY on the server, or run locally with a local-CLI provider (AI_PROVIDER=claude-code or antigravity).',
'No AI API key is configured. Add your OpenRouter key in Settings, set OPENROUTER_API_KEY on the server, or run locally with a local-CLI provider (AI_PROVIDER=claude or agy).',
);
}

Expand Down
2 changes: 1 addition & 1 deletion packages/mukti-cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ const HELP = `
--api-port <n> Port for the API (default ${DEFAULT_API_PORT})
--data-dir <path> Where to keep the database and logs
(default ~/.mukti, or $MUKTI_HOME)
--provider <name> AI CLI to run on: ${SUPPORTED_PROVIDERS.map((p) => `${p.id} (${p.binary})`).join(' or ')}
--provider <name> AI CLI to run on: ${SUPPORTED_PROVIDERS.map((p) => p.id).join(' or ')}
(default: your saved choice, else $AI_PROVIDER,
else whichever CLI is ready)
--save Save --provider as your default for later runs
Expand Down
8 changes: 4 additions & 4 deletions packages/mukti-cli/src/launcher/__tests__/preflight.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,18 +38,18 @@ function probes() {
}

test('preflight probes the provider when it is given no readiness', async () => {
const antigravity = providerById('antigravity')!;
const antigravity = providerById('agy')!;
const { calls, run } = probes();

const result = await runPreflight({ provider: antigravity, run });

assert.equal(result.provider.id, 'antigravity');
assert.equal(result.provider.id, 'agy');
assert.equal(result.version, '1.1.22');
assert.deepEqual(calls, ['agy --version', 'agy models']);
});

test('preflight reuses readiness the launcher already established', async () => {
const antigravity = providerById('antigravity')!;
const antigravity = providerById('agy')!;
const { calls, run } = probes();
const status: ProviderStatus = {
provider: antigravity,
Expand All @@ -64,7 +64,7 @@ test('preflight reuses readiness the launcher already established', async () =>
});

test('preflight still runs the mode-specific checks it is given', async () => {
const antigravity = providerById('antigravity')!;
const antigravity = providerById('agy')!;
const { run } = probes();
let ran = false;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,14 @@ test('no flags means nothing chosen, nothing saved', () => {
});

test('a provider can be named, with or without saving it', () => {
assert.deepEqual(parseProviderOptions(['--provider', 'antigravity']), {
assert.deepEqual(parseProviderOptions(['--provider', 'agy']), {
kind: 'ok',
options: { choose: false, provider: 'antigravity', save: false },
options: { choose: false, provider: 'agy', save: false },
rest: [],
});
assert.deepEqual(parseProviderOptions(['--provider', 'antigravity', '--save']), {
assert.deepEqual(parseProviderOptions(['--provider', 'agy', '--save']), {
kind: 'ok',
options: { choose: false, provider: 'antigravity', save: true },
options: { choose: false, provider: 'agy', save: true },
rest: [],
});
});
Expand Down Expand Up @@ -78,7 +78,7 @@ test('saving without naming a provider is refused', () => {
});

test('naming a provider and asking to choose one is refused', () => {
const result = parseProviderOptions(['--provider', 'antigravity', '--choose']);
const result = parseProviderOptions(['--provider', 'agy', '--choose']);

assert.equal(result.kind, 'error');
assert.match(result.kind === 'error' ? result.message : '', /--choose/);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ import type { ProviderStatus } from '../providers.ts';
import { isInteractive, pickProvider } from '../provider-picker.ts';
import { providerById } from '../providers.ts';

const CLAUDE = providerById('claude-code')!;
const ANTIGRAVITY = providerById('antigravity')!;
const CLAUDE = providerById('claude')!;
const ANTIGRAVITY = providerById('agy')!;

const ready = (provider = CLAUDE): ProviderStatus => ({
provider,
Expand Down Expand Up @@ -65,7 +65,7 @@ test('every supported CLI is offered, with its status and its trade-off', async

assert.deepEqual(
harness.offered.map((option) => option.value),
['claude-code', 'antigravity']
['claude', 'agy']
);
const [claude, antigravity] = harness.offered;
assert.match(claude.hint ?? '', new RegExp(CLAUDE.tradeOff));
Expand Down Expand Up @@ -97,26 +97,26 @@ test('the first ready CLI is selected to begin with', async () => {

const picked = await pickProvider(statuses, harness.prompts);

assert.equal(picked?.provider.id, 'antigravity');
assert.equal(picked?.provider.id, 'agy');
});

test('keeping the pick asks to remember it', async () => {
const harness = prompts({ confirm: true, select: 'antigravity' });
const harness = prompts({ confirm: true, select: 'agy' });

const picked = await pickProvider([ready(CLAUDE), ready(ANTIGRAVITY)], harness.prompts);

assert.equal(picked?.provider.id, 'antigravity');
assert.equal(picked?.provider.id, 'agy');
assert.equal(picked?.remember, true);
assert.equal(harness.asked.length, 2);
assert.match(harness.asked[1], /confirm:/);
});

test('declining to remember still uses the pick for this launch', async () => {
const harness = prompts({ confirm: false, select: 'antigravity' });
const harness = prompts({ confirm: false, select: 'agy' });

const picked = await pickProvider([ready(CLAUDE), ready(ANTIGRAVITY)], harness.prompts);

assert.equal(picked?.provider.id, 'antigravity');
assert.equal(picked?.provider.id, 'agy');
assert.equal(picked?.remember, false);
});

Expand All @@ -128,7 +128,7 @@ test('cancelling the choice picks nothing', async () => {
});

test('cancelling the remember question picks nothing', async () => {
const harness = prompts({ confirm: CANCELLED, select: 'antigravity' });
const harness = prompts({ confirm: CANCELLED, select: 'agy' });

assert.equal(await pickProvider([ready(CLAUDE), ready(ANTIGRAVITY)], harness.prompts), undefined);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import {
writeStoredProvider,
} from '../provider-settings.ts';

const SUPPORTED = ['claude-code', 'antigravity'] as const;
const SUPPORTED = ['claude', 'agy'] as const;

let root: string;

Expand Down Expand Up @@ -57,9 +57,9 @@ test('a missing file means no default, and says nothing', () => {
});

test('a valid file yields its provider', () => {
write({ provider: 'antigravity', version: 1 });
write({ provider: 'agy', version: 1 });

assert.deepEqual(readStoredProvider(root, SUPPORTED), { provider: 'antigravity' });
assert.deepEqual(readStoredProvider(root, SUPPORTED), { provider: 'agy' });
});

test('malformed JSON means no default, and names the file', () => {
Expand All @@ -72,7 +72,7 @@ test('malformed JSON means no default, and names the file', () => {
});

test('a version this launcher does not know means no default', () => {
write({ provider: 'antigravity', version: 99 });
write({ provider: 'agy', version: 99 });

const result = readStoredProvider(root, SUPPORTED);

Expand All @@ -96,36 +96,36 @@ test('a file with no provider means no default', () => {
});

test('writing records the provider and the format version', () => {
writeStoredProvider(root, 'antigravity');
writeStoredProvider(root, 'agy');

assert.deepEqual(JSON.parse(readFileSync(providerSettingsPath(root), 'utf8')), {
provider: 'antigravity',
provider: 'agy',
version: 1,
});
assert.deepEqual(readStoredProvider(root, SUPPORTED), { provider: 'antigravity' });
assert.deepEqual(readStoredProvider(root, SUPPORTED), { provider: 'agy' });
});

test('writing creates the home directory but nothing else', () => {
const fresh = join(root, 'not-yet');

writeStoredProvider(fresh, 'claude-code');
writeStoredProvider(fresh, 'claude');

assert.deepEqual(readdirSync(fresh), ['config.json']);
});

test('writing leaves no temporary file behind', () => {
writeStoredProvider(root, 'claude-code');
writeStoredProvider(root, 'claude');

assert.deepEqual(readdirSync(root), ['config.json']);
});

test('writing preserves settings this launcher does not know about', () => {
write({ provider: 'claude-code', somethingLater: { kept: true }, version: 1 });
write({ provider: 'claude', somethingLater: { kept: true }, version: 1 });

writeStoredProvider(root, 'antigravity');
writeStoredProvider(root, 'agy');

assert.deepEqual(JSON.parse(readFileSync(providerSettingsPath(root), 'utf8')), {
provider: 'antigravity',
provider: 'agy',
somethingLater: { kept: true },
version: 1,
});
Expand All @@ -134,9 +134,9 @@ test('writing preserves settings this launcher does not know about', () => {
test('writing over a malformed file replaces it rather than failing', () => {
write('{not json');

writeStoredProvider(root, 'antigravity');
writeStoredProvider(root, 'agy');

assert.deepEqual(readStoredProvider(root, SUPPORTED), { provider: 'antigravity' });
assert.deepEqual(readStoredProvider(root, SUPPORTED), { provider: 'agy' });
});

test('an unreadable settings path is reported, not thrown', () => {
Expand Down
Loading
Loading