gut-cli 0.1.18 → 0.1.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -361,6 +361,15 @@ gut config set lang en --local
361
361
 
362
362
  # Get current language
363
363
  gut config get lang
364
+
365
+ # Open global config folder
366
+ gut config open
367
+
368
+ # Open global templates folder
369
+ gut config open --templates
370
+
371
+ # Open project's .gut/ folder
372
+ gut config open --local
364
373
  ```
365
374
 
366
375
  **Available settings:**
@@ -372,12 +381,19 @@ gut config get lang
372
381
 
373
382
  ### `gut init`
374
383
 
375
- Initialize `.gut/` templates in your project for customization.
384
+ Initialize templates for customization (project-level or global).
376
385
 
377
386
  ```bash
378
387
  # Copy all templates to .gut/ (translates if language is not English)
379
388
  gut init
380
389
 
390
+ # Initialize global templates (~/.config/gut/templates/)
391
+ gut init --global
392
+
393
+ # Initialize and open folder
394
+ gut init --open
395
+ gut init --global --open
396
+
381
397
  # Force overwrite existing templates
382
398
  gut init --force
383
399
 
@@ -388,8 +404,15 @@ gut init --no-translate
388
404
  gut init --provider openai
389
405
  ```
390
406
 
407
+ To open templates folder without initializing, use `gut config open --templates`.
408
+
391
409
  Templates are automatically translated to your configured language (set via `gut lang`).
392
410
 
411
+ **Template precedence:**
412
+ 1. Project templates: `.gut/` (highest priority)
413
+ 2. Global templates: `~/.config/gut/templates/`
414
+ 3. Built-in templates (lowest priority)
415
+
393
416
  ### `gut gitignore`
394
417
 
395
418
  Generate a .gitignore file by analyzing your project structure.
@@ -466,26 +489,35 @@ API keys are stored securely using your operating system's native credential sto
466
489
 
467
490
  Keys are never stored in plain text files or configuration files. When you run `gut auth login`, the key is encrypted and managed by your OS.
468
491
 
469
- ## Project Configuration
492
+ ## Template Configuration
493
+
494
+ gut supports customizable templates at two levels:
495
+
496
+ **Project templates** (`.gut/`): Repository-specific customizations that apply only to the current project.
497
+
498
+ **Global templates** (`~/.config/gut/templates/`): User-wide defaults that apply across all projects.
499
+
500
+ **Precedence**: Project > Global > Built-in
470
501
 
471
- gut looks for template files in your repository's `.gut/` folder. Each template uses `{{variable}}` syntax for dynamic content.
502
+ Each template uses `{{variable}}` syntax for dynamic content.
472
503
 
473
504
  | File | Purpose |
474
505
  |------|---------|
475
- | `.gut/commit.md` | Commit message prompt |
476
- | `.gut/pr.md` | PR description prompt |
477
- | `.gut/branch.md` | Branch naming rules |
478
- | `.gut/checkout.md` | Checkout branch name prompt |
479
- | `.gut/merge.md` | Merge conflict resolution rules |
480
- | `.gut/review.md` | Code review criteria |
481
- | `.gut/explain.md` | Explanation context |
482
- | `.gut/explain-file.md` | File explanation context |
483
- | `.gut/find.md` | Commit search context |
484
- | `.gut/changelog.md` | Changelog format |
485
- | `.gut/stash.md` | Stash name prompt |
486
- | `.gut/summary.md` | Work summary format |
487
- | `.gut/gitignore.md` | Gitignore generation prompt |
488
- | `.github/pull_request_template.md` | GitHub PR template (prioritized over `.gut/pr.md`) |
506
+ | `commit.md` | Commit message prompt |
507
+ | `pr.md` | PR description prompt |
508
+ | `branch.md` | Branch naming rules |
509
+ | `checkout.md` | Checkout branch name prompt |
510
+ | `merge.md` | Merge conflict resolution rules |
511
+ | `review.md` | Code review criteria |
512
+ | `explain.md` | Explanation context |
513
+ | `explain-file.md` | File explanation context |
514
+ | `find.md` | Commit search context |
515
+ | `changelog.md` | Changelog format |
516
+ | `stash.md` | Stash name prompt |
517
+ | `summary.md` | Work summary format |
518
+ | `gitignore.md` | Gitignore generation prompt |
519
+
520
+ **Special case**: `.github/pull_request_template.md` is prioritized over `pr.md` for PR descriptions.
489
521
 
490
522
  ## Development
491
523
 
package/dist/index.js CHANGED
@@ -290,6 +290,7 @@ import { createOllama } from "ollama-ai-provider";
290
290
  import { z } from "zod";
291
291
  import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
292
292
  import { join as join2, dirname } from "path";
293
+ import { homedir as homedir2 } from "os";
293
294
  import { fileURLToPath } from "url";
294
295
 
295
296
  // src/lib/config.ts
@@ -414,13 +415,27 @@ function loadTemplate(name) {
414
415
  }
415
416
  throw new Error(`Template not found: ${templatePath}`);
416
417
  }
417
- function findTemplate(repoRoot, templateName) {
418
- const templatePath = join2(repoRoot, ".gut", `${templateName}.md`);
418
+ function getGlobalTemplatesDir() {
419
+ return join2(homedir2(), ".config", "gut", "templates");
420
+ }
421
+ function findGlobalTemplate(templateName) {
422
+ const templatePath = join2(getGlobalTemplatesDir(), `${templateName}.md`);
419
423
  if (existsSync2(templatePath)) {
420
424
  return readFileSync2(templatePath, "utf-8");
421
425
  }
422
426
  return null;
423
427
  }
428
+ function findTemplate(repoRoot, templateName) {
429
+ const projectTemplatePath = join2(repoRoot, ".gut", `${templateName}.md`);
430
+ if (existsSync2(projectTemplatePath)) {
431
+ return readFileSync2(projectTemplatePath, "utf-8");
432
+ }
433
+ const globalTemplate = findGlobalTemplate(templateName);
434
+ if (globalTemplate) {
435
+ return globalTemplate;
436
+ }
437
+ return null;
438
+ }
424
439
  function applyTemplate(userTemplate, templateName, variables) {
425
440
  const langInstruction = getLanguageInstruction(getLanguage());
426
441
  let result = userTemplate || loadTemplate(templateName);
@@ -852,14 +867,14 @@ var commitCommand = new Command3("commit").description("Generate a commit messag
852
867
  console.log(chalk3.green("\u2713 Committed successfully"));
853
868
  } else if (answer.toLowerCase() === "e") {
854
869
  console.log(chalk3.gray("Opening editor..."));
855
- const { execSync: execSync7 } = await import("child_process");
870
+ const { execSync: execSync9 } = await import("child_process");
856
871
  const editor = process.env.EDITOR || process.env.VISUAL || "vi";
857
872
  const fs2 = await import("fs");
858
873
  const os = await import("os");
859
874
  const path2 = await import("path");
860
875
  const tmpFile = path2.join(os.tmpdir(), "gut-commit-msg.txt");
861
876
  fs2.writeFileSync(tmpFile, message);
862
- execSync7(`${editor} "${tmpFile}"`, { stdio: "inherit" });
877
+ execSync9(`${editor} "${tmpFile}"`, { stdio: "inherit" });
863
878
  const editedMessage = fs2.readFileSync(tmpFile, "utf-8").trim();
864
879
  fs2.unlinkSync(tmpFile);
865
880
  if (editedMessage) {
@@ -2197,9 +2212,9 @@ var summaryCommand = new Command14("summary").description("Generate a work summa
2197
2212
  const output = options.markdown ? formatMarkdown(summary, author, since, options.until) : null;
2198
2213
  if (options.copy) {
2199
2214
  const textToCopy = output || formatMarkdown(summary, author, since, options.until);
2200
- const { execSync: execSync7 } = await import("child_process");
2215
+ const { execSync: execSync9 } = await import("child_process");
2201
2216
  try {
2202
- execSync7("pbcopy", { input: textToCopy });
2217
+ execSync9("pbcopy", { input: textToCopy });
2203
2218
  console.log(chalk15.green("Summary copied to clipboard!"));
2204
2219
  console.log();
2205
2220
  } catch {
@@ -2315,6 +2330,16 @@ function printSummary(summary, author, since, until) {
2315
2330
  // src/commands/config.ts
2316
2331
  import { Command as Command15 } from "commander";
2317
2332
  import chalk16 from "chalk";
2333
+ import { execSync as execSync7 } from "child_process";
2334
+ import { existsSync as existsSync5, mkdirSync as mkdirSync2 } from "fs";
2335
+ import { join as join5 } from "path";
2336
+ import { homedir as homedir3 } from "os";
2337
+ import { simpleGit as simpleGit14 } from "simple-git";
2338
+ function openFolder(path2) {
2339
+ const platform = process.platform;
2340
+ const cmd = platform === "darwin" ? "open" : platform === "win32" ? 'start ""' : "xdg-open";
2341
+ execSync7(`${cmd} "${path2}"`);
2342
+ }
2318
2343
  var configCommand = new Command15("config").description("Manage gut configuration");
2319
2344
  configCommand.command("set <key> <value>").description("Set a configuration value").option("--local", "Set for current repository only").action((key, value, options) => {
2320
2345
  if (key === "lang") {
@@ -2363,6 +2388,39 @@ configCommand.command("list").description("List all configuration values").actio
2363
2388
  console.log(chalk16.gray("Local config: .gut/config.json"));
2364
2389
  }
2365
2390
  });
2391
+ configCommand.command("open").description("Open configuration or templates folder").option("-t, --templates", "Open templates folder instead of config").option("-g, --global", "Open global folder (default)").option("-l, --local", "Open local/project folder").action(async (options) => {
2392
+ const git = simpleGit14();
2393
+ const isLocal = options.local === true;
2394
+ let targetPath;
2395
+ if (isLocal) {
2396
+ const isRepo = await git.checkIsRepo();
2397
+ if (!isRepo) {
2398
+ console.error(chalk16.red("Error: Not a git repository"));
2399
+ console.error(chalk16.gray("Use --global to open global config folder"));
2400
+ process.exit(1);
2401
+ }
2402
+ const repoRoot = await git.revparse(["--show-toplevel"]).catch(() => process.cwd());
2403
+ targetPath = join5(repoRoot.trim(), ".gut");
2404
+ } else {
2405
+ if (options.templates) {
2406
+ targetPath = join5(homedir3(), ".config", "gut", "templates");
2407
+ } else {
2408
+ targetPath = join5(homedir3(), ".config", "gut");
2409
+ }
2410
+ }
2411
+ if (!existsSync5(targetPath)) {
2412
+ mkdirSync2(targetPath, { recursive: true });
2413
+ console.log(chalk16.green(`Created ${targetPath}`));
2414
+ }
2415
+ try {
2416
+ openFolder(targetPath);
2417
+ console.log(chalk16.green(`Opened: ${targetPath}`));
2418
+ } catch (error) {
2419
+ console.error(chalk16.red(`Failed to open folder: ${targetPath}`));
2420
+ console.error(chalk16.gray(error.message));
2421
+ process.exit(1);
2422
+ }
2423
+ });
2366
2424
 
2367
2425
  // src/commands/lang.ts
2368
2426
  import { Command as Command16 } from "commander";
@@ -2395,17 +2453,24 @@ var langCommand = new Command16("lang").description("Set or show output language
2395
2453
  import { Command as Command17 } from "commander";
2396
2454
  import chalk18 from "chalk";
2397
2455
  import ora14 from "ora";
2398
- import { simpleGit as simpleGit14 } from "simple-git";
2399
- import { existsSync as existsSync5, mkdirSync as mkdirSync2, readFileSync as readFileSync6, writeFileSync as writeFileSync3 } from "fs";
2400
- import { join as join5, dirname as dirname2 } from "path";
2456
+ import { simpleGit as simpleGit15 } from "simple-git";
2457
+ import { existsSync as existsSync6, mkdirSync as mkdirSync3, readFileSync as readFileSync6, writeFileSync as writeFileSync3 } from "fs";
2458
+ import { join as join6, dirname as dirname2 } from "path";
2459
+ import { homedir as homedir4 } from "os";
2401
2460
  import { fileURLToPath as fileURLToPath2 } from "url";
2461
+ import { execSync as execSync8 } from "child_process";
2402
2462
  import { generateText as generateText2 } from "ai";
2403
2463
  import { createGoogleGenerativeAI as createGoogleGenerativeAI2 } from "@ai-sdk/google";
2404
2464
  import { createOpenAI as createOpenAI2 } from "@ai-sdk/openai";
2405
2465
  import { createAnthropic as createAnthropic2 } from "@ai-sdk/anthropic";
2466
+ function openFolder2(path2) {
2467
+ const platform = process.platform;
2468
+ const cmd = platform === "darwin" ? "open" : platform === "win32" ? 'start ""' : "xdg-open";
2469
+ execSync8(`${cmd} "${path2}"`);
2470
+ }
2406
2471
  var __filename2 = fileURLToPath2(import.meta.url);
2407
2472
  var __dirname2 = dirname2(__filename2);
2408
- var GUT_ROOT2 = join5(__dirname2, "..");
2473
+ var GUT_ROOT2 = join6(__dirname2, "..");
2409
2474
  var TEMPLATE_FILES = [
2410
2475
  "branch.md",
2411
2476
  "changelog.md",
@@ -2468,20 +2533,28 @@ Translated template:`
2468
2533
  });
2469
2534
  return text.trim();
2470
2535
  }
2471
- var initCommand = new Command17("init").description("Initialize .gut/ templates in your project").option("-p, --provider <provider>", "AI provider for translation (gemini, openai, anthropic)", "gemini").option("-f, --force", "Overwrite existing templates").option("--no-translate", "Skip translation even if language is not English").action(async (options) => {
2472
- const git = simpleGit14();
2473
- const isRepo = await git.checkIsRepo();
2474
- if (!isRepo) {
2475
- console.error(chalk18.red("Error: Not a git repository"));
2476
- process.exit(1);
2536
+ var initCommand = new Command17("init").description("Initialize .gut/ templates in your project or globally").option("-p, --provider <provider>", "AI provider for translation (gemini, openai, anthropic)", "gemini").option("-f, --force", "Overwrite existing templates").option("-g, --global", "Initialize templates globally (~/.config/gut/templates/)").option("-o, --open", "Open the templates folder (can be used alone)").option("--no-translate", "Skip translation even if language is not English").action(async (options) => {
2537
+ const isGlobal = options.global === true;
2538
+ const git = simpleGit15();
2539
+ let targetDir;
2540
+ if (isGlobal) {
2541
+ targetDir = join6(homedir4(), ".config", "gut", "templates");
2542
+ } else {
2543
+ const isRepo = await git.checkIsRepo();
2544
+ if (!isRepo) {
2545
+ console.error(chalk18.red("Error: Not a git repository"));
2546
+ console.error(chalk18.gray("Use --global to initialize templates globally"));
2547
+ process.exit(1);
2548
+ }
2549
+ const repoRoot = await git.revparse(["--show-toplevel"]).catch(() => process.cwd());
2550
+ targetDir = join6(repoRoot.trim(), ".gut");
2477
2551
  }
2478
- const repoRoot = await git.revparse(["--show-toplevel"]).catch(() => process.cwd());
2479
- const targetDir = join5(repoRoot.trim(), ".gut");
2480
- const sourceDir = join5(GUT_ROOT2, ".gut");
2481
- if (!existsSync5(targetDir)) {
2482
- mkdirSync2(targetDir, { recursive: true });
2552
+ if (!existsSync6(targetDir)) {
2553
+ mkdirSync3(targetDir, { recursive: true });
2483
2554
  console.log(chalk18.green(`Created ${targetDir}`));
2484
2555
  }
2556
+ console.log(chalk18.blue(isGlobal ? "Initializing global templates...\n" : "Initializing project templates...\n"));
2557
+ const sourceDir = join6(GUT_ROOT2, ".gut");
2485
2558
  const lang = getLanguage();
2486
2559
  const needsTranslation = options.translate !== false && lang !== "en";
2487
2560
  const provider = options.provider.toLowerCase();
@@ -2493,12 +2566,12 @@ var initCommand = new Command17("init").description("Initialize .gut/ templates
2493
2566
  let copied = 0;
2494
2567
  let skipped = 0;
2495
2568
  for (const filename of TEMPLATE_FILES) {
2496
- const sourcePath = join5(sourceDir, filename);
2497
- const targetPath = join5(targetDir, filename);
2498
- if (!existsSync5(sourcePath)) {
2569
+ const sourcePath = join6(sourceDir, filename);
2570
+ const targetPath = join6(targetDir, filename);
2571
+ if (!existsSync6(sourcePath)) {
2499
2572
  continue;
2500
2573
  }
2501
- if (existsSync5(targetPath) && !options.force) {
2574
+ if (existsSync6(targetPath) && !options.force) {
2502
2575
  console.log(chalk18.gray(` Skipped: ${filename} (already exists)`));
2503
2576
  skipped++;
2504
2577
  continue;
@@ -2522,21 +2595,37 @@ var initCommand = new Command17("init").description("Initialize .gut/ templates
2522
2595
  }
2523
2596
  console.log();
2524
2597
  if (copied > 0) {
2525
- console.log(chalk18.green(`\u2713 ${copied} template(s) initialized in .gut/`));
2598
+ const location = isGlobal ? "~/.config/gut/templates/" : ".gut/";
2599
+ console.log(chalk18.green(`\u2713 ${copied} template(s) initialized in ${location}`));
2526
2600
  }
2527
2601
  if (skipped > 0) {
2528
2602
  console.log(chalk18.gray(` ${skipped} template(s) skipped (use --force to overwrite)`));
2529
2603
  }
2530
- console.log(chalk18.gray("\nYou can now customize these templates for your project."));
2604
+ if (isGlobal) {
2605
+ console.log(chalk18.gray("\nGlobal templates will be used as fallback for all projects."));
2606
+ console.log(chalk18.gray("Project-level templates (.gut/) take priority over global templates."));
2607
+ } else {
2608
+ console.log(chalk18.gray("\nYou can now customize these templates for your project."));
2609
+ }
2610
+ if (options.open) {
2611
+ try {
2612
+ openFolder2(targetDir);
2613
+ console.log(chalk18.green(`
2614
+ Opened: ${targetDir}`));
2615
+ } catch (error) {
2616
+ console.error(chalk18.red(`
2617
+ Failed to open folder: ${targetDir}`));
2618
+ }
2619
+ }
2531
2620
  });
2532
2621
 
2533
2622
  // src/commands/gitignore.ts
2534
2623
  import { Command as Command18 } from "commander";
2535
2624
  import chalk19 from "chalk";
2536
2625
  import ora15 from "ora";
2537
- import { simpleGit as simpleGit15 } from "simple-git";
2538
- import { readdirSync as readdirSync2, readFileSync as readFileSync7, existsSync as existsSync6, writeFileSync as writeFileSync4 } from "fs";
2539
- import { join as join6 } from "path";
2626
+ import { simpleGit as simpleGit16 } from "simple-git";
2627
+ import { readdirSync as readdirSync2, readFileSync as readFileSync7, existsSync as existsSync7, writeFileSync as writeFileSync4 } from "fs";
2628
+ import { join as join7 } from "path";
2540
2629
  var CONFIG_FILES = [
2541
2630
  // JavaScript/TypeScript
2542
2631
  "package.json",
@@ -2589,7 +2678,7 @@ function getFiles(dir, maxDepth = 3, currentDepth = 0) {
2589
2678
  if (entry.name.startsWith(".") || entry.name === "node_modules" || entry.name === "vendor" || entry.name === "target" || entry.name === "__pycache__" || entry.name === "venv" || entry.name === ".venv") {
2590
2679
  continue;
2591
2680
  }
2592
- const fullPath = join6(dir, entry.name);
2681
+ const fullPath = join7(dir, entry.name);
2593
2682
  if (entry.isDirectory()) {
2594
2683
  files.push(entry.name + "/");
2595
2684
  const subFiles = getFiles(fullPath, maxDepth, currentDepth + 1);
@@ -2611,15 +2700,15 @@ function findConfigFiles(repoRoot) {
2611
2700
  const entries = readdirSync2(repoRoot);
2612
2701
  for (const entry of entries) {
2613
2702
  if (entry.endsWith(ext)) {
2614
- const content = readFileSync7(join6(repoRoot, entry), "utf-8");
2703
+ const content = readFileSync7(join7(repoRoot, entry), "utf-8");
2615
2704
  found.set(entry, content.slice(0, 2e3));
2616
2705
  }
2617
2706
  }
2618
2707
  } catch {
2619
2708
  }
2620
2709
  } else {
2621
- const filePath = join6(repoRoot, configFile);
2622
- if (existsSync6(filePath)) {
2710
+ const filePath = join7(repoRoot, configFile);
2711
+ if (existsSync7(filePath)) {
2623
2712
  try {
2624
2713
  const content = readFileSync7(filePath, "utf-8");
2625
2714
  found.set(configFile, content.slice(0, 2e3));
@@ -2631,7 +2720,7 @@ function findConfigFiles(repoRoot) {
2631
2720
  return found;
2632
2721
  }
2633
2722
  var gitignoreCommand = new Command18("gitignore").description("Generate .gitignore from current codebase").option("-p, --provider <provider>", "AI provider (gemini, openai, anthropic)", "gemini").option("-m, --model <model>", "Model to use (provider-specific)").option("-o, --output <file>", "Output file (default: .gitignore)", ".gitignore").option("--stdout", "Print to stdout instead of file").option("-y, --yes", "Overwrite existing .gitignore without confirmation").action(async (options) => {
2634
- const git = simpleGit15();
2723
+ const git = simpleGit16();
2635
2724
  const repoRoot = await git.revparse(["--show-toplevel"]).catch(() => process.cwd());
2636
2725
  const root = repoRoot.trim();
2637
2726
  const provider = options.provider.toLowerCase();
@@ -2642,9 +2731,9 @@ var gitignoreCommand = new Command18("gitignore").description("Generate .gitigno
2642
2731
  const spinner = ora15("Analyzing project structure...").start();
2643
2732
  const files = getFiles(root);
2644
2733
  const configFiles = findConfigFiles(root);
2645
- const gitignorePath = join6(root, options.output);
2734
+ const gitignorePath = join7(root, options.output);
2646
2735
  let existingGitignore;
2647
- if (existsSync6(gitignorePath)) {
2736
+ if (existsSync7(gitignorePath)) {
2648
2737
  existingGitignore = readFileSync7(gitignorePath, "utf-8");
2649
2738
  }
2650
2739
  let configFilesStr = "";
@@ -2679,7 +2768,7 @@ ${content}
2679
2768
  console.log(gitignoreContent);
2680
2769
  console.log(chalk19.gray("\u2500".repeat(50)));
2681
2770
  console.log();
2682
- if (existsSync6(gitignorePath) && !options.yes) {
2771
+ if (existsSync7(gitignorePath) && !options.yes) {
2683
2772
  const readline = await import("readline");
2684
2773
  const rl = readline.createInterface({
2685
2774
  input: process.stdin,