@massu/core 1.7.0 → 1.8.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@massu/core",
3
- "version": "1.7.0",
3
+ "version": "1.8.0",
4
4
  "type": "module",
5
5
  "description": "AI Engineering Governance MCP Server - Session memory, knowledge system, feature registry, code intelligence, rule enforcement, tiered tooling (12 free / 72 total), 55+ workflow commands, 11 agents, 20+ patterns",
6
6
  "main": "src/server.ts",
package/src/cli.ts CHANGED
@@ -47,6 +47,12 @@ async function main(): Promise<void> {
47
47
  await runInstallCommands();
48
48
  break;
49
49
  }
50
+ case 'permissions': {
51
+ const { handlePermissionsSubcommand } = await import('./commands/permissions.ts');
52
+ const result = await handlePermissionsSubcommand(args.slice(1));
53
+ process.exit(result.exitCode);
54
+ return;
55
+ }
50
56
  case 'show-template': {
51
57
  const { runShowTemplate } = await import('./commands/show-template.ts');
52
58
  await runShowTemplate(args.slice(1));
@@ -162,12 +168,13 @@ Commands:
162
168
  init Set up Massu AI in your project (one command, full setup)
163
169
  doctor Check installation health
164
170
  install-hooks Install/update Claude Code hooks
165
- install-commands Install/update slash commands
171
+ install-commands Install/update slash commands (use --skip-permissions to opt out of MCP allowlist seeding)
166
172
  show-template Print the resolved variant of a bundled template (e.g. for diffs)
167
173
  watch Run the file-watcher daemon (auto-refresh on stack changes)
168
174
  refresh-log [N] Show the last N watcher auto-refresh events
169
175
  validate-config Validate massu.config.yaml (alias: config validate)
170
176
  config <sub> Config lifecycle: refresh | validate | upgrade | doctor | check-drift
177
+ permissions <sub> MCP permission lifecycle: install | verify | check-drift
171
178
  adapters <sub> Third-party adapter registry: list | refresh | search | add-local | remove-local | install | resign
172
179
 
173
180
  Options:
@@ -23,6 +23,7 @@ import { fileURLToPath } from 'url';
23
23
  import { parse as parseYaml } from 'yaml';
24
24
  import { getConfig, getResolvedPaths } from '../config.ts';
25
25
  import { getCurrentTier, getLicenseInfo, daysUntilExpiry } from '../license.ts';
26
+ import { readSettingsAtPath } from '../lib/settings-local.ts';
26
27
 
27
28
  const __filename = fileURLToPath(import.meta.url);
28
29
  const __dirname = dirname(__filename);
@@ -103,7 +104,7 @@ function checkHooksConfig(projectRoot: string): CheckResult {
103
104
  }
104
105
 
105
106
  try {
106
- const content = JSON.parse(readFileSync(settingsPath, 'utf-8'));
107
+ const content = readSettingsAtPath(settingsPath);
107
108
  if (!content.hooks) {
108
109
  return { name: 'Hooks Config', status: 'fail', detail: 'No hooks configured. Run: npx massu install-hooks' };
109
110
  }
@@ -238,8 +239,8 @@ function checkShellHooksWired(_projectRoot: string): CheckResult {
238
239
  }
239
240
 
240
241
  try {
241
- const content = JSON.parse(readFileSync(settingsPath, 'utf-8'));
242
- const hooks = content.hooks ?? {};
242
+ const content = readSettingsAtPath(settingsPath);
243
+ const hooks = (content.hooks ?? {}) as Record<string, unknown>;
243
244
  const hasSessionStart = Array.isArray(hooks.SessionStart) && hooks.SessionStart.length > 0;
244
245
  const hasPreToolUse = Array.isArray(hooks.PreToolUse) && hooks.PreToolUse.length > 0;
245
246
  if (!hasSessionStart && !hasPreToolUse) {
@@ -34,6 +34,7 @@ import { stringify as yamlStringify, parse as yamlParse } from 'yaml';
34
34
  import { backfillMemoryFiles } from '../memory-file-ingest.ts';
35
35
  import { getConfig, resetConfig } from '../config.ts';
36
36
  import { installAll } from './install-commands.ts';
37
+ import { readSettingsLocal, writeSettingsLocalAtomic } from '../lib/settings-local.ts';
37
38
  import {
38
39
  runDetection,
39
40
  type DetectionResult,
@@ -1107,20 +1108,12 @@ export function installHooks(projectRoot: string): { installed: boolean; count:
1107
1108
  claudeDirName = '.claude';
1108
1109
  }
1109
1110
  const claudeDir = resolve(projectRoot, claudeDirName);
1110
- const settingsPath = resolve(claudeDir, 'settings.local.json');
1111
1111
 
1112
1112
  if (!existsSync(claudeDir)) {
1113
1113
  mkdirSync(claudeDir, { recursive: true });
1114
1114
  }
1115
1115
 
1116
- let settings: Record<string, unknown> = {};
1117
- if (existsSync(settingsPath)) {
1118
- try {
1119
- settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
1120
- } catch {
1121
- settings = {};
1122
- }
1123
- }
1116
+ const settings = readSettingsLocal(claudeDir);
1124
1117
 
1125
1118
  const hooksDir = resolveHooksDir();
1126
1119
  const hooksConfig = buildHooksConfig(hooksDir);
@@ -1134,7 +1127,7 @@ export function installHooks(projectRoot: string): { installed: boolean; count:
1134
1127
 
1135
1128
  settings.hooks = hooksConfig;
1136
1129
 
1137
- writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
1130
+ writeSettingsLocalAtomic(claudeDir, settings);
1138
1131
 
1139
1132
  return { installed: true, count: hookCount };
1140
1133
  }
@@ -20,17 +20,11 @@
20
20
  */
21
21
 
22
22
  import {
23
- closeSync,
24
23
  existsSync,
25
- fsyncSync,
26
- openSync,
27
24
  readFileSync,
28
- rmSync,
29
- writeSync,
30
25
  mkdirSync,
31
26
  readdirSync,
32
27
  statSync,
33
- renameSync,
34
28
  } from 'fs';
35
29
  import { resolve, dirname, relative, join } from 'path';
36
30
  import { fileURLToPath } from 'url';
@@ -38,6 +32,8 @@ import { createHash } from 'crypto';
38
32
  import { getConfig } from '../config.ts';
39
33
  import type { Config } from '../config.ts';
40
34
  import { renderTemplate, MissingVariableError, TemplateParseError } from './template-engine.ts';
35
+ import { atomicWriteFile } from '../lib/settings-local.ts';
36
+ import { installPermissions } from '../permissions.ts';
41
37
 
42
38
  const __filename = fileURLToPath(import.meta.url);
43
39
  const __dirname = dirname(__filename);
@@ -131,47 +127,7 @@ export function loadManifest(claudeDir: string): Manifest {
131
127
  }
132
128
  }
133
129
 
134
- /**
135
- * Iter-7 fix: atomic file write — tmp + fsync + rename.
136
- *
137
- * Plan 3a §3 Risk #4 ("Watcher writes to .claude/ while editor has it open:
138
- * editors can lose changes. Mitigation: write to .massu-tmp then atomic rename")
139
- * AND the watcher spec doc §3 Shutdown Semantics claim ("every file op the
140
- * refresh issues is already atomic-rename-safe... installAll writes <path>.tmp
141
- * then renameSync") both demand this. Previously installAll's per-file writes
142
- * (lines 463/467) and saveManifest were direct `writeFileSync` calls — a
143
- * SIGINT/power-loss between truncate and complete-write left a partial file.
144
- * Now both go through this helper so the watcher's iter-6 "we don't await
145
- * fireRefresh because atomic-rename covers everything" decision is sound.
146
- *
147
- * Writes via openSync + writeSync + fsyncSync + closeSync + renameSync so the
148
- * data hits the platter before the rename. On any error, removes the tmp file.
149
- * Tmp filename includes process.pid to avoid clashes with concurrent installs
150
- * from sibling processes (e.g. a manual `npx massu config refresh` racing the
151
- * watcher daemon — the install-lock should prevent this, but the file-level
152
- * tmp name disambiguates if it ever happens).
153
- */
154
- function atomicWriteFile(targetPath: string, content: string, mode = 0o644): void {
155
- const tmpPath = `${targetPath}.${process.pid}.tmp`;
156
- try {
157
- const fd = openSync(tmpPath, 'w', mode);
158
- try {
159
- const buf = Buffer.from(content, 'utf-8');
160
- writeSync(fd, buf, 0, buf.length, 0);
161
- fsyncSync(fd);
162
- } finally {
163
- closeSync(fd);
164
- }
165
- renameSync(tmpPath, targetPath);
166
- } catch (err) {
167
- if (existsSync(tmpPath)) {
168
- try { rmSync(tmpPath, { force: true }); } catch { /* ignore */ }
169
- }
170
- throw err;
171
- }
172
- }
173
-
174
- /** Write the manifest atomically: tempfile + fsync + renameSync. */
130
+ /** Write the manifest atomically: tempfile + fsync + renameSync (uses shared lib/settings-local.ts:atomicWriteFile). */
175
131
  export function saveManifest(claudeDir: string, manifest: Manifest): void {
176
132
  const dir = resolve(claudeDir, '.massu');
177
133
  if (!existsSync(dir)) {
@@ -541,7 +497,15 @@ export function buildTemplateVars(): Record<string, unknown> {
541
497
  };
542
498
  }
543
499
 
544
- export function installCommands(projectRoot: string): InstallCommandsResult {
500
+ export interface InstallCommandsOptions {
501
+ /** When true, skip seeding `mcp__massu__*` into permissions.allow. */
502
+ skipPermissions?: boolean;
503
+ }
504
+
505
+ export function installCommands(
506
+ projectRoot: string,
507
+ opts: InstallCommandsOptions = {},
508
+ ): InstallCommandsResult {
545
509
  const claudeDirName = getConfig().conventions?.claudeDirName ?? '.claude';
546
510
  const claudeDir = resolve(projectRoot, claudeDirName);
547
511
  const targetDir = resolve(claudeDir, 'commands');
@@ -559,9 +523,21 @@ export function installCommands(projectRoot: string): InstallCommandsResult {
559
523
 
560
524
  const framework = getConfig().framework;
561
525
  const templateVars = buildTemplateVars();
562
- const stats = runWithManifest(claudeDir, (manifest) =>
563
- syncDirectory(sourceDir, targetDir, framework, manifest, 'commands', true, templateVars),
564
- );
526
+ const stats = runWithManifest(claudeDir, (manifest) => {
527
+ const syncStats = syncDirectory(
528
+ sourceDir,
529
+ targetDir,
530
+ framework,
531
+ manifest,
532
+ 'commands',
533
+ true,
534
+ templateVars,
535
+ );
536
+ if (!opts.skipPermissions) {
537
+ installPermissions(claudeDir, manifest, { silent: true });
538
+ }
539
+ return syncStats;
540
+ });
565
541
  return { ...stats, commandsDir: targetDir };
566
542
  }
567
543
 
@@ -576,9 +552,19 @@ export interface InstallAllResult {
576
552
  totalSkipped: number;
577
553
  totalKept: number;
578
554
  claudeDir: string;
555
+ /** Permission-seeding outcome (undefined when --skip-permissions). */
556
+ permissions?: { installed: number; kept: number; skipped: number };
579
557
  }
580
558
 
581
- export function installAll(projectRoot: string): InstallAllResult {
559
+ export interface InstallAllOptions {
560
+ /** When true, skip seeding `mcp__massu__*` into permissions.allow. */
561
+ skipPermissions?: boolean;
562
+ }
563
+
564
+ export function installAll(
565
+ projectRoot: string,
566
+ opts: InstallAllOptions = {},
567
+ ): InstallAllResult {
582
568
  const claudeDirName = getConfig().conventions?.claudeDirName ?? '.claude';
583
569
  const claudeDir = resolve(projectRoot, claudeDirName);
584
570
 
@@ -587,6 +573,7 @@ export function installAll(projectRoot: string): InstallAllResult {
587
573
  let totalUpdated = 0;
588
574
  let totalSkipped = 0;
589
575
  let totalKept = 0;
576
+ let permissionsResult: { installed: number; kept: number; skipped: number } | undefined;
590
577
 
591
578
  const framework = getConfig().framework;
592
579
  const templateVars = buildTemplateVars();
@@ -613,6 +600,9 @@ export function installAll(projectRoot: string): InstallAllResult {
613
600
  totalSkipped += stats.skipped;
614
601
  totalKept += stats.kept;
615
602
  }
603
+ if (!opts.skipPermissions) {
604
+ permissionsResult = installPermissions(claudeDir, manifest, { silent: true });
605
+ }
616
606
  });
617
607
 
618
608
  return {
@@ -622,6 +612,7 @@ export function installAll(projectRoot: string): InstallAllResult {
622
612
  totalSkipped,
623
613
  totalKept,
624
614
  claudeDir,
615
+ permissions: permissionsResult,
625
616
  };
626
617
  }
627
618
 
@@ -631,13 +622,14 @@ export function installAll(projectRoot: string): InstallAllResult {
631
622
 
632
623
  export async function runInstallCommands(): Promise<void> {
633
624
  const projectRoot = process.cwd();
625
+ const skipPermissions = process.argv.slice(2).includes('--skip-permissions');
634
626
 
635
627
  console.log('');
636
628
  console.log('Massu AI - Install Project Assets');
637
629
  console.log('==================================');
638
630
  console.log('');
639
631
 
640
- const result = installAll(projectRoot);
632
+ const result = installAll(projectRoot, { skipPermissions });
641
633
 
642
634
  // Report per-asset-type
643
635
  for (const assetType of ASSET_TYPES) {
@@ -667,6 +659,23 @@ export async function runInstallCommands(): Promise<void> {
667
659
  ` ${result.totalKept} file(s) had local edits and were preserved (see stderr above).`,
668
660
  );
669
661
  }
662
+
663
+ // Permission seeding outcome line
664
+ if (skipPermissions) {
665
+ console.log(' Permission seeding skipped (--skip-permissions).');
666
+ } else if (result.permissions) {
667
+ if (result.permissions.installed > 0) {
668
+ console.log(
669
+ ` Wrote merged permissions block to .claude/settings.local.json (use --skip-permissions to opt out).`,
670
+ );
671
+ } else if (result.permissions.kept > 0) {
672
+ console.log(
673
+ ` MCP allowlist entry was edited by operator; preserved. Use \`npx massu permissions check-drift\` to inspect.`,
674
+ );
675
+ }
676
+ // skipped:1 → silent (already in sync, no operator-visible change)
677
+ }
678
+
670
679
  console.log('');
671
680
  console.log(' Restart your Claude Code session to use them.');
672
681
  console.log('');
@@ -0,0 +1,150 @@
1
+ // Copyright (c) 2026 Massu. All rights reserved.
2
+ // Licensed under BSL 1.1 - see LICENSE file for details.
3
+
4
+ /**
5
+ * `massu permissions <subcommand>` — MCP permission lifecycle CLI.
6
+ *
7
+ * Subcommands (each is documented at https://massu.ai/docs/reference/cli-reference):
8
+ * install Seed `mcp__massu__*` into permissions.allow + propagate global
9
+ * defaultMode (idempotent, kept-because-edited preservation).
10
+ * verify Read-only check; exit 0 if all canonical entries present, else 1.
11
+ * check-drift Extended diagnostic (4 drift kinds, severity-mapped exit codes).
12
+ *
13
+ * Exit code matrix for `check-drift` (highest severity wins when multiple kinds present):
14
+ * 0 = clean
15
+ * 1 = missing-allow
16
+ * 2 = invalid-default-mode
17
+ * 3 = unknown-key
18
+ * 4 = strips-global-defaultmode
19
+ *
20
+ * Mirrors the existing `handleConfigSubcommand` dispatch pattern at cli.ts.
21
+ */
22
+
23
+ import { resolve } from 'path';
24
+ import { getConfig } from '../config.ts';
25
+ import {
26
+ installPermissions,
27
+ verifyPermissions,
28
+ checkPermissionsDrift,
29
+ type DriftKind,
30
+ } from '../permissions.ts';
31
+ import { runWithManifest } from './install-commands.ts';
32
+
33
+ function resolveClaudeDir(): string {
34
+ let claudeDirName = '.claude';
35
+ try {
36
+ claudeDirName = getConfig().conventions?.claudeDirName ?? '.claude';
37
+ } catch {
38
+ claudeDirName = '.claude';
39
+ }
40
+ return resolve(process.cwd(), claudeDirName);
41
+ }
42
+
43
+ const DRIFT_KIND_EXIT_CODE: Record<DriftKind, number> = {
44
+ 'missing-allow': 1,
45
+ 'invalid-default-mode': 2,
46
+ 'unknown-key': 3,
47
+ 'strips-global-defaultmode': 4,
48
+ };
49
+
50
+ export async function handlePermissionsSubcommand(
51
+ args: string[],
52
+ ): Promise<{ exitCode: number }> {
53
+ const sub = args[0];
54
+
55
+ switch (sub) {
56
+ case 'install': {
57
+ const claudeDir = resolveClaudeDir();
58
+ const result = runWithManifest(claudeDir, (manifest) =>
59
+ installPermissions(claudeDir, manifest, { silent: false }),
60
+ );
61
+ // Final user-facing summary line on stdout
62
+ if (result.installed > 0) {
63
+ process.stdout.write(
64
+ 'Wrote merged permissions block to .claude/settings.local.json.\n',
65
+ );
66
+ } else if (result.skipped > 0) {
67
+ process.stdout.write('Permissions already in sync — no changes.\n');
68
+ } else if (result.kept > 0) {
69
+ process.stdout.write(
70
+ 'Operator-edited permissions block preserved. Run `npx massu permissions check-drift` to inspect.\n',
71
+ );
72
+ }
73
+ return { exitCode: 0 };
74
+ }
75
+
76
+ case 'verify': {
77
+ const claudeDir = resolveClaudeDir();
78
+ const { missing } = verifyPermissions(claudeDir);
79
+ if (missing.length === 0) {
80
+ process.stdout.write('All MCP allowlist entries present.\n');
81
+ return { exitCode: 0 };
82
+ }
83
+ for (const entry of missing) {
84
+ process.stderr.write(`missing: ${entry}\n`);
85
+ }
86
+ return { exitCode: 1 };
87
+ }
88
+
89
+ case 'check-drift': {
90
+ const claudeDir = resolveClaudeDir();
91
+ const { driftItems } = checkPermissionsDrift(claudeDir);
92
+ if (driftItems.length === 0) {
93
+ process.stdout.write('No permission drift detected.\n');
94
+ return { exitCode: 0 };
95
+ }
96
+ // Highest-severity kind wins for exit code
97
+ let highest = 0;
98
+ for (const item of driftItems) {
99
+ const code = DRIFT_KIND_EXIT_CODE[item.kind];
100
+ if (code > highest) highest = code;
101
+ process.stderr.write(
102
+ `drift[${item.kind}]: ${item.detail} — remediation: ${item.remediation}\n`,
103
+ );
104
+ }
105
+ return { exitCode: highest };
106
+ }
107
+
108
+ case '--help':
109
+ case '-h':
110
+ case undefined: {
111
+ printPermissionsHelp();
112
+ return { exitCode: 0 };
113
+ }
114
+
115
+ default: {
116
+ process.stderr.write(`massu: unknown permissions subcommand: ${sub}\n`);
117
+ printPermissionsHelp();
118
+ return { exitCode: 1 };
119
+ }
120
+ }
121
+ }
122
+
123
+ export function printPermissionsHelp(): void {
124
+ process.stdout.write(`
125
+ massu permissions <subcommand>
126
+
127
+ Subcommands:
128
+ install Seed mcp__massu__* into .claude/settings.local.json's permissions.allow.
129
+ Also propagates global defaultMode (from ~/.claude/settings.json) into
130
+ the project-local file to prevent the merge-replacement trap (see
131
+ https://massu.ai/docs/reference/cli-reference#permissions-trap).
132
+ Idempotent. Preserves operator-edited values.
133
+
134
+ verify Read-only check that all canonical MCP allowlist entries are present.
135
+ Exit 0 if clean, exit 1 with one diagnostic line per missing entry.
136
+
137
+ check-drift Extended diagnostic surfacing 4 drift kinds:
138
+ - missing-allow (exit 1) — canonical entries missing
139
+ - invalid-default-mode (exit 2) — defaultMode requires launch flag
140
+ - unknown-key (exit 3) — undocumented top-level setting
141
+ - strips-global-defaultmode (exit 4) — project-local would strip global value
142
+
143
+ Examples:
144
+ npx massu permissions install
145
+ npx massu permissions verify
146
+ npx massu permissions check-drift
147
+
148
+ Documentation: https://massu.ai/docs/reference/cli-reference#massu-permissions
149
+ `);
150
+ }
@@ -0,0 +1,110 @@
1
+ // Copyright (c) 2026 Massu. All rights reserved.
2
+ // Licensed under BSL 1.1 - see LICENSE file for details.
3
+
4
+ /**
5
+ * Shared IO helper for `.claude/settings.local.json` and the user-global
6
+ * `~/.claude/settings.json`. SSOT for read+atomic-write semantics; closes
7
+ * the pre-existing non-atomic-write bug at init.ts installHooks (previously
8
+ * used writeFileSync which was vulnerable to SIGINT-between-truncate-and-write).
9
+ *
10
+ * Three exports:
11
+ * - readSettingsLocal(claudeDir): safe-parse of <claudeDir>/settings.local.json
12
+ * - writeSettingsLocalAtomic(claudeDir, settings): atomic write of same
13
+ * - readSettingsAtPath(absolutePath): generic safe-parse used by readSettingsLocal
14
+ * AND by permissions.ts:readGlobalSettings() reading ~/.claude/settings.json
15
+ *
16
+ * Plus the atomicWriteFile primitive (moved here from install-commands.ts to
17
+ * centralize). All consumers — install-commands manifest save, install-commands
18
+ * per-file syncs, init.ts installHooks, doctor.ts hooks-config check — share
19
+ * this single source of truth for filesystem IO.
20
+ */
21
+
22
+ import {
23
+ closeSync,
24
+ existsSync,
25
+ fsyncSync,
26
+ mkdirSync,
27
+ openSync,
28
+ readFileSync,
29
+ renameSync,
30
+ rmSync,
31
+ writeSync,
32
+ } from 'fs';
33
+ import { dirname, resolve } from 'path';
34
+
35
+ /**
36
+ * Atomic file write — tmp + fsync + rename. Moved from install-commands.ts so
37
+ * it can be reused by settings-local IO without circular import.
38
+ *
39
+ * Writes via openSync + writeSync + fsyncSync + closeSync + renameSync so the
40
+ * data hits the platter before the rename. On any error, removes the tmp file.
41
+ * Tmp filename includes process.pid to avoid clashes with concurrent installs.
42
+ */
43
+ export function atomicWriteFile(targetPath: string, content: string, mode = 0o644): void {
44
+ const tmpPath = `${targetPath}.${process.pid}.tmp`;
45
+ try {
46
+ const fd = openSync(tmpPath, 'w', mode);
47
+ try {
48
+ const buf = Buffer.from(content, 'utf-8');
49
+ writeSync(fd, buf, 0, buf.length, 0);
50
+ fsyncSync(fd);
51
+ } finally {
52
+ closeSync(fd);
53
+ }
54
+ renameSync(tmpPath, targetPath);
55
+ } catch (err) {
56
+ if (existsSync(tmpPath)) {
57
+ try { rmSync(tmpPath, { force: true }); } catch { /* ignore */ }
58
+ }
59
+ throw err;
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Generic safe-parse of a JSON settings file. Used by readSettingsLocal
65
+ * (project-local) AND permissions.ts:readGlobalSettings (user-global).
66
+ *
67
+ * Returns `{}` on any failure path: missing file, unreadable, malformed JSON,
68
+ * non-object root. This contract lets callers do `(settings.permissions as any)`
69
+ * shape-checks defensively without a separate "does the file exist" pre-check.
70
+ */
71
+ export function readSettingsAtPath(absolutePath: string): Record<string, unknown> {
72
+ if (!existsSync(absolutePath)) {
73
+ return {};
74
+ }
75
+ try {
76
+ const raw = readFileSync(absolutePath, 'utf-8');
77
+ const parsed = JSON.parse(raw);
78
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
79
+ return {};
80
+ }
81
+ return parsed as Record<string, unknown>;
82
+ } catch {
83
+ return {};
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Read `<claudeDir>/settings.local.json`. Returns `{}` on missing/corrupt.
89
+ */
90
+ export function readSettingsLocal(claudeDir: string): Record<string, unknown> {
91
+ return readSettingsAtPath(resolve(claudeDir, 'settings.local.json'));
92
+ }
93
+
94
+ /**
95
+ * Atomically write `<claudeDir>/settings.local.json`. Creates the parent
96
+ * directory if missing. JSON.stringify with 2-space indent + trailing newline
97
+ * (matches the existing convention in init.ts installHooks).
98
+ */
99
+ export function writeSettingsLocalAtomic(
100
+ claudeDir: string,
101
+ settings: Record<string, unknown>,
102
+ ): void {
103
+ const targetPath = resolve(claudeDir, 'settings.local.json');
104
+ const dir = dirname(targetPath);
105
+ if (!existsSync(dir)) {
106
+ mkdirSync(dir, { recursive: true });
107
+ }
108
+ const content = JSON.stringify(settings, null, 2) + '\n';
109
+ atomicWriteFile(targetPath, content);
110
+ }