Skip to content
Closed
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
28 changes: 27 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,32 @@ This flow also lets you adjust the reasoning effort for the chosen model. If the
commit effort
```

### 3. Generate a Commit
### 3. Create Aliases (optional)

`commit` is the built-in name, but you can add your own short names bound to any subcommand:

```bash
commit alias
```

This opens an interactive hub that lists your aliases and lets you create or delete them. Or do it directly:

```bash
commit alias add cb branch # `cb` now runs `commit branch`
commit alias add cm generate # `cm` now runs `commit generate`
commit alias list
commit alias remove cb
```

Aliases are small shell scripts in `~/.commit-tools/bin`, so uninstalling or reinstalling the npm package never touches them. The first time you create one, the tool offers to add that directory to your `PATH` in your shell profile (`.zshrc`, `.bashrc`, or `config.fish`) inside a clearly marked block — you can also add it yourself:

```bash
export PATH="$HOME/.commit-tools/bin:$PATH"
```

Extra arguments are forwarded, so `cb --help` behaves like `commit branch --help`. Aliases are POSIX-only for now; `commit alias` is not yet supported on Windows.

### 4. Generate a Commit

Stage your changes, then run:

Expand Down Expand Up @@ -177,6 +202,7 @@ commit --help
| `commit doctor` | Check installation and environment |
| `commit model` | Select a different AI model |
| `commit effort` | Adjust the reasoning effort for the current model |
| `commit alias` | List, create, and delete extra CLI names |
| `commit update` | Install the latest version from npm |
| `commit --version`, `-v` | Show version |
| `commit --help`, `-h` | Show help |
Expand Down
5 changes: 4 additions & 1 deletion index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Setup } from "@/cli/setup";
import { Doctor } from "@/cli/doctor";
import { ModelCommand } from "@/cli/model";
import { EffortCommand } from "@/cli/effort";
import { AliasCommand } from "@/cli/alias";
import { Update } from "@/cli/update";
import { type CliCommand, parseArgs, showHelp, showVersion } from "@/cli/parser";
import { Future } from "@/libs/future";
Expand All @@ -12,7 +13,7 @@ import { checkUpdate } from "@/cli/update";

import color from "picocolors";

const NOTIFIER_COMMANDS = new Set<CliCommand["type"]>(["generate", "setup", "doctor", "model", "effort", "branch"]);
const NOTIFIER_COMMANDS = new Set<CliCommand["type"]>(["generate", "setup", "doctor", "model", "effort", "branch", "alias"]);

const main = () => {
const args = process.argv.slice(2);
Expand All @@ -38,6 +39,8 @@ const main = () => {
return EffortCommand.create().chain((e) => e.run());
case "branch":
return Branch.create().chain((b) => b.run());
case "alias":
return AliasCommand.create(command.action).chain((a) => a.run());
case "update":
return Update.create().run();
case "version":
Expand Down
255 changes: 255 additions & 0 deletions src/cli/alias.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,255 @@
export { AliasCommand };

import * as p from "@clack/prompts";

import { Future } from "@/libs/future";
import { Just } from "@/libs/maybe";
import { absurd } from "@/libs/types";
import { ALIAS_TARGETS, AliasName, addAlias, describeTarget, removeAlias, type Alias, type AliasTarget } from "@/domain/alias/alias";
import { type AliasAction } from "@/cli/parser";
import { loadAliases, saveAliases } from "@/infra/storage/aliases";
import { aliasBinDir, findConflictingBinary, reconcileShims, removeShim, shimPath, writeShim } from "@/infra/alias/shims";
import { detectProfile, ensureBinDirOnPath, isBinDirOnPath, pathExportLine } from "@/infra/alias/path-setup";

import color from "picocolors";
import Table from "cli-table3";

const cancelled = (): Error => new Error("Cancelled");

const parseName = (raw: string): Future<Error, AliasName> =>
AliasName.parse(raw).either(
(msg) => Future.reject<Error, AliasName>(new Error(msg)),
(name) => Future.resolve<Error, AliasName>(name)
);

class AliasCommand {
private constructor(
private readonly action: AliasAction,
private readonly initial: readonly Alias[]
) {}

/** Unlike ModelCommand, this needs no config — aliases work before `commit setup` has ever run. */
static create(action: AliasAction): Future<Error, AliasCommand> {
return loadAliases().map((aliases) => new AliasCommand(action, aliases));
}

run(): Future<Error, void> {
// index.ts exits without printing a rejection, so every user-facing error goes through here.
return this.dispatch().mapRej((e) => {
p.log.error(color.red(e.message));
return e;
});
}

private dispatch(): Future<Error, void> {
if (process.platform === "win32") {
return Future.reject(new Error("`commit alias` is not supported on Windows yet — it writes POSIX shell shims."));
}

// Bound to a const so the narrowing survives into the callbacks below.
const action = this.action;

switch (action.type) {
case "list":
return Future.resolve(this.renderTable(this.initial));
case "add":
return parseName(action.name)
.chain((name) => this.createAlias(this.initial, name, action.target))
.map(() => undefined);
case "remove":
return parseName(action.name)
.chain((name) => this.deleteAlias(this.initial, name))
.map(() => undefined);
case "hub":
return reconcileShims(this.initial).chain(() => {
p.intro(color.bgCyan(color.black(" Aliases ")));
return this.hub(this.initial).map(() => p.outro(color.green("Done!")));
});
default:
return absurd(action, "AliasAction");
}
}

/** The registry is threaded through the loop rather than read from `this`, so the table never goes stale. */
private hub(aliases: readonly Alias[]): Future<Error, void> {
this.renderTable(aliases);

return Future.attemptP(async () => {
const choice = await p.select({
message: "What next?",
options: [
{ value: "create" as const, label: "Create alias" },
{ value: "delete" as const, label: "Delete alias", disabled: aliases.length === 0 },
{ value: "done" as const, label: "Done" }
],
initialValue: "create" as const
});

if (p.isCancel(choice)) throw cancelled();
return choice;
}).chain((choice) => {
switch (choice) {
case "create":
return this.createAlias(aliases).chain((next) => this.hub(next));
case "delete":
return this.deleteAlias(aliases).chain((next) => this.hub(next));
case "done":
return Future.resolve(undefined);
default:
return absurd(choice, "HubChoice");
}
});
}

private createAlias(aliases: readonly Alias[], presetName?: AliasName, presetTarget?: AliasTarget): Future<Error, readonly Alias[]> {
return this.resolveNewAlias(presetName, presetTarget)
.chain((alias) =>
addAlias(aliases, alias).either(
(msg) => Future.reject<Error, readonly Alias[]>(new Error(msg)),
(next) =>
writeShim(alias)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reconcile existing shims during direct mutations

When a user changes an nvm prefix or reinstalls the CLI and then runs scripted commit alias add ..., this call rewrites only the new alias; every existing shim continues executing the old absolute Node and entry-script paths embedded by shimSource, so those aliases run stale code or fail once the old prefix is removed. This is a P1 availability regression and contradicts the documented self-healing behavior. Replace the per-alias write with reconcileShims(next), and likewise reconcile the remaining aliases after direct removal.

AGENTS.md reference: AGENTS.md:L5-L12

Useful? React with 👍 / 👎.

.chain(() => saveAliases(next))
.map(() => next)
)
)
.chain((next) => this.reportCreated(next).map(() => next));
}

private resolveNewAlias(presetName?: AliasName, presetTarget?: AliasTarget): Future<Error, Alias> {
const name =
presetName ?
Future.resolve<Error, AliasName>(presetName)
: Future.attemptP(async () => {
const raw = await p.text({
message: "Alias name:",
placeholder: "cb",
validate: (value) =>
AliasName.parse(value ?? "").either(
(msg) => msg,
() => undefined
)
});
if (p.isCancel(raw)) throw cancelled();
return raw;
}).chain(parseName);

const target = (chosen?: AliasTarget): Future<Error, AliasTarget> =>
chosen ?
Future.resolve<Error, AliasTarget>(chosen)
: Future.attemptP(async () => {
const value = await p.select({
message: "Runs which command?",
options: ALIAS_TARGETS.map((t) => ({ value: t, label: `commit ${t}`, hint: describeTarget(t) })),
initialValue: "generate" as const
});
if (p.isCancel(value)) throw cancelled();
return value;
});

return name.chain((n) => this.confirmShadowing(n).map(() => n)).chain((n) => target(presetTarget).map((t): Alias => ({ name: n, target: t })));
}

/** The bin dir is prepended to PATH, so a name that already resolves elsewhere would be shadowed. */
private confirmShadowing(name: AliasName): Future<Error, void> {
return findConflictingBinary(name).chain((conflict) => {
if (!(conflict instanceof Just)) return Future.resolve(undefined);

if (!process.stdout.isTTY) {
p.log.warn(color.yellow(`'${name.value}' already exists at ${conflict.value} — the alias will shadow it.`));
return Future.resolve(undefined);
}

return Future.attemptP(async () => {
const ok = await p.confirm({ message: `'${name.value}' already exists at ${conflict.value}. Shadow it?`, initialValue: false });
if (p.isCancel(ok) || !ok) throw cancelled();
});
});
}

private reportCreated(aliases: readonly Alias[]): Future<Error, void> {
const created = aliases[aliases.length - 1];
if (!created) return Future.resolve(undefined);

p.log.success(`Created ${color.cyan(created.name.value)} -> ${color.dim(`commit ${created.target}`)} (${shimPath(created.name)})`);
return isBinDirOnPath() ? Future.resolve(undefined) : this.offerPathSetup();
}

private offerPathSetup(): Future<Error, void> {
const profile = detectProfile();

if (!(profile instanceof Just) || !process.stdout.isTTY) {
const shell = profile instanceof Just ? profile.value.shell : "bash";
p.note(pathExportLine(shell), `Add ${aliasBinDir()} to your PATH`);
return Future.resolve(undefined);
}

const { file, shell } = profile.value;

return Future.attemptP(async () => {
const ok = await p.confirm({ message: `Add ${aliasBinDir()} to your PATH in ${file}?`, initialValue: true });
return !p.isCancel(ok) && ok;
}).chain((ok) => {
if (!ok) {
p.note(pathExportLine(shell), `Add ${aliasBinDir()} to your PATH`);
return Future.resolve(undefined);
}

return ensureBinDirOnPath(profile.value).map((outcome) => {
if (outcome === "added") p.note(`source ${file}`, "Run this to use the alias in this shell");
});
});
}

private deleteAlias(aliases: readonly Alias[], preset?: AliasName): Future<Error, readonly Alias[]> {
// Only the interactive picker needs something to pick from. A named delete must still
// reach `removeAlias`, so an unknown name fails instead of reporting a silent success.
if (aliases.length === 0 && !preset) {
p.log.info("No custom aliases to delete.");
return Future.resolve(aliases);
}

return this.resolveNameToDelete(aliases, preset).chain((name) =>
removeAlias(aliases, name).either(
(msg) => Future.reject<Error, readonly Alias[]>(new Error(msg)),
(next) =>
removeShim(name)
.chain(() => saveAliases(next))
.map(() => {
p.log.success(`Deleted ${color.cyan(name.value)}`);
return next;
})
)
);
}

private resolveNameToDelete(aliases: readonly Alias[], preset?: AliasName): Future<Error, AliasName> {
if (preset) return Future.resolve(preset);

return Future.attemptP(async () => {
const value = await p.select({
message: "Delete which alias?",
options: aliases.map((a) => ({ value: a.name.value, label: a.name.value, hint: `commit ${a.target}` }))
});
if (p.isCancel(value)) throw cancelled();

const confirmed = await p.confirm({ message: `Delete '${value}'?`, initialValue: false });
if (p.isCancel(confirmed) || !confirmed) throw cancelled();

return value;
}).chain(parseName);
}

private renderTable(aliases: readonly Alias[]): void {
const table = new Table({
head: [color.cyan("Alias"), color.cyan("Runs"), color.cyan("Source")],
colWidths: [16, 24, 12]
});

for (const alias of aliases) {
table.push([alias.name.value, `commit ${alias.target}`, color.green("custom")]);
}
table.push(["commit", "commit generate", color.gray("built-in")]);

process.stdout.write("\n" + table.toString() + "\n\n");
}
}
37 changes: 36 additions & 1 deletion src/cli/parser.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,52 @@
export { type CliCommand, parseArgs, showHelp, showVersion };
export { type AliasAction, type CliCommand, parseArgs, showHelp, showVersion };

import * as D from "@/libs/json/decoder";

import { ALIAS_TARGETS, type AliasTarget } from "@/domain/alias/alias";
import { Result } from "@/libs/result";
import { version as packageVersion } from "@/package.json";

type AliasAction = { type: "hub" } | { type: "list" } | { type: "add"; name: string; target: AliasTarget } | { type: "remove"; name: string };

type CliCommand =
| { type: "generate" }
| { type: "setup" }
| { type: "doctor" }
| { type: "model" }
| { type: "effort" }
| { type: "branch" }
| { type: "alias"; action: AliasAction }
| { type: "update" }
| { type: "version" }
| { type: "help" };

const isAliasTarget = (value: string): value is AliasTarget => (ALIAS_TARGETS as readonly string[]).includes(value);

// The name stays a raw string here; AliasCommand turns it into an AliasName so both
// the interactive and the scripted entry points report the same validation message.
const parseAliasAction = (args: string[]): D.Decoder<CliCommand> => {
const [sub, name, target] = [args[1], args[2], args[3]];

switch (sub) {
case undefined:
return D.succeed({ type: "alias", action: { type: "hub" } });
case "list":
case "ls":
return D.succeed({ type: "alias", action: { type: "list" } });
case "add":
case "new":
if (!name || !target) return D.fail(`Usage: commit alias add <name> <target>. Targets: ${ALIAS_TARGETS.join(", ")}`);
if (!isAliasTarget(target)) return D.fail(`Alias target must be one of: ${ALIAS_TARGETS.join(", ")}`);
return D.succeed({ type: "alias", action: { type: "add", name, target } });
case "remove":
case "rm":
if (!name) return D.fail("Usage: commit alias remove <name>");
return D.succeed({ type: "alias", action: { type: "remove", name } });
default:
return D.fail(`Unknown alias subcommand: ${sub}`);
}
};

const cliCommandDecoder: D.Decoder<CliCommand> = D.array(D.string).chain((args) => {
const cmd = args[0] || "generate";

Expand All @@ -34,6 +65,9 @@ const cliCommandDecoder: D.Decoder<CliCommand> = D.array(D.string).chain((args)
case "branch":
case "new-branch":
return D.succeed({ type: "branch" });
case "alias":
case "aliases":
return parseAliasAction(args);
case "update":
return D.succeed({ type: "update" });
case "--version":
Expand Down Expand Up @@ -62,6 +96,7 @@ Commands:
doctor Check installation and environment
model Select a different AI model
effort Adjust the reasoning effort for the current model
alias Manage extra CLI names (list, add <name> <target>, remove <name>)
update Install the latest version from npm
--version, -v Show version
--help, -h Show help
Expand Down
Loading