log-llm-config 1.5.11 → 1.5.14

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.
@@ -25,18 +25,34 @@ function emptyTotals() {
25
25
  }
26
26
  function addUsage(totals, usage) {
27
27
  for (const key of Object.keys(totals)) {
28
- const value = usage[key];
29
- if (typeof value === 'number' && Number.isFinite(value))
28
+ const value = readTokenField(usage, key);
29
+ if (value > 0)
30
30
  totals[key] += value;
31
31
  }
32
32
  }
33
- /** Sum the token fields of one usage block (used for per-model attribution). */
33
+ /** Read one token field, preferring the nested 5m+1h cache-creation sum over the flat field. */
34
+ function readTokenField(usage, key) {
35
+ if (key === 'cache_creation_input_tokens') {
36
+ const nested = usage.cache_creation;
37
+ if (nested && typeof nested === 'object') {
38
+ const n = nested;
39
+ const has5m = typeof n.ephemeral_5m_input_tokens === 'number' && Number.isFinite(n.ephemeral_5m_input_tokens);
40
+ const has1h = typeof n.ephemeral_1h_input_tokens === 'number' && Number.isFinite(n.ephemeral_1h_input_tokens);
41
+ // Fall back to the flat field only when the nested block is truly absent — a
42
+ // legitimately-zero nested sum (both fields present as 0) must not be overridden by it.
43
+ if (has5m || has1h) {
44
+ return (has5m ? n.ephemeral_5m_input_tokens : 0) + (has1h ? n.ephemeral_1h_input_tokens : 0);
45
+ }
46
+ }
47
+ }
48
+ const value = usage[key];
49
+ return typeof value === 'number' && Number.isFinite(value) ? value : 0;
50
+ }
51
+ /** Sum the token fields of one usage block (used for per-model attribution and dedup tiebreaks). */
34
52
  function usageSum(usage) {
35
53
  let sum = 0;
36
54
  for (const key of ['input_tokens', 'output_tokens', 'cache_creation_input_tokens', 'cache_read_input_tokens']) {
37
- const value = usage[key];
38
- if (typeof value === 'number' && Number.isFinite(value))
39
- sum += value;
55
+ sum += readTokenField(usage, key);
40
56
  }
41
57
  return sum;
42
58
  }
@@ -86,6 +102,14 @@ function collectClaudeTokenUsage(home = homedir()) {
86
102
  let version = '';
87
103
  let versionAt = '';
88
104
  let sawData = false;
105
+ // Claude Code writes one JSONL line per streaming snapshot of the same assistant message,
106
+ // so message.id/requestId repeat with a growing usage total; only the final snapshot is
107
+ // the true total. Dedup by (message.id, requestId), taking the per-field max across all
108
+ // snapshots for that key (order-independent — robust to out-of-order lines, and safe even
109
+ // if a snapshot has a genuinely-lower or reset value in one field while another snapshot in
110
+ // the same group peaks a different field). Entries without a message.id can't be deduped and
111
+ // are accumulated directly.
112
+ const dedupedUsage = new Map();
89
113
  for (const file of files) {
90
114
  let content;
91
115
  try {
@@ -125,16 +149,39 @@ function collectClaudeTokenUsage(home = homedir()) {
125
149
  models.add(model);
126
150
  if (msg.usage && typeof msg.usage === 'object') {
127
151
  const usage = msg.usage;
128
- addUsage(totals, usage);
129
- if (model) {
152
+ const messageId = typeof msg.id === 'string' ? msg.id : '';
153
+ if (messageId) {
154
+ const requestId = typeof entry.requestId === 'string' ? entry.requestId : '';
155
+ const dedupeKey = `${messageId} ${requestId}`;
156
+ const existing = dedupedUsage.get(dedupeKey) ?? { totals: emptyTotals(), model: '' };
157
+ for (const key of Object.keys(existing.totals)) {
158
+ const value = readTokenField(usage, key);
159
+ if (value > existing.totals[key])
160
+ existing.totals[key] = value;
161
+ }
162
+ if (model)
163
+ existing.model = model;
164
+ dedupedUsage.set(dedupeKey, existing);
165
+ }
166
+ else {
167
+ addUsage(totals, usage);
130
168
  const sum = usageSum(usage);
131
- if (sum > 0)
169
+ if (model && sum > 0)
132
170
  modelTokenSplit[model] = (modelTokenSplit[model] ?? 0) + sum;
133
171
  }
134
172
  }
135
173
  }
136
174
  }
137
175
  }
176
+ for (const { totals: merged, model } of dedupedUsage.values()) {
177
+ let sum = 0;
178
+ for (const key of Object.keys(totals)) {
179
+ totals[key] += merged[key];
180
+ sum += merged[key];
181
+ }
182
+ if (model && sum > 0)
183
+ modelTokenSplit[model] = (modelTokenSplit[model] ?? 0) + sum;
184
+ }
138
185
  if (!sawData)
139
186
  return [];
140
187
  return [
@@ -236,6 +236,7 @@ export { collectHermesTokenUsage } from './hermes_token_usage_collector.js';
236
236
  export { collectOpenclawTokenUsage } from './openclaw_token_usage_collector.js';
237
237
  export { collectPiTokenUsage } from './pi_token_usage_collector.js';
238
238
  export { collectCoworkDesktopVersion } from './cowork_desktop_version_collector.js';
239
+ export { collectGrokCliVersion } from './grok_cli_version_collector.js';
239
240
  export { collectClaudeDesktopExtensionManifests, collectClaudeDesktopExtensionSettingsFiles, enrichClaudeDesktopExtensionsInstallationsUpload, mergeClaudeDesktopExtensionEnableSettings, } from './claude_desktop_extensions_collector.js';
240
241
  export { collectCursorProjectWorkspaceMcpConfigs } from './cursor_project_mcp_collector.js';
241
242
  export { determineFileTypeFromPath } from './file_type_rules.js';
@@ -188,6 +188,9 @@ function collectCursorProjectWorkspaceMcpConfigs(pathSkipPrefixes = [], constant
188
188
  cursor_source: 'mcps',
189
189
  server_identifier: serverDir.name,
190
190
  };
191
+ if (metaObj) {
192
+ entry.cursor_server_metadata = metaObj;
193
+ }
191
194
  const cacheTools = cursorMcpCacheToolsForServer(mcpToolCache, label, serverDir.name);
192
195
  if (cacheTools !== undefined) {
193
196
  entry.cursor_mcp_cache_tools = cacheTools;
@@ -0,0 +1,53 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { homedir } from 'node:os';
5
+ /**
6
+ * Grok CLI version, read directly from the installed binary.
7
+ *
8
+ * Grok ships as a standalone native binary (not an npm/pip package, no manifest to read a
9
+ * version out of), installed at ~/.grok/bin/grok and typically symlinked onto PATH. The only
10
+ * way to get its version is to run it: `grok --version` prints e.g. "grok 0.2.114 (0c785038798)".
11
+ */
12
+ const FILE_TYPE = 'grok_cli_version';
13
+ const VERSION_RE = /^grok\s+(\d+\.\d+\.\d+)(?:\s+\(([0-9a-f]+)\))?/i;
14
+ function grokBinPath(home) {
15
+ return join(home, '.grok', 'bin', 'grok');
16
+ }
17
+ /** `<binPath> --version`, parsed into {version, build, raw}, or null if unavailable/unparsable. */
18
+ function readGrokVersion(binPath) {
19
+ let stdout;
20
+ try {
21
+ stdout = execFileSync(binPath, ['--version'], {
22
+ encoding: 'utf8',
23
+ stdio: ['ignore', 'pipe', 'ignore'],
24
+ timeout: 5000,
25
+ }).trim();
26
+ }
27
+ catch {
28
+ return null;
29
+ }
30
+ const match = VERSION_RE.exec(stdout);
31
+ if (!match)
32
+ return null;
33
+ return { version: match[1], build: match[2] ?? '', raw: stdout };
34
+ }
35
+ /**
36
+ * Emit the Grok CLI version as a single-element aggregate, or [] when Grok isn't installed
37
+ * or its version can't be determined.
38
+ */
39
+ function collectGrokCliVersion(home = homedir(), binPath = grokBinPath(home)) {
40
+ if (!existsSync(binPath))
41
+ return [];
42
+ const parsed = readGrokVersion(binPath);
43
+ if (!parsed)
44
+ return [];
45
+ return [
46
+ {
47
+ file_type: FILE_TYPE,
48
+ file_path: binPath,
49
+ raw_content: parsed,
50
+ },
51
+ ];
52
+ }
53
+ export { collectGrokCliVersion, FILE_TYPE as GROK_CLI_VERSION_FILE_TYPE };
@@ -1,4 +1,5 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
+ import { parse as parseToml, stringify as stringifyTomlValue } from 'smol-toml';
2
3
  function stripJsoncComments(input) {
3
4
  let out = '';
4
5
  let inString = false;
@@ -176,4 +177,20 @@ function readInstalledExtensions(extensionsCachePath) {
176
177
  }
177
178
  return extensions;
178
179
  }
179
- export { readMCPConfig, readJSONFile, readMarkdownFile, readInstalledExtensions, parseJsonWithJsoncFallback, };
180
+ /** Parse TOML text into a nested object. Returns null on parse failure. */
181
+ function parseTomlText(raw) {
182
+ try {
183
+ const parsed = parseToml(raw);
184
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
185
+ ? parsed
186
+ : null;
187
+ }
188
+ catch {
189
+ return null;
190
+ }
191
+ }
192
+ /** Serialize a nested object to TOML (used when writing remediated .toml configs). */
193
+ function stringifyToml(value) {
194
+ return stringifyTomlValue(value);
195
+ }
196
+ export { readMCPConfig, readJSONFile, readMarkdownFile, readInstalledExtensions, parseJsonWithJsoncFallback, parseTomlText, stringifyToml, };
@@ -13,7 +13,7 @@
13
13
  import { existsSync, readFileSync } from 'node:fs';
14
14
  import { homedir } from 'node:os';
15
15
  import { join } from 'node:path';
16
- import { parseJsonWithJsoncFallback } from '../readers/file_readers.js';
16
+ import { parseJsonWithJsoncFallback, parseTomlText } from '../readers/file_readers.js';
17
17
  import { mergeComposerShadowKeysFromReactiveBlob, readVscdbItemTableJson, } from '../readers/vscdb_reader.js';
18
18
  import { readRemediationInstructionsFile, writeRemediationInstructionsFile, } from './management_storage.js';
19
19
  import { resolveRemediationConfigPath, resolveRemediationUploadFileType } from './remediation_config_path.js';
@@ -280,7 +280,7 @@ function shouldMergeComposerShadowKeys(itemKeyFromPath, checkSettingPaths) {
280
280
  }
281
281
  /** @deprecated Import from vscdb_reader — re-export for existing tests. */
282
282
  export { mergeComposerShadowKeysFromReactiveBlob } from '../readers/vscdb_reader.js';
283
- /** Plain JSON file or virtual `…/state.vscdb#itemKey` path for ItemTable-backed settings. */
283
+ /** Plain JSON/TOML file or virtual `…/state.vscdb#itemKey` path for ItemTable-backed settings. */
284
284
  function loadRemediationConfigJson(configFilePath, checkSettingPaths = []) {
285
285
  const resolvedPath = resolveRemediationConfigPath(configFilePath);
286
286
  const hashIdx = resolvedPath.indexOf('#');
@@ -306,13 +306,23 @@ function loadRemediationConfigJson(configFilePath, checkSettingPaths = []) {
306
306
  }
307
307
  if (!existsSync(resolvedPath))
308
308
  return { ok: false, reason: 'file_not_found' };
309
+ const rawText = readFileSync(resolvedPath, 'utf8');
310
+ if (resolvedPath.toLowerCase().endsWith('.toml')) {
311
+ const parsedToml = parseTomlText(rawText);
312
+ if (parsedToml === null)
313
+ return { ok: false, reason: 'parse_error' };
314
+ return { ok: true, json: parsedToml };
315
+ }
309
316
  // OpenCode configs are JSONC (comments / trailing commas); parse strict JSON first, then
310
317
  // fall back to JSONC sanitization so comment-bearing files are not skipped as parse_error.
311
- const parsed = parseJsonWithJsoncFallback(readFileSync(resolvedPath, 'utf8'));
318
+ const parsed = parseJsonWithJsoncFallback(rawText);
312
319
  if (parsed === null)
313
320
  return { ok: false, reason: 'parse_error' };
314
321
  return { ok: true, json: parsed };
315
322
  }
323
+ function isSupportedTextConfigFormat(fileFormat) {
324
+ return fileFormat === 'json' || fileFormat === 'toml';
325
+ }
316
326
  /**
317
327
  * Evaluate all checks in a secondary group against the group's config file.
318
328
  * Returns true only if every check passes (ops-based). Non-ops checks are treated as failing.
@@ -354,7 +364,7 @@ function violationFromCheck(entry, compliance, check, expected) {
354
364
  /** Evaluate one manifest row against on-disk config (used by gate + post-restart verify). */
355
365
  export function evaluateManifestEntryCompliance(entry) {
356
366
  const compliance = entry.fix ?? entry.compliance;
357
- if (!compliance || compliance.file_format !== 'json')
367
+ if (!compliance || !isSupportedTextConfigFormat(compliance.file_format))
358
368
  return { violations: [] };
359
369
  const checks = compliance.checks ?? [];
360
370
  if (checks.length === 0)
@@ -463,9 +473,9 @@ export function runLocalRemediationComplianceCheck(agent = 'cursor') {
463
473
  skippedNoCompliance++;
464
474
  continue;
465
475
  }
466
- if (compliance.file_format !== 'json') {
476
+ if (!isSupportedTextConfigFormat(compliance.file_format)) {
467
477
  skippedNonJson++;
468
- hookRunLog(`compliance_check: skipping non-json entry uuid=${entry.uuid}`);
478
+ hookRunLog(`compliance_check: skipping unsupported file_format=${compliance.file_format} uuid=${entry.uuid}`);
469
479
  continue;
470
480
  }
471
481
  if ((compliance.checks ?? []).length === 0) {
@@ -795,7 +805,7 @@ export function pruneSatisfiedOneTimeRemediations(agent = 'cursor') {
795
805
  continue;
796
806
  }
797
807
  const spec = remediationFixSpec(inst);
798
- const checks = spec?.file_format === 'json' ? (spec.checks ?? []) : [];
808
+ const checks = isSupportedTextConfigFormat(spec?.file_format) ? (spec?.checks ?? []) : [];
799
809
  if (checks.length === 0) {
800
810
  remaining.push(raw);
801
811
  continue;
@@ -14,7 +14,7 @@ import { ensureAuthentication } from '../auth/auth_flow.js';
14
14
  import { readJSONFile, readMarkdownFile } from '../readers/file_readers.js';
15
15
  import { isVscdbVirtualPath, tryReadVscdbVirtualFile, summarizeComposerPayloadForDiagnostics, } from '../readers/vscdb_config_builder.js';
16
16
  import { persistVscdbComposerContractFromPatternsResponse } from '../readers/vscdb_reader.js';
17
- import { collectConfigFilesFromPatterns, collectMcpToolFiles, collectConfigFilesFromInstalledPlugins, collectPluginCacheMcpFiles, collectMcpFromClaudeJsonProjects, collectClaudeTokenUsage, collectCopilotTokenUsage, collectOpencodeTokenUsage, collectCodexTokenUsage, collectCursorTokenUsage, collectHermesTokenUsage, collectOpenclawTokenUsage, collectPiTokenUsage, collectCoworkDesktopVersion, collectClaudeDesktopExtensionManifests, collectClaudeDesktopExtensionSettingsFiles, enrichClaudeDesktopExtensionsInstallationsUpload, determineFileTypeFromPath, } from '../collection/config_collector.js';
17
+ import { collectConfigFilesFromPatterns, collectMcpToolFiles, collectConfigFilesFromInstalledPlugins, collectPluginCacheMcpFiles, collectMcpFromClaudeJsonProjects, collectClaudeTokenUsage, collectCopilotTokenUsage, collectOpencodeTokenUsage, collectCodexTokenUsage, collectCursorTokenUsage, collectHermesTokenUsage, collectOpenclawTokenUsage, collectPiTokenUsage, collectCoworkDesktopVersion, collectGrokCliVersion, collectClaudeDesktopExtensionManifests, collectClaudeDesktopExtensionSettingsFiles, enrichClaudeDesktopExtensionsInstallationsUpload, determineFileTypeFromPath, } from '../collection/config_collector.js';
18
18
  import { ensureCursorUserSettingsSnapshotInBatch } from '../collection/ensure_cursor_user_settings_snapshot.js';
19
19
  import { collectSkillsCliInstalled } from '../collection/skills_cli_collector.js';
20
20
  import { collectWorkspaceVscdbs } from '../collection/mcp_tool_collector.js';
@@ -170,6 +170,14 @@ async function collectAllConfigFiles(endpointBase) {
170
170
  configFiles.push(entry);
171
171
  }
172
172
  }
173
+ hookRunLog(`reading Grok CLI version`);
174
+ for (const entry of collectGrokCliVersion(HOME_DIR)) {
175
+ const key = `${entry.file_type}\t${entry.file_path}`;
176
+ if (!existingPaths.has(key)) {
177
+ existingPaths.add(key);
178
+ configFiles.push(entry);
179
+ }
180
+ }
173
181
  hookRunLog(`merging disk-only Claude Desktop extensions into extensions-installations.json`);
174
182
  const diskExtMerge = enrichClaudeDesktopExtensionsInstallationsUpload(configFiles, HOME_DIR);
175
183
  hookRunLog(`claude desktop extensions: merged=${diskExtMerge.mergedCount} had_registry_upload=${diskExtMerge.hadRegistryUpload}`);
@@ -5,7 +5,7 @@
5
5
  */
6
6
  import { existsSync, readFileSync } from 'node:fs';
7
7
  import { join } from 'node:path';
8
- import { atomicWriteJson, getRemediationStateDir } from './management_storage.js';
8
+ import { atomicWriteJson, getDeferredVscdbApplyPath, getRemediationStateDir, } from './management_storage.js';
9
9
  import { hookRunLog } from './hook_logger.js';
10
10
  export const REMEDIATION_APPLY_TRACKING_BASENAME = 'remediation_apply_tracking.json';
11
11
  /** Consecutive post-restart verification failures before we stop autofix for a UUID. */
@@ -116,6 +116,9 @@ export function recordRemediationVerificationFailure(uuid, reason) {
116
116
  * supported for unit tests only — do not use a global violation list from compliance_check
117
117
  * in production: shadow-key merge bugs once made that list empty while the setting was still
118
118
  * wrong, which incorrectly marked remediations verified.
119
+ *
120
+ * If the deferred vscdb queue file is still on disk, Cursor restart / apply_deferred_vscdb has
121
+ * not completed — leave pending and do not quarantine (setting unchanged is expected).
119
122
  */
120
123
  export function processPendingPostRestartVerifications(violationProbe) {
121
124
  const isStillViolating = typeof violationProbe === 'function'
@@ -123,6 +126,7 @@ export function processPendingPostRestartVerifications(violationProbe) {
123
126
  : (uuid) => violationProbe.has(uuid);
124
127
  const file = readRemediationApplyTrackingFile();
125
128
  const outcomes = [];
129
+ const deferredQueuePending = existsSync(getDeferredVscdbApplyPath());
126
130
  for (const [uuid, entry] of Object.entries(file.entries)) {
127
131
  if (!entry.pending_post_restart_verify)
128
132
  continue;
@@ -131,6 +135,10 @@ export function processPendingPostRestartVerifications(violationProbe) {
131
135
  outcomes.push({ uuid, status: 'verified', consecutive_failures: 0 });
132
136
  continue;
133
137
  }
138
+ if (deferredQueuePending) {
139
+ hookRunLog(`remediation_tracking: defer verify uuid=${uuid} — deferred vscdb queue still pending (restart/apply not done yet)`);
140
+ continue;
141
+ }
134
142
  const reason = 'Setting unchanged after apply — the agent may have changed its config format and auto-fix cannot apply this policy.';
135
143
  const { entry: updated, newlyQuarantined } = recordRemediationVerificationFailure(uuid, reason);
136
144
  outcomes.push({
@@ -2,7 +2,7 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, rename
2
2
  import { delimiter, dirname, join } from 'node:path';
3
3
  import { homedir } from 'node:os';
4
4
  import { executeBody } from '../../endpoint_client/http_transport.js';
5
- import { parseJsonWithJsoncFallback } from '../readers/file_readers.js';
5
+ import { parseJsonWithJsoncFallback, parseTomlText, stringifyToml } from '../readers/file_readers.js';
6
6
  import { complianceRunnerDiag, hookRunLog, logRemediationApplyFailure } from './hook_logger.js';
7
7
  import { atomicWriteJson, getDeferredVscdbApplyPath, getFileCollectionVscdbContractPath, getRemediationInstructionsPath, readRemediationInstructionsFile, writeRemediationInstructionsFile, } from './management_storage.js';
8
8
  import { readStoredAuthKey } from '../auth/auth_key_store.js';
@@ -1031,10 +1031,15 @@ export async function applyDeferredVscdbFromDisk() {
1031
1031
  return true;
1032
1032
  }
1033
1033
  try {
1034
+ let applied = 0;
1035
+ let skippedMissing = 0;
1034
1036
  for (const it of payload.items) {
1037
+ // Workspace storage can disappear between queue and restart (Cursor GC). Skip those
1038
+ // items instead of aborting the whole batch — other workspaces still need the write.
1035
1039
  if (!existsSync(it.dbPath)) {
1036
- hookRunLog(`deferred_vscdb: database missing ${it.dbPath}`);
1037
- return false;
1040
+ hookRunLog(`deferred_vscdb: database missing — skipping ${it.dbPath}`);
1041
+ skippedMissing++;
1042
+ continue;
1038
1043
  }
1039
1044
  if (!assertSafeSqliteIdentifiersForItemTable(it.table, it.key_column, it.value_column)) {
1040
1045
  return false;
@@ -1047,8 +1052,9 @@ export async function applyDeferredVscdbFromDisk() {
1047
1052
  hookRunLog(`deferred_vscdb: INSERT OR REPLACE changed 0 rows key=${it.target_key} db=${it.dbPath} — keeping queue file`);
1048
1053
  return false;
1049
1054
  }
1055
+ applied++;
1050
1056
  }
1051
- hookRunLog(`deferred_vscdb: applied ${payload.items.length} queued update(s)`);
1057
+ hookRunLog(`deferred_vscdb: applied ${applied} queued update(s) skipped_missing=${skippedMissing}`);
1052
1058
  const authKey = readStoredAuthKey();
1053
1059
  for (const u of postApplyUploads) {
1054
1060
  if (!authKey) {
@@ -1422,7 +1428,7 @@ export function enforceRemediation(instruction) {
1422
1428
  }
1423
1429
  return { ok: true, deferredSqlite: false };
1424
1430
  }
1425
- if (fixSpec?.file_format !== 'json') {
1431
+ if (fixSpec?.file_format !== 'json' && fixSpec?.file_format !== 'toml') {
1426
1432
  return fail(`unsupported file format: ${String(fixSpec?.file_format ?? 'undefined')}`);
1427
1433
  }
1428
1434
  const worktreeRoot = parseWorktreeRootFromPath(inst.config_file_path);
@@ -1445,26 +1451,39 @@ export function enforceRemediation(instruction) {
1445
1451
  else if (!existsSync(dir)) {
1446
1452
  mkdirSync(dir, { recursive: true, mode: 0o700 });
1447
1453
  }
1448
- let configJson = {};
1454
+ const isToml = fixSpec?.file_format === 'toml';
1455
+ let configObj = {};
1449
1456
  if (existsSync(inst.config_file_path)) {
1450
- // OpenCode configs are JSONC; parse strict JSON first, then JSONC fallback so a
1451
- // comment-bearing opencode.json(c) keeps its existing settings instead of being reset to
1452
- // {} on a parse failure. NOTE: the write-back below is JSON.stringify, so comments and
1453
- // trailing commas in the original file are not preserved (same as every other agent's
1454
- // JSON config) — only the key/value settings are retained and patched.
1455
- const parsed = parseJsonWithJsoncFallback(readFileSync(inst.config_file_path, 'utf8'));
1456
- if (parsed !== null) {
1457
- configJson = parsed;
1457
+ const rawText = readFileSync(inst.config_file_path, 'utf8');
1458
+ if (isToml) {
1459
+ const parsed = parseTomlText(rawText);
1460
+ if (parsed !== null) {
1461
+ configObj = parsed;
1462
+ }
1463
+ else {
1464
+ hookRunLog(`remediation_enforce: could not parse existing TOML file, starting fresh uuid=${inst.uuid}`);
1465
+ }
1458
1466
  }
1459
1467
  else {
1460
- hookRunLog(`remediation_enforce: could not parse existing file, starting fresh uuid=${inst.uuid}`);
1468
+ // OpenCode configs are JSONC; parse strict JSON first, then JSONC fallback so a
1469
+ // comment-bearing opencode.json(c) keeps its existing settings instead of being reset to
1470
+ // {} on a parse failure. NOTE: the write-back below is JSON.stringify, so comments and
1471
+ // trailing commas in the original file are not preserved (same as every other agent's
1472
+ // JSON config) — only the key/value settings are retained and patched.
1473
+ const parsed = parseJsonWithJsoncFallback(rawText);
1474
+ if (parsed !== null) {
1475
+ configObj = parsed;
1476
+ }
1477
+ else {
1478
+ hookRunLog(`remediation_enforce: could not parse existing file, starting fresh uuid=${inst.uuid}`);
1479
+ }
1461
1480
  }
1462
1481
  }
1463
1482
  for (const check of checks) {
1464
- applyCheck(configJson, check);
1483
+ applyCheck(configObj, check);
1465
1484
  }
1466
- syncFlatGlobalCursorIgnoreListKey(configJson);
1467
- const content = JSON.stringify(configJson, null, 2);
1485
+ syncFlatGlobalCursorIgnoreListKey(configObj);
1486
+ const content = isToml ? stringifyToml(configObj) : JSON.stringify(configObj, null, 2);
1468
1487
  const tmp = `${inst.config_file_path}.tmp`;
1469
1488
  writeFileSync(tmp, content, 'utf8');
1470
1489
  renameSync(tmp, inst.config_file_path);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "log-llm-config",
3
- "version": "1.5.11",
3
+ "version": "1.5.14",
4
4
  "description": "CLI helpers for logging hardware UUIDs and posting startup payloads to Optimus Security.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -56,8 +56,9 @@
56
56
  "vitest": "^4.1.8"
57
57
  },
58
58
  "dependencies": {
59
- "axios": "^1.15.2",
59
+ "axios": "^1.18.1",
60
60
  "canonicalize": "^2.1.0",
61
- "optimus-tofu": "^0.1.18"
61
+ "optimus-tofu": "^0.1.18",
62
+ "smol-toml": "^1.7.1"
62
63
  }
63
64
  }