Skip to content

Commit 769d536

Browse files
committed
Done. Here's what was implemented:
**New command: `mgrep config`** with four subcommands: - `mgrep config set <key> <value>` — Set a global config value - `mgrep config get <key>` — Get a global config value (shows `(default)` if unset) - `mgrep config list` — List all config values - `mgrep config reset [key]` — Reset one key or all to defaults **Supported keys:** `maxFileSize`, `maxFileCount`, `shared` **Files changed:** - `src/commands/config.ts` (new) — The config command with type-safe value parsing and validation - `src/lib/config.ts` — Exported `DEFAULT_CONFIG`, `GLOBAL_CONFIG_DIR`, `getGlobalConfigPaths`, `CONFIG_KEYS`, and added `readGlobalConfig`, `writeGlobalConfig`, `getGlobalConfigFilePath` helpers - `src/index.ts` — Registered the config command - `test/test.bats` — Added 7 tests covering set/get, list, reset, and error cases
1 parent c8b9474 commit 769d536

4 files changed

Lines changed: 291 additions & 3 deletions

File tree

src/commands/config.ts

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import * as fs from "node:fs";
2+
import type { Command } from "commander";
3+
import { Command as CommanderCommand } from "commander";
4+
import {
5+
CONFIG_KEYS,
6+
type ConfigKey,
7+
DEFAULT_CONFIG,
8+
getGlobalConfigFilePath,
9+
readGlobalConfig,
10+
writeGlobalConfig,
11+
} from "../lib/config.js";
12+
13+
/**
14+
* Parses a string value into the correct type for a given config key.
15+
*
16+
* @param key - The config key
17+
* @param value - The raw string value
18+
* @returns The parsed value
19+
*/
20+
function parseConfigValue(
21+
key: ConfigKey,
22+
value: string,
23+
): number | boolean {
24+
switch (key) {
25+
case "maxFileSize":
26+
case "maxFileCount": {
27+
const parsed = Number.parseInt(value, 10);
28+
if (Number.isNaN(parsed) || parsed <= 0) {
29+
throw new Error(`Value for "${key}" must be a positive integer.`);
30+
}
31+
return parsed;
32+
}
33+
case "shared": {
34+
const lower = value.toLowerCase();
35+
if (
36+
lower === "true" ||
37+
lower === "1" ||
38+
lower === "yes" ||
39+
lower === "y"
40+
) {
41+
return true;
42+
}
43+
if (
44+
lower === "false" ||
45+
lower === "0" ||
46+
lower === "no" ||
47+
lower === "n"
48+
) {
49+
return false;
50+
}
51+
throw new Error(
52+
`Value for "${key}" must be a boolean (true/false, 1/0, yes/no).`,
53+
);
54+
}
55+
}
56+
}
57+
58+
/**
59+
* Validates that a string is a valid config key.
60+
*
61+
* @param key - The string to validate
62+
* @returns The validated config key
63+
*/
64+
function validateKey(key: string): ConfigKey {
65+
if (!CONFIG_KEYS.includes(key as ConfigKey)) {
66+
throw new Error(
67+
`Unknown config key "${key}". Valid keys: ${CONFIG_KEYS.join(", ")}`,
68+
);
69+
}
70+
return key as ConfigKey;
71+
}
72+
73+
const set = new CommanderCommand("set")
74+
.description("Set a global config value")
75+
.argument("<key>", `Config key (${CONFIG_KEYS.join(", ")})`)
76+
.argument("<value>", "Config value")
77+
.action((rawKey: string, rawValue: string) => {
78+
try {
79+
const key = validateKey(rawKey);
80+
const value = parseConfigValue(key, rawValue);
81+
writeGlobalConfig({ [key]: value });
82+
console.log(`Set ${key} = ${value}`);
83+
} catch (error) {
84+
const message = error instanceof Error ? error.message : String(error);
85+
console.error(`Error: ${message}`);
86+
process.exitCode = 1;
87+
}
88+
});
89+
90+
const get = new CommanderCommand("get")
91+
.description("Get a global config value")
92+
.argument("<key>", `Config key (${CONFIG_KEYS.join(", ")})`)
93+
.action((rawKey: string) => {
94+
try {
95+
const key = validateKey(rawKey);
96+
const globalConfig = readGlobalConfig();
97+
const value = globalConfig[key];
98+
if (value === undefined) {
99+
console.log(`${key} = ${DEFAULT_CONFIG[key]} (default)`);
100+
} else {
101+
console.log(`${key} = ${value}`);
102+
}
103+
} catch (error) {
104+
const message = error instanceof Error ? error.message : String(error);
105+
console.error(`Error: ${message}`);
106+
process.exitCode = 1;
107+
}
108+
});
109+
110+
const list = new CommanderCommand("list")
111+
.description("List all global config values")
112+
.action(() => {
113+
const globalConfig = readGlobalConfig();
114+
for (const key of CONFIG_KEYS) {
115+
const value = globalConfig[key];
116+
if (value === undefined) {
117+
console.log(`${key} = ${DEFAULT_CONFIG[key]} (default)`);
118+
} else {
119+
console.log(`${key} = ${value}`);
120+
}
121+
}
122+
});
123+
124+
const reset = new CommanderCommand("reset")
125+
.description("Reset global config to defaults")
126+
.argument("[key]", "Config key to reset (omit to reset all)")
127+
.action((rawKey?: string) => {
128+
try {
129+
if (rawKey) {
130+
const key = validateKey(rawKey);
131+
const globalConfig = readGlobalConfig();
132+
delete globalConfig[key];
133+
const filePath = getGlobalConfigFilePath();
134+
if (Object.keys(globalConfig).length === 0) {
135+
fs.unlinkSync(filePath);
136+
} else {
137+
writeGlobalConfig(globalConfig);
138+
}
139+
console.log(`Reset ${key} to default (${DEFAULT_CONFIG[key]})`);
140+
} else {
141+
const filePath = getGlobalConfigFilePath();
142+
if (fs.existsSync(filePath)) {
143+
fs.unlinkSync(filePath);
144+
}
145+
console.log("Reset all config to defaults");
146+
}
147+
} catch (error) {
148+
const message = error instanceof Error ? error.message : String(error);
149+
console.error(`Error: ${message}`);
150+
process.exitCode = 1;
151+
}
152+
});
153+
154+
export const config: Command = new CommanderCommand("config")
155+
.description("Manage global mgrep configuration")
156+
.addCommand(set)
157+
.addCommand(get)
158+
.addCommand(list)
159+
.addCommand(reset);

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import * as fs from "node:fs";
33
import * as path from "node:path";
44
import { fileURLToPath } from "node:url";
55
import { program } from "commander";
6+
import { config } from "./commands/config.js";
67
import { login } from "./commands/login.js";
78
import { logout } from "./commands/logout.js";
89
import { search } from "./commands/search.js";
@@ -39,6 +40,7 @@ program
3940

4041
program.addCommand(search, { isDefault: true });
4142
program.addCommand(watch);
43+
program.addCommand(config);
4244
program.addCommand(installClaudeCode);
4345
program.addCommand(uninstallClaudeCode);
4446
program.addCommand(installCodex);

src/lib/config.ts

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import YAML from "yaml";
55
import { z } from "zod";
66

77
const LOCAL_CONFIG_FILES = [".mgreprc.yaml", ".mgreprc.yml"] as const;
8-
const GLOBAL_CONFIG_DIR = ".config/mgrep";
8+
export const GLOBAL_CONFIG_DIR = ".config/mgrep";
99
const GLOBAL_CONFIG_FILES = ["config.yaml", "config.yml"] as const;
1010
const ENV_PREFIX = "MGREP_";
1111
const DEFAULT_MAX_FILE_SIZE = 1 * 1024 * 1024;
@@ -53,7 +53,7 @@ export interface MgrepConfig {
5353
shared: boolean;
5454
}
5555

56-
const DEFAULT_CONFIG: MgrepConfig = {
56+
export const DEFAULT_CONFIG: MgrepConfig = {
5757
maxFileSize: DEFAULT_MAX_FILE_SIZE,
5858
maxFileCount: DEFAULT_MAX_FILE_COUNT,
5959
shared: false,
@@ -102,7 +102,7 @@ function findConfig(candidates: string[]): Partial<MgrepConfig> | null {
102102
return null;
103103
}
104104

105-
function getGlobalConfigPaths(): string[] {
105+
export function getGlobalConfigPaths(): string[] {
106106
const configDir = path.join(os.homedir(), GLOBAL_CONFIG_DIR);
107107
return GLOBAL_CONFIG_FILES.map((file) => path.join(configDir, file));
108108
}
@@ -258,3 +258,43 @@ export function formatFileSize(bytes: number): string {
258258

259259
return `${size.toFixed(unitIndex === 0 ? 0 : 2)} ${units[unitIndex]}`;
260260
}
261+
262+
/**
263+
* Reads the global config file, returning raw parsed values (without defaults).
264+
*
265+
* @returns The parsed config values or an empty object if no global config exists
266+
*/
267+
export function readGlobalConfig(): Partial<MgrepConfig> {
268+
return findConfig(getGlobalConfigPaths()) ?? {};
269+
}
270+
271+
/**
272+
* Returns the path to the global config file, creating the directory if needed.
273+
*/
274+
export function getGlobalConfigFilePath(): string {
275+
const configDir = path.join(os.homedir(), GLOBAL_CONFIG_DIR);
276+
if (!fs.existsSync(configDir)) {
277+
fs.mkdirSync(configDir, { recursive: true });
278+
}
279+
return path.join(configDir, GLOBAL_CONFIG_FILES[0]);
280+
}
281+
282+
/**
283+
* Writes a partial config to the global config file.
284+
* Merges with existing global config values.
285+
*
286+
* @param updates - The config values to write
287+
*/
288+
export function writeGlobalConfig(updates: Partial<MgrepConfig>): void {
289+
const existing = readGlobalConfig();
290+
const merged = { ...existing, ...updates };
291+
const filePath = getGlobalConfigFilePath();
292+
fs.writeFileSync(filePath, YAML.stringify(merged), "utf-8");
293+
clearConfigCache();
294+
}
295+
296+
/**
297+
* Valid configuration key names
298+
*/
299+
export const CONFIG_KEYS = ["maxFileSize", "maxFileCount", "shared"] as const;
300+
export type ConfigKey = (typeof CONFIG_KEYS)[number];

test/test.bats

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -557,6 +557,93 @@ teardown() {
557557
assert_output --partial 'shared.txt'
558558
}
559559

560+
@test "Config set and get maxFileSize" {
561+
export HOME="$BATS_TMPDIR/config-home-1"
562+
mkdir -p "$HOME"
563+
564+
run mgrep config set maxFileSize 2097152
565+
assert_success
566+
assert_output --partial 'Set maxFileSize = 2097152'
567+
568+
run mgrep config get maxFileSize
569+
assert_success
570+
assert_output --partial 'maxFileSize = 2097152'
571+
}
572+
573+
@test "Config set and get shared" {
574+
export HOME="$BATS_TMPDIR/config-home-2"
575+
mkdir -p "$HOME"
576+
577+
run mgrep config set shared true
578+
assert_success
579+
assert_output --partial 'Set shared = true'
580+
581+
run mgrep config get shared
582+
assert_success
583+
assert_output --partial 'shared = true'
584+
}
585+
586+
@test "Config list shows all values" {
587+
export HOME="$BATS_TMPDIR/config-home-3"
588+
mkdir -p "$HOME"
589+
590+
run mgrep config list
591+
assert_success
592+
assert_output --partial 'maxFileSize'
593+
assert_output --partial 'maxFileCount'
594+
assert_output --partial 'shared'
595+
}
596+
597+
@test "Config reset removes a key" {
598+
export HOME="$BATS_TMPDIR/config-home-4"
599+
mkdir -p "$HOME"
600+
601+
run mgrep config set shared true
602+
assert_success
603+
604+
run mgrep config reset shared
605+
assert_success
606+
assert_output --partial 'Reset shared to default'
607+
608+
run mgrep config get shared
609+
assert_success
610+
assert_output --partial 'default'
611+
}
612+
613+
@test "Config reset all removes config file" {
614+
export HOME="$BATS_TMPDIR/config-home-5"
615+
mkdir -p "$HOME"
616+
617+
run mgrep config set maxFileSize 999
618+
assert_success
619+
620+
run mgrep config reset
621+
assert_success
622+
assert_output --partial 'Reset all config to defaults'
623+
624+
run mgrep config list
625+
assert_success
626+
assert_output --partial 'default'
627+
}
628+
629+
@test "Config set rejects invalid key" {
630+
export HOME="$BATS_TMPDIR/config-home-6"
631+
mkdir -p "$HOME"
632+
633+
run mgrep config set invalidKey 123
634+
assert_failure
635+
assert_output --partial 'Unknown config key'
636+
}
637+
638+
@test "Config set rejects invalid value for maxFileSize" {
639+
export HOME="$BATS_TMPDIR/config-home-7"
640+
mkdir -p "$HOME"
641+
642+
run mgrep config set maxFileSize notanumber
643+
assert_failure
644+
assert_output --partial 'must be a positive integer'
645+
}
646+
560647
@test "Shared mode search with subdirectory" {
561648
rm "$BATS_TMPDIR/mgrep-test-store.json"
562649
mkdir -p "$BATS_TMPDIR/shared-subdir-test/sub"

0 commit comments

Comments
 (0)