claude-code-modes 0.1.0
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/LICENSE +21 -0
- package/README.md +304 -0
- package/package.json +35 -0
- package/prompts/axis/agency/autonomous.md +9 -0
- package/prompts/axis/agency/collaborative.md +9 -0
- package/prompts/axis/agency/surgical.md +9 -0
- package/prompts/axis/quality/architect.md +24 -0
- package/prompts/axis/quality/minimal.md +19 -0
- package/prompts/axis/quality/pragmatic.md +21 -0
- package/prompts/axis/scope/adjacent.md +9 -0
- package/prompts/axis/scope/narrow.md +9 -0
- package/prompts/axis/scope/unrestricted.md +8 -0
- package/prompts/base/actions.md +9 -0
- package/prompts/base/base.json +12 -0
- package/prompts/base/doing-tasks.md +12 -0
- package/prompts/base/env.md +16 -0
- package/prompts/base/intro.md +5 -0
- package/prompts/base/session-guidance.md +7 -0
- package/prompts/base/system.md +7 -0
- package/prompts/base/tone.md +5 -0
- package/prompts/base/tools.md +10 -0
- package/prompts/chill/actions.md +16 -0
- package/prompts/chill/base.json +8 -0
- package/prompts/chill/core.md +60 -0
- package/prompts/chill/env.md +15 -0
- package/prompts/chill/tools.md +12 -0
- package/prompts/modifiers/context-pacing.md +14 -0
- package/prompts/modifiers/readonly.md +10 -0
- package/src/args.ts +118 -0
- package/src/assemble.ts +187 -0
- package/src/build-prompt.ts +119 -0
- package/src/cli.ts +132 -0
- package/src/config-cli.ts +320 -0
- package/src/config.ts +300 -0
- package/src/embedded-prompts.ts +366 -0
- package/src/env.ts +64 -0
- package/src/inspect.ts +315 -0
- package/src/presets.ts +39 -0
- package/src/resolve.ts +278 -0
- package/src/types.ts +99 -0
- package/src/usage.ts +51 -0
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
import { writeFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join, dirname } from "node:path";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { parseArgs } from "node:util";
|
|
5
|
+
import {
|
|
6
|
+
type UserConfig,
|
|
7
|
+
readConfigFile,
|
|
8
|
+
checkModifierNameCollision,
|
|
9
|
+
checkPresetNameCollision,
|
|
10
|
+
checkAxisValueCollision,
|
|
11
|
+
} from "./config.js";
|
|
12
|
+
import { AXIS_BUILTINS } from "./types.js";
|
|
13
|
+
const VALID_AXES = Object.keys(AXIS_BUILTINS) as Array<"agency" | "quality" | "scope">;
|
|
14
|
+
type ValidAxis = (typeof VALID_AXES)[number];
|
|
15
|
+
|
|
16
|
+
function getConfigPath(isGlobal: boolean): string {
|
|
17
|
+
if (isGlobal) {
|
|
18
|
+
return join(homedir(), ".config", "claude-mode", "config.json");
|
|
19
|
+
}
|
|
20
|
+
return join(process.cwd(), ".claude-mode.json");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function configFileName(configPath: string): string {
|
|
24
|
+
// Return a user-friendly name for confirmation messages
|
|
25
|
+
const home = homedir();
|
|
26
|
+
if (configPath.startsWith(home)) {
|
|
27
|
+
return "~" + configPath.slice(home.length);
|
|
28
|
+
}
|
|
29
|
+
return configPath.split("/").pop() ?? configPath;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function readConfig(configPath: string): UserConfig {
|
|
33
|
+
if (!existsSync(configPath)) return {};
|
|
34
|
+
return readConfigFile(configPath);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function writeConfig(configPath: string, config: UserConfig): void {
|
|
38
|
+
mkdirSync(dirname(configPath), { recursive: true });
|
|
39
|
+
writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf8");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function configShow(configPath: string): void {
|
|
43
|
+
if (!existsSync(configPath)) {
|
|
44
|
+
process.stdout.write("No config file found.\n");
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const text = readFileSync(configPath, "utf8");
|
|
48
|
+
process.stdout.write(text.trimEnd() + "\n");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function configInit(configPath: string): void {
|
|
52
|
+
if (existsSync(configPath)) {
|
|
53
|
+
throw new Error(`Config file already exists: ${configPath}`);
|
|
54
|
+
}
|
|
55
|
+
const scaffold: UserConfig = {
|
|
56
|
+
defaultModifiers: [],
|
|
57
|
+
modifiers: {},
|
|
58
|
+
axes: {},
|
|
59
|
+
presets: {},
|
|
60
|
+
};
|
|
61
|
+
writeConfig(configPath, scaffold);
|
|
62
|
+
process.stdout.write(`Created ${configFileName(configPath)}\n`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function configAddDefault(configPath: string, value: string): void {
|
|
66
|
+
const config = readConfig(configPath);
|
|
67
|
+
const defaults = config.defaultModifiers ?? [];
|
|
68
|
+
if (defaults.includes(value)) {
|
|
69
|
+
process.stdout.write(
|
|
70
|
+
`"${value}" is already in defaultModifiers in ${configFileName(configPath)}\n`
|
|
71
|
+
);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
config.defaultModifiers = [...defaults, value];
|
|
75
|
+
writeConfig(configPath, config);
|
|
76
|
+
process.stdout.write(
|
|
77
|
+
`Added "${value}" to defaultModifiers in ${configFileName(configPath)}\n`
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function configRemoveDefault(configPath: string, value: string): void {
|
|
82
|
+
const config = readConfig(configPath);
|
|
83
|
+
const defaults = config.defaultModifiers ?? [];
|
|
84
|
+
if (!defaults.includes(value)) {
|
|
85
|
+
throw new Error(
|
|
86
|
+
`"${value}" not found in defaultModifiers in ${configFileName(configPath)}`
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
config.defaultModifiers = defaults.filter((v) => v !== value);
|
|
90
|
+
writeConfig(configPath, config);
|
|
91
|
+
process.stdout.write(
|
|
92
|
+
`Removed "${value}" from defaultModifiers in ${configFileName(configPath)}\n`
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function configAddModifier(configPath: string, name: string, mdPath: string): void {
|
|
97
|
+
checkModifierNameCollision(name);
|
|
98
|
+
const config = readConfig(configPath);
|
|
99
|
+
config.modifiers = { ...config.modifiers, [name]: mdPath };
|
|
100
|
+
writeConfig(configPath, config);
|
|
101
|
+
process.stdout.write(
|
|
102
|
+
`Registered modifier "${name}" in ${configFileName(configPath)}\n`
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function configRemoveModifier(configPath: string, name: string): void {
|
|
107
|
+
const config = readConfig(configPath);
|
|
108
|
+
const modifiers = config.modifiers ?? {};
|
|
109
|
+
if (!(name in modifiers)) {
|
|
110
|
+
throw new Error(
|
|
111
|
+
`Modifier "${name}" not found in ${configFileName(configPath)}`
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
const updated = { ...modifiers };
|
|
115
|
+
delete updated[name];
|
|
116
|
+
config.modifiers = updated;
|
|
117
|
+
writeConfig(configPath, config);
|
|
118
|
+
process.stdout.write(
|
|
119
|
+
`Unregistered modifier "${name}" from ${configFileName(configPath)}\n`
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function configAddAxis(configPath: string, axis: string, name: string, mdPath: string): void {
|
|
124
|
+
if (!VALID_AXES.includes(axis as ValidAxis)) {
|
|
125
|
+
throw new Error(
|
|
126
|
+
`Invalid axis "${axis}"; must be one of: ${VALID_AXES.join(", ")}`
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
const validAxis = axis as ValidAxis;
|
|
130
|
+
|
|
131
|
+
checkAxisValueCollision(validAxis, name);
|
|
132
|
+
|
|
133
|
+
const config = readConfig(configPath);
|
|
134
|
+
config.axes = config.axes ?? {};
|
|
135
|
+
config.axes[validAxis] = { ...config.axes[validAxis], [name]: mdPath };
|
|
136
|
+
writeConfig(configPath, config);
|
|
137
|
+
process.stdout.write(
|
|
138
|
+
`Registered ${axis} value "${name}" in ${configFileName(configPath)}\n`
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function configRemoveAxis(configPath: string, axis: string, name: string): void {
|
|
143
|
+
if (!VALID_AXES.includes(axis as ValidAxis)) {
|
|
144
|
+
throw new Error(
|
|
145
|
+
`Invalid axis "${axis}"; must be one of: ${VALID_AXES.join(", ")}`
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
const validAxis = axis as ValidAxis;
|
|
149
|
+
|
|
150
|
+
const config = readConfig(configPath);
|
|
151
|
+
const axisMap = config.axes?.[validAxis] ?? {};
|
|
152
|
+
if (!(name in axisMap)) {
|
|
153
|
+
throw new Error(
|
|
154
|
+
`${axis} value "${name}" not found in ${configFileName(configPath)}`
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
const updated = { ...axisMap };
|
|
158
|
+
delete updated[name];
|
|
159
|
+
config.axes = config.axes ?? {};
|
|
160
|
+
config.axes[validAxis] = updated;
|
|
161
|
+
writeConfig(configPath, config);
|
|
162
|
+
process.stdout.write(
|
|
163
|
+
`Unregistered ${axis} value "${name}" from ${configFileName(configPath)}\n`
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function configAddPreset(configPath: string, name: string, flags: string[]): void {
|
|
168
|
+
checkPresetNameCollision(name);
|
|
169
|
+
|
|
170
|
+
const { values } = parseArgs({
|
|
171
|
+
args: flags,
|
|
172
|
+
options: {
|
|
173
|
+
agency: { type: "string" },
|
|
174
|
+
quality: { type: "string" },
|
|
175
|
+
scope: { type: "string" },
|
|
176
|
+
modifier: { type: "string", multiple: true },
|
|
177
|
+
readonly: { type: "boolean" },
|
|
178
|
+
"context-pacing": { type: "boolean" },
|
|
179
|
+
},
|
|
180
|
+
strict: true,
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
const presetDef: NonNullable<UserConfig["presets"]>[string] = {};
|
|
184
|
+
if (values.agency !== undefined) presetDef.agency = values.agency;
|
|
185
|
+
if (values.quality !== undefined) presetDef.quality = values.quality;
|
|
186
|
+
if (values.scope !== undefined) presetDef.scope = values.scope;
|
|
187
|
+
if (values.modifier !== undefined && values.modifier.length > 0) {
|
|
188
|
+
presetDef.modifiers = values.modifier as string[];
|
|
189
|
+
}
|
|
190
|
+
if (values.readonly !== undefined) presetDef.readonly = values.readonly;
|
|
191
|
+
if (values["context-pacing"] !== undefined) presetDef.contextPacing = values["context-pacing"];
|
|
192
|
+
|
|
193
|
+
const config = readConfig(configPath);
|
|
194
|
+
config.presets = { ...config.presets, [name]: presetDef };
|
|
195
|
+
writeConfig(configPath, config);
|
|
196
|
+
process.stdout.write(
|
|
197
|
+
`Created preset "${name}" in ${configFileName(configPath)}\n`
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function configRemovePreset(configPath: string, name: string): void {
|
|
202
|
+
const config = readConfig(configPath);
|
|
203
|
+
const presets = config.presets ?? {};
|
|
204
|
+
if (!(name in presets)) {
|
|
205
|
+
throw new Error(
|
|
206
|
+
`Preset "${name}" not found in ${configFileName(configPath)}`
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
const updated = { ...presets };
|
|
210
|
+
delete updated[name];
|
|
211
|
+
config.presets = updated;
|
|
212
|
+
writeConfig(configPath, config);
|
|
213
|
+
process.stdout.write(
|
|
214
|
+
`Removed preset "${name}" from ${configFileName(configPath)}\n`
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Handles `claude-mode config <subcommand> [args] [--global]`.
|
|
220
|
+
* Defaults to project-local (.claude-mode.json in CWD).
|
|
221
|
+
* --global targets ~/.config/claude-mode/config.json.
|
|
222
|
+
*/
|
|
223
|
+
export function runConfigCommand(argv: string[]): void {
|
|
224
|
+
// Extract --global flag
|
|
225
|
+
const isGlobal = argv.includes("--global");
|
|
226
|
+
const args = argv.filter((a) => a !== "--global");
|
|
227
|
+
|
|
228
|
+
const configPath = getConfigPath(isGlobal);
|
|
229
|
+
const [subcommand, ...rest] = args;
|
|
230
|
+
|
|
231
|
+
if (!subcommand || subcommand === "help") {
|
|
232
|
+
const usage = `Usage: claude-mode config <subcommand> [args] [--global]
|
|
233
|
+
|
|
234
|
+
Subcommands:
|
|
235
|
+
show Print current config
|
|
236
|
+
init Create .claude-mode.json scaffold
|
|
237
|
+
add-default <name-or-path> Add to defaultModifiers
|
|
238
|
+
remove-default <name> Remove from defaultModifiers
|
|
239
|
+
add-modifier <name> <path> Register a named modifier
|
|
240
|
+
remove-modifier <name> Unregister a named modifier
|
|
241
|
+
add-axis <axis> <name> <path> Register custom axis value
|
|
242
|
+
remove-axis <axis> <name> Unregister custom axis value
|
|
243
|
+
add-preset <name> [flags] Create a custom preset
|
|
244
|
+
remove-preset <name> Remove a custom preset
|
|
245
|
+
|
|
246
|
+
Flags for add-preset:
|
|
247
|
+
--agency <value>
|
|
248
|
+
--quality <value>
|
|
249
|
+
--scope <value>
|
|
250
|
+
--modifier <name> (repeatable)
|
|
251
|
+
--readonly
|
|
252
|
+
--context-pacing`;
|
|
253
|
+
process.stdout.write(usage + "\n");
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
switch (subcommand) {
|
|
258
|
+
case "show":
|
|
259
|
+
configShow(configPath);
|
|
260
|
+
break;
|
|
261
|
+
|
|
262
|
+
case "init":
|
|
263
|
+
configInit(configPath);
|
|
264
|
+
break;
|
|
265
|
+
|
|
266
|
+
case "add-default": {
|
|
267
|
+
if (rest.length < 1) throw new Error("add-default requires <name-or-path>");
|
|
268
|
+
configAddDefault(configPath, rest[0]);
|
|
269
|
+
break;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
case "remove-default": {
|
|
273
|
+
if (rest.length < 1) throw new Error("remove-default requires <name>");
|
|
274
|
+
configRemoveDefault(configPath, rest[0]);
|
|
275
|
+
break;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
case "add-modifier": {
|
|
279
|
+
if (rest.length < 2) throw new Error("add-modifier requires <name> <path>");
|
|
280
|
+
configAddModifier(configPath, rest[0], rest[1]);
|
|
281
|
+
break;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
case "remove-modifier": {
|
|
285
|
+
if (rest.length < 1) throw new Error("remove-modifier requires <name>");
|
|
286
|
+
configRemoveModifier(configPath, rest[0]);
|
|
287
|
+
break;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
case "add-axis": {
|
|
291
|
+
if (rest.length < 3) throw new Error("add-axis requires <axis> <name> <path>");
|
|
292
|
+
configAddAxis(configPath, rest[0], rest[1], rest[2]);
|
|
293
|
+
break;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
case "remove-axis": {
|
|
297
|
+
if (rest.length < 2) throw new Error("remove-axis requires <axis> <name>");
|
|
298
|
+
configRemoveAxis(configPath, rest[0], rest[1]);
|
|
299
|
+
break;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
case "add-preset": {
|
|
303
|
+
if (rest.length < 1) throw new Error("add-preset requires <name>");
|
|
304
|
+
const [presetName, ...presetFlags] = rest;
|
|
305
|
+
configAddPreset(configPath, presetName, presetFlags);
|
|
306
|
+
break;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
case "remove-preset": {
|
|
310
|
+
if (rest.length < 1) throw new Error("remove-preset requires <name>");
|
|
311
|
+
configRemovePreset(configPath, rest[0]);
|
|
312
|
+
break;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
default:
|
|
316
|
+
throw new Error(
|
|
317
|
+
`Unknown config subcommand: "${subcommand}". Run "claude-mode config help" for usage.`
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
2
|
+
import { join, dirname, resolve, isAbsolute } from "node:path";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { PRESET_NAMES, BUILTIN_MODIFIER_NAMES, AXIS_BUILTINS, BUILTIN_BASE_NAMES, isBuiltinModifier, isPresetName, isBuiltinBase, isBuiltinAxisValue } from "./types.js";
|
|
5
|
+
|
|
6
|
+
/** Matches paths that reference potentially sensitive files (SSH keys, credentials, etc.) */
|
|
7
|
+
const SUSPICIOUS_PATH_PATTERN = /\.(ssh|env|gnupg|aws|kube|docker|pgpass)|\.npmrc|\.netrc|id_rsa|id_ed25519|id_ecdsa|credentials|secret|token|password|private/i;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Validates a file path defined in a config file.
|
|
11
|
+
* Config-defined paths must end with .md and must not reference sensitive files.
|
|
12
|
+
* This prevents malicious .claude-mode.json files from exfiltrating secrets.
|
|
13
|
+
*/
|
|
14
|
+
function validateConfigDefinedPath(path: string, context: string, configPath: string): void {
|
|
15
|
+
if (!path.endsWith(".md")) {
|
|
16
|
+
throw new Error(
|
|
17
|
+
`Invalid config file ${configPath}: ${context} must be a .md file, got "${path}"`
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
if (SUSPICIOUS_PATH_PATTERN.test(path)) {
|
|
21
|
+
throw new Error(
|
|
22
|
+
`Invalid config file ${configPath}: ${context} references a potentially sensitive path "${path}"`
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Schema for .claude-mode.json */
|
|
28
|
+
export interface UserConfig {
|
|
29
|
+
defaultBase?: string;
|
|
30
|
+
bases?: Record<string, string>; // name → directory path (relative to config dir)
|
|
31
|
+
defaultModifiers?: string[];
|
|
32
|
+
modifiers?: Record<string, string>;
|
|
33
|
+
axes?: {
|
|
34
|
+
agency?: Record<string, string>;
|
|
35
|
+
quality?: Record<string, string>;
|
|
36
|
+
scope?: Record<string, string>;
|
|
37
|
+
};
|
|
38
|
+
presets?: Record<string, CustomPresetDef>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface CustomPresetDef {
|
|
42
|
+
base?: string; // base name for this preset
|
|
43
|
+
agency?: string;
|
|
44
|
+
quality?: string;
|
|
45
|
+
scope?: string;
|
|
46
|
+
modifiers?: string[];
|
|
47
|
+
readonly?: boolean;
|
|
48
|
+
contextPacing?: boolean;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface LoadedConfig {
|
|
52
|
+
config: UserConfig;
|
|
53
|
+
configDir: string; // directory the config file lives in, for resolving relative paths
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Throws if name collides with a built-in modifier name. */
|
|
57
|
+
export function checkModifierNameCollision(name: string): void {
|
|
58
|
+
if (isBuiltinModifier(name)) {
|
|
59
|
+
throw new Error(
|
|
60
|
+
`"${name}" is a built-in modifier name (${BUILTIN_MODIFIER_NAMES.join(", ")}); choose a different name`
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Throws if name collides with a built-in preset name. */
|
|
66
|
+
export function checkPresetNameCollision(name: string): void {
|
|
67
|
+
if (isPresetName(name)) {
|
|
68
|
+
throw new Error(
|
|
69
|
+
`"${name}" is a built-in preset name (${PRESET_NAMES.join(", ")}); choose a different name`
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Throws if name collides with a built-in base name. */
|
|
75
|
+
export function checkBaseNameCollision(name: string): void {
|
|
76
|
+
if (isBuiltinBase(name)) {
|
|
77
|
+
throw new Error(
|
|
78
|
+
`"${name}" is a built-in base name (${BUILTIN_BASE_NAMES.join(", ")}); choose a different name`
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Throws if name collides with a built-in value for the given axis. */
|
|
84
|
+
export function checkAxisValueCollision(axis: "agency" | "quality" | "scope", name: string): void {
|
|
85
|
+
const builtins = AXIS_BUILTINS[axis];
|
|
86
|
+
if (isBuiltinAxisValue(axis, name)) {
|
|
87
|
+
throw new Error(
|
|
88
|
+
`"${name}" is a built-in ${axis} value (${builtins.join(", ")}); choose a different name`
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Reads and parses a config file, checking only that the top-level value is an object.
|
|
95
|
+
* Does NOT run full schema validation — callers that need full validation should use loadConfig().
|
|
96
|
+
* Throws on invalid JSON or non-object top-level value.
|
|
97
|
+
*/
|
|
98
|
+
export function readConfigFile(configPath: string): UserConfig {
|
|
99
|
+
let raw: unknown;
|
|
100
|
+
try {
|
|
101
|
+
const text = readFileSync(configPath, "utf8");
|
|
102
|
+
raw = JSON.parse(text);
|
|
103
|
+
} catch (err) {
|
|
104
|
+
throw new Error(
|
|
105
|
+
`Invalid config file ${configPath}: ${(err as Error).message}`
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
109
|
+
throw new Error(
|
|
110
|
+
`Invalid config file ${configPath}: top-level value must be an object`
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
return raw as UserConfig;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Loads config from .claude-mode.json in CWD, falling back to
|
|
118
|
+
* ~/.config/claude-mode/config.json. Returns null if neither exists.
|
|
119
|
+
* Throws on invalid JSON or schema violations.
|
|
120
|
+
*/
|
|
121
|
+
export function loadConfig(): LoadedConfig | null {
|
|
122
|
+
const candidates = [
|
|
123
|
+
join(process.cwd(), ".claude-mode.json"),
|
|
124
|
+
join(homedir(), ".config", "claude-mode", "config.json"),
|
|
125
|
+
];
|
|
126
|
+
|
|
127
|
+
for (const configPath of candidates) {
|
|
128
|
+
if (!existsSync(configPath)) continue;
|
|
129
|
+
|
|
130
|
+
const config = validateConfig(readConfigFile(configPath), configPath);
|
|
131
|
+
return { config, configDir: dirname(configPath) };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Validates a value is a Record<string, string>. Returns the typed record or throws. */
|
|
138
|
+
function validateStringRecord(
|
|
139
|
+
value: unknown,
|
|
140
|
+
fieldName: string,
|
|
141
|
+
configPath: string,
|
|
142
|
+
): Record<string, string> {
|
|
143
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
144
|
+
throw new Error(
|
|
145
|
+
`Invalid config file ${configPath}: "${fieldName}" must be an object (Record<string, string>)`
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
const record = value as Record<string, unknown>;
|
|
149
|
+
for (const [key, val] of Object.entries(record)) {
|
|
150
|
+
if (typeof val !== "string") {
|
|
151
|
+
throw new Error(
|
|
152
|
+
`Invalid config file ${configPath}: "${fieldName}.${key}" must be a string`
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return record as Record<string, string>;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Validates a value is a string[]. Returns the typed array or throws. */
|
|
160
|
+
function validateStringArray(
|
|
161
|
+
value: unknown,
|
|
162
|
+
fieldName: string,
|
|
163
|
+
configPath: string,
|
|
164
|
+
): string[] {
|
|
165
|
+
if (!Array.isArray(value) || !value.every((v) => typeof v === "string")) {
|
|
166
|
+
throw new Error(
|
|
167
|
+
`Invalid config file ${configPath}: "${fieldName}" must be an array of strings`
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
return value as string[];
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Validates the loaded config object. Throws descriptive errors for:
|
|
175
|
+
* - Non-object top-level value
|
|
176
|
+
* - Invalid field types
|
|
177
|
+
* - Custom preset names that collide with built-in preset names
|
|
178
|
+
* - Custom modifier names that collide with built-in modifier names
|
|
179
|
+
*/
|
|
180
|
+
function validateConfig(raw: unknown, configPath: string): UserConfig {
|
|
181
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
182
|
+
throw new Error(
|
|
183
|
+
`Invalid config file ${configPath}: top-level value must be an object`
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const obj = raw as Record<string, unknown>;
|
|
188
|
+
|
|
189
|
+
// Validate defaultBase
|
|
190
|
+
if (obj.defaultBase !== undefined && typeof obj.defaultBase !== "string") {
|
|
191
|
+
throw new Error(
|
|
192
|
+
`Invalid config file ${configPath}: "defaultBase" must be a string`
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Validate bases map
|
|
197
|
+
if (obj.bases !== undefined) {
|
|
198
|
+
const bases = validateStringRecord(obj.bases, "bases", configPath);
|
|
199
|
+
for (const key of Object.keys(bases)) checkBaseNameCollision(key);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Validate defaultModifiers
|
|
203
|
+
if (obj.defaultModifiers !== undefined) {
|
|
204
|
+
const defaultModifiers = validateStringArray(obj.defaultModifiers, "defaultModifiers", configPath);
|
|
205
|
+
// Validate file path entries in defaultModifiers
|
|
206
|
+
for (const entry of defaultModifiers) {
|
|
207
|
+
if (entry.includes("/") || entry.includes("\\") || entry.endsWith(".md")) {
|
|
208
|
+
validateConfigDefinedPath(entry, `"defaultModifiers" entry "${entry}"`, configPath);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Validate modifiers map
|
|
214
|
+
if (obj.modifiers !== undefined) {
|
|
215
|
+
const modifiers = validateStringRecord(obj.modifiers, "modifiers", configPath);
|
|
216
|
+
for (const [key, val] of Object.entries(modifiers)) {
|
|
217
|
+
checkModifierNameCollision(key);
|
|
218
|
+
validateConfigDefinedPath(val, `"modifiers.${key}"`, configPath);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Validate axes
|
|
223
|
+
if (obj.axes !== undefined) {
|
|
224
|
+
if (typeof obj.axes !== "object" || obj.axes === null || Array.isArray(obj.axes)) {
|
|
225
|
+
throw new Error(
|
|
226
|
+
`Invalid config file ${configPath}: "axes" must be an object`
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
const axesObj = obj.axes as Record<string, unknown>;
|
|
230
|
+
for (const axisName of ["agency", "quality", "scope"] as const) {
|
|
231
|
+
if (axesObj[axisName] !== undefined) {
|
|
232
|
+
const axisMap = validateStringRecord(axesObj[axisName], `axes.${axisName}`, configPath);
|
|
233
|
+
for (const [key, val] of Object.entries(axisMap)) {
|
|
234
|
+
validateConfigDefinedPath(val, `"axes.${axisName}.${key}"`, configPath);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Validate presets
|
|
241
|
+
if (obj.presets !== undefined) {
|
|
242
|
+
if (typeof obj.presets !== "object" || obj.presets === null || Array.isArray(obj.presets)) {
|
|
243
|
+
throw new Error(
|
|
244
|
+
`Invalid config file ${configPath}: "presets" must be an object`
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
for (const [presetName, presetDef] of Object.entries(obj.presets as Record<string, unknown>)) {
|
|
248
|
+
checkPresetNameCollision(presetName);
|
|
249
|
+
if (typeof presetDef !== "object" || presetDef === null || Array.isArray(presetDef)) {
|
|
250
|
+
throw new Error(
|
|
251
|
+
`Invalid config file ${configPath}: preset "${presetName}" must be an object`
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
const def = presetDef as Record<string, unknown>;
|
|
255
|
+
if (def.base !== undefined && typeof def.base !== "string") {
|
|
256
|
+
throw new Error(
|
|
257
|
+
`Invalid config file ${configPath}: preset "${presetName}.base" must be a string`
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
for (const field of ["agency", "quality", "scope"] as const) {
|
|
261
|
+
if (def[field] !== undefined && typeof def[field] !== "string") {
|
|
262
|
+
throw new Error(
|
|
263
|
+
`Invalid config file ${configPath}: preset "${presetName}.${field}" must be a string`
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
if (def.modifiers !== undefined) {
|
|
268
|
+
if (
|
|
269
|
+
!Array.isArray(def.modifiers) ||
|
|
270
|
+
!def.modifiers.every((v) => typeof v === "string")
|
|
271
|
+
) {
|
|
272
|
+
throw new Error(
|
|
273
|
+
`Invalid config file ${configPath}: preset "${presetName}.modifiers" must be an array of strings`
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
if (def.readonly !== undefined && typeof def.readonly !== "boolean") {
|
|
278
|
+
throw new Error(
|
|
279
|
+
`Invalid config file ${configPath}: preset "${presetName}.readonly" must be a boolean`
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
if (def.contextPacing !== undefined && typeof def.contextPacing !== "boolean") {
|
|
283
|
+
throw new Error(
|
|
284
|
+
`Invalid config file ${configPath}: preset "${presetName}.contextPacing" must be a boolean`
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return raw as UserConfig;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Resolves a path from the config file relative to the config file's directory.
|
|
295
|
+
* Absolute paths are returned as-is.
|
|
296
|
+
*/
|
|
297
|
+
export function resolveConfigPath(configDir: string, relativePath: string): string {
|
|
298
|
+
if (isAbsolute(relativePath)) return relativePath;
|
|
299
|
+
return resolve(configDir, relativePath);
|
|
300
|
+
}
|