hermes-git 0.3.0 → 0.3.1

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 +49 -0
  2. package/dist/index.js +712 -10
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -171,6 +171,55 @@ Decides commit vs stash based on what's safest in your current state.
171
171
 
172
172
  ---
173
173
 
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
+
174
223
  ### `hermes conflict explain`
175
224
 
176
225
  Understand why a conflict exists.
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.1";
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.1",
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",