@denisvieiradev/gitwise-core 1.1.1 → 1.3.0

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.
package/dist/index.js CHANGED
@@ -7,7 +7,7 @@ var __export = (target, all) => {
7
7
  // package.json
8
8
  var package_default = {
9
9
  name: "@denisvieiradev/gitwise-core",
10
- version: "1.1.0",
10
+ version: "1.3.0",
11
11
  description: "Shared logic for gitwise: non-interactive commit/review/pr/release commands, LLM providers, git/github primitives, prompt templates.",
12
12
  type: "module",
13
13
  main: "./dist/index.js",
@@ -60,7 +60,7 @@ var package_default = {
60
60
  node: ">=22.12.0"
61
61
  },
62
62
  dependencies: {
63
- "@anthropic-ai/sdk": "^0.109.0"
63
+ "@anthropic-ai/sdk": "^0.127.0"
64
64
  }
65
65
  };
66
66
 
@@ -486,10 +486,10 @@ async function stashDropNamed(cwd, stashName) {
486
486
  async function cleanForced(cwd) {
487
487
  await run(["clean", "-fd"], cwd);
488
488
  }
489
- async function showFileAtHead(cwd, path3) {
490
- debug("git command", { args: ["show", `HEAD:${path3}`], cwd });
489
+ async function showFileAtHead(cwd, path7) {
490
+ debug("git command", { args: ["show", `HEAD:${path7}`], cwd });
491
491
  try {
492
- const result = await exec("git", ["show", `HEAD:${path3}`], {
492
+ const result = await exec("git", ["show", `HEAD:${path7}`], {
493
493
  cwd,
494
494
  timeout: GIT_TIMEOUT_MS,
495
495
  maxBuffer: GIT_MAX_BUFFER
@@ -890,20 +890,16 @@ function defaultIsProcessAlive(pid) {
890
890
  }
891
891
 
892
892
  // src/providers/claude-code.ts
893
+ import os2 from "os";
894
+ import path3 from "path";
895
+
896
+ // src/providers/cli-subprocess.ts
893
897
  import { execSync, spawn } from "child_process";
894
898
  import fs from "fs";
895
899
  import os from "os";
896
900
  import path2 from "path";
897
- var LARGE_PROMPT_THRESHOLD = 1e5;
898
- var DEFAULT_TIMEOUT_MS = 12e4;
899
- var COMMON_CLAUDE_PATHS = [
900
- // Native installs (Homebrew, manual) — preferred over npm
901
- "/opt/homebrew/bin/claude",
902
- "/usr/local/bin/claude",
903
- path2.join(os.homedir(), ".claude", "local", "claude"),
904
- // npm global installs — fallback
905
- path2.join(os.homedir(), ".npm-global", "bin", "claude")
906
- ];
901
+ import { StringDecoder } from "string_decoder";
902
+ import { clearTimeout, setTimeout as setTimeout2 } from "timers";
907
903
  function isExecutable(filePath) {
908
904
  try {
909
905
  fs.accessSync(filePath, fs.constants.X_OK);
@@ -912,184 +908,441 @@ function isExecutable(filePath) {
912
908
  return false;
913
909
  }
914
910
  }
915
- function resolveClaudeBinary(customPath) {
916
- if (customPath) {
917
- if (isExecutable(customPath)) return customPath;
918
- return null;
919
- }
920
- for (const candidate of COMMON_CLAUDE_PATHS) {
911
+ function resolveCliBinary(name, commonPaths, customPath) {
912
+ if (customPath) return isExecutable(customPath) ? customPath : null;
913
+ for (const candidate of commonPaths) {
921
914
  if (isExecutable(candidate)) return candidate;
922
915
  }
923
916
  try {
924
- const found = execSync("which claude", { stdio: "pipe" }).toString().trim();
917
+ const found = execSync(`which ${name}`, { stdio: "pipe" }).toString().trim();
925
918
  if (found && isExecutable(found)) return found;
926
919
  } catch {
927
920
  }
928
921
  const nvmDir = path2.join(os.homedir(), ".nvm", "versions", "node");
929
922
  try {
930
- const versions = fs.readdirSync(nvmDir);
931
- for (const version2 of versions) {
932
- const candidate = path2.join(nvmDir, version2, "bin", "claude");
923
+ for (const version2 of fs.readdirSync(nvmDir)) {
924
+ const candidate = path2.join(nvmDir, version2, "bin", name);
933
925
  if (isExecutable(candidate)) return candidate;
934
926
  }
935
927
  } catch {
936
928
  }
937
929
  return null;
938
930
  }
939
- var ClaudeCodeProvider = class {
940
- models;
941
- claudeBinaryPath;
942
- constructor(models, claudeCliPath) {
931
+ var LARGE_PROMPT_THRESHOLD = 1e5;
932
+ var DEFAULT_TIMEOUT_MS = 12e4;
933
+ var KILL_GRACE_MS = 5e3;
934
+ var CliSubprocessProvider = class {
935
+ constructor(spec, models, cliPath) {
936
+ this.spec = spec;
943
937
  this.models = models;
944
- this.claudeBinaryPath = claudeCliPath ?? resolveClaudeBinary() ?? "claude";
938
+ this.binaryPath = cliPath || spec.resolveBinary() || spec.defaultCommand;
945
939
  }
940
+ binaryPath;
946
941
  async chat(req) {
947
- const modelId = this.resolveModel(req.tier);
948
- debug("Calling Claude Code CLI", { model: modelId, tier: req.tier, binary: this.claudeBinaryPath });
949
- const userContent = req.userMessage;
950
- const args = this.buildArgs(req.systemPrompt, modelId, userContent);
951
- const result = userContent.length > LARGE_PROMPT_THRESHOLD ? await this.callViaStdin(args, userContent) : await this.callViaCli(args);
942
+ const modelId = this.models[req.tier];
943
+ debug(`Calling ${this.spec.toolName}`, { model: modelId, tier: req.tier, binary: this.binaryPath });
944
+ const prompt = this.spec.foldSystemPrompt ? `${req.systemPrompt}
945
+
946
+ ${req.userMessage}` : req.userMessage;
947
+ const large = Buffer.byteLength(prompt, "utf8") > LARGE_PROMPT_THRESHOLD;
948
+ const args = this.spec.buildArgs({ prompt, systemPrompt: req.systemPrompt, modelId, large });
949
+ const stdout = await this.spawnCli(args, large ? prompt : "");
950
+ const parsed = this.spec.parseOutput(stdout);
952
951
  return {
953
- content: result.result,
954
- tokens: {
955
- input: result.usage.input_tokens,
956
- output: result.usage.output_tokens
957
- }
952
+ content: parsed.content,
953
+ tokens: parsed.tokens ?? { input: 0, output: 0 },
954
+ tokensAvailable: parsed.tokens !== null
958
955
  };
959
956
  }
960
- buildArgs(systemPrompt, modelId, userContent) {
961
- const args = [
962
- "-p",
963
- ...userContent.length <= LARGE_PROMPT_THRESHOLD ? [userContent] : [],
964
- "--system-prompt",
965
- systemPrompt,
966
- "--model",
967
- modelId,
968
- "--output-format",
969
- "json"
970
- ];
971
- return args;
972
- }
973
- async callViaCli(args) {
974
- return this.spawnClaude(args, "");
975
- }
976
- async callViaStdin(args, input) {
977
- return this.spawnClaude(args, input);
978
- }
979
- async spawnClaude(args, input) {
957
+ spawnCli(args, input) {
980
958
  return new Promise((resolve, reject) => {
981
- const child = spawn(this.claudeBinaryPath, args, {
959
+ const ownGroup = process.platform !== "win32";
960
+ const child = spawn(this.binaryPath, args, {
982
961
  stdio: ["pipe", "pipe", "pipe"],
983
- timeout: DEFAULT_TIMEOUT_MS
962
+ detached: ownGroup
984
963
  });
964
+ const killTree = (sig) => {
965
+ try {
966
+ if (ownGroup && child.pid !== void 0) process.kill(-child.pid, sig);
967
+ else child.kill(sig);
968
+ } catch {
969
+ }
970
+ };
971
+ const timeoutMs = this.spec.timeoutMs ?? DEFAULT_TIMEOUT_MS;
972
+ let timedOut = false;
973
+ let escalation;
974
+ const timer = setTimeout2(() => {
975
+ timedOut = true;
976
+ killTree("SIGTERM");
977
+ escalation = setTimeout2(() => killTree("SIGKILL"), KILL_GRACE_MS);
978
+ escalation.unref();
979
+ }, timeoutMs);
980
+ const reapOnExit = () => killTree("SIGKILL");
981
+ let interruptedBy;
982
+ const onSignal = (sig) => {
983
+ interruptedBy = sig;
984
+ killTree("SIGKILL");
985
+ reject(new Error(`${this.spec.toolName} was interrupted by ${sig}`));
986
+ cleanup();
987
+ if (process.listenerCount(sig) === 0) process.kill(process.pid, sig);
988
+ };
989
+ const onSigint = () => onSignal("SIGINT");
990
+ const onSigterm = () => onSignal("SIGTERM");
991
+ process.once("exit", reapOnExit);
992
+ process.on("SIGINT", onSigint);
993
+ process.on("SIGTERM", onSigterm);
994
+ function cleanup() {
995
+ clearTimeout(timer);
996
+ clearTimeout(escalation);
997
+ process.off("exit", reapOnExit);
998
+ process.off("SIGINT", onSigint);
999
+ process.off("SIGTERM", onSigterm);
1000
+ }
985
1001
  let stdout = "";
986
1002
  let stderr = "";
1003
+ const outDecoder = new StringDecoder("utf8");
1004
+ const errDecoder = new StringDecoder("utf8");
987
1005
  child.stdout.on("data", (data) => {
988
- stdout += data.toString();
1006
+ stdout += outDecoder.write(data);
989
1007
  });
990
1008
  child.stderr.on("data", (data) => {
991
- stderr += data.toString();
1009
+ stderr += errDecoder.write(data);
992
1010
  });
993
- child.on("close", (code) => {
994
- if (code !== 0) {
995
- if (stdout) {
996
- try {
997
- const parsed = JSON.parse(stdout);
998
- if (parsed.is_error) {
999
- reject(new Error(`Claude CLI error: ${parsed.result}`));
1000
- return;
1001
- }
1002
- } catch {
1003
- }
1004
- }
1005
- const filteredStderr = stderr.replace(/Warning: no stdin data.*\n?/g, "").trim();
1006
- reject(
1007
- new Error(
1008
- `Claude CLI exited with code ${code}${filteredStderr ? `: ${filteredStderr}` : ""}`
1009
- )
1010
- );
1011
+ child.on("close", (code, signal) => {
1012
+ if (timedOut) killTree("SIGKILL");
1013
+ cleanup();
1014
+ stdout += outDecoder.end();
1015
+ stderr += errDecoder.end();
1016
+ if (interruptedBy) return;
1017
+ if (timedOut) {
1018
+ reject(new Error(`${this.spec.toolName} timed out after ${Math.round(timeoutMs / 1e3)}s`));
1011
1019
  return;
1012
1020
  }
1013
- try {
1014
- resolve(this.parseResponse(stdout));
1015
- } catch (err) {
1016
- reject(err);
1021
+ if (code === null && signal) {
1022
+ reject(new Error(`${this.spec.toolName} was terminated by signal ${signal}`));
1023
+ return;
1024
+ }
1025
+ if (code !== 0) {
1026
+ reject(new Error(this.exitErrorMessage(code, stdout, stderr)));
1027
+ return;
1017
1028
  }
1029
+ resolve(stdout);
1018
1030
  });
1019
1031
  child.on("error", (err) => {
1032
+ cleanup();
1020
1033
  reject(this.wrapError(err));
1021
1034
  });
1035
+ child.stdin.on("error", () => void 0);
1022
1036
  child.stdin.write(input);
1023
1037
  child.stdin.end();
1024
1038
  });
1025
1039
  }
1026
- parseResponse(stdout) {
1040
+ exitErrorMessage(code, stdout, stderr) {
1041
+ if (this.spec.formatExitError) return this.spec.formatExitError(code, stdout, stderr);
1042
+ const trimmed = stderr.trim();
1043
+ return `${this.spec.toolName} exited with code ${code}${trimmed ? `: ${trimmed}` : ""}`;
1044
+ }
1045
+ wrapError(err) {
1046
+ const message = err instanceof Error ? err.message : String(err?.message ?? err);
1047
+ const code = err?.code;
1048
+ if (code === "ENOENT" || message.includes("ENOENT")) {
1049
+ return new GitwiseError({
1050
+ code: "PROVIDER_UNAVAILABLE",
1051
+ message: `${this.spec.toolName} not found at "${this.binaryPath}". ${this.spec.installHint}`,
1052
+ exitCode: EXIT_CODES.API_FAILED,
1053
+ cause: err
1054
+ });
1055
+ }
1056
+ return err instanceof Error ? err : new Error(message);
1057
+ }
1058
+ };
1059
+
1060
+ // src/providers/claude-code.ts
1061
+ var COMMON_CLAUDE_PATHS = [
1062
+ // Native installs (Homebrew, manual) — preferred over npm
1063
+ "/opt/homebrew/bin/claude",
1064
+ "/usr/local/bin/claude",
1065
+ path3.join(os2.homedir(), ".claude", "local", "claude"),
1066
+ // npm global installs — fallback
1067
+ path3.join(os2.homedir(), ".npm-global", "bin", "claude")
1068
+ ];
1069
+ function resolveClaudeBinary(customPath) {
1070
+ return resolveCliBinary("claude", COMMON_CLAUDE_PATHS, customPath);
1071
+ }
1072
+ var claudeCodeSpec = {
1073
+ toolName: "Claude Code CLI",
1074
+ installHint: "Re-run `gw config` to reconfigure.",
1075
+ defaultCommand: "claude",
1076
+ foldSystemPrompt: false,
1077
+ resolveBinary: resolveClaudeBinary,
1078
+ buildArgs({ prompt, systemPrompt, modelId, large }) {
1079
+ return [
1080
+ "-p",
1081
+ ...large ? [] : [prompt],
1082
+ "--system-prompt",
1083
+ systemPrompt,
1084
+ "--model",
1085
+ modelId,
1086
+ "--output-format",
1087
+ "json"
1088
+ ];
1089
+ },
1090
+ parseOutput(stdout) {
1027
1091
  const parsed = JSON.parse(stdout);
1028
1092
  if (parsed.is_error) {
1029
1093
  throw new Error(`Claude CLI returned error: ${parsed.result}`);
1030
1094
  }
1031
- const usage = { input_tokens: 0, output_tokens: 0 };
1032
- if (parsed.usage) {
1033
- usage.input_tokens = parsed.usage.input_tokens ?? 0;
1034
- usage.output_tokens = parsed.usage.output_tokens ?? 0;
1035
- }
1036
1095
  return {
1037
- result: parsed.result ?? "",
1038
- is_error: false,
1039
- usage
1096
+ content: parsed.result ?? "",
1097
+ tokens: {
1098
+ input: parsed.usage?.input_tokens ?? 0,
1099
+ output: parsed.usage?.output_tokens ?? 0
1100
+ }
1040
1101
  };
1102
+ },
1103
+ formatExitError(code, stdout, stderr) {
1104
+ if (stdout) {
1105
+ try {
1106
+ const parsed = JSON.parse(stdout);
1107
+ if (parsed.is_error) return `Claude CLI error: ${parsed.result}`;
1108
+ } catch {
1109
+ }
1110
+ }
1111
+ const filteredStderr = stderr.replace(/Warning: no stdin data.*\n?/g, "").trim();
1112
+ return `Claude CLI exited with code ${code}${filteredStderr ? `: ${filteredStderr}` : ""}`;
1041
1113
  }
1042
- resolveModel(tier) {
1043
- return this.models[tier];
1114
+ };
1115
+ var ClaudeCodeProvider = class extends CliSubprocessProvider {
1116
+ constructor(models, claudeCliPath) {
1117
+ super(claudeCodeSpec, models, claudeCliPath);
1044
1118
  }
1045
- wrapError(err) {
1046
- if (err instanceof Error) {
1047
- if (err.message.includes("ENOENT")) {
1048
- return new GitwiseError({
1049
- code: "PROVIDER_UNAVAILABLE",
1050
- message: `Claude Code CLI not found at "${this.claudeBinaryPath}". Re-run \`gw config\` to reconfigure.`,
1051
- exitCode: EXIT_CODES.API_FAILED,
1052
- cause: err
1053
- });
1119
+ };
1120
+
1121
+ // src/providers/codex.ts
1122
+ import os3 from "os";
1123
+ import path4 from "path";
1124
+ var COMMON_CODEX_PATHS = [
1125
+ // Native installs (Homebrew cask, manual, standalone installer) — preferred over npm
1126
+ "/opt/homebrew/bin/codex",
1127
+ "/usr/local/bin/codex",
1128
+ path4.join(os3.homedir(), ".local", "bin", "codex"),
1129
+ // npm global installs (`npm install -g @openai/codex`) — fallback
1130
+ path4.join(os3.homedir(), ".npm-global", "bin", "codex")
1131
+ ];
1132
+ function resolveCodexBinary(customPath) {
1133
+ return resolveCliBinary("codex", COMMON_CODEX_PATHS, customPath);
1134
+ }
1135
+ function parseEvents(stdout) {
1136
+ const events = [];
1137
+ for (const line of stdout.split("\n")) {
1138
+ const trimmed = line.trim();
1139
+ if (!trimmed) continue;
1140
+ try {
1141
+ events.push(JSON.parse(trimmed));
1142
+ } catch {
1143
+ }
1144
+ }
1145
+ return events;
1146
+ }
1147
+ var codexSpec = {
1148
+ toolName: "Codex CLI",
1149
+ installHint: "Install it (`npm install -g @openai/codex`) or re-run `gw provider` to choose another provider.",
1150
+ defaultCommand: "codex",
1151
+ foldSystemPrompt: true,
1152
+ // Full agent turns take longer than a single Claude Code completion.
1153
+ timeoutMs: 3e5,
1154
+ resolveBinary: resolveCodexBinary,
1155
+ buildArgs({ prompt, modelId, large }) {
1156
+ return [
1157
+ "exec",
1158
+ "--json",
1159
+ "--ephemeral",
1160
+ "--skip-git-repo-check",
1161
+ "--sandbox",
1162
+ "read-only",
1163
+ "--model",
1164
+ modelId,
1165
+ "--",
1166
+ large ? "-" : prompt
1167
+ ];
1168
+ },
1169
+ parseOutput(stdout) {
1170
+ let content;
1171
+ let tokens = null;
1172
+ for (const event of parseEvents(stdout)) {
1173
+ if (event.type === "item.completed" && event.item?.type === "agent_message") {
1174
+ content = event.item.text ?? "";
1175
+ } else if (event.type === "turn.completed" && event.usage) {
1176
+ tokens = {
1177
+ input: event.usage.input_tokens ?? 0,
1178
+ output: event.usage.output_tokens ?? 0
1179
+ };
1054
1180
  }
1055
- return err;
1056
1181
  }
1057
- return new Error(String(err));
1182
+ if (content === void 0) {
1183
+ throw new Error("Codex CLI returned no final agent message");
1184
+ }
1185
+ return { content, tokens };
1186
+ },
1187
+ // The CLI's own error text lives in the JSONL stream, not stderr; surface it
1188
+ // verbatim, falling back to stderr when the stream carries none.
1189
+ formatExitError(code, stdout, stderr) {
1190
+ const messages = /* @__PURE__ */ new Set();
1191
+ for (const event of parseEvents(stdout)) {
1192
+ if (event.type === "error" && event.message) messages.add(event.message);
1193
+ if (event.type === "turn.failed" && event.error?.message) messages.add(event.error.message);
1194
+ }
1195
+ const detail = messages.size > 0 ? [...messages].join("\n") : stderr.trim();
1196
+ return `Codex CLI exited with code ${code}${detail ? `: ${detail}` : ""}`;
1197
+ }
1198
+ };
1199
+
1200
+ // src/providers/copilot.ts
1201
+ import os4 from "os";
1202
+ import path5 from "path";
1203
+ var COMMON_COPILOT_PATHS = [
1204
+ // Native/Homebrew installs — preferred over npm
1205
+ "/opt/homebrew/bin/copilot",
1206
+ "/usr/local/bin/copilot",
1207
+ // Copilot's install script (non-root) target
1208
+ path5.join(os4.homedir(), ".local", "bin", "copilot"),
1209
+ // npm global installs (`npm install -g @github/copilot`) — fallback
1210
+ path5.join(os4.homedir(), ".npm-global", "bin", "copilot")
1211
+ ];
1212
+ function resolveCopilotBinary(customPath) {
1213
+ return resolveCliBinary("copilot", COMMON_COPILOT_PATHS, customPath);
1214
+ }
1215
+ var copilotSpec = {
1216
+ toolName: "Copilot CLI",
1217
+ installHint: "Install it (`npm install -g @github/copilot`) or re-run `gw provider` to choose another provider.",
1218
+ defaultCommand: "copilot",
1219
+ foldSystemPrompt: true,
1220
+ // Full agent turns take longer than a single Claude Code completion.
1221
+ timeoutMs: 3e5,
1222
+ resolveBinary: resolveCopilotBinary,
1223
+ buildArgs({ prompt, modelId, large }) {
1224
+ return [...large ? [] : [`--prompt=${prompt}`], "--no-ask-user", "--silent", "--model", modelId];
1225
+ },
1226
+ parseOutput(stdout) {
1227
+ const content = stdout.trim();
1228
+ if (!content) throw new Error("Copilot CLI returned an empty response");
1229
+ return { content, tokens: null };
1230
+ }
1231
+ };
1232
+
1233
+ // src/providers/kiro.ts
1234
+ import os5 from "os";
1235
+ import path6 from "path";
1236
+ import { stripVTControlCharacters } from "util";
1237
+ var COMMON_KIRO_PATHS = [
1238
+ // macOS app bundle and the installer's symlink location — preferred
1239
+ "/Applications/Kiro CLI.app/Contents/MacOS/kiro-cli",
1240
+ path6.join(os5.homedir(), ".local", "bin", "kiro-cli"),
1241
+ "/opt/homebrew/bin/kiro-cli",
1242
+ "/usr/local/bin/kiro-cli"
1243
+ ];
1244
+ function resolveKiroBinary(customPath) {
1245
+ return resolveCliBinary("kiro-cli", COMMON_KIRO_PATHS, customPath);
1246
+ }
1247
+ var kiroSpec = {
1248
+ toolName: "Kiro CLI",
1249
+ installHint: "Install it from https://kiro.dev/docs/cli/ or re-run `gw provider` to choose another provider.",
1250
+ defaultCommand: "kiro-cli",
1251
+ foldSystemPrompt: true,
1252
+ // Full agent turns take longer than a single Claude Code completion.
1253
+ timeoutMs: 3e5,
1254
+ resolveBinary: resolveKiroBinary,
1255
+ buildArgs({ prompt, modelId, large }) {
1256
+ return [
1257
+ "chat",
1258
+ "--no-interactive",
1259
+ "--trust-tools=",
1260
+ "--wrap",
1261
+ "never",
1262
+ "--model",
1263
+ modelId,
1264
+ // `--` (standard for kiro-cli's clap-style parser) keeps a prompt that
1265
+ // starts with "-" from being read as an option.
1266
+ ...large ? [] : ["--", prompt]
1267
+ ];
1268
+ },
1269
+ parseOutput(stdout) {
1270
+ const content = stripVTControlCharacters(stdout).trim();
1271
+ if (!content) throw new Error("Kiro CLI returned an empty response");
1272
+ return { content, tokens: null };
1273
+ },
1274
+ formatExitError(code, stdout, stderr) {
1275
+ const detail = stderr.trim() || stripVTControlCharacters(stdout).trim();
1276
+ return `Kiro CLI exited with code ${code}${detail ? `: ${detail}` : ""}`;
1058
1277
  }
1059
1278
  };
1060
1279
 
1061
1280
  // src/config/types.ts
1281
+ var CLAUDE_MODELS = {
1282
+ fast: "claude-haiku-4-5-20251001",
1283
+ balanced: "claude-sonnet-4-6",
1284
+ powerful: "claude-opus-4-7"
1285
+ };
1062
1286
  var DEFAULT_USER_CONFIG = {
1063
1287
  provider: "api",
1064
1288
  models: {
1065
- fast: "claude-haiku-4-5-20251001",
1066
- balanced: "claude-sonnet-4-6",
1067
- powerful: "claude-opus-4-7"
1289
+ api: { ...CLAUDE_MODELS },
1290
+ "claude-code": { ...CLAUDE_MODELS },
1291
+ codex: {
1292
+ fast: "gpt-6-luna",
1293
+ balanced: "gpt-6-sol",
1294
+ powerful: "gpt-6-astra"
1295
+ },
1296
+ copilot: {
1297
+ fast: "claude-haiku-4.5",
1298
+ balanced: "claude-sonnet-4.6",
1299
+ powerful: "claude-opus-4.7"
1300
+ },
1301
+ kiro: {
1302
+ fast: "claude-haiku-4.5",
1303
+ balanced: "claude-sonnet-4.5",
1304
+ powerful: "claude-sonnet-4.5"
1305
+ }
1068
1306
  },
1069
1307
  language: "en",
1070
1308
  commitConvention: "conventional"
1071
1309
  };
1072
1310
 
1073
1311
  // src/config/merge.ts
1074
- import os3 from "os";
1312
+ import os7 from "os";
1075
1313
 
1076
1314
  // src/config/user.ts
1077
1315
  import { join as join2 } from "path";
1078
- import os2 from "os";
1316
+ import os6 from "os";
1317
+
1318
+ // src/providers/types.ts
1319
+ var PROVIDER_KINDS = ["api", "claude-code", "codex", "copilot", "kiro"];
1320
+
1321
+ // src/config/user.ts
1079
1322
  var GITWISE_DIR = ".gitwise";
1080
1323
  var USER_CONFIG_FILE = "config.json";
1081
1324
  function getUserConfigPath(homeDir) {
1082
- return join2(homeDir ?? os2.homedir(), GITWISE_DIR, USER_CONFIG_FILE);
1325
+ return join2(homeDir ?? os6.homedir(), GITWISE_DIR, USER_CONFIG_FILE);
1326
+ }
1327
+ function isLegacyFlatModels(value) {
1328
+ if (!value || typeof value !== "object") return false;
1329
+ const v = value;
1330
+ return typeof v["fast"] === "string" && typeof v["balanced"] === "string" && typeof v["powerful"] === "string";
1331
+ }
1332
+ function migrateFlatModels(flat, provider) {
1333
+ const target = provider === void 0 ? DEFAULT_USER_CONFIG.provider : provider;
1334
+ const migrated = { ...DEFAULT_USER_CONFIG.models };
1335
+ if (typeof target === "string" && PROVIDER_KINDS.includes(target)) {
1336
+ migrated[target] = { ...flat };
1337
+ }
1338
+ return migrated;
1083
1339
  }
1084
1340
  function mergeWithDefaults(partial) {
1085
- return {
1086
- ...DEFAULT_USER_CONFIG,
1087
- ...partial,
1088
- models: {
1089
- ...DEFAULT_USER_CONFIG.models,
1090
- ...partial.models ?? {}
1091
- }
1092
- };
1341
+ const models = {};
1342
+ for (const kind of PROVIDER_KINDS) {
1343
+ models[kind] = { ...DEFAULT_USER_CONFIG.models[kind], ...partial.models?.[kind] ?? {} };
1344
+ }
1345
+ return { ...DEFAULT_USER_CONFIG, ...partial, models };
1093
1346
  }
1094
1347
  async function readUserConfig(homeDir) {
1095
1348
  const configPath = getUserConfigPath(homeDir);
@@ -1098,6 +1351,17 @@ async function readUserConfig(homeDir) {
1098
1351
  return { ...DEFAULT_USER_CONFIG };
1099
1352
  }
1100
1353
  const raw = await readJSON(configPath);
1354
+ if (isLegacyFlatModels(raw.models)) {
1355
+ const migratedModels = migrateFlatModels(raw.models, raw.provider);
1356
+ const merged = mergeWithDefaults({ ...raw, models: migratedModels });
1357
+ debug("Migrated legacy flat models config to per-provider shape", { path: configPath });
1358
+ try {
1359
+ await writeJSON(configPath, merged);
1360
+ } catch (err) {
1361
+ debug("Could not persist migrated config; using it in memory", { path: configPath, error: String(err) });
1362
+ }
1363
+ return merged;
1364
+ }
1101
1365
  return mergeWithDefaults(raw);
1102
1366
  }
1103
1367
  async function writeUserConfig(partial, homeDir) {
@@ -1108,7 +1372,7 @@ async function writeUserConfig(partial, homeDir) {
1108
1372
  await writeJSON(configPath, updated);
1109
1373
  }
1110
1374
  async function writeApiKey(value, homeDir) {
1111
- const home = homeDir ?? os2.homedir();
1375
+ const home = homeDir ?? os6.homedir();
1112
1376
  await writeEnvVar(home, "ANTHROPIC_API_KEY", value);
1113
1377
  }
1114
1378
 
@@ -1135,6 +1399,26 @@ async function readRepoConfig(cwd) {
1135
1399
  }
1136
1400
 
1137
1401
  // src/config/merge.ts
1402
+ function isPlainObject(value) {
1403
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1404
+ }
1405
+ var MODEL_TIERS = ["fast", "balanced", "powerful"];
1406
+ function mergeRepoModels(base, override) {
1407
+ if (!isPlainObject(override)) return base.models;
1408
+ const source = override;
1409
+ const flat = {};
1410
+ for (const tier of MODEL_TIERS) {
1411
+ const value = source[tier];
1412
+ if (typeof value === "string") flat[tier] = value;
1413
+ }
1414
+ const merged = { ...base.models };
1415
+ merged[base.provider] = { ...base.models[base.provider], ...flat };
1416
+ for (const kind of PROVIDER_KINDS) {
1417
+ const block = source[kind];
1418
+ if (isPlainObject(block)) merged[kind] = { ...merged[kind], ...block };
1419
+ }
1420
+ return merged;
1421
+ }
1138
1422
  function deepMerge(base, override) {
1139
1423
  return {
1140
1424
  ...base,
@@ -1144,10 +1428,7 @@ function deepMerge(base, override) {
1144
1428
  ...override.templatesPath !== void 0 && { templatesPath: override.templatesPath },
1145
1429
  ...override.releaseStrategy !== void 0 && { releaseStrategy: override.releaseStrategy },
1146
1430
  ...override.developBranch !== void 0 && { developBranch: override.developBranch },
1147
- models: {
1148
- ...base.models,
1149
- ...override.models ?? {}
1150
- }
1431
+ models: mergeRepoModels(base, override.models)
1151
1432
  };
1152
1433
  }
1153
1434
  async function getMergedConfig(options) {
@@ -1160,7 +1441,7 @@ async function getMergedConfig(options) {
1160
1441
  return deepMerge(userConfig, repoConfig);
1161
1442
  }
1162
1443
  async function getApiKey(homeDir) {
1163
- const home = homeDir ?? os3.homedir();
1444
+ const home = homeDir ?? os7.homedir();
1164
1445
  return read("ANTHROPIC_API_KEY", home);
1165
1446
  }
1166
1447
 
@@ -1168,7 +1449,7 @@ async function getApiKey(homeDir) {
1168
1449
  import { readFile as readFile4 } from "fs/promises";
1169
1450
  import { dirname as dirname2, join as join4 } from "path";
1170
1451
  import { fileURLToPath } from "url";
1171
- import os4 from "os";
1452
+ import os8 from "os";
1172
1453
 
1173
1454
  // src/template/interpolate.ts
1174
1455
  function interpolate(template, ctx) {
@@ -1196,7 +1477,7 @@ function validateTemplateName(name) {
1196
1477
  async function loadTemplate(name, options = {}) {
1197
1478
  validateTemplateName(name);
1198
1479
  const repoRoot = options.repoRoot ?? process.cwd();
1199
- const userTemplatesPath = options.templatesPath ?? join4(os4.homedir(), ".gitwise", "templates");
1480
+ const userTemplatesPath = options.templatesPath ?? join4(os8.homedir(), ".gitwise", "templates");
1200
1481
  const repoOverride = join4(repoRoot, ".gitwise", "templates", `${name}.md`);
1201
1482
  if (await fileExists(repoOverride)) {
1202
1483
  debug("Loading repo-level template override", { path: repoOverride });
@@ -1437,20 +1718,22 @@ IMPORTANT: Generate exactly 3 different alternative commit messages. Return JSON
1437
1718
  const response = await provider.chat({ systemPrompt: effectiveSystemPrompt, userMessage, tier });
1438
1719
  const parsed = parseCommitResponse(response.content);
1439
1720
  const tokens = { input: response.tokens.input, output: response.tokens.output };
1721
+ const tokensAvailable = response.tokensAvailable;
1440
1722
  if (opts.generateAlternatives) {
1441
1723
  const options = parseAlternativesResponse(response.content);
1442
1724
  if (options && options.length > 0) {
1443
- return { kind: "alternatives", options, tokens };
1725
+ return { kind: "alternatives", options, tokens, tokensAvailable };
1444
1726
  }
1445
1727
  const fallbackMsg = parsed.type === "single" ? parsed.message : parsed.commits[0]?.message ?? response.content.trim().slice(0, 100);
1446
- return { kind: "alternatives", options: [fallbackMsg], tokens };
1728
+ return { kind: "alternatives", options: [fallbackMsg], tokens, tokensAvailable };
1447
1729
  }
1448
1730
  if (split === "never") {
1449
1731
  const message2 = parsed.type === "single" ? parsed.message : parsed.commits.map((c) => c.message).join("\n\n");
1450
1732
  return {
1451
1733
  kind: "single",
1452
1734
  commits: [{ message: message2, files: stagedFiles }],
1453
- tokens
1735
+ tokens,
1736
+ tokensAvailable
1454
1737
  };
1455
1738
  }
1456
1739
  if (parsed.type === "plan" && parsed.commits.length > 1) {
@@ -1463,7 +1746,8 @@ IMPORTANT: Generate exactly 3 different alternative commit messages. Return JSON
1463
1746
  return {
1464
1747
  kind: "split",
1465
1748
  commits: parsed.commits,
1466
- tokens
1749
+ tokens,
1750
+ tokensAvailable
1467
1751
  };
1468
1752
  }
1469
1753
  }
@@ -1478,7 +1762,8 @@ IMPORTANT: Generate exactly 3 different alternative commit messages. Return JSON
1478
1762
  return {
1479
1763
  kind: "single",
1480
1764
  commits: [{ message, files: stagedFiles }],
1481
- tokens
1765
+ tokens,
1766
+ tokensAvailable
1482
1767
  };
1483
1768
  }
1484
1769
  function takeNamedStashStep(cwd, stashName) {
@@ -1688,7 +1973,8 @@ Additional context: ${prompt}` : "");
1688
1973
  suggestions: parsed.suggestions,
1689
1974
  nitpicks: parsed.nitpicks,
1690
1975
  markdown,
1691
- tokens
1976
+ tokens,
1977
+ tokensAvailable: response.tokensAvailable
1692
1978
  };
1693
1979
  }
1694
1980
  async function resolveBaseBranch(cwd) {
@@ -1796,7 +2082,8 @@ Additional context: ${prompt}` : "");
1796
2082
  title,
1797
2083
  body,
1798
2084
  existingPrNumber,
1799
- tokens
2085
+ tokens,
2086
+ tokensAvailable: response.tokensAvailable
1800
2087
  };
1801
2088
  }
1802
2089
  async function applyPr(draft, opts) {
@@ -1913,7 +2200,7 @@ async function loadReleasePlan(cwd) {
1913
2200
  exitCode: EXIT_CODES.CONFIG_INVALID
1914
2201
  });
1915
2202
  }
1916
- return parsed;
2203
+ return { ...parsed, tokensAvailable: parsed.tokensAvailable ?? true };
1917
2204
  }
1918
2205
  function isPersistedReleasePlan(value) {
1919
2206
  if (!value || typeof value !== "object") return false;
@@ -1936,6 +2223,7 @@ function isPersistedReleasePlan(value) {
1936
2223
  const tokens = p.tokens;
1937
2224
  if (typeof tokens.input !== "number" || !Number.isFinite(tokens.input)) return false;
1938
2225
  if (typeof tokens.output !== "number" || !Number.isFinite(tokens.output)) return false;
2226
+ if (p.tokensAvailable !== void 0 && typeof p.tokensAvailable !== "boolean") return false;
1939
2227
  return true;
1940
2228
  }
1941
2229
  async function deleteReleasePlan(cwd) {
@@ -2067,6 +2355,7 @@ async function release(opts) {
2067
2355
  const tier = resolveModelTier("release");
2068
2356
  let totalInput = 0;
2069
2357
  let totalOutput = 0;
2358
+ let tokensAvailable = true;
2070
2359
  let suggestedBump;
2071
2360
  if (opts.bump) {
2072
2361
  suggestedBump = opts.bump;
@@ -2084,6 +2373,7 @@ ${commits}`,
2084
2373
  });
2085
2374
  totalInput += versionResponse.tokens.input;
2086
2375
  totalOutput += versionResponse.tokens.output;
2376
+ tokensAvailable = versionResponse.tokensAvailable;
2087
2377
  const suggestion = parseVersionSuggestion(versionResponse.content);
2088
2378
  suggestedBump = suggestion?.suggestion ?? heuristicBump(commits);
2089
2379
  }
@@ -2101,6 +2391,7 @@ ${commits}`,
2101
2391
  });
2102
2392
  totalInput += changelogResponse.tokens.input;
2103
2393
  totalOutput += changelogResponse.tokens.output;
2394
+ tokensAvailable = tokensAvailable && changelogResponse.tokensAvailable;
2104
2395
  const changelog = changelogResponse.content;
2105
2396
  const notesTemplate = await loadTemplate("release-notes", templateOpts);
2106
2397
  const notesPrompt = interpolate(notesTemplate, {
@@ -2119,6 +2410,7 @@ ${commits}`,
2119
2410
  });
2120
2411
  totalInput += notesResponse.tokens.input;
2121
2412
  totalOutput += notesResponse.tokens.output;
2413
+ tokensAvailable = tokensAvailable && notesResponse.tokensAvailable;
2122
2414
  const notes = notesResponse.content;
2123
2415
  return {
2124
2416
  suggestedBump,
@@ -2127,7 +2419,8 @@ ${commits}`,
2127
2419
  changelog,
2128
2420
  notes,
2129
2421
  commits,
2130
- tokens: { input: totalInput, output: totalOutput }
2422
+ tokens: { input: totalInput, output: totalOutput },
2423
+ tokensAvailable
2131
2424
  };
2132
2425
  }
2133
2426
  function createReleaseBranchStep(cwd, branchName, startPoint) {
@@ -2275,10 +2568,10 @@ async function prepareRelease(opts) {
2275
2568
  });
2276
2569
  try {
2277
2570
  const dirtyEntries = (await status(cwd)).split("\n").map((line) => line.replace(/\s+$/, "")).filter((line) => line.length >= 3).filter((line) => {
2278
- const path3 = line.slice(3).trim();
2279
- if (path3 === ".gitignore") return false;
2280
- if (path3 === ".gitwise/" || path3 === ".gitwise") return false;
2281
- if (path3.startsWith(".gitwise/")) return false;
2571
+ const path7 = line.slice(3).trim();
2572
+ if (path7 === ".gitignore") return false;
2573
+ if (path7 === ".gitwise/" || path7 === ".gitwise") return false;
2574
+ if (path7.startsWith(".gitwise/")) return false;
2282
2575
  return true;
2283
2576
  });
2284
2577
  if (dirtyEntries.length > 0) {
@@ -2376,7 +2669,8 @@ ${dirtyEntries.join("\n")}`,
2376
2669
  baseCommit,
2377
2670
  targetBranch,
2378
2671
  releaseBranchCreated: releaseBranch !== null,
2379
- tokens: plan.tokens
2672
+ tokens: plan.tokens,
2673
+ tokensAvailable: plan.tokensAvailable
2380
2674
  };
2381
2675
  await tx.run(savePlanStep(cwd, persistedPlan));
2382
2676
  debug("release.prepare.plan.saved", {
@@ -2441,7 +2735,8 @@ ${dirty}`,
2441
2735
  baseCommit: await headSha(cwd),
2442
2736
  targetBranch: await getBranch(cwd),
2443
2737
  releaseBranchCreated: false,
2444
- tokens: plan.tokens
2738
+ tokens: plan.tokens,
2739
+ tokensAvailable: plan.tokensAvailable
2445
2740
  };
2446
2741
  await ensureGitignored(cwd, RELEASE_PLAN_REL_PATH);
2447
2742
  await ensureGitignored(cwd, RELEASE_NOTES_GLOB_REL_PATH);
@@ -2790,13 +3085,35 @@ async function gitignoreMatchesPrepareOutput(cwd) {
2790
3085
  expected = applyGitignoreEntry(expected, RELEASE_NOTES_GLOB_REL_PATH);
2791
3086
  return currentContent === expected;
2792
3087
  }
2793
- function writeWorkspaceVersionStep(manifestPath, newVersion) {
3088
+ var CROSS_WORKSPACE_DEPENDENCY_FIELDS = [
3089
+ "dependencies",
3090
+ "devDependencies",
3091
+ "optionalDependencies",
3092
+ "peerDependencies"
3093
+ ];
3094
+ function updateCrossWorkspaceDependencies(parsed, workspaceNames, newVersion) {
3095
+ for (const field of CROSS_WORKSPACE_DEPENDENCY_FIELDS) {
3096
+ const deps = parsed[field];
3097
+ if (!deps || typeof deps !== "object") continue;
3098
+ const depsRecord = deps;
3099
+ for (const depName of Object.keys(depsRecord)) {
3100
+ const spec = depsRecord[depName];
3101
+ if (!workspaceNames.has(depName) || typeof spec !== "string") continue;
3102
+ const prefix = spec.startsWith("^") || spec.startsWith("~") ? spec[0] : "";
3103
+ depsRecord[depName] = `${prefix}${newVersion}`;
3104
+ }
3105
+ }
3106
+ }
3107
+ function writeWorkspaceVersionStep(manifestPath, newVersion, workspaceNames) {
2794
3108
  return {
2795
3109
  name: `write-version:${manifestPath}`,
2796
3110
  apply: async () => {
2797
3111
  const priorBytes = await readFile6(manifestPath);
2798
3112
  const parsed = JSON.parse(priorBytes.toString("utf-8"));
2799
3113
  parsed["version"] = newVersion;
3114
+ if (workspaceNames && workspaceNames.size > 0) {
3115
+ updateCrossWorkspaceDependencies(parsed, workspaceNames, newVersion);
3116
+ }
2800
3117
  await writeJSON(manifestPath, parsed);
2801
3118
  return priorBytes;
2802
3119
  },
@@ -2835,17 +3152,28 @@ async function propagateVersionToWorkspaces(cwd, version2) {
2835
3152
  async function runWorkspaceVersionStepsInto(tx, cwd, version2) {
2836
3153
  const patterns = await readWorkspacePatterns(cwd);
2837
3154
  const workspaceDirs = (await expandWorkspacePatterns(cwd, patterns)).sort();
3155
+ const workspaceNames = /* @__PURE__ */ new Set();
3156
+ for (const dir of workspaceDirs) {
3157
+ const pkgPath = join6(dir, "package.json");
3158
+ if (!await fileExists(pkgPath)) continue;
3159
+ const parsed = await readJSON(pkgPath);
3160
+ if (typeof parsed.name === "string") workspaceNames.add(parsed.name);
3161
+ }
2838
3162
  const modified = [];
2839
3163
  for (const dir of workspaceDirs) {
2840
3164
  const pkgPath = join6(dir, "package.json");
2841
3165
  if (await fileExists(pkgPath)) {
2842
- await tx.run(writeWorkspaceVersionStep(pkgPath, version2));
3166
+ await tx.run(writeWorkspaceVersionStep(pkgPath, version2, workspaceNames));
2843
3167
  modified.push(relative(cwd, pkgPath));
2844
3168
  }
2845
- const pluginPath = join6(dir, "plugin.json");
2846
- if (await fileExists(pluginPath)) {
2847
- await tx.run(writeWorkspaceVersionStep(pluginPath, version2));
2848
- modified.push(relative(cwd, pluginPath));
3169
+ for (const pluginPath of [
3170
+ join6(dir, ".claude-plugin", "plugin.json"),
3171
+ join6(dir, "plugin.json")
3172
+ ]) {
3173
+ if (await fileExists(pluginPath)) {
3174
+ await tx.run(writeWorkspaceVersionStep(pluginPath, version2));
3175
+ modified.push(relative(cwd, pluginPath));
3176
+ }
2849
3177
  }
2850
3178
  }
2851
3179
  return modified;
@@ -2937,6 +3265,11 @@ function segmentToRegex(segment) {
2937
3265
  return new RegExp(`^${escaped}$`);
2938
3266
  }
2939
3267
 
3268
+ // src/commands/token-format.ts
3269
+ function formatTokens(tokens, tokensAvailable) {
3270
+ return tokensAvailable ? `${tokens.input} in / ${tokens.output} out` : "n/a";
3271
+ }
3272
+
2940
3273
  // src/providers/anthropic.ts
2941
3274
  import Anthropic from "@anthropic-ai/sdk";
2942
3275
  var DEFAULT_MAX_TOKENS = 4096;
@@ -2993,7 +3326,8 @@ var AnthropicProvider = class {
2993
3326
  tokens: {
2994
3327
  input: response.usage.input_tokens,
2995
3328
  output: response.usage.output_tokens
2996
- }
3329
+ },
3330
+ tokensAvailable: true
2997
3331
  };
2998
3332
  }
2999
3333
  resolveModel(tier) {
@@ -3012,10 +3346,36 @@ var AnthropicProvider = class {
3012
3346
 
3013
3347
  // src/providers/factory.ts
3014
3348
  function createProvider(config) {
3015
- if (config.kind === "claude-code") {
3016
- return new ClaudeCodeProvider(config.models, config.claudeCliPath);
3349
+ switch (config.kind) {
3350
+ case "claude-code":
3351
+ return new ClaudeCodeProvider(config.models, config.claudeCliPath);
3352
+ case "codex":
3353
+ return new CliSubprocessProvider(codexSpec, config.models, config.codexCliPath);
3354
+ case "copilot":
3355
+ return new CliSubprocessProvider(copilotSpec, config.models, config.copilotCliPath);
3356
+ case "kiro":
3357
+ return new CliSubprocessProvider(kiroSpec, config.models, config.kiroCliPath);
3358
+ case "api":
3359
+ return new AnthropicProvider(config.apiKey, config.models);
3360
+ default: {
3361
+ const unhandled = config.kind;
3362
+ throw new GitwiseError({
3363
+ code: "CONFIG_INVALID",
3364
+ message: `Unknown provider "${String(unhandled)}" in config. Re-run \`gw provider\` to choose a supported provider.`
3365
+ });
3366
+ }
3017
3367
  }
3018
- return new AnthropicProvider(config.apiKey, config.models);
3368
+ }
3369
+ function buildProviderConfig(merged, apiKey) {
3370
+ return {
3371
+ kind: merged.provider,
3372
+ models: merged.models[merged.provider],
3373
+ apiKey,
3374
+ claudeCliPath: merged.claudeCliPath,
3375
+ codexCliPath: merged.codexCliPath,
3376
+ copilotCliPath: merged.copilotCliPath,
3377
+ kiroCliPath: merged.kiroCliPath
3378
+ };
3019
3379
  }
3020
3380
 
3021
3381
  // src/index.ts
@@ -3025,6 +3385,7 @@ export {
3025
3385
  DEFAULT_USER_CONFIG,
3026
3386
  EXIT_CODES,
3027
3387
  GitwiseError,
3388
+ PROVIDER_KINDS,
3028
3389
  STALE_LOCK_MS,
3029
3390
  SUPPORTED_COMMANDS,
3030
3391
  Transaction,
@@ -3035,6 +3396,7 @@ export {
3035
3396
  applyOneCommitStep,
3036
3397
  applyPr,
3037
3398
  applyRelease,
3399
+ buildProviderConfig,
3038
3400
  bumpVersion,
3039
3401
  commit2 as commit,
3040
3402
  createProvider,
@@ -3048,6 +3410,7 @@ export {
3048
3410
  error,
3049
3411
  fileExists,
3050
3412
  finishRelease,
3413
+ formatTokens,
3051
3414
  getApiKey,
3052
3415
  getMergedConfig,
3053
3416
  git_exports as git,
@@ -3068,6 +3431,9 @@ export {
3068
3431
  readUserConfig,
3069
3432
  release,
3070
3433
  resolveClaudeBinary,
3434
+ resolveCodexBinary,
3435
+ resolveCopilotBinary,
3436
+ resolveKiroBinary,
3071
3437
  resolveModelTier,
3072
3438
  review,
3073
3439
  runReleaseInProcess,