contribute-now 0.8.0-dev.7db6dea → 0.8.0-dev.7eaa951

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +16 -5
  2. package/dist/cli.js +483 -167
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -88,7 +88,8 @@ cn setup # short alias — even shorter than git!
88
88
 
89
89
  - **[Git](https://git-scm.com/)** — required
90
90
  - **[GitHub CLI](https://cli.github.com)** (`gh`) — recommended; required for PR creation, role detection, and merge status checks
91
- - **[GitHub Copilot](https://github.com/features/copilot)** — optional; enables AI features
91
+ - **[GitHub Copilot](https://github.com/features/copilot)** — optional; one of the supported AI providers
92
+ - **[Ollama Cloud](https://ollama.com)** or **[OpenRouter](https://openrouter.ai)** API key — optional; alternative AI providers
92
93
 
93
94
  ---
94
95
 
@@ -106,7 +107,7 @@ Steps:
106
107
  1. Choose **workflow mode** — Clean Flow, GitHub Flow, or Git Flow
107
108
  2. Choose **commit convention** — Clean Commit, Conventional Commits, or None
108
109
  3. Choose whether **AI features** should be enabled for this repo
109
- 4. If using **Ollama Cloud**, pick from the available models returned by your API key, or enter one manually
110
+ 4. If using **Ollama Cloud** or **OpenRouter**, enter your API key; pick from the available models returned by your key, or enter one manually
110
111
  5. Detect remotes and auto-detect your **role** (maintainer or contributor)
111
112
  6. Confirm branch and remote names
112
113
  7. Write `.git/contribute-now/config.json` (or update `.contributerc.json` if that legacy file is still the active source)
@@ -127,7 +128,7 @@ cn config --json
127
128
  cn config --edit
128
129
  ```
129
130
 
130
- Use `--edit` to update workflow settings, branch names, commit convention, AI provider details, the stored Ollama Cloud API key, and to choose from the currently available Ollama Cloud models. Ollama Cloud uses the built-in default host and does not ask for a custom host URL.
131
+ Use `--edit` to update workflow settings, branch names, commit convention, AI provider details, stored API keys (Ollama Cloud or OpenRouter), and to choose from the currently available models for the selected provider.
131
132
 
132
133
  ---
133
134
 
@@ -306,7 +307,7 @@ cn validate "added stuff" # exit 1
306
307
 
307
308
  ## AI Features
308
309
 
309
- All AI features are powered by **GitHub Copilot** via `@github/copilot-sdk` and are entirely **optional** — every command has a manual fallback.
310
+ All AI features are **optional** — every command has a manual fallback. Three providers are supported: **GitHub Copilot**, **Ollama Cloud**, and **OpenRouter**.
310
311
 
311
312
  | Command | AI Feature | Fallback |
312
313
  |---------|------------|----------|
@@ -316,7 +317,17 @@ All AI features are powered by **GitHub Copilot** via `@github/copilot-sdk` and
316
317
  | `update` | Conflict resolution guidance | Standard git instructions |
317
318
  | `submit` | Generate PR title and body | `gh pr create --fill` or manual |
318
319
 
319
- Pass `--no-ai` to any command to skip AI entirely. Use `--model <name>` to select a specific Copilot model (e.g., `gpt-4.1`, `claude-sonnet-4`).
320
+ Pass `--no-ai` to any command to skip AI entirely. Use `--model <name>` to select a specific model (e.g., `gpt-4.1`, `claude-sonnet-4`).
321
+
322
+ ### AI Providers
323
+
324
+ | Provider | Auth | How it works |
325
+ |----------|------|--------------|
326
+ | **GitHub Copilot** *(default)* | `gh auth login` | Uses your existing GitHub/Copilot auth via the `@github/copilot-sdk` |
327
+ | **Ollama Cloud** | API key (stored in local secrets) | OpenAI-compatible API; model list fetched from your key on setup |
328
+ | **OpenRouter** | API key (stored in local secrets) | Unified API that routes to many model providers (OpenAI, Anthropic, Google, etc.) |
329
+
330
+ Select your provider during `cn setup` or change it later with `cn config --edit`. API keys for Ollama Cloud and OpenRouter are stored as plain JSON in `~/.contribute-now/secrets/store.json` with file permissions restricted to the current user (mode 0600) — never in the plain config file.
320
331
 
321
332
  ---
322
333
 
package/dist/cli.js CHANGED
@@ -7206,7 +7206,7 @@ function configExists(cwd = process.cwd()) {
7206
7206
  var VALID_WORKFLOWS = ["clean-flow", "github-flow", "git-flow"];
7207
7207
  var VALID_ROLES = ["maintainer", "contributor"];
7208
7208
  var VALID_CONVENTIONS = ["conventional", "clean-commit", "none"];
7209
- var VALID_AI_PROVIDERS = ["copilot", "ollama-cloud"];
7209
+ var VALID_AI_PROVIDERS = ["copilot", "ollama-cloud", "openrouter"];
7210
7210
  function isAIEnabled(config, cliNoAI = false) {
7211
7211
  return config.aiEnabled !== false && !cliNoAI;
7212
7212
  }
@@ -10624,6 +10624,7 @@ import { join as join5, resolve as resolve4 } from "path";
10624
10624
  var CONTRIBUTE_NOW_SECRETS_DIRNAME = ".contribute-now";
10625
10625
  var CONTRIBUTE_NOW_SECRETS_STORE_DIRNAME = "secrets";
10626
10626
  var OLLAMA_CLOUD_API_KEY = "ollama.cloud.apiKey";
10627
+ var OPENROUTER_API_KEY = "openrouter.apiKey";
10627
10628
  function getSecretsStorePath(baseDir = homedir()) {
10628
10629
  return resolve4(baseDir, CONTRIBUTE_NOW_SECRETS_DIRNAME, CONTRIBUTE_NOW_SECRETS_STORE_DIRNAME);
10629
10630
  }
@@ -10689,6 +10690,36 @@ async function deleteOllamaCloudApiKey(baseDir = homedir()) {
10689
10690
  writeSecretsStore(nextStore, baseDir);
10690
10691
  return true;
10691
10692
  }
10693
+ async function hasOpenRouterApiKey(baseDir = homedir()) {
10694
+ return typeof readSecretsStore(baseDir)?.[OPENROUTER_API_KEY] === "string";
10695
+ }
10696
+ async function getOpenRouterApiKey(baseDir = homedir()) {
10697
+ return readSecretsStore(baseDir)?.[OPENROUTER_API_KEY] ?? null;
10698
+ }
10699
+ async function setOpenRouterApiKey(value, baseDir = homedir()) {
10700
+ const existingStore = readSecretsStore(baseDir) ?? {};
10701
+ writeSecretsStore({
10702
+ ...existingStore,
10703
+ [OPENROUTER_API_KEY]: value
10704
+ }, baseDir);
10705
+ }
10706
+ async function deleteOpenRouterApiKey(baseDir = homedir()) {
10707
+ const existingStore = readSecretsStore(baseDir);
10708
+ if (!existingStore || !(OPENROUTER_API_KEY in existingStore)) {
10709
+ return false;
10710
+ }
10711
+ const nextStore = { ...existingStore };
10712
+ delete nextStore[OPENROUTER_API_KEY];
10713
+ if (Object.keys(nextStore).length === 0) {
10714
+ try {
10715
+ rmSync(getSecretsFilePath(baseDir), { force: true });
10716
+ rmSync(getSecretsStorePath(baseDir), { recursive: true, force: true });
10717
+ } catch {}
10718
+ return true;
10719
+ }
10720
+ writeSecretsStore(nextStore, baseDir);
10721
+ return true;
10722
+ }
10692
10723
 
10693
10724
  // src/utils/copilot.ts
10694
10725
  var CONVENTIONAL_COMMIT_SYSTEM_PROMPT = `Git commit message generator. Format: <type>[!][(<scope>)]: <description>
@@ -10756,6 +10787,8 @@ Rules: title concise present tense, describes the PR theme not individual commit
10756
10787
  var CONFLICT_RESOLUTION_SYSTEM_PROMPT = `Git merge conflict advisor. Explain each side, suggest resolution strategy. Never auto-resolve \u2014 guidance only. Be concise and actionable.`;
10757
10788
  var DEFAULT_OLLAMA_CLOUD_MODEL = "gpt-oss:120b";
10758
10789
  var DEFAULT_OLLAMA_CLOUD_HOST = "https://ollama.com/v1";
10790
+ var DEFAULT_OPENROUTER_MODEL = "openai/gpt-4o-mini";
10791
+ var DEFAULT_OPENROUTER_HOST = "https://openrouter.ai/api/v1";
10759
10792
  function prioritizeOllamaCloudModels(models, preferredModel = DEFAULT_OLLAMA_CLOUD_MODEL) {
10760
10793
  const uniqueModels = [...new Set(models.map((model) => model.trim()).filter(Boolean))];
10761
10794
  const sortedModels = [...uniqueModels].sort((left, right) => left.localeCompare(right));
@@ -10763,7 +10796,9 @@ function prioritizeOllamaCloudModels(models, preferredModel = DEFAULT_OLLAMA_CLO
10763
10796
  }
10764
10797
  function extractOllamaCloudModelIds(payload) {
10765
10798
  const records = typeof payload === "object" && payload !== null ? Array.isArray(payload.data) ? payload.data : Array.isArray(payload.models) ? payload.models : [] : [];
10766
- return [...new Set(records.map(getOllamaCloudModelId).filter(Boolean))].sort((left, right) => left.localeCompare(right));
10799
+ return [
10800
+ ...new Set(records.map(getOllamaCloudModelId).filter((id) => id !== null))
10801
+ ].sort((left, right) => left.localeCompare(right));
10767
10802
  }
10768
10803
  function getOllamaCloudModelId(record) {
10769
10804
  if (typeof record !== "object" || record === null) {
@@ -10792,6 +10827,40 @@ function normalizeOllamaCloudHost(host) {
10792
10827
  const trimmed = (host?.trim() || DEFAULT_OLLAMA_CLOUD_HOST).replace(/\/+$/, "");
10793
10828
  return trimmed.endsWith("/v1") ? trimmed : `${trimmed}/v1`;
10794
10829
  }
10830
+ function extractOpenRouterModelIds(payload) {
10831
+ const records = typeof payload === "object" && payload !== null ? Array.isArray(payload.data) ? payload.data : [] : [];
10832
+ return [
10833
+ ...new Set(records.map(getOpenRouterModelId).filter((id) => id !== null))
10834
+ ].sort((left, right) => left.localeCompare(right));
10835
+ }
10836
+ function getOpenRouterModelId(record) {
10837
+ if (typeof record !== "object" || record === null) {
10838
+ return null;
10839
+ }
10840
+ const candidate = typeof record.id === "string" ? record.id : null;
10841
+ const normalized = candidate?.trim();
10842
+ return normalized ? normalized : null;
10843
+ }
10844
+ async function fetchOpenRouterModels(apiKey) {
10845
+ const response = await fetch(`${DEFAULT_OPENROUTER_HOST}/models`, {
10846
+ headers: {
10847
+ Accept: "application/json",
10848
+ Authorization: `Bearer ${apiKey}`
10849
+ }
10850
+ });
10851
+ if (!response.ok) {
10852
+ if (response.status === 401 || response.status === 403) {
10853
+ throw new Error("OpenRouter authentication failed");
10854
+ }
10855
+ throw new Error(`OpenRouter model lookup failed (${response.status} ${response.statusText})`);
10856
+ }
10857
+ return extractOpenRouterModelIds(await response.json());
10858
+ }
10859
+ function prioritizeOpenRouterModels(models, preferredModel = DEFAULT_OPENROUTER_MODEL) {
10860
+ const uniqueModels = [...new Set(models.map((model) => model.trim()).filter(Boolean))];
10861
+ const sortedModels = [...uniqueModels].sort((left, right) => left.localeCompare(right));
10862
+ return sortedModels.includes(preferredModel) ? [preferredModel, ...sortedModels.filter((model) => model !== preferredModel)] : sortedModels;
10863
+ }
10795
10864
  function resolveAIConfig(config) {
10796
10865
  const resolvedConfig = config ?? readConfig();
10797
10866
  const provider = resolvedConfig?.aiProvider ?? "copilot";
@@ -10803,6 +10872,14 @@ function resolveAIConfig(config) {
10803
10872
  host: DEFAULT_OLLAMA_CLOUD_HOST
10804
10873
  };
10805
10874
  }
10875
+ if (provider === "openrouter") {
10876
+ return {
10877
+ provider,
10878
+ providerLabel: "OpenRouter",
10879
+ model: resolvedConfig?.aiModel?.trim() || DEFAULT_OPENROUTER_MODEL,
10880
+ host: DEFAULT_OPENROUTER_HOST
10881
+ };
10882
+ }
10806
10883
  return {
10807
10884
  provider: "copilot",
10808
10885
  providerLabel: "GitHub Copilot"
@@ -10996,6 +11073,28 @@ async function checkCopilotAvailable2() {
10996
11073
  return `Could not reach Ollama Cloud API: ${msg}`;
10997
11074
  }
10998
11075
  }
11076
+ if (aiConfig.provider === "openrouter") {
11077
+ if (!await hasOpenRouterApiKey()) {
11078
+ return "OpenRouter API key not found. Run `cn setup` to save it.";
11079
+ }
11080
+ try {
11081
+ const apiKey = await getOpenRouterApiKey();
11082
+ if (!apiKey) {
11083
+ return "OpenRouter API key not found. Run `cn setup` to save it.";
11084
+ }
11085
+ await fetchOpenRouterModels(apiKey);
11086
+ return null;
11087
+ } catch (err) {
11088
+ const msg = err instanceof Error ? err.message : String(err);
11089
+ if (msg === "OpenRouter authentication failed") {
11090
+ return "OpenRouter authentication failed. Update your saved API key with `cn setup`.";
11091
+ }
11092
+ if (msg.startsWith("OpenRouter model lookup failed")) {
11093
+ return msg.replace("model lookup", "health check");
11094
+ }
11095
+ return `Could not reach OpenRouter API: ${msg}`;
11096
+ }
11097
+ }
10999
11098
  try {
11000
11099
  const client = await getManagedClient();
11001
11100
  try {
@@ -11097,11 +11196,54 @@ async function callOllamaCloud(systemMessage, userMessage, model, timeoutMs = CO
11097
11196
  clearTimeout(timer);
11098
11197
  }
11099
11198
  }
11199
+ async function callOpenRouter(systemMessage, userMessage, model, timeoutMs = COPILOT_TIMEOUT_MS) {
11200
+ const aiConfig = resolveAIConfig();
11201
+ const apiKey = await getOpenRouterApiKey();
11202
+ if (!apiKey) {
11203
+ throw new Error("OpenRouter API key is not configured");
11204
+ }
11205
+ const controller = new AbortController;
11206
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
11207
+ try {
11208
+ const response = await fetch(`${DEFAULT_OPENROUTER_HOST}/chat/completions`, {
11209
+ method: "POST",
11210
+ headers: {
11211
+ Authorization: `Bearer ${apiKey}`,
11212
+ "Content-Type": "application/json",
11213
+ "HTTP-Referer": "https://github.com/warengonzaga/contribute-now",
11214
+ "X-Title": "contribute-now"
11215
+ },
11216
+ body: JSON.stringify({
11217
+ model: model?.trim() || aiConfig.model || DEFAULT_OPENROUTER_MODEL,
11218
+ messages: [
11219
+ { role: "system", content: systemMessage },
11220
+ { role: "user", content: userMessage }
11221
+ ],
11222
+ stream: false
11223
+ }),
11224
+ signal: controller.signal
11225
+ });
11226
+ if (!response.ok) {
11227
+ const body = await response.text();
11228
+ if (response.status === 401 || response.status === 403) {
11229
+ throw new Error("OpenRouter authentication failed");
11230
+ }
11231
+ throw new Error(`OpenRouter request failed (${response.status} ${response.statusText}): ${body.slice(0, 200)}`);
11232
+ }
11233
+ const data = await response.json();
11234
+ return data.choices?.[0]?.message?.content?.trim() || null;
11235
+ } finally {
11236
+ clearTimeout(timer);
11237
+ }
11238
+ }
11100
11239
  async function callAI(systemMessage, userMessage, model, timeoutMs = COPILOT_TIMEOUT_MS) {
11101
11240
  const aiConfig = resolveAIConfig();
11102
11241
  if (aiConfig.provider === "ollama-cloud") {
11103
11242
  return callOllamaCloud(systemMessage, userMessage, model, timeoutMs);
11104
11243
  }
11244
+ if (aiConfig.provider === "openrouter") {
11245
+ return callOpenRouter(systemMessage, userMessage, model, timeoutMs);
11246
+ }
11105
11247
  return callCopilot(systemMessage, userMessage, model, timeoutMs);
11106
11248
  }
11107
11249
  function getCommitSystemPrompt(convention) {
@@ -11992,116 +12134,8 @@ ${import_picocolors8.default.bold("Stale branches (remote deleted, likely squash
11992
12134
  }
11993
12135
  });
11994
12136
 
11995
- // src/commands/discard.ts
11996
- var import_picocolors9 = __toESM(require_picocolors(), 1);
11997
- var discard_default = defineCommand({
11998
- meta: {
11999
- name: "discard",
12000
- description: "Discard the current feature branch and return to the base branch"
12001
- },
12002
- args: {
12003
- force: {
12004
- type: "boolean",
12005
- alias: "f",
12006
- description: "Skip confirmation and discard immediately",
12007
- default: false
12008
- }
12009
- },
12010
- async run({ args }) {
12011
- if (!await isGitRepo()) {
12012
- error("Not inside a git repository.");
12013
- process.exit(1);
12014
- }
12015
- await assertCleanGitState("discarding a branch");
12016
- const config = readConfig();
12017
- if (!config) {
12018
- error("No repo config found. Run `cn setup` first.");
12019
- process.exit(1);
12020
- }
12021
- const currentBranch = await getCurrentBranch();
12022
- const baseBranch = getBaseBranch(config);
12023
- await projectHeading("discard", "\uD83D\uDDD1\uFE0F");
12024
- if (isBranchProtected(currentBranch, config)) {
12025
- error(`${import_picocolors9.default.bold(currentBranch)} is a protected branch and cannot be discarded.`);
12026
- info(`Switch to a feature branch first, then run ${import_picocolors9.default.bold("cn discard")}.`);
12027
- process.exit(1);
12028
- }
12029
- if (currentBranch === baseBranch) {
12030
- info(`You are already on ${import_picocolors9.default.bold(baseBranch)}.`);
12031
- process.exit(0);
12032
- }
12033
- const { origin } = config;
12034
- const localWork = await hasLocalWork(origin, currentBranch);
12035
- const hasWork = localWork.uncommitted || localWork.unpushedCommits > 0;
12036
- if (hasWork) {
12037
- if (localWork.uncommitted) {
12038
- warn("You have uncommitted changes in your working tree.");
12039
- }
12040
- if (localWork.unpushedCommits > 0) {
12041
- warn(`You have ${import_picocolors9.default.bold(String(localWork.unpushedCommits))} unpushed commit${localWork.unpushedCommits !== 1 ? "s" : ""} on this branch.`);
12042
- }
12043
- warn("Discarding this branch will permanently lose that work.");
12044
- const SAVE_FIRST = "Save my changes first (cn save), then discard";
12045
- const DISCARD_ANYWAY = "Discard anyway \u2014 I do not need this work";
12046
- const CANCEL = "Keep the branch, take me back";
12047
- const action = await selectPrompt("This branch has unsaved work. What would you like to do?", [SAVE_FIRST, DISCARD_ANYWAY, CANCEL]);
12048
- if (action === CANCEL) {
12049
- info("Discard cancelled. Your branch is untouched.");
12050
- process.exit(0);
12051
- }
12052
- if (action === SAVE_FIRST) {
12053
- if (!localWork.uncommitted) {
12054
- info("No uncommitted changes to stash \u2014 unpushed commits will still be lost.");
12055
- const confirm = await confirmPrompt("Continue discarding the branch?");
12056
- if (!confirm) {
12057
- info("Discard cancelled.");
12058
- process.exit(0);
12059
- }
12060
- } else {
12061
- const stashResult = await stashChanges(`work-in-progress on ${currentBranch}`);
12062
- if (stashResult.exitCode !== 0) {
12063
- error(`Failed to save changes: ${stashResult.stderr}`);
12064
- process.exit(1);
12065
- }
12066
- success(`Changes saved. Use ${import_picocolors9.default.bold("cn save --restore")} to bring them back.`);
12067
- }
12068
- }
12069
- } else if (!args.force) {
12070
- const confirmed = await confirmPrompt(`Discard ${import_picocolors9.default.bold(currentBranch)} and return to ${import_picocolors9.default.bold(baseBranch)}?`);
12071
- if (!confirmed) {
12072
- info("Discard cancelled.");
12073
- process.exit(0);
12074
- }
12075
- }
12076
- const upstreamRef = await getUpstreamRef();
12077
- let deleteRemote = false;
12078
- if (upstreamRef) {
12079
- deleteRemote = await confirmPrompt(`Also delete the remote branch ${import_picocolors9.default.bold(upstreamRef)}?`);
12080
- }
12081
- const checkoutResult = await checkoutBranch(baseBranch);
12082
- if (checkoutResult.exitCode !== 0) {
12083
- error(`Failed to switch to ${import_picocolors9.default.bold(baseBranch)}: ${checkoutResult.stderr}`);
12084
- process.exit(1);
12085
- }
12086
- const deleteResult = await forceDeleteBranch(currentBranch);
12087
- if (deleteResult.exitCode !== 0) {
12088
- error(`Failed to delete branch ${import_picocolors9.default.bold(currentBranch)}: ${deleteResult.stderr}`);
12089
- process.exit(1);
12090
- }
12091
- success(`Discarded ${import_picocolors9.default.bold(currentBranch)} and switched back to ${import_picocolors9.default.bold(baseBranch)}`);
12092
- if (deleteRemote) {
12093
- const remoteDeleteResult = await deleteRemoteBranch(origin, currentBranch);
12094
- if (remoteDeleteResult.exitCode !== 0) {
12095
- warn(`Could not delete remote branch: ${remoteDeleteResult.stderr.trim()}`);
12096
- } else {
12097
- success(`Deleted remote branch ${import_picocolors9.default.bold(`${origin}/${currentBranch}`)}`);
12098
- }
12099
- }
12100
- }
12101
- });
12102
-
12103
12137
  // src/commands/commit.ts
12104
- var import_picocolors10 = __toESM(require_picocolors(), 1);
12138
+ var import_picocolors9 = __toESM(require_picocolors(), 1);
12105
12139
 
12106
12140
  // src/utils/convention.ts
12107
12141
  var CLEAN_COMMIT_PATTERN = /^(\uD83D\uDCE6|\uD83D\uDD27|\uD83D\uDDD1\uFE0F?|\uD83D\uDD12|\u2699\uFE0F?|\u2615|\uD83E\uDDEA|\uD83D\uDCD6|\uD83D\uDE80) (new|update|remove|security|setup|chore|test|docs|release)(!?)( \([a-zA-Z0-9][a-zA-Z0-9-]*\))?: .{1,72}$/u;
@@ -12208,9 +12242,9 @@ var commit_default = defineCommand({
12208
12242
  process.exit(1);
12209
12243
  }
12210
12244
  console.log(`
12211
- ${import_picocolors10.default.bold("Changed files:")}`);
12245
+ ${import_picocolors9.default.bold("Changed files:")}`);
12212
12246
  for (const f3 of changedFiles) {
12213
- console.log(` ${import_picocolors10.default.dim("\u2022")} ${f3}`);
12247
+ console.log(` ${import_picocolors9.default.dim("\u2022")} ${f3}`);
12214
12248
  }
12215
12249
  const stageAction = await selectPrompt("No staged changes. How would you like to stage?", [
12216
12250
  "Stage all changes",
@@ -12252,8 +12286,8 @@ ${import_picocolors10.default.bold("Changed files:")}`);
12252
12286
  const dirs = new Set(stagedFiles.map((f3) => f3.split("/")[0]));
12253
12287
  if (dirs.size > 1) {
12254
12288
  console.log();
12255
- warn(`You're staging ${import_picocolors10.default.bold(String(stagedFiles.length))} files across ${import_picocolors10.default.bold(String(dirs.size))} directories in a single commit.`);
12256
- info(import_picocolors10.default.dim("Large commits mixing different topics make history harder to read and bisect. " + "For cleaner history, consider splitting into atomic commits."));
12289
+ warn(`You're staging ${import_picocolors9.default.bold(String(stagedFiles.length))} files across ${import_picocolors9.default.bold(String(dirs.size))} directories in a single commit.`);
12290
+ info(import_picocolors9.default.dim("Large commits mixing different topics make history harder to read and bisect. " + "For cleaner history, consider splitting into atomic commits."));
12257
12291
  const choice = await selectPrompt("How would you like to proceed?", [
12258
12292
  "Continue as single commit",
12259
12293
  "Switch to group mode (AI splits into atomic commits)",
@@ -12284,7 +12318,7 @@ ${import_picocolors10.default.bold("Changed files:")}`);
12284
12318
  if (commitMessage) {
12285
12319
  spinner.success("AI commit message generated.");
12286
12320
  console.log(`
12287
- ${import_picocolors10.default.dim("AI suggestion:")} ${import_picocolors10.default.bold(import_picocolors10.default.cyan(commitMessage))}`);
12321
+ ${import_picocolors9.default.dim("AI suggestion:")} ${import_picocolors9.default.bold(import_picocolors9.default.cyan(commitMessage))}`);
12288
12322
  } else {
12289
12323
  spinner.fail("AI did not return a commit message.");
12290
12324
  warn("Falling back to manual entry.");
@@ -12312,7 +12346,7 @@ ${import_picocolors10.default.bold("Changed files:")}`);
12312
12346
  if (regen) {
12313
12347
  spinner.success("Commit message regenerated.");
12314
12348
  console.log(`
12315
- ${import_picocolors10.default.dim("AI suggestion:")} ${import_picocolors10.default.bold(import_picocolors10.default.cyan(regen))}`);
12349
+ ${import_picocolors9.default.dim("AI suggestion:")} ${import_picocolors9.default.bold(import_picocolors9.default.cyan(regen))}`);
12316
12350
  const ok = await confirmPrompt("Use this message?");
12317
12351
  finalMessage = ok ? regen : await inputPrompt("Enter commit message manually");
12318
12352
  } else {
@@ -12327,7 +12361,7 @@ ${import_picocolors10.default.bold("Changed files:")}`);
12327
12361
  if (convention2 !== "none") {
12328
12362
  console.log();
12329
12363
  for (const hint of CONVENTION_FORMAT_HINTS[convention2]) {
12330
- console.log(import_picocolors10.default.dim(hint));
12364
+ console.log(import_picocolors9.default.dim(hint));
12331
12365
  }
12332
12366
  console.log();
12333
12367
  }
@@ -12351,7 +12385,7 @@ ${import_picocolors10.default.bold("Changed files:")}`);
12351
12385
  error(`Failed to commit: ${result.stderr}`);
12352
12386
  process.exit(1);
12353
12387
  }
12354
- success(`Committed: ${import_picocolors10.default.bold(finalMessage)}`);
12388
+ success(`Committed: ${import_picocolors9.default.bold(finalMessage)}`);
12355
12389
  }
12356
12390
  });
12357
12391
  async function runGroupCommit(model, config) {
@@ -12368,9 +12402,9 @@ async function runGroupCommit(model, config) {
12368
12402
  process.exit(1);
12369
12403
  }
12370
12404
  console.log(`
12371
- ${import_picocolors10.default.bold("Changed files:")}`);
12405
+ ${import_picocolors9.default.bold("Changed files:")}`);
12372
12406
  for (const f3 of changedFiles) {
12373
- console.log(` ${import_picocolors10.default.dim("\u2022")} ${f3}`);
12407
+ console.log(` ${import_picocolors9.default.dim("\u2022")} ${f3}`);
12374
12408
  }
12375
12409
  const spinner = createSpinner(changedFiles.length >= BATCH_CONFIG.LARGE_CHANGESET_THRESHOLD ? `Asking AI to group ${changedFiles.length} file(s) into logical commits (using optimized batching)...` : `Asking AI to group ${changedFiles.length} file(s) into logical commits...`, {
12376
12410
  tips: LOADING_TIPS
@@ -12416,13 +12450,13 @@ ${import_picocolors10.default.bold("Changed files:")}`);
12416
12450
  let commitAll = false;
12417
12451
  while (!proceedToCommit) {
12418
12452
  console.log(`
12419
- ${import_picocolors10.default.bold(`AI suggested ${validGroups.length} commit group(s):`)}
12453
+ ${import_picocolors9.default.bold(`AI suggested ${validGroups.length} commit group(s):`)}
12420
12454
  `);
12421
12455
  for (let i2 = 0;i2 < validGroups.length; i2++) {
12422
12456
  const g3 = validGroups[i2];
12423
- console.log(` ${import_picocolors10.default.cyan(`Group ${i2 + 1}:`)} ${import_picocolors10.default.bold(g3.message)}`);
12457
+ console.log(` ${import_picocolors9.default.cyan(`Group ${i2 + 1}:`)} ${import_picocolors9.default.bold(g3.message)}`);
12424
12458
  for (const f3 of g3.files) {
12425
- console.log(` ${import_picocolors10.default.dim("\u2022")} ${f3}`);
12459
+ console.log(` ${import_picocolors9.default.dim("\u2022")} ${f3}`);
12426
12460
  }
12427
12461
  console.log();
12428
12462
  }
@@ -12488,16 +12522,16 @@ ${import_picocolors10.default.bold(`AI suggested ${validGroups.length} commit gr
12488
12522
  continue;
12489
12523
  }
12490
12524
  committed++;
12491
- success(`Committed group ${i2 + 1}: ${import_picocolors10.default.bold(group.message)}`);
12525
+ success(`Committed group ${i2 + 1}: ${import_picocolors9.default.bold(group.message)}`);
12492
12526
  }
12493
12527
  } else {
12494
12528
  for (let i2 = 0;i2 < validGroups.length; i2++) {
12495
12529
  const group = validGroups[i2];
12496
- console.log(import_picocolors10.default.bold(`
12530
+ console.log(import_picocolors9.default.bold(`
12497
12531
  \u2500\u2500 Group ${i2 + 1}/${validGroups.length} \u2500\u2500`));
12498
- console.log(` ${import_picocolors10.default.cyan(group.message)}`);
12532
+ console.log(` ${import_picocolors9.default.cyan(group.message)}`);
12499
12533
  for (const f3 of group.files) {
12500
- console.log(` ${import_picocolors10.default.dim("\u2022")} ${f3}`);
12534
+ console.log(` ${import_picocolors9.default.dim("\u2022")} ${f3}`);
12501
12535
  }
12502
12536
  let message = group.message;
12503
12537
  let actionDone = false;
@@ -12521,7 +12555,7 @@ ${import_picocolors10.default.bold(`AI suggested ${validGroups.length} commit gr
12521
12555
  if (newMsg) {
12522
12556
  message = newMsg;
12523
12557
  group.message = newMsg;
12524
- regenSpinner.success(`New message: ${import_picocolors10.default.bold(message)}`);
12558
+ regenSpinner.success(`New message: ${import_picocolors9.default.bold(message)}`);
12525
12559
  } else {
12526
12560
  regenSpinner.fail("AI could not generate a new message. Keeping current one.");
12527
12561
  }
@@ -12584,7 +12618,7 @@ ${import_picocolors10.default.bold(`AI suggested ${validGroups.length} commit gr
12584
12618
  continue;
12585
12619
  }
12586
12620
  committed++;
12587
- success(`Committed group ${i2 + 1}: ${import_picocolors10.default.bold(message)}`);
12621
+ success(`Committed group ${i2 + 1}: ${import_picocolors9.default.bold(message)}`);
12588
12622
  actionDone = true;
12589
12623
  }
12590
12624
  }
@@ -12599,7 +12633,7 @@ ${import_picocolors10.default.bold(`AI suggested ${validGroups.length} commit gr
12599
12633
  }
12600
12634
 
12601
12635
  // src/commands/config.ts
12602
- var import_picocolors11 = __toESM(require_picocolors(), 1);
12636
+ var import_picocolors10 = __toESM(require_picocolors(), 1);
12603
12637
  var WORKFLOW_OPTIONS = [
12604
12638
  { value: "clean-flow", label: WORKFLOW_DESCRIPTIONS["clean-flow"] },
12605
12639
  { value: "github-flow", label: WORKFLOW_DESCRIPTIONS["github-flow"] },
@@ -12616,7 +12650,8 @@ var CONVENTION_OPTIONS = [
12616
12650
  ];
12617
12651
  var AI_PROVIDER_OPTIONS = [
12618
12652
  { value: "copilot", label: "GitHub Copilot" },
12619
- { value: "ollama-cloud", label: "Ollama Cloud" }
12653
+ { value: "ollama-cloud", label: "Ollama Cloud" },
12654
+ { value: "openrouter", label: "OpenRouter" }
12620
12655
  ];
12621
12656
  function parseBranchPrefixesInput(input, fallback) {
12622
12657
  const values = input.split(",").map((value) => value.trim()).filter(Boolean);
@@ -12650,6 +12685,10 @@ function finalizeEditedConfig(current, draft) {
12650
12685
  next.aiModel = (draft.aiModel?.trim() || DEFAULT_OLLAMA_CLOUD_MODEL).trim();
12651
12686
  return next;
12652
12687
  }
12688
+ if (next.aiProvider === "openrouter") {
12689
+ next.aiModel = (draft.aiModel?.trim() || DEFAULT_OPENROUTER_MODEL).trim();
12690
+ return next;
12691
+ }
12653
12692
  delete next.aiModel;
12654
12693
  return next;
12655
12694
  }
@@ -12657,6 +12696,8 @@ function buildConfigSnapshot(config, meta) {
12657
12696
  const aiConfig = resolveAIConfig(config);
12658
12697
  const aiEnabled = isAIEnabled(config);
12659
12698
  const usingOllamaCloud = aiEnabled && aiConfig.provider === "ollama-cloud";
12699
+ const usingOpenRouter = aiEnabled && aiConfig.provider === "openrouter";
12700
+ const needsSecretInfo = usingOllamaCloud || usingOpenRouter;
12660
12701
  return {
12661
12702
  source: meta.source,
12662
12703
  location: meta.location,
@@ -12677,7 +12718,8 @@ function buildConfigSnapshot(config, meta) {
12677
12718
  providerLabel: aiEnabled ? aiConfig.providerLabel : null,
12678
12719
  model: aiEnabled ? aiConfig.model ?? null : null,
12679
12720
  ollamaCloudApiKeyPresent: usingOllamaCloud ? meta.hasOllamaCloudApiKey : null,
12680
- secretsPath: usingOllamaCloud ? meta.secretsPath : null
12721
+ openrouterApiKeyPresent: usingOpenRouter ? meta.hasOpenRouterApiKey : null,
12722
+ secretsPath: needsSecretInfo ? meta.secretsPath : null
12681
12723
  }
12682
12724
  };
12683
12725
  }
@@ -12714,6 +12756,36 @@ async function promptForOllamaCloudModelSelection(apiKey, fallbackModel) {
12714
12756
  }
12715
12757
  return inputPrompt("Ollama Cloud model", fallbackModel);
12716
12758
  }
12759
+ async function promptForOpenRouterModelSelection(apiKey, fallbackModel) {
12760
+ if (apiKey) {
12761
+ try {
12762
+ info("Fetching available OpenRouter models...");
12763
+ const models = prioritizeOpenRouterModels(await fetchOpenRouterModels(apiKey));
12764
+ if (models.length > 0) {
12765
+ const manualChoice = "Enter model manually";
12766
+ const choices = models.map((model) => ({
12767
+ value: model,
12768
+ label: model === DEFAULT_OPENROUTER_MODEL ? `${model} (default)` : model
12769
+ }));
12770
+ const selected = await selectPrompt("OpenRouter model", [
12771
+ ...choices.map((choice) => choice.label),
12772
+ manualChoice
12773
+ ]);
12774
+ if (selected !== manualChoice) {
12775
+ return choices.find((choice) => choice.label === selected)?.value ?? fallbackModel;
12776
+ }
12777
+ } else {
12778
+ warn("OpenRouter returned no available models. Enter the model name manually.");
12779
+ }
12780
+ } catch (err) {
12781
+ const message = err instanceof Error ? err.message : String(err);
12782
+ warn(`Could not fetch OpenRouter models: ${message}`);
12783
+ }
12784
+ } else {
12785
+ warn("No OpenRouter API key is available yet, so the model list cannot be fetched.");
12786
+ }
12787
+ return inputPrompt("OpenRouter model", fallbackModel);
12788
+ }
12717
12789
  async function selectCurrentValue(message, options, current) {
12718
12790
  const choices = options.map((option) => ({
12719
12791
  value: option.value,
@@ -12728,7 +12800,7 @@ async function selectBooleanValue(message, current, trueLabel, falseLabel) {
12728
12800
  { value: "false", label: falseLabel }
12729
12801
  ], current ? "true" : "false").then((value) => value === "true");
12730
12802
  }
12731
- async function promptForConfigEdits(current, hasExistingOllamaApiKey) {
12803
+ async function promptForConfigEdits(current, hasExistingOllamaApiKey, hasExistingOpenRouterApiKey) {
12732
12804
  const workflow = await selectCurrentValue("Workflow mode", WORKFLOW_OPTIONS, current.workflow);
12733
12805
  const role = await selectCurrentValue("Your role in this clone", ROLE_OPTIONS, current.role);
12734
12806
  const mainBranch = await inputPrompt("Main branch name", current.mainBranch);
@@ -12749,6 +12821,8 @@ async function promptForConfigEdits(current, hasExistingOllamaApiKey) {
12749
12821
  let aiModel;
12750
12822
  let ollamaApiKeyAction = "keep";
12751
12823
  let ollamaApiKey;
12824
+ let openrouterApiKeyAction = "keep";
12825
+ let openrouterApiKey;
12752
12826
  if (aiEnabled) {
12753
12827
  const currentProvider = current.aiProvider ?? "copilot";
12754
12828
  aiProvider = await selectCurrentValue("AI provider", AI_PROVIDER_OPTIONS, currentProvider);
@@ -12780,16 +12854,72 @@ async function promptForConfigEdits(current, hasExistingOllamaApiKey) {
12780
12854
  }
12781
12855
  const modelLookupApiKey = ollamaApiKeyAction === "set" ? ollamaApiKey ?? null : ollamaApiKeyAction === "keep" ? await getOllamaCloudApiKey() : null;
12782
12856
  aiModel = await promptForOllamaCloudModelSelection(modelLookupApiKey, current.aiProvider === "ollama-cloud" ? current.aiModel ?? DEFAULT_OLLAMA_CLOUD_MODEL : DEFAULT_OLLAMA_CLOUD_MODEL);
12783
- } else if (hasExistingOllamaApiKey) {
12784
- const shouldDeleteStoredKey = await confirmPrompt("Delete the stored Ollama Cloud API key from the local secrets store?");
12857
+ if (hasExistingOpenRouterApiKey) {
12858
+ const shouldDeleteOpenRouterKey = await confirmPrompt("Delete the stored OpenRouter API key from the local secrets store?");
12859
+ if (shouldDeleteOpenRouterKey) {
12860
+ openrouterApiKeyAction = "delete";
12861
+ }
12862
+ }
12863
+ } else if (aiProvider === "openrouter") {
12864
+ if (hasExistingOpenRouterApiKey) {
12865
+ const apiKeyChoice = await selectPrompt("OpenRouter API key", [
12866
+ "Keep existing stored key",
12867
+ "Replace stored key",
12868
+ "Delete stored key"
12869
+ ]);
12870
+ if (apiKeyChoice === "Replace stored key") {
12871
+ openrouterApiKey = (await passwordPrompt("Enter the new OpenRouter API key")).trim();
12872
+ if (!openrouterApiKey) {
12873
+ throw new Error("OpenRouter API key cannot be empty when replacing the stored key.");
12874
+ }
12875
+ openrouterApiKeyAction = "set";
12876
+ } else if (apiKeyChoice === "Delete stored key") {
12877
+ openrouterApiKeyAction = "delete";
12878
+ }
12879
+ } else {
12880
+ const addApiKey = await confirmPrompt("No OpenRouter API key is stored. Add one now?");
12881
+ if (addApiKey) {
12882
+ openrouterApiKey = (await passwordPrompt("Enter your OpenRouter API key")).trim();
12883
+ if (!openrouterApiKey) {
12884
+ throw new Error("OpenRouter API key cannot be empty when enabling OpenRouter.");
12885
+ }
12886
+ openrouterApiKeyAction = "set";
12887
+ }
12888
+ }
12889
+ const modelLookupApiKey = openrouterApiKeyAction === "set" ? openrouterApiKey ?? null : openrouterApiKeyAction === "keep" ? await getOpenRouterApiKey() : null;
12890
+ aiModel = await promptForOpenRouterModelSelection(modelLookupApiKey, current.aiProvider === "openrouter" ? current.aiModel ?? DEFAULT_OPENROUTER_MODEL : DEFAULT_OPENROUTER_MODEL);
12891
+ if (hasExistingOllamaApiKey) {
12892
+ const shouldDeleteStoredKey = await confirmPrompt("Delete the stored Ollama Cloud API key from the local secrets store?");
12893
+ if (shouldDeleteStoredKey) {
12894
+ ollamaApiKeyAction = "delete";
12895
+ }
12896
+ }
12897
+ } else {
12898
+ if (hasExistingOllamaApiKey) {
12899
+ const shouldDeleteStoredKey = await confirmPrompt("Delete the stored Ollama Cloud API key from the local secrets store?");
12900
+ if (shouldDeleteStoredKey) {
12901
+ ollamaApiKeyAction = "delete";
12902
+ }
12903
+ }
12904
+ if (hasExistingOpenRouterApiKey) {
12905
+ const shouldDeleteOpenRouterKey = await confirmPrompt("Delete the stored OpenRouter API key from the local secrets store?");
12906
+ if (shouldDeleteOpenRouterKey) {
12907
+ openrouterApiKeyAction = "delete";
12908
+ }
12909
+ }
12910
+ }
12911
+ } else {
12912
+ if (hasExistingOllamaApiKey) {
12913
+ const shouldDeleteStoredKey = await confirmPrompt("AI is disabled. Delete the stored Ollama Cloud API key from the local secrets store?");
12785
12914
  if (shouldDeleteStoredKey) {
12786
12915
  ollamaApiKeyAction = "delete";
12787
12916
  }
12788
12917
  }
12789
- } else if (hasExistingOllamaApiKey) {
12790
- const shouldDeleteStoredKey = await confirmPrompt("AI is disabled. Delete the stored Ollama Cloud API key from the local secrets store?");
12791
- if (shouldDeleteStoredKey) {
12792
- ollamaApiKeyAction = "delete";
12918
+ if (hasExistingOpenRouterApiKey) {
12919
+ const shouldDeleteOpenRouterKey = await confirmPrompt("AI is disabled. Delete the stored OpenRouter API key from the local secrets store?");
12920
+ if (shouldDeleteOpenRouterKey) {
12921
+ openrouterApiKeyAction = "delete";
12922
+ }
12793
12923
  }
12794
12924
  }
12795
12925
  return {
@@ -12808,17 +12938,17 @@ async function promptForConfigEdits(current, hasExistingOllamaApiKey) {
12808
12938
  showTips
12809
12939
  }),
12810
12940
  ollamaApiKeyAction,
12811
- ollamaApiKey
12941
+ ollamaApiKey,
12942
+ openrouterApiKeyAction,
12943
+ openrouterApiKey
12812
12944
  };
12813
12945
  }
12814
- async function applyOllamaApiKeyEdit(result) {
12946
+ async function applyApiKeyEdits(result) {
12815
12947
  if (result.ollamaApiKeyAction === "set" && result.ollamaApiKey) {
12816
12948
  await setOllamaCloudApiKey(result.ollamaApiKey);
12817
12949
  success("Stored Ollama Cloud API key in the local secrets store.");
12818
- info(`Secrets path: ${import_picocolors11.default.bold(getSecretsStorePath())}`);
12819
- return;
12820
- }
12821
- if (result.ollamaApiKeyAction === "delete") {
12950
+ info(`Secrets path: ${import_picocolors10.default.bold(getSecretsStorePath())}`);
12951
+ } else if (result.ollamaApiKeyAction === "delete") {
12822
12952
  const deleted = await deleteOllamaCloudApiKey();
12823
12953
  if (deleted) {
12824
12954
  success("Deleted stored Ollama Cloud API key.");
@@ -12826,31 +12956,49 @@ async function applyOllamaApiKeyEdit(result) {
12826
12956
  info("No stored Ollama Cloud API key was found to delete.");
12827
12957
  }
12828
12958
  }
12959
+ if (result.openrouterApiKeyAction === "set" && result.openrouterApiKey) {
12960
+ await setOpenRouterApiKey(result.openrouterApiKey);
12961
+ success("Stored OpenRouter API key in the local secrets store.");
12962
+ info(`Secrets path: ${import_picocolors10.default.bold(getSecretsStorePath())}`);
12963
+ } else if (result.openrouterApiKeyAction === "delete") {
12964
+ const deleted = await deleteOpenRouterApiKey();
12965
+ if (deleted) {
12966
+ success("Deleted stored OpenRouter API key.");
12967
+ } else {
12968
+ info("No stored OpenRouter API key was found to delete.");
12969
+ }
12970
+ }
12829
12971
  }
12830
12972
  function printConfigSummary(snapshot) {
12831
- info(`Config source: ${import_picocolors11.default.bold(snapshot.source)}`);
12832
- info(`Config path: ${import_picocolors11.default.bold(snapshot.location)}`);
12833
- info(`Workflow: ${import_picocolors11.default.bold(snapshot.workflowLabel)}`);
12834
- info(`Convention: ${import_picocolors11.default.bold(snapshot.commitConventionLabel)}`);
12835
- info(`Role: ${import_picocolors11.default.bold(snapshot.role)}`);
12973
+ info(`Config source: ${import_picocolors10.default.bold(snapshot.source)}`);
12974
+ info(`Config path: ${import_picocolors10.default.bold(snapshot.location)}`);
12975
+ info(`Workflow: ${import_picocolors10.default.bold(snapshot.workflowLabel)}`);
12976
+ info(`Convention: ${import_picocolors10.default.bold(snapshot.commitConventionLabel)}`);
12977
+ info(`Role: ${import_picocolors10.default.bold(snapshot.role)}`);
12836
12978
  if (snapshot.devBranch) {
12837
- info(`Main: ${import_picocolors11.default.bold(snapshot.mainBranch)} | Dev: ${import_picocolors11.default.bold(snapshot.devBranch)}`);
12979
+ info(`Main: ${import_picocolors10.default.bold(snapshot.mainBranch)} | Dev: ${import_picocolors10.default.bold(snapshot.devBranch)}`);
12838
12980
  } else {
12839
- info(`Main: ${import_picocolors11.default.bold(snapshot.mainBranch)}`);
12981
+ info(`Main: ${import_picocolors10.default.bold(snapshot.mainBranch)}`);
12840
12982
  }
12841
- info(`Origin: ${import_picocolors11.default.bold(snapshot.origin)} | Upstream: ${import_picocolors11.default.bold(snapshot.upstream)}`);
12842
- info(`Branch prefixes: ${import_picocolors11.default.bold(snapshot.branchPrefixes.join(", "))}`);
12843
- info(`Guides: ${import_picocolors11.default.bold(snapshot.showTips ? "shown" : "hidden")}`);
12844
- info(`AI: ${import_picocolors11.default.bold(snapshot.ai.enabled ? "enabled" : "disabled")}`);
12983
+ info(`Origin: ${import_picocolors10.default.bold(snapshot.origin)} | Upstream: ${import_picocolors10.default.bold(snapshot.upstream)}`);
12984
+ info(`Branch prefixes: ${import_picocolors10.default.bold(snapshot.branchPrefixes.join(", "))}`);
12985
+ info(`Guides: ${import_picocolors10.default.bold(snapshot.showTips ? "shown" : "hidden")}`);
12986
+ info(`AI: ${import_picocolors10.default.bold(snapshot.ai.enabled ? "enabled" : "disabled")}`);
12845
12987
  if (snapshot.ai.enabled && snapshot.ai.providerLabel) {
12846
- info(`AI provider: ${import_picocolors11.default.bold(snapshot.ai.providerLabel)}`);
12988
+ info(`AI provider: ${import_picocolors10.default.bold(snapshot.ai.providerLabel)}`);
12847
12989
  if (snapshot.ai.model) {
12848
- info(`AI model: ${import_picocolors11.default.bold(snapshot.ai.model)}`);
12990
+ info(`AI model: ${import_picocolors10.default.bold(snapshot.ai.model)}`);
12849
12991
  }
12850
12992
  if (snapshot.ai.provider === "ollama-cloud") {
12851
- info(`Ollama Cloud API key: ${import_picocolors11.default.bold(snapshot.ai.ollamaCloudApiKeyPresent ? "stored" : "missing")}`);
12993
+ info(`Ollama Cloud API key: ${import_picocolors10.default.bold(snapshot.ai.ollamaCloudApiKeyPresent ? "stored" : "missing")}`);
12994
+ if (snapshot.ai.secretsPath) {
12995
+ info(`Secrets path: ${import_picocolors10.default.bold(snapshot.ai.secretsPath)}`);
12996
+ }
12997
+ }
12998
+ if (snapshot.ai.provider === "openrouter") {
12999
+ info(`OpenRouter API key: ${import_picocolors10.default.bold(snapshot.ai.openrouterApiKeyPresent ? "stored" : "missing")}`);
12852
13000
  if (snapshot.ai.secretsPath) {
12853
- info(`Secrets path: ${import_picocolors11.default.bold(snapshot.ai.secretsPath)}`);
13001
+ info(`Secrets path: ${import_picocolors10.default.bold(snapshot.ai.secretsPath)}`);
12854
13002
  }
12855
13003
  }
12856
13004
  }
@@ -12898,14 +13046,15 @@ var config_default = defineCommand({
12898
13046
  }
12899
13047
  if (args.edit) {
12900
13048
  try {
12901
- const editResult = await promptForConfigEdits(config, await hasOllamaCloudApiKey());
13049
+ const editResult = await promptForConfigEdits(config, await hasOllamaCloudApiKey(), await hasOpenRouterApiKey());
12902
13050
  writeConfig(editResult.config);
12903
- await applyOllamaApiKeyEdit(editResult);
13051
+ await applyApiKeyEdits(editResult);
12904
13052
  success("Updated repo config.");
12905
13053
  printConfigSummary(buildConfigSnapshot(editResult.config, {
12906
13054
  source,
12907
13055
  location: getConfigLocationLabel(),
12908
13056
  hasOllamaCloudApiKey: await hasOllamaCloudApiKey(),
13057
+ hasOpenRouterApiKey: await hasOpenRouterApiKey(),
12909
13058
  secretsPath: getSecretsStorePath()
12910
13059
  }));
12911
13060
  return;
@@ -12918,6 +13067,7 @@ var config_default = defineCommand({
12918
13067
  source,
12919
13068
  location: getConfigLocationLabel(),
12920
13069
  hasOllamaCloudApiKey: await hasOllamaCloudApiKey(),
13070
+ hasOpenRouterApiKey: await hasOpenRouterApiKey(),
12921
13071
  secretsPath: getSecretsStorePath()
12922
13072
  });
12923
13073
  if (args.json) {
@@ -12926,18 +13076,126 @@ var config_default = defineCommand({
12926
13076
  }
12927
13077
  printConfigSummary(snapshot);
12928
13078
  console.log();
12929
- console.log(` ${import_picocolors11.default.dim("Run `cn config --edit` to update these settings.")}`);
13079
+ console.log(` ${import_picocolors10.default.dim("Run `cn config --edit` to update these settings.")}`);
12930
13080
  console.log();
12931
13081
  }
12932
13082
  });
12933
13083
 
13084
+ // src/commands/discard.ts
13085
+ var import_picocolors11 = __toESM(require_picocolors(), 1);
13086
+ var discard_default = defineCommand({
13087
+ meta: {
13088
+ name: "discard",
13089
+ description: "Discard the current feature branch and return to the base branch"
13090
+ },
13091
+ args: {
13092
+ force: {
13093
+ type: "boolean",
13094
+ alias: "f",
13095
+ description: "Skip confirmation and discard immediately",
13096
+ default: false
13097
+ }
13098
+ },
13099
+ async run({ args }) {
13100
+ if (!await isGitRepo()) {
13101
+ error("Not inside a git repository.");
13102
+ process.exit(1);
13103
+ }
13104
+ await assertCleanGitState("discarding a branch");
13105
+ const config = readConfig();
13106
+ if (!config) {
13107
+ error("No repo config found. Run `cn setup` first.");
13108
+ process.exit(1);
13109
+ }
13110
+ const currentBranch = await getCurrentBranch();
13111
+ const baseBranch = getBaseBranch(config);
13112
+ await projectHeading("discard", "\uD83D\uDDD1\uFE0F");
13113
+ if (isBranchProtected(currentBranch, config)) {
13114
+ error(`${import_picocolors11.default.bold(currentBranch)} is a protected branch and cannot be discarded.`);
13115
+ info(`Switch to a feature branch first, then run ${import_picocolors11.default.bold("cn discard")}.`);
13116
+ process.exit(1);
13117
+ }
13118
+ if (currentBranch === baseBranch) {
13119
+ info(`You are already on ${import_picocolors11.default.bold(baseBranch)}.`);
13120
+ process.exit(0);
13121
+ }
13122
+ const { origin } = config;
13123
+ const localWork = await hasLocalWork(origin, currentBranch);
13124
+ const hasWork = localWork.uncommitted || localWork.unpushedCommits > 0;
13125
+ if (hasWork) {
13126
+ if (localWork.uncommitted) {
13127
+ warn("You have uncommitted changes in your working tree.");
13128
+ }
13129
+ if (localWork.unpushedCommits > 0) {
13130
+ warn(`You have ${import_picocolors11.default.bold(String(localWork.unpushedCommits))} unpushed commit${localWork.unpushedCommits !== 1 ? "s" : ""} on this branch.`);
13131
+ }
13132
+ warn("Discarding this branch will permanently lose that work.");
13133
+ const SAVE_FIRST = "Save my changes first (cn save), then discard";
13134
+ const DISCARD_ANYWAY = "Discard anyway \u2014 I do not need this work";
13135
+ const CANCEL = "Keep the branch, take me back";
13136
+ const action = await selectPrompt("This branch has unsaved work. What would you like to do?", [SAVE_FIRST, DISCARD_ANYWAY, CANCEL]);
13137
+ if (action === CANCEL) {
13138
+ info("Discard cancelled. Your branch is untouched.");
13139
+ process.exit(0);
13140
+ }
13141
+ if (action === SAVE_FIRST) {
13142
+ if (!localWork.uncommitted) {
13143
+ info("No uncommitted changes to stash \u2014 unpushed commits will still be lost.");
13144
+ const confirm = await confirmPrompt("Continue discarding the branch?");
13145
+ if (!confirm) {
13146
+ info("Discard cancelled.");
13147
+ process.exit(0);
13148
+ }
13149
+ } else {
13150
+ const stashResult = await stashChanges(`work-in-progress on ${currentBranch}`);
13151
+ if (stashResult.exitCode !== 0) {
13152
+ error(`Failed to save changes: ${stashResult.stderr}`);
13153
+ process.exit(1);
13154
+ }
13155
+ success(`Changes saved. Use ${import_picocolors11.default.bold("cn save --restore")} to bring them back.`);
13156
+ }
13157
+ }
13158
+ } else if (!args.force) {
13159
+ const confirmed = await confirmPrompt(`Discard ${import_picocolors11.default.bold(currentBranch)} and return to ${import_picocolors11.default.bold(baseBranch)}?`);
13160
+ if (!confirmed) {
13161
+ info("Discard cancelled.");
13162
+ process.exit(0);
13163
+ }
13164
+ }
13165
+ const upstreamRef = await getUpstreamRef();
13166
+ let deleteRemote = false;
13167
+ if (upstreamRef) {
13168
+ deleteRemote = await confirmPrompt(`Also delete the remote branch ${import_picocolors11.default.bold(upstreamRef)}?`);
13169
+ }
13170
+ const checkoutResult = await checkoutBranch(baseBranch);
13171
+ if (checkoutResult.exitCode !== 0) {
13172
+ error(`Failed to switch to ${import_picocolors11.default.bold(baseBranch)}: ${checkoutResult.stderr}`);
13173
+ process.exit(1);
13174
+ }
13175
+ const deleteResult = await forceDeleteBranch(currentBranch);
13176
+ if (deleteResult.exitCode !== 0) {
13177
+ error(`Failed to delete branch ${import_picocolors11.default.bold(currentBranch)}: ${deleteResult.stderr}`);
13178
+ process.exit(1);
13179
+ }
13180
+ success(`Discarded ${import_picocolors11.default.bold(currentBranch)} and switched back to ${import_picocolors11.default.bold(baseBranch)}`);
13181
+ if (deleteRemote) {
13182
+ const remoteDeleteResult = await deleteRemoteBranch(origin, currentBranch);
13183
+ if (remoteDeleteResult.exitCode !== 0) {
13184
+ warn(`Could not delete remote branch: ${remoteDeleteResult.stderr.trim()}`);
13185
+ } else {
13186
+ success(`Deleted remote branch ${import_picocolors11.default.bold(`${origin}/${currentBranch}`)}`);
13187
+ }
13188
+ }
13189
+ }
13190
+ });
13191
+
12934
13192
  // src/commands/doctor.ts
12935
13193
  import { execFile as execFileCb3 } from "child_process";
12936
13194
  var import_picocolors12 = __toESM(require_picocolors(), 1);
12937
13195
  // package.json
12938
13196
  var package_default = {
12939
13197
  name: "contribute-now",
12940
- version: "0.8.0-dev.7db6dea",
13198
+ version: "0.8.0-dev.7eaa951",
12941
13199
  description: "Developer CLI that automates git workflows \u2014 branching, syncing, committing, and PRs \u2014 with multi-workflow and commit convention support.",
12942
13200
  type: "module",
12943
13201
  bin: {
@@ -13190,6 +13448,15 @@ async function configSection() {
13190
13448
  detail: hasSecretsStore() ? "stored in the local secrets store" : "run `cn setup` to save it"
13191
13449
  });
13192
13450
  }
13451
+ if (aiConfig.provider === "openrouter") {
13452
+ const hasApiKey = await hasOpenRouterApiKey();
13453
+ checks.push({
13454
+ label: hasApiKey ? "OpenRouter API key present" : "OpenRouter API key missing",
13455
+ ok: true,
13456
+ warning: !hasApiKey,
13457
+ detail: hasSecretsStore() ? "stored in the local secrets store" : "run `cn setup` to save it"
13458
+ });
13459
+ }
13193
13460
  }
13194
13461
  if (hasDevBranch(config.workflow)) {
13195
13462
  checks.push({
@@ -14121,6 +14388,32 @@ async function promptForOllamaCloudModel(apiKey, host = DEFAULT_OLLAMA_CLOUD_HOS
14121
14388
  }
14122
14389
  return inputPrompt(`Ollama Cloud model (default: ${DEFAULT_OLLAMA_CLOUD_MODEL} \u2014 press Enter to keep)`, DEFAULT_OLLAMA_CLOUD_MODEL);
14123
14390
  }
14391
+ async function promptForOpenRouterModel(apiKey) {
14392
+ try {
14393
+ info("Fetching available OpenRouter models...");
14394
+ const models = prioritizeOpenRouterModels(await fetchOpenRouterModels(apiKey));
14395
+ if (models.length > 0) {
14396
+ const manualChoice = "Enter model manually";
14397
+ const choices = models.map((model) => ({
14398
+ value: model,
14399
+ label: model === DEFAULT_OPENROUTER_MODEL ? `${model} (default)` : model
14400
+ }));
14401
+ const selected = await selectPrompt("Which OpenRouter model should this clone use?", [
14402
+ ...choices.map((choice) => choice.label),
14403
+ manualChoice
14404
+ ]);
14405
+ if (selected !== manualChoice) {
14406
+ return choices.find((choice) => choice.label === selected)?.value ?? DEFAULT_OPENROUTER_MODEL;
14407
+ }
14408
+ } else {
14409
+ warn("OpenRouter returned no available models. Enter the model name manually.");
14410
+ }
14411
+ } catch (err) {
14412
+ const message = err instanceof Error ? err.message : String(err);
14413
+ warn(`Could not fetch OpenRouter models: ${message}`);
14414
+ }
14415
+ return inputPrompt(`OpenRouter model (default: ${DEFAULT_OPENROUTER_MODEL} \u2014 press Enter to keep)`, DEFAULT_OPENROUTER_MODEL);
14416
+ }
14124
14417
  var setup_default = defineCommand({
14125
14418
  meta: {
14126
14419
  name: "setup",
@@ -14172,9 +14465,16 @@ var setup_default = defineCommand({
14172
14465
  if (enableAI) {
14173
14466
  const providerChoice = await selectPrompt("Which AI provider should this clone use?", [
14174
14467
  "GitHub Copilot \u2014 use your existing GitHub/Copilot auth",
14175
- "Ollama Cloud \u2014 use an API key stored in the local secrets store"
14468
+ "Ollama Cloud \u2014 use an API key stored in the local secrets store",
14469
+ "OpenRouter \u2014 use an API key stored in the local secrets store"
14176
14470
  ]);
14177
- aiProvider = providerChoice.startsWith("Ollama Cloud") ? "ollama-cloud" : "copilot";
14471
+ if (providerChoice.startsWith("Ollama Cloud")) {
14472
+ aiProvider = "ollama-cloud";
14473
+ } else if (providerChoice.startsWith("OpenRouter")) {
14474
+ aiProvider = "openrouter";
14475
+ } else {
14476
+ aiProvider = "copilot";
14477
+ }
14178
14478
  if (aiProvider === "ollama-cloud") {
14179
14479
  const apiKey = (await passwordPrompt("Enter your Ollama Cloud API key")).trim();
14180
14480
  if (!apiKey) {
@@ -14191,6 +14491,22 @@ var setup_default = defineCommand({
14191
14491
  error(`Failed to store Ollama Cloud API key: ${message}`);
14192
14492
  process.exit(1);
14193
14493
  }
14494
+ } else if (aiProvider === "openrouter") {
14495
+ const apiKey = (await passwordPrompt("Enter your OpenRouter API key")).trim();
14496
+ if (!apiKey) {
14497
+ error("OpenRouter API key is required when OpenRouter is selected.");
14498
+ process.exit(1);
14499
+ }
14500
+ aiModel = await promptForOpenRouterModel(apiKey);
14501
+ try {
14502
+ await setOpenRouterApiKey(apiKey);
14503
+ success("Stored OpenRouter API key in the local secrets store.");
14504
+ info(`Secrets path: ${import_picocolors16.default.bold(getSecretsStorePath())}`);
14505
+ } catch (err) {
14506
+ const message = err instanceof Error ? err.message : String(err);
14507
+ error(`Failed to store OpenRouter API key: ${message}`);
14508
+ process.exit(1);
14509
+ }
14194
14510
  }
14195
14511
  }
14196
14512
  const showTips = await confirmPrompt("Show beginner quick guides and loading tips in command output?");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "contribute-now",
3
- "version": "0.8.0-dev.7db6dea",
3
+ "version": "0.8.0-dev.7eaa951",
4
4
  "description": "Developer CLI that automates git workflows — branching, syncing, committing, and PRs — with multi-workflow and commit convention support.",
5
5
  "type": "module",
6
6
  "bin": {