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
24 changes: 24 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,30 @@ The `.apijack/` directory at a project root is auto-loaded when the CLI runs ins
| `.apijack/plugins.ts` | Project-level plugin registrations (`default: ApijackPlugin[]` — each entry passed to `cli.use(...)`) |
| `.apijack/routines/*.yaml` | Routines available via `routine run <name>` |
| `.apijack/settings.json` | Framework defaults (see below) |
| `.apijack/aliases.json` | Project-local command aliases (see below) |

### Command aliases

Generated leaf-command names come from the OpenAPI `operationId` and can be verbose (e.g. `customers get-customer-order-summary`). `.apijack/aliases.json` is a flat map from a typed alias to the canonical command path:

```json
{
"customers list": "customers get-all-customers",
"customers summary": "customers get-customer-order-summary",
"cs": "customers get-customer-order-summary"
}
```

Resolution rules:

- Argv is rewritten at CLI bootstrap, before Commander parses. `mycli cs 42 --foo bar` becomes `mycli customers get-customer-order-summary 42 --foo bar`.
- Single-token aliases (`cs`) and multi-token aliases (`customers summary`) use the same mechanism.
- Trailing positional args and flags pass through unchanged.
- Longest-prefix wins when multiple aliases could match.
- An alias that shadows a real command path emits a startup warning and the real command keeps winning.
- An expansion that doesn't resolve to a known command emits a startup error; the CLI continues without that alias applied.
- A global `~/.<cliName>/aliases.json` is also consulted; project-local entries override global entries on conflict.
- **Routines and MCP tool resolution use canonical names only.** Aliases are a CLI ergonomics layer; routine YAML and MCP tool names are not affected.

### Opt-in auth for custom commands and dispatchers

Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,11 @@ apijack supports pre-built plugins as standalone npm packages for common utiliti
Full documentation is available on the [wiki](https://github.com/normalled/apijack/wiki):

- [Quickstart](https://github.com/normalled/apijack/wiki/Quickstart)
- [Building a CLI](https://github.com/normalled/apijack/wiki/Building-a-CLI)
- [Writing Routines](https://github.com/normalled/apijack/wiki/Writing-Routines)
- [Authentication Strategies](https://github.com/normalled/apijack/wiki/Authentication-Strategies)
- [Session Auth](https://github.com/normalled/apijack/wiki/Session-Auth)
- [Project Mode](https://github.com/normalled/apijack/wiki/Project-Mode)
- [CLI Command Reference](https://github.com/normalled/apijack/wiki/CLI-Command-Reference)
- [Routine YAML Schema](https://github.com/normalled/apijack/wiki/Routine-YAML-Schema)

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
"api-client",
"sdk-generator"
],
"version": "1.13.0",
"version": "1.14.0",
"license": "MIT",
"repository": {
"type": "git",
Expand Down
231 changes: 231 additions & 0 deletions src/aliases.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
import { existsSync, readFileSync } from 'fs';
import { join } from 'path';
import { homedir } from 'os';
import type { Command } from 'commander';

export type AliasMap = Record<string, string>;

export interface LoadedAliases {
map: AliasMap;
errors: string[];
}

/**
* Load alias definitions from `<configDir>/aliases.json` and `~/.<cliName>/aliases.json`.
* Project-local entries override global entries on conflict.
*/
export function loadAliases(configDir: string, cliName: string): LoadedAliases {
const errors: string[] = [];
const globalPath = join(homedir(), '.' + cliName, 'aliases.json');
const projectPath = join(configDir, 'aliases.json');

const global = readAliasFile(globalPath, errors);
const project = projectPath === globalPath ? {} : readAliasFile(projectPath, errors);

return { map: { ...global, ...project }, errors };
}

function readAliasFile(path: string, errors: string[]): AliasMap {
if (!existsSync(path)) return {};

let raw: string;
try {
raw = readFileSync(path, 'utf-8');
} catch (e) {
errors.push(`${path}: failed to read (${(e as Error).message})`);

return {};
}

let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch (e) {
errors.push(`${path}: malformed JSON (${(e as Error).message})`);

return {};
}

if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
errors.push(`${path}: expected a JSON object mapping alias to canonical command`);

return {};
}

const out: AliasMap = {};

for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) {
if (typeof v !== 'string') {
errors.push(`${path}: alias "${k}" must map to a string`);
continue;
}

const alias = k.trim();
const expansion = v.trim();

if (!alias || !expansion) {
errors.push(`${path}: alias keys and values must be non-empty`);
continue;
}

out[alias] = expansion;
}

return out;
}

/**
* Best-effort longest-prefix resolution of leading argv tokens through the
* alias map, without validating against the real command tree.
*
* Used for early bootstrap reads of argv[2]/argv[3] (auth-skip detection,
* plugins-check guard, dry-run heuristic) that happen before the Commander
* tree is built. The full validated rewrite happens later via `rewriteArgv`.
*
* If an alias key collides with a real command, this helper still applies the
* alias — but the late `rewriteArgv` pass will detect the shadow and skip the
* rewrite for actual execution. The early reads only care about a few
* built-in command names, where shadowing is highly unlikely in practice.
*/
export function resolveLeadingTokens(args: string[], aliases: AliasMap): string[] {
const keys = Object.keys(aliases);

if (args.length === 0 || keys.length === 0) return args;

const sortedAliases = keys.slice().sort((a, b) => {
const aTokens = a.split(/\s+/).length;
const bTokens = b.split(/\s+/).length;

if (aTokens !== bTokens) return bTokens - aTokens;

return b.length - a.length;
});

for (const alias of sortedAliases) {
const tokens = alias.split(/\s+/);

if (args.length < tokens.length) continue;

let matches = true;

for (let i = 0; i < tokens.length; i++) {
if (args[i] !== tokens[i]) {
matches = false;
break;
}
}

if (matches) {
const expansion = aliases[alias].split(/\s+/);

return [...expansion, ...args.slice(tokens.length)];
}
}

return args;
}

/**
* Walk the Commander program tree and collect every canonical command path
* (space-separated tokens, e.g. "customers get-all-customers").
*/
export function collectCommandPaths(program: Command): Set<string> {
const paths = new Set<string>();

function walk(cmd: Command, prefix: string[]): void {
const here = [...prefix, cmd.name()];
paths.add(here.join(' '));

for (const sub of cmd.commands) walk(sub, here);
}

for (const sub of program.commands) walk(sub, []);

return paths;
}

export interface RewriteResult {
rewrittenArgs: string[];
warnings: string[];
errors: string[];
}

/**
* Apply alias rewriting to the user-supplied portion of argv (everything after
* the node binary and script path).
*
* - Aliases that match a real command path are skipped (warning emitted, real
* command keeps winning).
* - Aliases whose expansion does not resolve to a real command path are skipped
* (error emitted, CLI continues without the alias).
* - Longest-prefix wins: multi-token aliases are matched before shorter ones.
* - Trailing args (positional and flags) are appended verbatim.
*/
export function rewriteArgv(
args: string[],
aliases: AliasMap,
realPaths: Set<string>,
): RewriteResult {
const warnings: string[] = [];
const errors: string[] = [];
const valid = new Map<string, string[]>();

for (const [alias, expansion] of Object.entries(aliases)) {
if (realPaths.has(alias)) {
warnings.push(
`alias "${alias}" shadows a real command and will be ignored`,
);
continue;
}

if (!realPaths.has(expansion)) {
errors.push(
`alias "${alias}" → "${expansion}" does not resolve to a known command`,
);
continue;
}

valid.set(alias, expansion.split(/\s+/));
}

if (args.length === 0 || valid.size === 0) {
return { rewrittenArgs: args, warnings, errors };
}

const sortedAliases = [...valid.keys()].sort((a, b) => {
const aTokens = a.split(/\s+/).length;
const bTokens = b.split(/\s+/).length;

if (aTokens !== bTokens) return bTokens - aTokens;

return b.length - a.length;
});

for (const alias of sortedAliases) {
const tokens = alias.split(/\s+/);

if (args.length < tokens.length) continue;

let matches = true;

for (let i = 0; i < tokens.length; i++) {
if (args[i] !== tokens[i]) {
matches = false;
break;
}
}

if (matches) {
const expansion = valid.get(alias)!;
const tail = args.slice(tokens.length);

return {
rewrittenArgs: [...expansion, ...tail],
warnings,
errors,
};
}
}

return { rewrittenArgs: args, warnings, errors };
}
47 changes: 43 additions & 4 deletions src/cli-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import { loadPreRequestHook } from './pre-request';
import type { RoutineResult } from './routine/executor';
import { executeRoutine } from './routine/executor';
import { loadRoutineFile, validateRoutine } from './routine/loader';
import { loadAliases, collectCommandPaths, rewriteArgv, resolveLeadingTokens } from './aliases';

const coreManifest = JSON.parse(
readFileSync(join(import.meta.dir, '..', 'package.json'), 'utf-8'),
Expand Down Expand Up @@ -386,10 +387,23 @@ export function createCli(options: CliOptions): Cli {
},

async run(): Promise<void> {
// Load aliases once; reused both for early best-effort token resolution
// (so pre-build argv reads see through aliases) and for the late
// validated argv rewrite that runs just before parseAsync.
const { map: aliasMap, errors: aliasLoadErrors } = loadAliases(configDir, cliName);

for (const err of aliasLoadErrors) {
process.stderr.write(`${displayName}: aliases: ${err}\n`);
}

const effectiveArgs = resolveLeadingTokens(process.argv.slice(2), aliasMap);
const effectiveArgv2 = effectiveArgs[0];
const effectiveArgv3 = effectiveArgs[1];

// 0. Validate plugins (namespace, collisions, peer versions).
// Skip throwing when the user invoked `plugins check` so the command
// can report all issues non-destructively.
const isPluginsCheck = process.argv[2] === 'plugins' && process.argv[3] === 'check';
const isPluginsCheck = effectiveArgv2 === 'plugins' && effectiveArgv3 === 'check';

if (!isPluginsCheck) {
pluginRegistry.validateAll(consumerResolvers);
Expand Down Expand Up @@ -466,7 +480,7 @@ export function createCli(options: CliOptions): Cli {
// 4. Resolve auth
let resolved = resolveAuth(cliName, configOpts);

const cmd = process.argv[2];
const cmd = effectiveArgv2;
const skipAuthCommands = new Set([
'login',
'setup',
Expand Down Expand Up @@ -566,7 +580,7 @@ export function createCli(options: CliOptions): Cli {
// 7. Detect request-preview output modes
const oIdx = process.argv.indexOf('-o');
const oVal = oIdx >= 0 ? process.argv[oIdx + 1] : undefined;
const isDryRun = process.argv.includes('--dry-run') && process.argv[2] !== 'routine';
const isDryRun = process.argv.includes('--dry-run') && effectiveArgv2 !== 'routine';
const isCurl = oVal === 'curl';
const isCurlWithCreds = oVal === 'curl-with-creds';
const isRoutineStep = oVal === 'routine-step';
Expand Down Expand Up @@ -920,7 +934,32 @@ export function createCli(options: CliOptions): Cli {
process.exit(0);
}

// 13. Parse and execute
// 13. Apply project-local aliases (.apijack/aliases.json) by rewriting argv
// before Commander parses. Real command paths always win over aliases;
// expansions that don't resolve to a known command are skipped with an error.
// The map was already loaded at the top of run() for early best-effort
// resolution of pre-build argv reads — we reuse it here for the validated
// rewrite now that the full Commander tree exists.
if (Object.keys(aliasMap).length > 0) {
const realPaths = collectCommandPaths(program);
const { rewrittenArgs, warnings, errors } = rewriteArgv(
process.argv.slice(2),
aliasMap,
realPaths,
);

for (const w of warnings) {
process.stderr.write(`${displayName}: aliases: ${w}\n`);
}

for (const e of errors) {
process.stderr.write(`${displayName}: aliases: ${e}\n`);
}

process.argv = [...process.argv.slice(0, 2), ...rewrittenArgs];
}

// 14. Parse and execute
await program.parseAsync();
},
};
Expand Down
Loading
Loading