hermes-git 0.3.1 → 0.3.5

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 +82 -73
  2. package/dist/index.js +413 -50
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -42,6 +42,8 @@ hermes wip
42
42
 
43
43
  No magic. Every command shows exactly what git operations it runs.
44
44
 
45
+ Run `hermes` with no arguments to see the full command reference and available workflows.
46
+
45
47
  ---
46
48
 
47
49
  ## Installation
@@ -78,7 +80,7 @@ hermes config set provider gemini
78
80
  hermes config set gemini-key AIza...
79
81
  ```
80
82
 
81
- Verify it worked:
83
+ Verify:
82
84
 
83
85
  ```bash
84
86
  hermes config list
@@ -93,7 +95,7 @@ hermes config list
93
95
  npm install -g hermes-git
94
96
  hermes config setup
95
97
 
96
- # 2. Initialize your project (optional, enables team config sharing)
98
+ # 2. Initialize your project (optional enables team config sharing)
97
99
  cd your-project
98
100
  hermes init
99
101
 
@@ -105,13 +107,26 @@ hermes start "user authentication"
105
107
 
106
108
  ## Commands
107
109
 
110
+ ### `hermes update`
111
+
112
+ Update hermes to the latest version.
113
+
114
+ ```bash
115
+ hermes update # check and install if a newer version exists
116
+ hermes update --check # check only, don't install
117
+ ```
118
+
119
+ Auto-detects your package manager (npm, bun, or pnpm).
120
+
121
+ ---
122
+
108
123
  ### `hermes config`
109
124
 
110
125
  Manage API keys and provider settings.
111
126
 
112
127
  ```bash
113
- hermes config setup # Interactive wizard
114
- hermes config list # Show current config (keys masked)
128
+ hermes config setup # interactive wizard
129
+ hermes config list # show current config (keys masked, sources shown)
115
130
  hermes config set provider openai
116
131
  hermes config set openai-key sk-...
117
132
  hermes config get provider
@@ -122,6 +137,52 @@ Config is stored in `~/.config/hermes/config.json`. You can also use environment
122
137
 
123
138
  ---
124
139
 
140
+ ### `hermes guard`
141
+
142
+ Scan staged files for secrets and sensitive content before committing.
143
+
144
+ ```bash
145
+ hermes guard
146
+ ```
147
+
148
+ Scans every staged file for:
149
+
150
+ - **Sensitive filenames** — `.env`, `id_rsa`, `*.pem`, `credentials.json`, `google-services.json`, etc.
151
+ - **API keys** — Anthropic, OpenAI, Google, AWS, GitHub, Stripe, SendGrid, Twilio
152
+ - **Private key headers** — `-----BEGIN PRIVATE KEY-----` and variants
153
+ - **Database URLs** with embedded credentials — `postgres://user:pass@host`
154
+ - **Hardcoded passwords/tokens** — common assignment patterns
155
+
156
+ Findings are categorised as `BLOCKED` (definite secret) or `WARN` (suspicious). The AI explains each finding and what to do about it, then you choose: abort, unstage the flagged files, or proceed anyway.
157
+
158
+ ```
159
+ BLOCKED src/config.ts
160
+ ● Anthropic API key line 12
161
+ apiKey: "sk-a...****",
162
+ Rotate at: https://console.anthropic.com/settings/keys
163
+
164
+ What this means:
165
+ This key gives anyone with repo access full billing access to your
166
+ Anthropic account. Rotate it immediately and load it from an
167
+ environment variable instead.
168
+
169
+ ? Blocked secrets found. What do you want to do?
170
+ ❯ Abort — I will fix these before committing
171
+ Unstage the flagged files and continue
172
+ Proceed anyway (I know what I'm doing)
173
+ ```
174
+
175
+ **Install as a git pre-commit hook** so it runs automatically on every commit:
176
+
177
+ ```bash
178
+ hermes guard install-hook # installs to .git/hooks/pre-commit
179
+ hermes guard uninstall-hook # removes it
180
+ ```
181
+
182
+ In hook mode the scan is non-interactive: prints findings to stderr and exits 1 on any blocker.
183
+
184
+ ---
185
+
125
186
  ### `hermes plan "<intent>"`
126
187
 
127
188
  Analyze repo state and propose a safe Git plan. **Makes no changes.**
@@ -154,7 +215,7 @@ hermes sync
154
215
  hermes sync --from develop
155
216
  ```
156
217
 
157
- Hermes evaluates whether rebase or merge is safer given your branch state and explains before executing.
218
+ Evaluates whether rebase or merge is safer given your branch state and explains before executing.
158
219
 
159
220
  ---
160
221
 
@@ -171,55 +232,6 @@ Decides commit vs stash based on what's safest in your current state.
171
232
 
172
233
  ---
173
234
 
174
- ### `hermes guard`
175
-
176
- Scan staged files for secrets and sensitive content before committing.
177
-
178
- ```bash
179
- hermes guard
180
- ```
181
-
182
- Hermes scans every staged file for:
183
-
184
- - **Sensitive filenames** — `.env`, `id_rsa`, `*.pem`, `credentials.json`, `google-services.json`, etc.
185
- - **API keys** — Anthropic, OpenAI, Google, AWS, GitHub, Stripe, SendGrid, Twilio
186
- - **Private key headers** — `-----BEGIN PRIVATE KEY-----` and variants
187
- - **Database URLs** with embedded credentials — `postgres://user:pass@host`
188
- - **Hardcoded passwords/tokens** — common assignment patterns
189
-
190
- Findings are categorized as `BLOCKED` (definite secret) or `WARN` (suspicious). The AI explains each finding and what to do about it. Then you choose: abort, unstage the flagged files, or proceed anyway.
191
-
192
- ```
193
- BLOCKED src/config.ts
194
- ● Anthropic API key line 12
195
- apiKey: "sk-a...****",
196
- Rotate at: https://console.anthropic.com/settings/keys
197
- ● Database URL with credentials line 15
198
- dbUrl: "post...****prod.db.internal/app"
199
-
200
- What this means:
201
- The Anthropic API key on line 12 would give anyone with repository
202
- access full billing access to your Anthropic account. Rotate it
203
- immediately and use process.env.ANTHROPIC_API_KEY instead.
204
- ...
205
-
206
- ? Blocked secrets found. What do you want to do?
207
- ❯ Abort — I will fix these before committing
208
- Unstage the flagged files and continue
209
- Proceed anyway (I know what I'm doing)
210
- ```
211
-
212
- **Install as a git pre-commit hook** so it runs automatically on every commit:
213
-
214
- ```bash
215
- hermes guard install-hook # installs to .git/hooks/pre-commit
216
- hermes guard uninstall-hook # removes it
217
- ```
218
-
219
- In hook mode (`--hook`), the scan is non-interactive: it prints findings to stderr and exits 1 on any blocker.
220
-
221
- ---
222
-
223
235
  ### `hermes conflict explain`
224
236
 
225
237
  Understand why a conflict exists.
@@ -246,15 +258,17 @@ For each file: shows a proposed resolution, lets you accept, edit manually, or s
246
258
 
247
259
  ### `hermes workflow <name>`
248
260
 
249
- One-command workflows for common patterns.
261
+ One-command workflows for common patterns. Available workflows are shown when you run `hermes` with no arguments.
250
262
 
251
263
  ```bash
252
264
  hermes workflow pr-ready # fetch → rebase → push --force-with-lease
253
265
  hermes workflow daily-sync # fetch all → show status → suggest next action
254
266
  hermes workflow quick-commit # generate commit message from staged diff
255
- hermes workflow list # show available workflows
267
+ hermes workflow list # show all workflows including project-specific
256
268
  ```
257
269
 
270
+ Define project-specific workflows in `.hermes/config.json` and they appear automatically in the help output.
271
+
258
272
  ---
259
273
 
260
274
  ### `hermes worktree new "<task>"`
@@ -273,8 +287,8 @@ hermes worktree new "fix memory leak"
273
287
  Initialize project-level config (`.hermes/config.json`). Commit this to share branch patterns and workflows with your team.
274
288
 
275
289
  ```bash
276
- hermes init # Interactive
277
- hermes init --quick # Use defaults
290
+ hermes init # interactive
291
+ hermes init --quick # use defaults
278
292
  ```
279
293
 
280
294
  ---
@@ -301,6 +315,8 @@ Hermes resolves config in this priority order:
301
315
  | `.env` file in current dir | `ANTHROPIC_API_KEY=sk-ant-...` |
302
316
  | `~/.config/hermes/config.json` | set via `hermes config set` |
303
317
 
318
+ Environment variables always win — useful for CI and Docker environments where you don't want a config file.
319
+
304
320
  **Supported env vars:**
305
321
 
306
322
  | Variable | Description |
@@ -326,9 +342,9 @@ If `HERMES_PROVIDER` is not set, Hermes auto-detects by using whichever key it f
326
342
 
327
343
  1. **Reads your repo state** — branch, commits, dirty files, conflicts, remote tracking
328
344
  2. **Sends context + intent to an AI** — using your configured provider
329
- 3. **Validates the response** — all returned commands must start with `git`, destructive flags are blocked
345
+ 3. **Validates the response** — all returned commands must start with `git`; destructive flags are blocked
330
346
  4. **Executes with display** — shows every command before running it
331
- 5. **You always stay in control** — interactive prompts for anything irreversible
347
+ 5. **You stay in control** — interactive prompts for anything irreversible
332
348
 
333
349
  ---
334
350
 
@@ -350,30 +366,23 @@ If `HERMES_PROVIDER` is not set, Hermes auto-detects by using whichever key it f
350
366
  hermes config setup
351
367
  ```
352
368
 
353
- **Wrong provider selected**
369
+ **Wrong provider being used**
354
370
 
355
371
  ```bash
356
372
  hermes config set provider anthropic
357
- hermes config list # verify
373
+ hermes config list # check sources — env vars override saved config
358
374
  ```
359
375
 
360
- **Key saved but not working**
376
+ **Key set but not working**
361
377
 
362
378
  ```bash
363
- # Check what Hermes sees
364
- hermes config list
365
-
366
- # Environment variables override saved config
367
- # Check for conflicting vars:
368
- echo $ANTHROPIC_API_KEY
379
+ hermes config list # shows value and where it came from (env / .env / config)
369
380
  ```
370
381
 
371
- **General debugging**
382
+ **Update to latest**
372
383
 
373
384
  ```bash
374
- hermes --version
375
- hermes config list
376
- git status
385
+ hermes update
377
386
  ```
378
387
 
379
388
  ---
package/dist/index.js CHANGED
@@ -37589,9 +37589,11 @@ function initCommand(program2) {
37589
37589
  await mkdir3(".hermes", { recursive: true });
37590
37590
  await writeFile4(".hermes/config.json", JSON.stringify(config, null, 2));
37591
37591
  await mkdir3(".hermes/backups", { recursive: true });
37592
- await appendToGitignore(`.hermes/backups/
37593
- .hermes/stats.json
37594
- `);
37592
+ const added = await updateGitignore();
37593
+ if (added.length > 0) {
37594
+ console.log(`
37595
+ \uD83D\uDCDD Added to .gitignore: ${added.join(", ")}`);
37596
+ }
37595
37597
  displaySuccess("Hermes initialized successfully!");
37596
37598
  console.log(`
37597
37599
  \uD83D\uDCC4 Configuration saved to .hermes/config.json`);
@@ -37734,23 +37736,38 @@ async function interactiveConfig(repoInfo) {
37734
37736
  }
37735
37737
  };
37736
37738
  }
37737
- async function appendToGitignore(content) {
37738
- try {
37739
- const { appendFile } = await import("fs/promises");
37740
- const gitignorePath = ".gitignore";
37741
- if (existsSync4(gitignorePath)) {
37742
- const { readFile: readFile5 } = await import("fs/promises");
37743
- const existing = await readFile5(gitignorePath, "utf-8");
37744
- if (!existing.includes(".hermes/backups")) {
37745
- await appendFile(gitignorePath, `
37739
+ var GITIGNORE_ENTRIES = [
37740
+ { pattern: ".env", comment: null },
37741
+ { pattern: ".env.*", comment: null },
37742
+ { pattern: "!.env.example", comment: null },
37743
+ { pattern: ".hermes/backups/", comment: null },
37744
+ { pattern: ".hermes/stats.json", comment: null }
37745
+ ];
37746
+ async function updateGitignore() {
37747
+ const { appendFile, readFile: rf } = await import("fs/promises");
37748
+ const gitignorePath = ".gitignore";
37749
+ let existing = "";
37750
+ if (existsSync4(gitignorePath)) {
37751
+ try {
37752
+ existing = await rf(gitignorePath, "utf-8");
37753
+ } catch {}
37754
+ }
37755
+ const existingLines = existing.split(`
37756
+ `).map((l) => l.trim());
37757
+ const toAdd = GITIGNORE_ENTRIES.filter((e) => !existingLines.includes(e.pattern));
37758
+ if (toAdd.length === 0)
37759
+ return [];
37760
+ const block = `
37746
37761
  # Hermes
37747
- ` + content);
37748
- }
37749
- } else {
37750
- await writeFile4(gitignorePath, `# Hermes
37751
- ` + content);
37752
- }
37753
- } catch {}
37762
+ ` + toAdd.map((e) => e.pattern).join(`
37763
+ `) + `
37764
+ `;
37765
+ if (existsSync4(gitignorePath)) {
37766
+ await appendFile(gitignorePath, block);
37767
+ } else {
37768
+ await writeFile4(gitignorePath, block.trimStart());
37769
+ }
37770
+ return toAdd.map((e) => e.pattern);
37754
37771
  }
37755
37772
 
37756
37773
  // src/commands/stats.ts
@@ -38737,32 +38754,348 @@ function buildInstallCommand(pm) {
38737
38754
  }
38738
38755
  }
38739
38756
 
38757
+ // src/commands/commit.ts
38758
+ import { exec as exec6 } from "child_process";
38759
+ import { promisify as promisify6 } from "util";
38760
+ var execAsync6 = promisify6(exec6);
38761
+ async function getStagedFiles2() {
38762
+ try {
38763
+ const { stdout } = await execAsync6("git diff --cached --name-status");
38764
+ return stdout.trim().split(`
38765
+ `).filter(Boolean).map((line) => {
38766
+ const [status, ...rest] = line.split("\t");
38767
+ return { status: status.trim(), path: rest.join("\t").trim() };
38768
+ });
38769
+ } catch {
38770
+ return [];
38771
+ }
38772
+ }
38773
+ async function getUnstagedFiles() {
38774
+ try {
38775
+ const { stdout } = await execAsync6("git status --porcelain");
38776
+ return stdout.trim().split(`
38777
+ `).filter(Boolean).map((line) => {
38778
+ const status = line.slice(0, 2).trim();
38779
+ const path5 = line.slice(3).trim();
38780
+ return { status, path: path5 };
38781
+ }).filter(({ status }) => status === "??" || status[0] === " " || status.length === 1);
38782
+ } catch {
38783
+ return [];
38784
+ }
38785
+ }
38786
+ async function getRecentBranchCommits(n = 5) {
38787
+ try {
38788
+ const { stdout } = await execAsync6(`git log --oneline -${n} --no-merges 2>/dev/null`);
38789
+ return stdout.trim();
38790
+ } catch {
38791
+ return "";
38792
+ }
38793
+ }
38794
+ function statusLabel(s) {
38795
+ const map = {
38796
+ M: source_default.yellow("modified "),
38797
+ A: source_default.green("new file "),
38798
+ D: source_default.red("deleted "),
38799
+ R: source_default.blue("renamed "),
38800
+ C: source_default.blue("copied "),
38801
+ "??": source_default.dim("untracked"),
38802
+ " M": source_default.yellow("modified "),
38803
+ " D": source_default.red("deleted ")
38804
+ };
38805
+ return map[s] ?? source_default.dim(s.padEnd(9));
38806
+ }
38807
+ function divider() {
38808
+ console.log(source_default.dim(" " + "─".repeat(54)));
38809
+ }
38810
+ async function analyzeChanges(branch, stagedDiff, stagedStat, recentCommits) {
38811
+ const prompt2 = `You are an expert Git historian. Analyze these staged changes and produce a structured commit analysis.
38812
+
38813
+ Branch: ${branch}
38814
+ Recent commits on this branch:
38815
+ ${recentCommits || "(none — first commit)"}
38816
+
38817
+ Staged diff summary:
38818
+ ${stagedStat}
38819
+
38820
+ Full staged diff (truncated to 12000 chars):
38821
+ ${stagedDiff.slice(0, 12000)}
38822
+
38823
+ Return RAW JSON ONLY — no markdown, no code fences, just the JSON object:
38824
+ {
38825
+ "summary": "2-3 sentences: what was changed and the likely intent behind it",
38826
+ "concerns": ["array of concerns — mixed unrelated changes, debug/temp code, large binary, TODO left in, test missing, etc. Empty array if none."],
38827
+ "message": "conventional commit: type(scope): subject (max 72 chars). Be specific.",
38828
+ "body": "optional multi-line body explaining WHY (not WHAT). Empty string if the subject is self-explanatory.",
38829
+ "alternatives": ["two alternative commit messages if the intent is ambiguous"]
38830
+ }
38831
+
38832
+ Rules for message:
38833
+ - type: feat | fix | refactor | docs | test | chore | style | perf | build | ci
38834
+ - scope: the primary module/component affected (omit if changes are cross-cutting)
38835
+ - subject: imperative mood, no period, lowercase after colon
38836
+ - be specific — "add OAuth login" not "add feature"`;
38837
+ const raw = await getAISuggestion(prompt2);
38838
+ const cleaned = raw.replace(/^```(?:json)?\s*/i, "").replace(/\s*```\s*$/, "").trim();
38839
+ try {
38840
+ return JSON.parse(cleaned);
38841
+ } catch {
38842
+ return {
38843
+ summary: "Could not parse analysis.",
38844
+ concerns: [],
38845
+ message: cleaned.split(`
38846
+ `)[0].slice(0, 72),
38847
+ body: "",
38848
+ alternatives: []
38849
+ };
38850
+ }
38851
+ }
38852
+ function commitCommand(program2) {
38853
+ program2.command("commit").description("Analyze staged changes with AI and craft a commit message").option("-a, --all", "Stage all tracked changes before analysis").option("--no-body", "Omit the commit body even if one is suggested").action(async (options) => {
38854
+ const start = Date.now();
38855
+ try {
38856
+ const repoState = await getRepoState();
38857
+ if (options.all) {
38858
+ displayStep("git add -u");
38859
+ await execAsync6("git add -u");
38860
+ }
38861
+ const staged = await getStagedFiles2();
38862
+ if (staged.length === 0) {
38863
+ console.log(source_default.yellow(`
38864
+ Nothing staged to commit.`));
38865
+ console.log(source_default.dim(" Use `git add <file>` or run `hermes commit --all` to stage tracked changes.\n"));
38866
+ process.exit(0);
38867
+ }
38868
+ const unstaged = await getUnstagedFiles();
38869
+ const recentCommits = await getRecentBranchCommits(5);
38870
+ console.log();
38871
+ console.log(source_default.bold(` Staged (${staged.length} file${staged.length === 1 ? "" : "s"}):`));
38872
+ for (const f of staged) {
38873
+ console.log(` ${statusLabel(f.status)} ${source_default.white(f.path)}`);
38874
+ }
38875
+ const relevantUnstaged = unstaged.filter((u) => !staged.some((s) => s.path === u.path));
38876
+ if (relevantUnstaged.length > 0) {
38877
+ console.log();
38878
+ console.log(source_default.dim(` Not staged (${relevantUnstaged.length}):`));
38879
+ for (const f of relevantUnstaged.slice(0, 8)) {
38880
+ console.log(` ${statusLabel(f.status)} ${source_default.dim(f.path)}`);
38881
+ }
38882
+ if (relevantUnstaged.length > 8) {
38883
+ console.log(source_default.dim(` ...and ${relevantUnstaged.length - 8} more`));
38884
+ }
38885
+ }
38886
+ const { name, model } = await getActiveProvider();
38887
+ console.log();
38888
+ console.log(` Analyzing... ${source_default.dim(`[${name} / ${model}]`)}`);
38889
+ const [stagedDiff, stagedStat] = await Promise.all([
38890
+ execAsync6("git diff --cached").then((r) => r.stdout),
38891
+ execAsync6("git diff --cached --stat").then((r) => r.stdout)
38892
+ ]);
38893
+ const analysis = await analyzeChanges(repoState.currentBranch, stagedDiff, stagedStat, recentCommits);
38894
+ console.log();
38895
+ divider();
38896
+ console.log();
38897
+ console.log(" " + source_default.bold("Understanding"));
38898
+ console.log();
38899
+ const words = analysis.summary.split(" ");
38900
+ let line = " ";
38901
+ for (const w of words) {
38902
+ if (line.length + w.length > 62) {
38903
+ console.log(source_default.white(line));
38904
+ line = " " + w + " ";
38905
+ } else {
38906
+ line += w + " ";
38907
+ }
38908
+ }
38909
+ if (line.trim())
38910
+ console.log(source_default.white(line));
38911
+ if (analysis.concerns.length > 0) {
38912
+ console.log();
38913
+ for (const c of analysis.concerns) {
38914
+ console.log(` ${source_default.yellow("⚠")} ${source_default.yellow(c)}`);
38915
+ }
38916
+ }
38917
+ console.log();
38918
+ divider();
38919
+ console.log();
38920
+ const hasBody = options.body !== false && analysis.body.trim().length > 0;
38921
+ console.log(" " + source_default.bold("Proposed commit"));
38922
+ console.log();
38923
+ console.log(" " + source_default.cyan.bold(analysis.message));
38924
+ if (hasBody) {
38925
+ console.log();
38926
+ analysis.body.split(`
38927
+ `).forEach((l) => {
38928
+ console.log(" " + source_default.dim(l));
38929
+ });
38930
+ }
38931
+ console.log();
38932
+ let finalMessage = analysis.message;
38933
+ let finalBody = hasBody ? analysis.body : "";
38934
+ let committed = false;
38935
+ while (!committed) {
38936
+ const choices = [
38937
+ { name: "Commit", value: "commit" },
38938
+ { name: "Edit message", value: "edit" }
38939
+ ];
38940
+ if (analysis.alternatives?.length > 0) {
38941
+ choices.push({ name: "See alternative messages", value: "alts" });
38942
+ }
38943
+ if (relevantUnstaged.length > 0) {
38944
+ choices.push({ name: "Stage more files first", value: "stage" });
38945
+ }
38946
+ choices.push({ name: "Cancel", value: "cancel" });
38947
+ const { action } = await esm_default12.prompt([
38948
+ {
38949
+ type: "list",
38950
+ name: "action",
38951
+ message: "What would you like to do?",
38952
+ choices
38953
+ }
38954
+ ]);
38955
+ if (action === "cancel") {
38956
+ console.log(source_default.dim(`
38957
+ Cancelled. Changes remain staged.
38958
+ `));
38959
+ await recordCommand("commit", [], Date.now() - start, false);
38960
+ return;
38961
+ }
38962
+ if (action === "alts") {
38963
+ console.log();
38964
+ analysis.alternatives.forEach((alt, i) => {
38965
+ console.log(` ${source_default.dim(`${i + 1}.`)} ${source_default.cyan(alt)}`);
38966
+ });
38967
+ console.log();
38968
+ const { pick } = await esm_default12.prompt([
38969
+ {
38970
+ type: "list",
38971
+ name: "pick",
38972
+ message: "Use an alternative?",
38973
+ choices: [
38974
+ ...analysis.alternatives.map((a, i) => ({ name: a, value: `${i}` })),
38975
+ { name: "Keep original", value: "keep" }
38976
+ ]
38977
+ }
38978
+ ]);
38979
+ if (pick !== "keep") {
38980
+ finalMessage = analysis.alternatives[parseInt(pick, 10)];
38981
+ finalBody = "";
38982
+ console.log();
38983
+ console.log(" " + source_default.cyan.bold(finalMessage));
38984
+ console.log();
38985
+ }
38986
+ continue;
38987
+ }
38988
+ if (action === "edit") {
38989
+ const { edited } = await esm_default12.prompt([
38990
+ {
38991
+ type: "input",
38992
+ name: "edited",
38993
+ message: "Commit message:",
38994
+ default: finalMessage
38995
+ }
38996
+ ]);
38997
+ finalMessage = edited.trim();
38998
+ finalBody = "";
38999
+ console.log();
39000
+ continue;
39001
+ }
39002
+ if (action === "stage") {
39003
+ const { files } = await esm_default12.prompt([
39004
+ {
39005
+ type: "checkbox",
39006
+ name: "files",
39007
+ message: "Select files to stage:",
39008
+ choices: relevantUnstaged.map((f) => ({
39009
+ name: `${statusLabel(f.status)} ${f.path}`,
39010
+ value: f.path
39011
+ }))
39012
+ }
39013
+ ]);
39014
+ if (files.length > 0) {
39015
+ for (const f of files) {
39016
+ const cmd = `git add ${JSON.stringify(f)}`;
39017
+ displayStep(cmd);
39018
+ await execAsync6(cmd);
39019
+ }
39020
+ console.log(source_default.dim(`
39021
+ Staged ${files.length} file(s). Re-running analysis...
39022
+ `));
39023
+ const [newDiff, newStat] = await Promise.all([
39024
+ execAsync6("git diff --cached").then((r) => r.stdout),
39025
+ execAsync6("git diff --cached --stat").then((r) => r.stdout)
39026
+ ]);
39027
+ const newAnalysis = await analyzeChanges(repoState.currentBranch, newDiff, newStat, recentCommits);
39028
+ analysis.summary = newAnalysis.summary;
39029
+ analysis.concerns = newAnalysis.concerns;
39030
+ analysis.message = newAnalysis.message;
39031
+ analysis.body = newAnalysis.body;
39032
+ analysis.alternatives = newAnalysis.alternatives;
39033
+ finalMessage = newAnalysis.message;
39034
+ finalBody = options.body !== false && newAnalysis.body.trim().length > 0 ? newAnalysis.body : "";
39035
+ console.log(" " + source_default.bold("Updated proposal"));
39036
+ console.log();
39037
+ console.log(" " + source_default.cyan.bold(finalMessage));
39038
+ if (finalBody) {
39039
+ console.log();
39040
+ finalBody.split(`
39041
+ `).forEach((l) => console.log(" " + source_default.dim(l)));
39042
+ }
39043
+ if (newAnalysis.concerns.length > 0) {
39044
+ console.log();
39045
+ newAnalysis.concerns.forEach((c) => {
39046
+ console.log(` ${source_default.yellow("⚠")} ${source_default.yellow(c)}`);
39047
+ });
39048
+ }
39049
+ console.log();
39050
+ }
39051
+ continue;
39052
+ }
39053
+ if (action === "commit") {
39054
+ const fullMessage = finalBody ? `${finalMessage}
39055
+
39056
+ ${finalBody}` : finalMessage;
39057
+ displayStep(`git commit -m "${finalMessage}"${finalBody ? " (with body)" : ""}`);
39058
+ await execAsync6(`git commit -m ${JSON.stringify(fullMessage)}`);
39059
+ displaySuccess("Committed!");
39060
+ const { stdout: hash } = await execAsync6("git rev-parse --short HEAD");
39061
+ console.log(source_default.dim(` ${hash.trim()} ${finalMessage}
39062
+ `));
39063
+ await recordCommand("commit", [], Date.now() - start, true);
39064
+ committed = true;
39065
+ }
39066
+ }
39067
+ } catch (error3) {
39068
+ console.error("❌ Error:", error3 instanceof Error ? error3.message : error3);
39069
+ await recordCommand("commit", [], Date.now() - start, false);
39070
+ process.exit(1);
39071
+ }
39072
+ });
39073
+ }
39074
+
38740
39075
  // src/lib/update-notifier.ts
38741
39076
  import { readFile as readFile6, writeFile as writeFile6, mkdir as mkdir5 } from "fs/promises";
38742
39077
  import { existsSync as existsSync6 } from "fs";
38743
39078
  import { homedir as homedir2 } from "os";
38744
39079
  import path5 from "path";
38745
39080
  var PACKAGE_NAME2 = "hermes-git";
38746
- var CHECK_INTERVAL = 24 * 60 * 60 * 1000;
39081
+ var CHECK_INTERVAL = 12 * 60 * 60 * 1000;
38747
39082
  var CACHE_DIR = path5.join(homedir2(), ".hermes", "cache");
38748
39083
  var CACHE_FILE = path5.join(CACHE_DIR, "update-check.json");
38749
- async function getLatestVersion() {
39084
+ async function fetchDistTags() {
38750
39085
  try {
38751
- const response = await fetch(`https://registry.npmjs.org/${PACKAGE_NAME2}/latest`);
38752
- if (!response.ok)
38753
- return null;
38754
- const data = await response.json();
38755
- return data.version || null;
39086
+ const res = await fetch(`https://registry.npmjs.org/-/package/${PACKAGE_NAME2}/dist-tags`, { signal: AbortSignal.timeout(5000) });
39087
+ if (!res.ok)
39088
+ return {};
39089
+ return await res.json();
38756
39090
  } catch {
38757
- return null;
39091
+ return {};
38758
39092
  }
38759
39093
  }
38760
39094
  async function readCache() {
38761
39095
  try {
38762
39096
  if (!existsSync6(CACHE_FILE))
38763
39097
  return null;
38764
- const content = await readFile6(CACHE_FILE, "utf-8");
38765
- return JSON.parse(content);
39098
+ return JSON.parse(await readFile6(CACHE_FILE, "utf-8"));
38766
39099
  } catch {
38767
39100
  return null;
38768
39101
  }
@@ -38773,36 +39106,66 @@ async function writeCache(cache) {
38773
39106
  await writeFile6(CACHE_FILE, JSON.stringify(cache, null, 2));
38774
39107
  } catch {}
38775
39108
  }
38776
- function isNewerVersion(current, latest) {
38777
- const currentParts = current.split(".").map(Number);
38778
- const latestParts = latest.split(".").map(Number);
39109
+ function compareVersions2(a, b) {
39110
+ const pa = a.split(".").map(Number);
39111
+ const pb = b.split(".").map(Number);
38779
39112
  for (let i = 0;i < 3; i++) {
38780
- if (latestParts[i] > currentParts[i])
38781
- return true;
38782
- if (latestParts[i] < currentParts[i])
38783
- return false;
39113
+ if (pa[i] > pb[i])
39114
+ return 1;
39115
+ if (pa[i] < pb[i])
39116
+ return -1;
38784
39117
  }
38785
- return false;
39118
+ return 0;
39119
+ }
39120
+ async function enforceMinimumVersion(currentVersion) {
39121
+ try {
39122
+ const cache = await readCache();
39123
+ const now = Date.now();
39124
+ const stale = !cache || now - cache.lastChecked > CHECK_INTERVAL;
39125
+ let minimumVersion = cache?.minimumVersion ?? null;
39126
+ let latestVersion = cache?.latestVersion ?? null;
39127
+ if (stale) {
39128
+ const tags = await fetchDistTags();
39129
+ latestVersion = tags.latest ?? latestVersion;
39130
+ minimumVersion = tags.minimum ?? null;
39131
+ await writeCache({ lastChecked: now, latestVersion: latestVersion ?? undefined, minimumVersion: minimumVersion ?? undefined });
39132
+ }
39133
+ if (minimumVersion && compareVersions2(currentVersion, minimumVersion) < 0) {
39134
+ console.error("");
39135
+ console.error(source_default.red.bold(" ✖ This version of hermes is no longer supported."));
39136
+ console.error("");
39137
+ console.error(` You have ${source_default.dim(currentVersion)}, minimum required is ${source_default.red.bold(minimumVersion)}.`);
39138
+ console.error("");
39139
+ console.error(" Update now:");
39140
+ console.error(` ${source_default.cyan("npm install -g hermes-git@latest")}`);
39141
+ console.error(` ${source_default.dim("or: hermes update")}`);
39142
+ console.error("");
39143
+ process.exit(1);
39144
+ }
39145
+ } catch {}
38786
39146
  }
38787
39147
  async function checkForUpdates(currentVersion) {
38788
39148
  try {
38789
39149
  const cache = await readCache();
38790
39150
  const now = Date.now();
38791
- const shouldCheck = !cache || now - cache.lastChecked > CHECK_INTERVAL;
38792
- let latestVersion = cache?.latestVersion || null;
38793
- if (shouldCheck) {
38794
- latestVersion = await getLatestVersion();
39151
+ const stale = !cache || now - cache.lastChecked > CHECK_INTERVAL;
39152
+ let latestVersion = cache?.latestVersion ?? null;
39153
+ if (stale) {
39154
+ const tags = await fetchDistTags();
39155
+ latestVersion = tags.latest ?? null;
38795
39156
  await writeCache({
38796
39157
  lastChecked: now,
38797
- latestVersion: latestVersion || undefined
39158
+ latestVersion: latestVersion ?? undefined,
39159
+ minimumVersion: cache?.minimumVersion
38798
39160
  });
38799
39161
  }
38800
- if (latestVersion && isNewerVersion(currentVersion, latestVersion)) {
39162
+ if (latestVersion && compareVersions2(latestVersion, currentVersion) > 0) {
39163
+ const gap = " ".repeat(Math.max(0, 17 - currentVersion.length - latestVersion.length));
38801
39164
  console.log(source_default.yellow(`
38802
- ┌─────────────────────────────────────────────────────┐`));
38803
- console.log(source_default.yellow("│") + " " + source_default.bold("Update available!") + " " + source_default.dim(currentVersion) + " " + source_default.green.bold(latestVersion) + " " + source_default.yellow("│"));
38804
- console.log(source_default.yellow("│") + " Run: " + source_default.cyan("npm install -g hermes-git@latest") + " " + source_default.yellow("│"));
38805
- console.log(source_default.yellow(`└─────────────────────────────────────────────────────┘
39165
+ ┌──────────────────────────────────────────────────────┐`));
39166
+ console.log(source_default.yellow("│") + ` ${source_default.bold("Update available!")} ${source_default.dim(currentVersion)}${source_default.green.bold(latestVersion)}${gap}` + source_default.yellow("│"));
39167
+ console.log(source_default.yellow("│") + ` Run: ${source_default.cyan("hermes update")}` + " ".repeat(36) + source_default.yellow("│"));
39168
+ console.log(source_default.yellow(`└──────────────────────────────────────────────────────┘
38806
39169
  `));
38807
39170
  }
38808
39171
  } catch {}
@@ -38889,7 +39252,7 @@ function printWorkflows() {
38889
39252
 
38890
39253
  // src/index.ts
38891
39254
  var program2 = new Command;
38892
- var CURRENT_VERSION = "0.3.1";
39255
+ var CURRENT_VERSION = "0.3.5";
38893
39256
  program2.name("hermes").description("Intent-driven Git, guided by AI").version(CURRENT_VERSION).action(() => {
38894
39257
  printBanner(CURRENT_VERSION);
38895
39258
  printWorkflows();
@@ -38907,5 +39270,5 @@ workflowCommand(program2);
38907
39270
  configCommand(program2);
38908
39271
  guardCommand(program2);
38909
39272
  updateCommand(program2, CURRENT_VERSION);
38910
- checkForUpdates(CURRENT_VERSION).catch(() => {});
38911
- program2.parse();
39273
+ commitCommand(program2);
39274
+ enforceMinimumVersion(CURRENT_VERSION).then(() => program2.parseAsync()).then(() => checkForUpdates(CURRENT_VERSION)).catch(() => {});
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hermes-git",
3
- "version": "0.3.1",
3
+ "version": "0.3.5",
4
4
  "description": "Intent-driven Git, guided by AI. Turn natural language into safe, explainable Git operations.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",