hermes-git 0.3.0 → 0.3.2

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 -24
  2. package/dist/index.js +712 -10
  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
 
@@ -197,15 +258,17 @@ For each file: shows a proposed resolution, lets you accept, edit manually, or s
197
258
 
198
259
  ### `hermes workflow <name>`
199
260
 
200
- One-command workflows for common patterns.
261
+ One-command workflows for common patterns. Available workflows are shown when you run `hermes` with no arguments.
201
262
 
202
263
  ```bash
203
264
  hermes workflow pr-ready # fetch → rebase → push --force-with-lease
204
265
  hermes workflow daily-sync # fetch all → show status → suggest next action
205
266
  hermes workflow quick-commit # generate commit message from staged diff
206
- hermes workflow list # show available workflows
267
+ hermes workflow list # show all workflows including project-specific
207
268
  ```
208
269
 
270
+ Define project-specific workflows in `.hermes/config.json` and they appear automatically in the help output.
271
+
209
272
  ---
210
273
 
211
274
  ### `hermes worktree new "<task>"`
@@ -224,8 +287,8 @@ hermes worktree new "fix memory leak"
224
287
  Initialize project-level config (`.hermes/config.json`). Commit this to share branch patterns and workflows with your team.
225
288
 
226
289
  ```bash
227
- hermes init # Interactive
228
- hermes init --quick # Use defaults
290
+ hermes init # interactive
291
+ hermes init --quick # use defaults
229
292
  ```
230
293
 
231
294
  ---
@@ -252,6 +315,8 @@ Hermes resolves config in this priority order:
252
315
  | `.env` file in current dir | `ANTHROPIC_API_KEY=sk-ant-...` |
253
316
  | `~/.config/hermes/config.json` | set via `hermes config set` |
254
317
 
318
+ Environment variables always win — useful for CI and Docker environments where you don't want a config file.
319
+
255
320
  **Supported env vars:**
256
321
 
257
322
  | Variable | Description |
@@ -277,9 +342,9 @@ If `HERMES_PROVIDER` is not set, Hermes auto-detects by using whichever key it f
277
342
 
278
343
  1. **Reads your repo state** — branch, commits, dirty files, conflicts, remote tracking
279
344
  2. **Sends context + intent to an AI** — using your configured provider
280
- 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
281
346
  4. **Executes with display** — shows every command before running it
282
- 5. **You always stay in control** — interactive prompts for anything irreversible
347
+ 5. **You stay in control** — interactive prompts for anything irreversible
283
348
 
284
349
  ---
285
350
 
@@ -301,30 +366,23 @@ If `HERMES_PROVIDER` is not set, Hermes auto-detects by using whichever key it f
301
366
  hermes config setup
302
367
  ```
303
368
 
304
- **Wrong provider selected**
369
+ **Wrong provider being used**
305
370
 
306
371
  ```bash
307
372
  hermes config set provider anthropic
308
- hermes config list # verify
373
+ hermes config list # check sources — env vars override saved config
309
374
  ```
310
375
 
311
- **Key saved but not working**
376
+ **Key set but not working**
312
377
 
313
378
  ```bash
314
- # Check what Hermes sees
315
- hermes config list
316
-
317
- # Environment variables override saved config
318
- # Check for conflicting vars:
319
- echo $ANTHROPIC_API_KEY
379
+ hermes config list # shows value and where it came from (env / .env / config)
320
380
  ```
321
381
 
322
- **General debugging**
382
+ **Update to latest**
323
383
 
324
384
  ```bash
325
- hermes --version
326
- hermes config list
327
- git status
385
+ hermes update
328
386
  ```
329
387
 
330
388
  ---
package/dist/index.js CHANGED
@@ -38120,18 +38120,635 @@ Valid keys:`));
38120
38120
  }
38121
38121
  }
38122
38122
 
38123
- // src/lib/update-notifier.ts
38124
- import { readFile as readFile5, writeFile as writeFile5, mkdir as mkdir4 } from "fs/promises";
38123
+ // src/commands/guard.ts
38124
+ import { writeFile as writeFile5, readFile as readFile5, chmod as chmod2, mkdir as mkdir4 } from "fs/promises";
38125
38125
  import { existsSync as existsSync5 } from "fs";
38126
+ import { exec as exec4 } from "child_process";
38127
+ import { promisify as promisify4 } from "util";
38128
+
38129
+ // src/lib/secrets.ts
38130
+ import { exec as exec3 } from "child_process";
38131
+ import { promisify as promisify3 } from "util";
38132
+ var execAsync3 = promisify3(exec3);
38133
+ var BLOCKED_FILENAMES = [
38134
+ { pattern: /^\.env$/, description: "Environment variable file" },
38135
+ { pattern: /^\.env\..+/, description: "Environment variable file" },
38136
+ { pattern: /id_rsa$/, description: "SSH private key" },
38137
+ { pattern: /id_ed25519$/, description: "SSH private key" },
38138
+ { pattern: /id_ecdsa$/, description: "SSH private key" },
38139
+ { pattern: /id_dsa$/, description: "SSH private key" },
38140
+ { pattern: /\.pem$/, description: "PEM certificate/key file" },
38141
+ { pattern: /\.p12$/, description: "PKCS#12 certificate file" },
38142
+ { pattern: /\.pfx$/, description: "PKCS#12 certificate file" },
38143
+ { pattern: /\.jks$/, description: "Java keystore file" },
38144
+ { pattern: /\.keystore$/, description: "Keystore file" },
38145
+ { pattern: /credentials\.json$/, description: "Credentials file" },
38146
+ { pattern: /google-services\.json$/, description: "Google services config (may contain API keys)" },
38147
+ { pattern: /firebase-adminsdk.*\.json$/, description: "Firebase admin SDK credentials" },
38148
+ { pattern: /serviceAccountKey.*\.json$/, description: "Service account credentials" },
38149
+ { pattern: /\.netrc$/, description: ".netrc credentials file" }
38150
+ ];
38151
+ var WARNED_FILENAMES = [
38152
+ { pattern: /\.npmrc$/, description: ".npmrc (may contain auth tokens)" },
38153
+ { pattern: /\.pypirc$/, description: ".pypirc (may contain PyPI token)" },
38154
+ { pattern: /secrets?\.(json|yaml|yml|toml)$/, description: "Secrets config file" },
38155
+ { pattern: /config\.(local|private|secret)\.(json|yaml|yml|toml|js|ts)$/, description: "Local/private config file" },
38156
+ { pattern: /private.*\.(json|yaml|yml|toml)$/, description: "Private config file" }
38157
+ ];
38158
+ var SECRET_PATTERNS = [
38159
+ {
38160
+ id: "private-key",
38161
+ description: "Private key header",
38162
+ severity: "blocked",
38163
+ pattern: /-----BEGIN\s+(RSA\s+|EC\s+|OPENSSH\s+|DSA\s+)?PRIVATE KEY-----/
38164
+ },
38165
+ {
38166
+ id: "anthropic-key",
38167
+ description: "Anthropic API key",
38168
+ severity: "blocked",
38169
+ pattern: /sk-ant-[a-zA-Z0-9_-]{80,}/,
38170
+ rotation: "https://console.anthropic.com/settings/keys"
38171
+ },
38172
+ {
38173
+ id: "openai-key",
38174
+ description: "OpenAI API key",
38175
+ severity: "blocked",
38176
+ pattern: /sk-(?:proj-)?[a-zA-Z0-9]{40,}/,
38177
+ rotation: "https://platform.openai.com/api-keys"
38178
+ },
38179
+ {
38180
+ id: "google-api-key",
38181
+ description: "Google API key",
38182
+ severity: "blocked",
38183
+ pattern: /AIza[a-zA-Z0-9_-]{35}/,
38184
+ rotation: "https://console.cloud.google.com/apis/credentials"
38185
+ },
38186
+ {
38187
+ id: "aws-access-key",
38188
+ description: "AWS access key ID",
38189
+ severity: "blocked",
38190
+ pattern: /AKIA[A-Z0-9]{16}/,
38191
+ rotation: "https://console.aws.amazon.com/iam/home#/security_credentials"
38192
+ },
38193
+ {
38194
+ id: "aws-secret-key",
38195
+ description: "AWS secret access key",
38196
+ severity: "blocked",
38197
+ pattern: /aws[_-]?secret[_-]?access[_-]?key\s*[=:]\s*["']?[a-zA-Z0-9/+]{40}/i,
38198
+ rotation: "https://console.aws.amazon.com/iam/home#/security_credentials"
38199
+ },
38200
+ {
38201
+ id: "github-token",
38202
+ description: "GitHub personal access token",
38203
+ severity: "blocked",
38204
+ pattern: /gh[pousr]_[a-zA-Z0-9]{36}/,
38205
+ rotation: "https://github.com/settings/tokens"
38206
+ },
38207
+ {
38208
+ id: "github-pat-v2",
38209
+ description: "GitHub fine-grained personal access token",
38210
+ severity: "blocked",
38211
+ pattern: /github_pat_[a-zA-Z0-9_]{82}/,
38212
+ rotation: "https://github.com/settings/tokens"
38213
+ },
38214
+ {
38215
+ id: "stripe-key",
38216
+ description: "Stripe secret key",
38217
+ severity: "blocked",
38218
+ pattern: /(?:sk|rk)_live_[a-zA-Z0-9]{24,}/,
38219
+ rotation: "https://dashboard.stripe.com/apikeys"
38220
+ },
38221
+ {
38222
+ id: "sendgrid-key",
38223
+ description: "SendGrid API key",
38224
+ severity: "blocked",
38225
+ pattern: /SG\.[a-zA-Z0-9_-]{22}\.[a-zA-Z0-9_-]{43}/,
38226
+ rotation: "https://app.sendgrid.com/settings/api_keys"
38227
+ },
38228
+ {
38229
+ id: "twilio-key",
38230
+ description: "Twilio API key",
38231
+ severity: "blocked",
38232
+ pattern: /SK[a-f0-9]{32}/,
38233
+ rotation: "https://www.twilio.com/console/project/api-keys"
38234
+ },
38235
+ {
38236
+ id: "db-url",
38237
+ description: "Database URL with credentials",
38238
+ severity: "blocked",
38239
+ pattern: /(?:mongodb(?:\+srv)?|postgres(?:ql)?|mysql|redis):\/\/[^:/?#\s]+:[^@\s]+@/
38240
+ },
38241
+ {
38242
+ id: "password-assignment",
38243
+ description: "Hardcoded password",
38244
+ severity: "warn",
38245
+ pattern: /(?:password|passwd|pwd)\s*[=:]\s*["'][^"']{6,}["']/i
38246
+ },
38247
+ {
38248
+ id: "secret-assignment",
38249
+ description: "Hardcoded secret",
38250
+ severity: "warn",
38251
+ pattern: /(?:secret|api_secret|client_secret)\s*[=:]\s*["'][^"']{8,}["']/i
38252
+ },
38253
+ {
38254
+ id: "token-assignment",
38255
+ description: "Hardcoded token",
38256
+ severity: "warn",
38257
+ pattern: /(?<![a-z])token\s*[=:]\s*["'][a-zA-Z0-9_\-\.]{16,}["']/i
38258
+ }
38259
+ ];
38260
+ async function getStagedFiles() {
38261
+ const { stdout } = await execAsync3("git diff --cached --name-only --diff-filter=ACMR");
38262
+ return stdout.trim().split(`
38263
+ `).filter(Boolean);
38264
+ }
38265
+ async function readStagedFile(file) {
38266
+ try {
38267
+ const { stdout } = await execAsync3(`git show ":${file}"`, {
38268
+ maxBuffer: 2 * 1024 * 1024
38269
+ });
38270
+ return stdout;
38271
+ } catch {
38272
+ return null;
38273
+ }
38274
+ }
38275
+ function checkFilename(file) {
38276
+ const basename = file.split("/").pop() || file;
38277
+ for (const { pattern, description } of BLOCKED_FILENAMES) {
38278
+ if (pattern.test(basename)) {
38279
+ return {
38280
+ patternId: "filename-blocked",
38281
+ description: `Sensitive filename: ${description}`,
38282
+ severity: "blocked",
38283
+ line: 0,
38284
+ preview: file
38285
+ };
38286
+ }
38287
+ }
38288
+ for (const { pattern, description } of WARNED_FILENAMES) {
38289
+ if (pattern.test(basename)) {
38290
+ return {
38291
+ patternId: "filename-warned",
38292
+ description: `Potentially sensitive filename: ${description}`,
38293
+ severity: "warn",
38294
+ line: 0,
38295
+ preview: file
38296
+ };
38297
+ }
38298
+ }
38299
+ return null;
38300
+ }
38301
+ function scanContent(content) {
38302
+ const lines2 = content.split(`
38303
+ `);
38304
+ const findings = [];
38305
+ const seen = new Set;
38306
+ for (let i = 0;i < lines2.length; i++) {
38307
+ const line = lines2[i];
38308
+ for (const sp of SECRET_PATTERNS) {
38309
+ if (sp.pattern.test(line)) {
38310
+ const dedupeKey = `${sp.id}:${i}`;
38311
+ if (seen.has(dedupeKey))
38312
+ continue;
38313
+ seen.add(dedupeKey);
38314
+ findings.push({
38315
+ patternId: sp.id,
38316
+ description: sp.description,
38317
+ severity: sp.severity,
38318
+ line: i + 1,
38319
+ preview: redactLine(line, sp.pattern),
38320
+ rotation: sp.rotation
38321
+ });
38322
+ }
38323
+ }
38324
+ }
38325
+ return findings;
38326
+ }
38327
+ function redactLine(line, pattern) {
38328
+ const redacted = line.replace(pattern, (match) => {
38329
+ if (match.length <= 8)
38330
+ return "****";
38331
+ return match.slice(0, 4) + "..." + match.slice(-4).replace(/./g, "*");
38332
+ });
38333
+ return redacted.length > 120 ? redacted.slice(0, 117) + "..." : redacted;
38334
+ }
38335
+ async function scanStagedFiles() {
38336
+ const files = await getStagedFiles();
38337
+ const result = { blocked: [], warned: [], clean: [], skipped: [] };
38338
+ await Promise.all(files.map(async (file) => {
38339
+ const allFindings = [];
38340
+ const filenameFindig = checkFilename(file);
38341
+ if (filenameFindig)
38342
+ allFindings.push(filenameFindig);
38343
+ const content = await readStagedFile(file);
38344
+ if (content === null) {
38345
+ result.skipped.push(file);
38346
+ return;
38347
+ }
38348
+ allFindings.push(...scanContent(content));
38349
+ if (allFindings.length === 0) {
38350
+ result.clean.push(file);
38351
+ return;
38352
+ }
38353
+ const maxSeverity = allFindings.some((f) => f.severity === "blocked") ? "blocked" : "warn";
38354
+ const fileFindings = { file, findings: allFindings, maxSeverity };
38355
+ if (maxSeverity === "blocked") {
38356
+ result.blocked.push(fileFindings);
38357
+ } else {
38358
+ result.warned.push(fileFindings);
38359
+ }
38360
+ }));
38361
+ return result;
38362
+ }
38363
+
38364
+ // src/commands/guard.ts
38365
+ var execAsync4 = promisify4(exec4);
38366
+ function guardCommand(program2) {
38367
+ const guard = program2.command("guard").description("Scan staged files for secrets and sensitive content before committing");
38368
+ guard.option("--hook", "Run in non-interactive hook mode (exit 1 on blockers)").action(async (options) => {
38369
+ await runScan(options.hook ?? false);
38370
+ });
38371
+ guard.command("install-hook").description("Install hermes guard as a git pre-commit hook").action(async () => {
38372
+ try {
38373
+ const gitDir = await execAsync4("git rev-parse --git-dir").then((r) => r.stdout.trim()).catch(() => null);
38374
+ if (!gitDir) {
38375
+ console.error(source_default.red("❌ Not a git repository"));
38376
+ process.exit(1);
38377
+ }
38378
+ const hooksDir = `${gitDir}/hooks`;
38379
+ await mkdir4(hooksDir, { recursive: true });
38380
+ const hookPath = `${hooksDir}/pre-commit`;
38381
+ const hookScript = [
38382
+ "#!/bin/sh",
38383
+ "# Installed by hermes guard install-hook",
38384
+ "hermes guard --hook"
38385
+ ].join(`
38386
+ `) + `
38387
+ `;
38388
+ if (existsSync5(hookPath)) {
38389
+ const existing = await readFile5(hookPath, "utf-8");
38390
+ if (existing.includes("hermes guard")) {
38391
+ console.log(source_default.dim("Hook already installed."));
38392
+ return;
38393
+ }
38394
+ await writeFile5(hookPath, existing.trimEnd() + `
38395
+
38396
+ ` + hookScript.split(`
38397
+ `).slice(1).join(`
38398
+ `));
38399
+ } else {
38400
+ await writeFile5(hookPath, hookScript);
38401
+ }
38402
+ await chmod2(hookPath, 493);
38403
+ console.log(`${source_default.green("✓")} Pre-commit hook installed at ${source_default.dim(hookPath)}`);
38404
+ console.log(source_default.dim(" hermes guard will run automatically before every commit."));
38405
+ } catch (error3) {
38406
+ console.error("❌ Error:", error3 instanceof Error ? error3.message : error3);
38407
+ process.exit(1);
38408
+ }
38409
+ });
38410
+ guard.command("uninstall-hook").description("Remove hermes guard from the git pre-commit hook").action(async () => {
38411
+ try {
38412
+ const gitDir = await execAsync4("git rev-parse --git-dir").then((r) => r.stdout.trim()).catch(() => null);
38413
+ if (!gitDir) {
38414
+ console.error(source_default.red("❌ Not a git repository"));
38415
+ process.exit(1);
38416
+ }
38417
+ const hookPath = `${gitDir}/hooks/pre-commit`;
38418
+ if (!existsSync5(hookPath)) {
38419
+ console.log(source_default.dim("No pre-commit hook found."));
38420
+ return;
38421
+ }
38422
+ const content = await readFile5(hookPath, "utf-8");
38423
+ if (!content.includes("hermes guard")) {
38424
+ console.log(source_default.dim("hermes guard is not in the pre-commit hook."));
38425
+ return;
38426
+ }
38427
+ const cleaned = content.split(`
38428
+ `).filter((line) => !line.includes("hermes guard") && line !== "# Installed by hermes guard install-hook").join(`
38429
+ `).replace(/\n{3,}/g, `
38430
+
38431
+ `).trim() + `
38432
+ `;
38433
+ if (cleaned === `#!/bin/sh
38434
+ `) {
38435
+ const { unlink } = await import("fs/promises");
38436
+ await unlink(hookPath);
38437
+ console.log(`${source_default.green("✓")} Pre-commit hook removed.`);
38438
+ } else {
38439
+ await writeFile5(hookPath, cleaned);
38440
+ console.log(`${source_default.green("✓")} hermes guard removed from pre-commit hook.`);
38441
+ }
38442
+ } catch (error3) {
38443
+ console.error("❌ Error:", error3 instanceof Error ? error3.message : error3);
38444
+ process.exit(1);
38445
+ }
38446
+ });
38447
+ }
38448
+ async function runScan(hookMode) {
38449
+ try {
38450
+ const staged = await getStagedFiles();
38451
+ if (staged.length === 0) {
38452
+ if (!hookMode)
38453
+ console.log(source_default.dim("No staged files to scan."));
38454
+ return;
38455
+ }
38456
+ if (!hookMode) {
38457
+ console.log(source_default.bold(`
38458
+ Scanning ${staged.length} staged file${staged.length === 1 ? "" : "s"}...
38459
+ `));
38460
+ }
38461
+ const result = await scanStagedFiles();
38462
+ const hasBlockers = result.blocked.length > 0;
38463
+ const hasWarnings = result.warned.length > 0;
38464
+ if (!hasBlockers && !hasWarnings) {
38465
+ if (!hookMode) {
38466
+ console.log(source_default.green(" ✓ All clear") + source_default.dim(` — ${staged.length} file${staged.length === 1 ? "" : "s"} scanned, nothing suspicious found.
38467
+ `));
38468
+ }
38469
+ return;
38470
+ }
38471
+ if (!hookMode) {
38472
+ printReport(result);
38473
+ } else {
38474
+ if (hasBlockers) {
38475
+ process.stderr.write(source_default.red(`
38476
+ hermes guard: ${result.blocked.length} blocked file${result.blocked.length === 1 ? "" : "s"} with secrets detected.
38477
+ `));
38478
+ for (const f of result.blocked) {
38479
+ process.stderr.write(source_default.dim(` ${f.file}: ${f.findings.map((x) => x.description).join(", ")}
38480
+ `));
38481
+ }
38482
+ process.stderr.write(source_default.dim(`
38483
+ Run \`hermes guard\` for details and remediation advice.
38484
+
38485
+ `));
38486
+ }
38487
+ }
38488
+ if (!hookMode && (hasBlockers || hasWarnings)) {
38489
+ await printAIExplanation(result);
38490
+ if (hasBlockers) {
38491
+ const { action } = await esm_default12.prompt([
38492
+ {
38493
+ type: "list",
38494
+ name: "action",
38495
+ message: source_default.red("Blocked secrets found. What do you want to do?"),
38496
+ choices: [
38497
+ { name: "Abort — I will fix these before committing", value: "abort" },
38498
+ { name: "Unstage the flagged files and continue", value: "unstage" },
38499
+ { name: "Proceed anyway (I know what I'm doing)", value: "proceed" }
38500
+ ],
38501
+ default: "abort"
38502
+ }
38503
+ ]);
38504
+ if (action === "abort") {
38505
+ console.log(source_default.dim(`
38506
+ Commit aborted. Fix the issues above, then try again.
38507
+ `));
38508
+ process.exit(1);
38509
+ }
38510
+ if (action === "unstage") {
38511
+ for (const f of result.blocked) {
38512
+ await execAsync4(`git restore --staged "${f.file}"`);
38513
+ console.log(source_default.dim(` Unstaged: ${f.file}`));
38514
+ }
38515
+ console.log(source_default.yellow(`
38516
+ Flagged files have been unstaged. Commit the rest? (run git commit again)`));
38517
+ process.exit(0);
38518
+ }
38519
+ console.log(source_default.dim(`
38520
+ Proceeding. Be careful.
38521
+ `));
38522
+ } else if (hasWarnings) {
38523
+ const { proceed } = await esm_default12.prompt([
38524
+ {
38525
+ type: "confirm",
38526
+ name: "proceed",
38527
+ message: source_default.yellow("Warnings found. Proceed with commit?"),
38528
+ default: true
38529
+ }
38530
+ ]);
38531
+ if (!proceed) {
38532
+ console.log(source_default.dim(`
38533
+ Commit aborted.
38534
+ `));
38535
+ process.exit(1);
38536
+ }
38537
+ }
38538
+ }
38539
+ if (hookMode && hasBlockers) {
38540
+ process.exit(1);
38541
+ }
38542
+ } catch (error3) {
38543
+ if (!hookMode) {
38544
+ console.error(source_default.dim(" guard scan failed:"), error3 instanceof Error ? error3.message : error3);
38545
+ }
38546
+ process.exit(0);
38547
+ }
38548
+ }
38549
+ function printReport(result) {
38550
+ const totalFiles = result.blocked.length + result.warned.length + result.clean.length + result.skipped.length;
38551
+ if (result.blocked.length > 0) {
38552
+ console.log(source_default.red.bold(" ✖ Secrets detected") + source_default.dim(` — review before committing
38553
+ `));
38554
+ } else {
38555
+ console.log(source_default.yellow.bold(" ⚠ Warnings") + source_default.dim(` — review before committing
38556
+ `));
38557
+ }
38558
+ for (const ff of result.blocked) {
38559
+ console.log(source_default.red(` BLOCKED `) + source_default.bold(ff.file));
38560
+ for (const f of ff.findings) {
38561
+ const lineRef = f.line > 0 ? source_default.dim(` line ${f.line}`) : "";
38562
+ console.log(source_default.dim(" ") + source_default.red("●") + " " + f.description + lineRef);
38563
+ if (f.preview && f.line > 0) {
38564
+ console.log(source_default.dim(" " + f.preview.trim()));
38565
+ }
38566
+ if (f.rotation) {
38567
+ console.log(source_default.dim(` Rotate at: ${f.rotation}`));
38568
+ }
38569
+ }
38570
+ console.log();
38571
+ }
38572
+ for (const ff of result.warned) {
38573
+ console.log(source_default.yellow(` WARN `) + source_default.bold(ff.file));
38574
+ for (const f of ff.findings) {
38575
+ const lineRef = f.line > 0 ? source_default.dim(` line ${f.line}`) : "";
38576
+ console.log(source_default.dim(" ") + source_default.yellow("●") + " " + f.description + lineRef);
38577
+ if (f.preview && f.line > 0) {
38578
+ console.log(source_default.dim(" " + f.preview.trim()));
38579
+ }
38580
+ }
38581
+ console.log();
38582
+ }
38583
+ if (result.clean.length > 0) {
38584
+ console.log(source_default.dim(` ✓ ${result.clean.length} file${result.clean.length === 1 ? "" : "s"} clean`));
38585
+ }
38586
+ if (result.skipped.length > 0) {
38587
+ console.log(source_default.dim(` − ${result.skipped.length} binary/unreadable file${result.skipped.length === 1 ? "" : "s"} skipped`));
38588
+ }
38589
+ console.log();
38590
+ }
38591
+ async function printAIExplanation(result) {
38592
+ const allFindings = [...result.blocked, ...result.warned];
38593
+ if (allFindings.length === 0)
38594
+ return;
38595
+ const findingSummary = allFindings.map((ff) => {
38596
+ const items = ff.findings.map((f) => ` - ${f.description}${f.line > 0 ? ` (line ${f.line})` : ""}`).join(`
38597
+ `);
38598
+ return `File: ${ff.file}
38599
+ Severity: ${ff.maxSeverity}
38600
+ ${items}`;
38601
+ }).join(`
38602
+
38603
+ `);
38604
+ process.stdout.write(source_default.dim(" Asking AI for context...\r"));
38605
+ try {
38606
+ const explanation = await getAISuggestion(`A developer is about to commit these files that were flagged by a secret scanner:
38607
+
38608
+ ${findingSummary}
38609
+
38610
+ For each finding, briefly explain:
38611
+ 1. The specific risk if this gets committed (e.g., "Anyone with repo access can use this key to...")
38612
+ 2. One concrete remediation step (e.g., "Add .env to .gitignore and use process.env instead")
38613
+
38614
+ Be direct and specific. 3-4 sentences per finding max. No preamble.`);
38615
+ process.stdout.write(" \r");
38616
+ console.log(source_default.bold(` What this means:
38617
+ `));
38618
+ const indented = explanation.split(`
38619
+ `).map((l) => " " + l).join(`
38620
+ `);
38621
+ console.log(indented);
38622
+ console.log();
38623
+ } catch {
38624
+ process.stdout.write(" \r");
38625
+ }
38626
+ }
38627
+
38628
+ // src/commands/update.ts
38629
+ import { exec as exec5 } from "child_process";
38630
+ import { promisify as promisify5 } from "util";
38631
+ var execAsync5 = promisify5(exec5);
38632
+ var PACKAGE_NAME = "hermes-git";
38633
+ function updateCommand(program2, currentVersion) {
38634
+ program2.command("update").description("Update hermes to the latest version").option("--check", "Check for updates without installing").option("--bun", "Use bun instead of npm to install").action(async (options) => {
38635
+ try {
38636
+ process.stdout.write(source_default.dim(" Checking npm for latest version..."));
38637
+ const latest = await fetchLatestVersion();
38638
+ process.stdout.write("\r" + " ".repeat(50) + "\r");
38639
+ if (!latest) {
38640
+ console.error(source_default.red(" ✖ Could not reach npm registry. Check your connection."));
38641
+ process.exit(1);
38642
+ }
38643
+ const isNewer = compareVersions(latest, currentVersion) > 0;
38644
+ const isAhead = compareVersions(currentVersion, latest) > 0;
38645
+ if (!isNewer) {
38646
+ if (isAhead) {
38647
+ console.log(source_default.green(" ✓ ") + `You're running ${source_default.cyan(`v${currentVersion}`)} ` + source_default.dim(`(latest on npm is ${latest})`));
38648
+ } else {
38649
+ console.log(source_default.green(` ✓ Already on the latest version`) + source_default.dim(` (v${currentVersion})`));
38650
+ }
38651
+ return;
38652
+ }
38653
+ console.log(` Update available ${source_default.dim(currentVersion)} ${source_default.dim("→")} ${source_default.green.bold(latest)}
38654
+ `);
38655
+ if (options.check)
38656
+ return;
38657
+ const pm = options.bun ? "bun" : await detectPackageManager();
38658
+ const installCmd = buildInstallCommand(pm);
38659
+ console.log(` Running: ${source_default.cyan(installCmd)}
38660
+ `);
38661
+ const { stdout, stderr } = await execAsync5(installCmd, { timeout: 120000 });
38662
+ const output = (stdout + stderr).trim();
38663
+ if (output) {
38664
+ output.split(`
38665
+ `).forEach((line) => {
38666
+ console.log(source_default.dim(" " + line));
38667
+ });
38668
+ console.log();
38669
+ }
38670
+ console.log(source_default.green(" ✓ Updated to ") + source_default.bold(`v${latest}`) + source_default.dim(" Restart your terminal if the version does not change."));
38671
+ } catch (error3) {
38672
+ console.error(source_default.red(`
38673
+ ✖ Update failed`));
38674
+ console.error(source_default.dim(" " + (error3.message || error3)));
38675
+ console.log(source_default.dim(`
38676
+ Try manually: npm install -g ${PACKAGE_NAME}@latest`));
38677
+ process.exit(1);
38678
+ }
38679
+ });
38680
+ }
38681
+ async function fetchLatestVersion() {
38682
+ try {
38683
+ const res = await fetch(`https://registry.npmjs.org/${PACKAGE_NAME}/latest`);
38684
+ if (!res.ok)
38685
+ return null;
38686
+ const data = await res.json();
38687
+ return data.version ?? null;
38688
+ } catch {
38689
+ return null;
38690
+ }
38691
+ }
38692
+ function compareVersions(a, b) {
38693
+ const pa = a.split(".").map(Number);
38694
+ const pb = b.split(".").map(Number);
38695
+ for (let i = 0;i < 3; i++) {
38696
+ if (pa[i] > pb[i])
38697
+ return 1;
38698
+ if (pa[i] < pb[i])
38699
+ return -1;
38700
+ }
38701
+ return 0;
38702
+ }
38703
+ async function detectPackageManager() {
38704
+ if (process.execPath?.includes("bun") || process.argv[0]?.includes("bun")) {
38705
+ return "bun";
38706
+ }
38707
+ try {
38708
+ const { stdout } = await execAsync5("bun --version", { timeout: 3000 });
38709
+ if (stdout.trim()) {
38710
+ try {
38711
+ const { stdout: list } = await execAsync5("bun pm ls -g 2>/dev/null", { timeout: 3000 });
38712
+ if (list.includes(PACKAGE_NAME))
38713
+ return "bun";
38714
+ } catch {}
38715
+ }
38716
+ } catch {}
38717
+ try {
38718
+ const { stdout } = await execAsync5("pnpm --version", { timeout: 3000 });
38719
+ if (stdout.trim()) {
38720
+ try {
38721
+ const { stdout: list } = await execAsync5("pnpm list -g --depth=0 2>/dev/null", { timeout: 3000 });
38722
+ if (list.includes(PACKAGE_NAME))
38723
+ return "pnpm";
38724
+ } catch {}
38725
+ }
38726
+ } catch {}
38727
+ return "npm";
38728
+ }
38729
+ function buildInstallCommand(pm) {
38730
+ switch (pm) {
38731
+ case "bun":
38732
+ return `bun add -g ${PACKAGE_NAME}@latest`;
38733
+ case "pnpm":
38734
+ return `pnpm add -g ${PACKAGE_NAME}@latest`;
38735
+ default:
38736
+ return `npm install -g ${PACKAGE_NAME}@latest`;
38737
+ }
38738
+ }
38739
+
38740
+ // src/lib/update-notifier.ts
38741
+ import { readFile as readFile6, writeFile as writeFile6, mkdir as mkdir5 } from "fs/promises";
38742
+ import { existsSync as existsSync6 } from "fs";
38126
38743
  import { homedir as homedir2 } from "os";
38127
38744
  import path5 from "path";
38128
- var PACKAGE_NAME = "hermes-git";
38745
+ var PACKAGE_NAME2 = "hermes-git";
38129
38746
  var CHECK_INTERVAL = 24 * 60 * 60 * 1000;
38130
38747
  var CACHE_DIR = path5.join(homedir2(), ".hermes", "cache");
38131
38748
  var CACHE_FILE = path5.join(CACHE_DIR, "update-check.json");
38132
38749
  async function getLatestVersion() {
38133
38750
  try {
38134
- const response = await fetch(`https://registry.npmjs.org/${PACKAGE_NAME}/latest`);
38751
+ const response = await fetch(`https://registry.npmjs.org/${PACKAGE_NAME2}/latest`);
38135
38752
  if (!response.ok)
38136
38753
  return null;
38137
38754
  const data = await response.json();
@@ -38142,9 +38759,9 @@ async function getLatestVersion() {
38142
38759
  }
38143
38760
  async function readCache() {
38144
38761
  try {
38145
- if (!existsSync5(CACHE_FILE))
38762
+ if (!existsSync6(CACHE_FILE))
38146
38763
  return null;
38147
- const content = await readFile5(CACHE_FILE, "utf-8");
38764
+ const content = await readFile6(CACHE_FILE, "utf-8");
38148
38765
  return JSON.parse(content);
38149
38766
  } catch {
38150
38767
  return null;
@@ -38152,8 +38769,8 @@ async function readCache() {
38152
38769
  }
38153
38770
  async function writeCache(cache) {
38154
38771
  try {
38155
- await mkdir4(CACHE_DIR, { recursive: true });
38156
- await writeFile5(CACHE_FILE, JSON.stringify(cache, null, 2));
38772
+ await mkdir5(CACHE_DIR, { recursive: true });
38773
+ await writeFile6(CACHE_FILE, JSON.stringify(cache, null, 2));
38157
38774
  } catch {}
38158
38775
  }
38159
38776
  function isNewerVersion(current, latest) {
@@ -38191,10 +38808,93 @@ async function checkForUpdates(currentVersion) {
38191
38808
  } catch {}
38192
38809
  }
38193
38810
 
38811
+ // src/lib/banner.ts
38812
+ import { readFileSync, existsSync as existsSync7 } from "fs";
38813
+ var ART_LINES = [
38814
+ "██╗ ██╗███████╗██████╗ ███╗ ███╗███████╗███████╗",
38815
+ "██║ ██║██╔════╝██╔══██╗████╗ ████║██╔════╝██╔════╝",
38816
+ "███████║█████╗ ██████╔╝██╔████╔██║█████╗ ███████╗",
38817
+ "██╔══██║██╔══╝ ██╔══██╗██║╚██╔╝██║██╔══╝ ╚════██║",
38818
+ "██║ ██║███████╗██║ ██║██║ ╚═╝ ██║███████╗███████║",
38819
+ "╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝╚══════╝"
38820
+ ];
38821
+ var ROW_COLORS = [
38822
+ (s) => source_default.bold.cyanBright(s),
38823
+ (s) => source_default.bold.cyanBright(s),
38824
+ (s) => source_default.bold.cyan(s),
38825
+ (s) => source_default.bold.cyan(s),
38826
+ (s) => source_default.cyan(s),
38827
+ (s) => source_default.dim.cyan(s)
38828
+ ];
38829
+ var ART_WIDTH = 52;
38830
+ function printBanner(version) {
38831
+ const wings = source_default.dim.cyan("~>))><");
38832
+ const tail = source_default.dim.cyan("><((<~");
38833
+ console.log();
38834
+ console.log(` ${wings}${"─".repeat(ART_WIDTH - 14)}${tail}`);
38835
+ for (let i = 0;i < ART_LINES.length; i++) {
38836
+ console.log(" " + ROW_COLORS[i](ART_LINES[i]));
38837
+ }
38838
+ const subtitle = "intent-driven git";
38839
+ const versionLabel = source_default.dim(`v${version}`);
38840
+ const dot = source_default.dim("·");
38841
+ const padding = " ".repeat(Math.max(0, Math.floor((ART_WIDTH - subtitle.length - version.length - 4) / 2)));
38842
+ console.log();
38843
+ console.log(` ${padding}${source_default.dim(subtitle)} ${dot} ${versionLabel}`);
38844
+ console.log(` ${wings}${"─".repeat(ART_WIDTH - 14)}${tail}`);
38845
+ console.log();
38846
+ }
38847
+ var BUILTIN_WORKFLOWS = [
38848
+ { name: "pr-ready", description: "Sync, rebase, push — ready for pull request" },
38849
+ { name: "daily-sync", description: "Fetch + status check to start the day" },
38850
+ { name: "quick-commit", description: "Stage changes and commit with AI message" }
38851
+ ];
38852
+ function loadProjectWorkflows() {
38853
+ try {
38854
+ if (!existsSync7(".hermes/config.json"))
38855
+ return [];
38856
+ const config = JSON.parse(readFileSync(".hermes/config.json", "utf-8"));
38857
+ const workflows = config?.workflows;
38858
+ if (!workflows || typeof workflows !== "object")
38859
+ return [];
38860
+ return Object.entries(workflows).filter(([name]) => !BUILTIN_WORKFLOWS.some((b) => b.name === name)).map(([name, steps]) => ({
38861
+ name,
38862
+ description: Array.isArray(steps) ? steps.join(" → ") : String(steps),
38863
+ custom: true
38864
+ }));
38865
+ } catch {
38866
+ return [];
38867
+ }
38868
+ }
38869
+ function printWorkflows() {
38870
+ const project = loadProjectWorkflows();
38871
+ const all = [...BUILTIN_WORKFLOWS, ...project];
38872
+ const nameWidth = Math.max(...all.map((w) => w.name.length)) + 2;
38873
+ console.log(source_default.bold(" Workflows"));
38874
+ console.log();
38875
+ for (const w of BUILTIN_WORKFLOWS) {
38876
+ const cmd = source_default.cyan(`hermes workflow ${w.name.padEnd(nameWidth)}`);
38877
+ console.log(` ${cmd} ${source_default.dim(w.description)}`);
38878
+ }
38879
+ if (project.length > 0) {
38880
+ console.log();
38881
+ console.log(source_default.dim(" Project-specific:"));
38882
+ for (const w of project) {
38883
+ const cmd = source_default.cyan(`hermes workflow ${w.name.padEnd(nameWidth)}`);
38884
+ console.log(` ${cmd} ${source_default.dim(w.description)}`);
38885
+ }
38886
+ }
38887
+ console.log();
38888
+ }
38889
+
38194
38890
  // src/index.ts
38195
38891
  var program2 = new Command;
38196
- var CURRENT_VERSION = "0.3.0";
38197
- program2.name("hermes").description("\uD83E\uDEBD Intent-driven Git, guided by AI").version(CURRENT_VERSION);
38892
+ var CURRENT_VERSION = "0.3.2";
38893
+ program2.name("hermes").description("Intent-driven Git, guided by AI").version(CURRENT_VERSION).action(() => {
38894
+ printBanner(CURRENT_VERSION);
38895
+ printWorkflows();
38896
+ program2.help();
38897
+ });
38198
38898
  initCommand(program2);
38199
38899
  planCommand(program2);
38200
38900
  startCommand(program2);
@@ -38205,5 +38905,7 @@ worktreeCommand(program2);
38205
38905
  statsCommand(program2);
38206
38906
  workflowCommand(program2);
38207
38907
  configCommand(program2);
38908
+ guardCommand(program2);
38909
+ updateCommand(program2, CURRENT_VERSION);
38208
38910
  checkForUpdates(CURRENT_VERSION).catch(() => {});
38209
38911
  program2.parse();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hermes-git",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
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",