log-llm-config-staging 1.3.97 → 1.3.99

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.
@@ -19,6 +19,109 @@ function extensionsInstallationsPath(home) {
19
19
  const base = claudeAppSupportDir(home);
20
20
  return base ? join(base, 'extensions-installations.json') : '';
21
21
  }
22
+ function claudeExtensionSettingsDir(home) {
23
+ const base = claudeAppSupportDir(home);
24
+ return base ? join(base, 'Claude Extensions Settings') : '';
25
+ }
26
+ /** Per-connector toggle: Claude Extensions Settings/<extension-id>.json → { isEnabled: boolean }. */
27
+ function readClaudeDesktopExtensionEnableSettings(home) {
28
+ const settingsDir = claudeExtensionSettingsDir(home);
29
+ if (!settingsDir || !existsSync(settingsDir))
30
+ return new Map();
31
+ const out = new Map();
32
+ let entries;
33
+ try {
34
+ entries = readdirSync(settingsDir, { withFileTypes: true }).filter((d) => d.isFile());
35
+ }
36
+ catch {
37
+ return out;
38
+ }
39
+ for (const dirent of entries) {
40
+ if (!dirent.name.endsWith('.json'))
41
+ continue;
42
+ const extId = dirent.name.slice(0, -'.json'.length);
43
+ if (!extId)
44
+ continue;
45
+ const raw = readJSONFile(join(settingsDir, dirent.name));
46
+ if (!raw || typeof raw !== 'object')
47
+ continue;
48
+ const isEnabled = raw.isEnabled;
49
+ if (typeof isEnabled === 'boolean') {
50
+ out.set(extId, isEnabled);
51
+ }
52
+ }
53
+ return out;
54
+ }
55
+ function applyExtensionEnableSettingsToRawContent(rawContent, settings) {
56
+ if (settings.size === 0)
57
+ return 0;
58
+ const extensions = rawContent.extensions;
59
+ if (!extensions || typeof extensions !== 'object')
60
+ return 0;
61
+ let applied = 0;
62
+ for (const [extId, enabled] of settings) {
63
+ const ext = extensions[extId];
64
+ if (!ext || typeof ext !== 'object')
65
+ continue;
66
+ ext.isEnabled = enabled;
67
+ applied += 1;
68
+ }
69
+ return applied;
70
+ }
71
+ /**
72
+ * Upload each Claude Extensions Settings/*.json as its own config file (raw on-disk JSON).
73
+ * Do not merge isEnabled into extensions-installations.json — that mutates the payload
74
+ * vs the source file and creates spurious inventory audit diffs.
75
+ */
76
+ function collectClaudeDesktopExtensionSettingsFiles(home = homedir()) {
77
+ const settingsDir = claudeExtensionSettingsDir(home);
78
+ if (!settingsDir || !existsSync(settingsDir))
79
+ return [];
80
+ let entries;
81
+ try {
82
+ entries = readdirSync(settingsDir, { withFileTypes: true }).filter((d) => d.isFile());
83
+ }
84
+ catch {
85
+ return [];
86
+ }
87
+ const results = [];
88
+ for (const dirent of entries) {
89
+ if (!dirent.name.endsWith('.json'))
90
+ continue;
91
+ const filePath = join(settingsDir, dirent.name);
92
+ const raw = readJSONFile(filePath);
93
+ if (!raw || typeof raw !== 'object')
94
+ continue;
95
+ results.push({
96
+ file_type: 'claude_extension_settings',
97
+ file_path: filePath,
98
+ raw_content: raw,
99
+ });
100
+ }
101
+ return results;
102
+ }
103
+ /**
104
+ * @deprecated Do not call during collection — mutates extensions-installations upload payloads.
105
+ * isEnabled is resolved server-side from claude_extension_settings uploads.
106
+ */
107
+ function mergeClaudeDesktopExtensionEnableSettings(configFiles, home = homedir()) {
108
+ const settings = readClaudeDesktopExtensionEnableSettings(home);
109
+ if (settings.size === 0) {
110
+ return { applied: 0, settingsCount: 0 };
111
+ }
112
+ let applied = 0;
113
+ for (const entry of configFiles) {
114
+ if (entry.file_type !== 'claude_extensions_installations')
115
+ continue;
116
+ if (!entry.raw_content || typeof entry.raw_content !== 'object')
117
+ continue;
118
+ const extensions = entry.raw_content.extensions;
119
+ if (!extensions || typeof extensions !== 'object')
120
+ continue;
121
+ applied += applyExtensionEnableSettingsToRawContent(entry.raw_content, settings);
122
+ }
123
+ return { applied, settingsCount: settings.size };
124
+ }
22
125
  function normalizePathForMatch(path) {
23
126
  return path.replace(/\\/g, '/');
24
127
  }
@@ -142,4 +245,4 @@ function collectClaudeDesktopExtensionManifests(home = homedir()) {
142
245
  }
143
246
  return results;
144
247
  }
145
- export { claudeAppSupportDir, claudeDesktopExtensionsDir, collectClaudeDesktopExtensionManifests, collectDiskOnlyClaudeDesktopExtensions, enrichClaudeDesktopExtensionsInstallationsUpload, };
248
+ export { claudeAppSupportDir, claudeDesktopExtensionsDir, claudeExtensionSettingsDir, collectClaudeDesktopExtensionSettingsFiles, collectClaudeDesktopExtensionManifests, collectDiskOnlyClaudeDesktopExtensions, enrichClaudeDesktopExtensionsInstallationsUpload, mergeClaudeDesktopExtensionEnableSettings, readClaudeDesktopExtensionEnableSettings, };
@@ -4,10 +4,51 @@ import { readJSONFile, readMCPConfig, readMarkdownFile, readInstalledExtensions
4
4
  import { getExtensionsCachePath, getExtensionsCacheInstalledSuffix, getVscdbPath } from '../paths/path_constants_helpers.js';
5
5
  import { resolvePatternToTargets, normalizePathSkipPrefixes } from '../paths/pattern_resolver.js';
6
6
  import { enrichRawFromRecipe } from './enrichment_helpers.js';
7
+ import { applyCoworkSessionWhitelist } from './cowork_session_whitelist.js';
7
8
  import { collectDirectoryEntries, collectDirectoryMetadata } from './directory_collector.js';
8
9
  import { collectVscdbEntries } from '../readers/vscdb_config_builder.js';
9
10
  import { pushDerivedFilesFromRecipe } from './openclaw_helpers.js';
10
11
  import { collapseMetadataByFileType } from './metadata_merge.js';
12
+ function withOpencodeEnvPatterns(patterns) {
13
+ const out = [...patterns];
14
+ const seen = new Set(out.map((p) => `${p.type}:${p.file_type}:${p.path}`));
15
+ const add = (pattern) => {
16
+ const key = `${pattern.type}:${pattern.file_type}:${pattern.path}`;
17
+ if (seen.has(key))
18
+ return;
19
+ seen.add(key);
20
+ out.push(pattern);
21
+ };
22
+ const envConfig = (process.env.OPENCODE_CONFIG || '').trim();
23
+ if (envConfig) {
24
+ add({
25
+ path: envConfig,
26
+ type: 'file',
27
+ file_type: 'opencode_mcp',
28
+ content_format: 'json',
29
+ });
30
+ }
31
+ const envDirRaw = (process.env.OPENCODE_CONFIG_DIR || '').trim();
32
+ if (!envDirRaw)
33
+ return out;
34
+ const envDir = envDirRaw.replace(/[\\/]+$/, '');
35
+ add({ path: `${envDir}/opencode.json`, type: 'file', file_type: 'opencode_mcp', content_format: 'json' });
36
+ add({ path: `${envDir}/opencode.jsonc`, type: 'file', file_type: 'opencode_mcp', content_format: 'json' });
37
+ add({ path: `${envDir}/command-hooks.json`, type: 'file', file_type: 'opencode_command_hooks', content_format: 'json' });
38
+ add({ path: `${envDir}/command-hooks.jsonc`, type: 'file', file_type: 'opencode_command_hooks', content_format: 'json' });
39
+ add({ path: `${envDir}/AGENTS.md`, type: 'file', file_type: 'opencode_rule', content_format: 'markdown' });
40
+ add({ path: `${envDir}/agents/`, type: 'dir', file_type: 'opencode_subagent', content_format: 'markdown', dir_glob: '*.md' });
41
+ add({ path: `${envDir}/commands/`, type: 'dir', file_type: 'opencode_command', content_format: 'markdown', dir_glob: '*.md' });
42
+ add({ path: `${envDir}/skills/`, type: 'dir', file_type: 'opencode_skill', content_format: 'markdown', dir_glob: '*.md' });
43
+ add({ path: `${envDir}/mcp-auth.json`, type: 'file', file_type: 'opencode_mcp_auth', content_format: 'json' });
44
+ add({ path: `${envDir}/managed.json`, type: 'file', file_type: 'opencode_managed_preference', content_format: 'json' });
45
+ add({ path: `${envDir}/package.json`, type: 'file', file_type: 'opencode_plugin_manifest', content_format: 'json' });
46
+ add({ path: `${envDir}/plugins/`, type: 'dir', file_type: 'opencode_plugin', content_format: 'text', dir_glob: '*' });
47
+ add({ path: `${envDir}/plugin/`, type: 'dir', file_type: 'opencode_plugin', content_format: 'text', dir_glob: '*' });
48
+ add({ path: `${envDir}/plugins/*/package.json`, type: 'file', file_type: 'opencode_plugin_manifest', content_format: 'json' });
49
+ add({ path: `${envDir}/plugin/*/package.json`, type: 'file', file_type: 'opencode_plugin_manifest', content_format: 'json' });
50
+ return out;
51
+ }
11
52
  function buildCollectionContext(patterns, projectRoot, home, homeRecurseSkipDirs = [], absolutePathPrefixes = [], mcpToolGlobSpec = null, clientPathConstants = null, pathResolutionSpecs = null) {
12
53
  const seenPaths = new Set();
13
54
  const targets = [];
@@ -69,6 +110,16 @@ function collectRegularFileEntry(t, enrichByFileType) {
69
110
  const recipe = enrichByFileType[t.file_type];
70
111
  if (recipe)
71
112
  enrichRawFromRecipe(raw, recipe);
113
+ // Cowork session metadata: reduce to the PII whitelist (hash identity, drop
114
+ // server URLs, keep only the activity-signal keys) BEFORE it leaves the
115
+ // device. Drop the entry entirely if nothing whitelisted survives so raw
116
+ // session content can never be emitted (e.g. on a JSON parse failure).
117
+ if (t.file_type === 'cowork_session_metadata') {
118
+ const filtered = applyCoworkSessionWhitelist(raw);
119
+ if (filtered === null)
120
+ return null;
121
+ raw = filtered;
122
+ }
72
123
  return { file_type: t.file_type, file_path: t.logicalFilePath ?? t.path, raw_content: raw };
73
124
  }
74
125
  function collectMetadataEntry(t) {
@@ -99,7 +150,8 @@ function collectConfigFilesFromPatterns(patterns, projectRoot, onProgress, optio
99
150
  const vscdbPath = getVscdbPath(home, pathConstants);
100
151
  const extensionsCachePath = getExtensionsCachePath(home, pathConstants);
101
152
  const extensionsInstalledSuffix = getExtensionsCacheInstalledSuffix(pathConstants);
102
- const { targets, enrichByFileType, metadataOnlyFileTypes } = buildCollectionContext(patterns, projectRoot, home, options?.home_recurse_skip_dirs ?? [], options?.absolute_path_prefixes ?? [], options?.mcp_tool_glob_spec ?? null, pathConstants, options?.path_resolution_specs ?? null);
153
+ const effectivePatterns = withOpencodeEnvPatterns(patterns);
154
+ const { targets, enrichByFileType, metadataOnlyFileTypes } = buildCollectionContext(effectivePatterns, projectRoot, home, options?.home_recurse_skip_dirs ?? [], options?.absolute_path_prefixes ?? [], options?.mcp_tool_glob_spec ?? null, pathConstants, options?.path_resolution_specs ?? null);
103
155
  const configFiles = [];
104
156
  const handledSpecialPaths = new Set();
105
157
  const loggedFileTypes = new Set();
@@ -156,7 +208,7 @@ export { collectConfigFilesFromPatterns };
156
208
  export { collectMcpToolFiles } from './mcp_tool_collector.js';
157
209
  export { collectConfigFilesFromInstalledPlugins, collectPluginCacheMcpFiles } from './plugin_collector.js';
158
210
  export { collectMcpFromClaudeJsonProjects } from './claude_known_projects_collector.js';
159
- export { collectClaudeDesktopExtensionManifests, enrichClaudeDesktopExtensionsInstallationsUpload, } from './claude_desktop_extensions_collector.js';
211
+ export { collectClaudeDesktopExtensionManifests, collectClaudeDesktopExtensionSettingsFiles, enrichClaudeDesktopExtensionsInstallationsUpload, mergeClaudeDesktopExtensionEnableSettings, } from './claude_desktop_extensions_collector.js';
160
212
  export { collectCursorProjectWorkspaceMcpConfigs } from './cursor_project_mcp_collector.js';
161
213
  export { determineFileTypeFromPath } from './file_type_rules.js';
162
214
  export { enrichRawFromRecipe } from './enrichment_helpers.js';
@@ -0,0 +1,89 @@
1
+ /**
2
+ * PII boundary for Cowork session metadata (`local_<sessionId>.json`).
3
+ *
4
+ * Each Cowork session file carries ~47 KB of system prompt plus the user's
5
+ * conversation seed, selected folders, etc. We only need a small activity
6
+ * signal (which MCP servers were connected, recency, archived state). This
7
+ * reduces a parsed session object to a whitelist of top-level keys BEFORE it
8
+ * leaves the device — minimum-egress, matching DeepSecurityAndPrivacyEnhancer.
9
+ *
10
+ * - Only the keys in ALLOWED_KEYS survive; everything else is dropped.
11
+ * - `accountName` / `emailAddress` are sha256-hashed (cross-session joins
12
+ * without storing raw identity).
13
+ * - `remoteMcpServersConfig[]` keeps `{name, uuid, tools}` only; `url` is
14
+ * dropped (remote-MCP endpoint URLs can embed workspace ids / tokens).
15
+ *
16
+ * The sibling `local_<sessionId>/` directory (audit.jsonl, outputs/, uploads/)
17
+ * is never collected — see the collection glob in agents/cowork.py. Server-side
18
+ * ingest re-validates the shape and rejects any denied key as defense in depth.
19
+ *
20
+ * See design_notes/Phase0_5CoworkActivityEvidence.md.
21
+ */
22
+ import crypto from 'node:crypto';
23
+ export const COWORK_SESSION_ALLOWED_KEYS = [
24
+ 'sessionId',
25
+ 'accountName', // hashed
26
+ 'emailAddress', // hashed
27
+ 'model',
28
+ 'createdAt',
29
+ 'lastActivityAt',
30
+ 'isArchived',
31
+ 'pluginsEnabled',
32
+ 'skillsEnabled',
33
+ 'memoryEnabled',
34
+ 'hostLoopMode',
35
+ 'cwd',
36
+ 'enabledMcpTools', // dict "<serverUUID>:<tool>" -> bool
37
+ 'remoteMcpServersConfig', // list of {name, uuid, tools} (url dropped)
38
+ ];
39
+ const HASHED_KEYS = new Set(['accountName', 'emailAddress']);
40
+ const REMOTE_SERVER_KEEP = ['name', 'uuid', 'tools'];
41
+ function sha256Hex(value) {
42
+ return crypto.createHash('sha256').update(String(value)).digest('hex');
43
+ }
44
+ function sanitizeRemoteServers(value) {
45
+ if (!Array.isArray(value))
46
+ return [];
47
+ const out = [];
48
+ for (const entry of value) {
49
+ // Drop any non-object entry: a malformed/tampered remoteMcpServersConfig
50
+ // could carry a raw string (e.g. "https://…?token=…") that would otherwise
51
+ // survive the whitelist. Only keep whitelisted keys of well-formed objects.
52
+ if (entry === null || typeof entry !== 'object' || Array.isArray(entry))
53
+ continue;
54
+ const e = entry;
55
+ const kept = {};
56
+ for (const k of REMOTE_SERVER_KEEP) {
57
+ if (k in e)
58
+ kept[k] = e[k];
59
+ }
60
+ out.push(kept);
61
+ }
62
+ return out;
63
+ }
64
+ /**
65
+ * Reduce a parsed Cowork session object to the whitelisted, hashed shape.
66
+ * Returns `null` when nothing survives (e.g. a non-object or a file that
67
+ * failed to parse to the expected shape) so the caller can drop the entry
68
+ * rather than emit an empty row — and, critically, never emit raw content.
69
+ */
70
+ export function applyCoworkSessionWhitelist(raw) {
71
+ if (raw === null || raw === undefined || typeof raw !== 'object')
72
+ return null;
73
+ const out = {};
74
+ for (const key of COWORK_SESSION_ALLOWED_KEYS) {
75
+ if (!(key in raw))
76
+ continue;
77
+ if (HASHED_KEYS.has(key)) {
78
+ const v = raw[key];
79
+ out[key] = v === null || v === undefined || v === '' ? '' : sha256Hex(v);
80
+ }
81
+ else if (key === 'remoteMcpServersConfig') {
82
+ out[key] = sanitizeRemoteServers(raw[key]);
83
+ }
84
+ else {
85
+ out[key] = raw[key];
86
+ }
87
+ }
88
+ return Object.keys(out).length > 0 ? out : null;
89
+ }
@@ -105,10 +105,45 @@ function isEphemeralCursorProjectDir(name) {
105
105
  function isInternalCursorWorkspaceSlug(slug) {
106
106
  return normalizeSlugPart(slug) === 'empty-window';
107
107
  }
108
+ function readMcpToolCache(projectPath) {
109
+ const cachePath = join(projectPath, 'mcp-cache.json');
110
+ if (!existsSync(cachePath))
111
+ return null;
112
+ const cache = readJSONFile(cachePath);
113
+ if (cache && typeof cache === 'object' && !Array.isArray(cache)) {
114
+ return cache;
115
+ }
116
+ return null;
117
+ }
118
+ /** Tool schemas from mcp-cache.json for a connected mcps/ server (never creates inventory rows). */
119
+ function cursorMcpCacheToolsForServer(cache, label, serverDirName) {
120
+ if (!cache)
121
+ return undefined;
122
+ const aliases = new Set();
123
+ const addAlias = (s) => {
124
+ const t = s.trim();
125
+ if (t)
126
+ aliases.add(t.toLowerCase());
127
+ };
128
+ addAlias(label);
129
+ addAlias(serverDirName);
130
+ if (serverDirName.startsWith('user-'))
131
+ addAlias(serverDirName.slice('user-'.length));
132
+ else
133
+ addAlias(`user-${label}`);
134
+ for (const [key, val] of Object.entries(cache)) {
135
+ if (!key.trim() || !aliases.has(key.trim().toLowerCase()))
136
+ continue;
137
+ if (val && typeof val === 'object' && Array.isArray(val.tools)) {
138
+ return val.tools;
139
+ }
140
+ }
141
+ return undefined;
142
+ }
108
143
  /**
109
144
  * Cursor stores per-workspace MCP state under ~/.cursor/projects/<slug>/ (mcp-cache.json, mcps/*).
110
145
  * This is not the repo file at <repo>/.cursor/mcp.json. Upload one mcp_config per slug with
111
- * normalized mcpServers for inventory plus cursor_workspace metadata for scope display.
146
+ * mcpServers from mcps/ only; mcp-cache.json supplies supplementary tool schemas when names match.
112
147
  */
113
148
  function collectCursorProjectWorkspaceMcpConfigs(pathSkipPrefixes = [], constants, workspaceVscdbSpec, home = homedir()) {
114
149
  const skipPrefixes = normalizePathSkipPrefixes(pathSkipPrefixes);
@@ -136,18 +171,7 @@ function collectCursorProjectWorkspaceMcpConfigs(pathSkipPrefixes = [], constant
136
171
  continue;
137
172
  const mcpServers = {};
138
173
  const cachePath = join(projectPath, 'mcp-cache.json');
139
- if (existsSync(cachePath)) {
140
- const cache = readJSONFile(cachePath);
141
- if (cache && typeof cache === 'object' && !Array.isArray(cache)) {
142
- for (const [name, val] of Object.entries(cache)) {
143
- if (!name.trim())
144
- continue;
145
- if (val && typeof val === 'object' && Array.isArray(val.tools)) {
146
- mcpServers[name] = { cursor_source: 'mcp-cache.json' };
147
- }
148
- }
149
- }
150
- }
174
+ const mcpToolCache = readMcpToolCache(projectPath);
151
175
  const mcpsPath = join(projectPath, 'mcps');
152
176
  if (existsSync(mcpsPath)) {
153
177
  try {
@@ -158,12 +182,17 @@ function collectCursorProjectWorkspaceMcpConfigs(pathSkipPrefixes = [], constant
158
182
  const label = mcpServerLabelFromMcpsDir(serverDir.name, metaObj);
159
183
  if (!label)
160
184
  continue;
161
- if (!(label in mcpServers)) {
162
- mcpServers[label] = {
163
- cursor_source: 'mcps',
164
- server_identifier: serverDir.name,
165
- };
185
+ if (label in mcpServers)
186
+ continue;
187
+ const entry = {
188
+ cursor_source: 'mcps',
189
+ server_identifier: serverDir.name,
190
+ };
191
+ const cacheTools = cursorMcpCacheToolsForServer(mcpToolCache, label, serverDir.name);
192
+ if (cacheTools !== undefined) {
193
+ entry.cursor_mcp_cache_tools = cacheTools;
166
194
  }
195
+ mcpServers[label] = entry;
167
196
  }
168
197
  }
169
198
  catch {
@@ -25,7 +25,9 @@ function expandRecursiveGlobPathPattern(pathPattern, fileType, home, contentForm
25
25
  return [];
26
26
  }
27
27
  const targets = [];
28
- const parts = after.split('/');
28
+ const parts = after.split('/').filter((s) => s.length > 0);
29
+ if (parts.length === 0)
30
+ return [];
29
31
  const dirName = parts[0];
30
32
  const fileName = parts.length > 1 ? parts[parts.length - 1] : null;
31
33
  const skipSet = homeRecurseSkipDirs.length ? new Set(homeRecurseSkipDirs) : new Set();
@@ -41,8 +43,21 @@ function expandRecursiveGlobPathPattern(pathPattern, fileType, home, contentForm
41
43
  continue;
42
44
  if (entry.name === dirName) {
43
45
  const filePath = parts.length === 1 ? full : join(full, ...parts.slice(1));
44
- if (fileName && existsSync(filePath))
46
+ if (parts.length === 1) {
47
+ // e.g. ~/.cursor/plugins/cache/**/skills/ — collect each matched skills/ tree as a directory target
48
+ if (existsSync(filePath)) {
49
+ targets.push({
50
+ path: filePath,
51
+ file_type: fileType,
52
+ isDirectory: true,
53
+ content_format: contentFormat,
54
+ dir_glob: dirGlob,
55
+ });
56
+ }
57
+ }
58
+ else if (fileName && existsSync(filePath)) {
45
59
  targets.push({ path: filePath, file_type: fileType, content_format: contentFormat });
60
+ }
46
61
  }
47
62
  walk(full, depth + 1, false);
48
63
  }
@@ -54,24 +69,18 @@ function expandRecursiveGlobPathPattern(pathPattern, fileType, home, contentForm
54
69
  }
55
70
  function expandGlobPathPattern(pathPattern, fileType, home, projectRoot, contentFormat, dirGlob, absolutePathPrefixes = []) {
56
71
  const norm = pathPattern.replace(/\\/g, '/');
57
- const targets = [];
58
- const asteriskIndex = norm.indexOf('*');
59
- if (asteriskIndex === -1)
60
- return targets;
72
+ if (!norm.includes('*'))
73
+ return [];
74
+ // `**` is the recursive-glob sigil handled by expandRecursiveGlobPathPattern.
75
+ if (norm.includes('**'))
76
+ return [];
61
77
  const isDir = norm.endsWith('/');
62
- const [before, after] = norm.split('*');
63
- const afterNorm = after.replace(/^\/+/, '');
64
- // If `before` doesn't end with '/', the * is mid-segment (e.g. "extensions/saoudrizwan.claude-dev*/" →
65
- // parent="extensions/", namePrefix="saoudrizwan.claude-dev"). Split on last '/' to get the real base dir.
66
- let parentPart = before.replace(/\/+$/, '');
67
- let namePrefix = '';
68
- if (!before.endsWith('/')) {
69
- const lastSlash = parentPart.lastIndexOf('/');
70
- if (lastSlash !== -1) {
71
- namePrefix = parentPart.slice(lastSlash + 1);
72
- parentPart = parentPart.slice(0, lastSlash);
73
- }
74
- }
78
+ // Resolve the literal prefix that ends before the first wildcard segment.
79
+ const firstStarIndex = norm.indexOf('*');
80
+ const lastSlashBeforeStar = norm.lastIndexOf('/', firstStarIndex);
81
+ if (lastSlashBeforeStar === -1)
82
+ return [];
83
+ const parentPart = norm.slice(0, lastSlashBeforeStar);
75
84
  let basePath;
76
85
  if (parentPart.startsWith('~/')) {
77
86
  basePath = join(home, parentPart.slice(2));
@@ -83,20 +92,61 @@ function expandGlobPathPattern(pathPattern, fileType, home, projectRoot, content
83
92
  basePath = join(projectRoot, parentPart.startsWith('/') ? parentPart.slice(1) : parentPart);
84
93
  }
85
94
  if (!existsSync(basePath))
86
- return targets;
87
- try {
88
- for (const entry of readdirSync(basePath, { withFileTypes: true })) {
89
- if (!entry.isDirectory())
90
- continue;
91
- if (namePrefix && !entry.name.startsWith(namePrefix))
92
- continue;
93
- const resolvedPath = join(basePath, entry.name, afterNorm);
94
- if (!existsSync(resolvedPath))
95
- continue;
96
- targets.push({ path: resolvedPath, file_type: fileType, isDirectory: isDir, dir_glob: dirGlob, content_format: contentFormat });
95
+ return [];
96
+ // Walk remaining segments. Each segment may be a bare wildcard '*' (matches
97
+ // any single directory), a prefix/suffix wildcard like 'foo*' or 'foo*bar',
98
+ // or a literal name. Recursion correctly handles multi-wildcard patterns
99
+ // such as ``*/*/cowork_plugins/marketplaces/*/.claude-plugin/marketplace.json``
100
+ // the prior single-split implementation only expanded the first wildcard
101
+ // and silently pushed a partial directory path as the target, which then
102
+ // surfaced as EISDIR when downstream collectors tried to read it as a file.
103
+ const remaining = norm.slice(lastSlashBeforeStar + 1);
104
+ const segments = remaining.split('/').filter((s) => s !== '');
105
+ const targets = [];
106
+ function recurse(currentPath, segIdx) {
107
+ if (segIdx === segments.length) {
108
+ if (!existsSync(currentPath))
109
+ return;
110
+ targets.push({ path: currentPath, file_type: fileType, isDirectory: isDir, dir_glob: dirGlob, content_format: contentFormat });
111
+ return;
112
+ }
113
+ const seg = segments[segIdx];
114
+ if (seg === '*') {
115
+ if (!existsSync(currentPath))
116
+ return;
117
+ try {
118
+ for (const entry of readdirSync(currentPath, { withFileTypes: true })) {
119
+ if (!entry.isDirectory())
120
+ continue;
121
+ recurse(join(currentPath, entry.name), segIdx + 1);
122
+ }
123
+ }
124
+ catch { /* ignore read errors */ }
125
+ }
126
+ else if (seg.includes('*')) {
127
+ const wildcardIdx = seg.indexOf('*');
128
+ const prefix = seg.slice(0, wildcardIdx);
129
+ const suffix = seg.slice(wildcardIdx + 1);
130
+ if (!existsSync(currentPath))
131
+ return;
132
+ try {
133
+ for (const entry of readdirSync(currentPath, { withFileTypes: true })) {
134
+ if (!entry.isDirectory())
135
+ continue;
136
+ if (prefix && !entry.name.startsWith(prefix))
137
+ continue;
138
+ if (suffix && !entry.name.endsWith(suffix))
139
+ continue;
140
+ recurse(join(currentPath, entry.name), segIdx + 1);
141
+ }
142
+ }
143
+ catch { /* ignore read errors */ }
144
+ }
145
+ else {
146
+ recurse(join(currentPath, seg), segIdx + 1);
97
147
  }
98
148
  }
99
- catch { /* ignore read errors */ }
149
+ recurse(basePath, 0);
100
150
  return targets;
101
151
  }
102
152
  /**
@@ -1,10 +1,120 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
+ function stripJsoncComments(input) {
3
+ let out = '';
4
+ let inString = false;
5
+ let quote = '';
6
+ let escaped = false;
7
+ let inLineComment = false;
8
+ let inBlockComment = false;
9
+ for (let i = 0; i < input.length; i += 1) {
10
+ const ch = input[i];
11
+ const next = i + 1 < input.length ? input[i + 1] : '';
12
+ if (inLineComment) {
13
+ if (ch === '\n') {
14
+ inLineComment = false;
15
+ out += ch;
16
+ }
17
+ continue;
18
+ }
19
+ if (inBlockComment) {
20
+ if (ch === '*' && next === '/') {
21
+ inBlockComment = false;
22
+ i += 1;
23
+ }
24
+ continue;
25
+ }
26
+ if (inString) {
27
+ out += ch;
28
+ if (escaped) {
29
+ escaped = false;
30
+ }
31
+ else if (ch === '\\') {
32
+ escaped = true;
33
+ }
34
+ else if (ch === quote) {
35
+ inString = false;
36
+ quote = '';
37
+ }
38
+ continue;
39
+ }
40
+ if (ch === '"' || ch === "'") {
41
+ inString = true;
42
+ quote = ch;
43
+ out += ch;
44
+ continue;
45
+ }
46
+ if (ch === '/' && next === '/') {
47
+ inLineComment = true;
48
+ i += 1;
49
+ continue;
50
+ }
51
+ if (ch === '/' && next === '*') {
52
+ inBlockComment = true;
53
+ i += 1;
54
+ continue;
55
+ }
56
+ out += ch;
57
+ }
58
+ return out;
59
+ }
60
+ function stripTrailingCommas(input) {
61
+ let out = '';
62
+ let inString = false;
63
+ let quote = '';
64
+ let escaped = false;
65
+ for (let i = 0; i < input.length; i += 1) {
66
+ const ch = input[i];
67
+ if (inString) {
68
+ out += ch;
69
+ if (escaped) {
70
+ escaped = false;
71
+ }
72
+ else if (ch === '\\') {
73
+ escaped = true;
74
+ }
75
+ else if (ch === quote) {
76
+ inString = false;
77
+ quote = '';
78
+ }
79
+ continue;
80
+ }
81
+ if (ch === '"' || ch === "'") {
82
+ inString = true;
83
+ quote = ch;
84
+ out += ch;
85
+ continue;
86
+ }
87
+ if (ch === ',') {
88
+ let j = i + 1;
89
+ while (j < input.length && /\s/.test(input[j]))
90
+ j += 1;
91
+ if (j < input.length && (input[j] === '}' || input[j] === ']'))
92
+ continue;
93
+ }
94
+ out += ch;
95
+ }
96
+ return out;
97
+ }
98
+ function parseJsonWithJsoncFallback(raw) {
99
+ try {
100
+ return JSON.parse(raw);
101
+ }
102
+ catch {
103
+ try {
104
+ const sanitized = stripTrailingCommas(stripJsoncComments(raw));
105
+ return JSON.parse(sanitized);
106
+ }
107
+ catch {
108
+ return null;
109
+ }
110
+ }
111
+ }
2
112
  /** Read and parse an MCP config file. Returns null on any error. */
3
113
  function readMCPConfig(filePath) {
4
114
  try {
5
115
  if (!existsSync(filePath))
6
116
  return null;
7
- return JSON.parse(readFileSync(filePath, 'utf-8'));
117
+ return parseJsonWithJsoncFallback(readFileSync(filePath, 'utf-8'));
8
118
  }
9
119
  catch (error) {
10
120
  console.error(`Error reading ${filePath}:`, error instanceof Error ? error.message : String(error));
@@ -16,7 +126,7 @@ function readJSONFile(filePath) {
16
126
  try {
17
127
  if (!existsSync(filePath))
18
128
  return null;
19
- return JSON.parse(readFileSync(filePath, 'utf-8'));
129
+ return parseJsonWithJsoncFallback(readFileSync(filePath, 'utf-8'));
20
130
  }
21
131
  catch (error) {
22
132
  if (error.code === 'EACCES' || error.code === 'EPERM') {
@@ -13,7 +13,7 @@ import { ensureAuthentication } from '../auth/auth_flow.js';
13
13
  import { readJSONFile, readMarkdownFile } from '../readers/file_readers.js';
14
14
  import { isVscdbVirtualPath, tryReadVscdbVirtualFile, summarizeComposerPayloadForDiagnostics, } from '../readers/vscdb_config_builder.js';
15
15
  import { persistVscdbComposerContractFromPatternsResponse } from '../readers/vscdb_reader.js';
16
- import { collectConfigFilesFromPatterns, collectMcpToolFiles, collectConfigFilesFromInstalledPlugins, collectPluginCacheMcpFiles, collectMcpFromClaudeJsonProjects, collectClaudeDesktopExtensionManifests, enrichClaudeDesktopExtensionsInstallationsUpload, determineFileTypeFromPath, } from '../collection/config_collector.js';
16
+ import { collectConfigFilesFromPatterns, collectMcpToolFiles, collectConfigFilesFromInstalledPlugins, collectPluginCacheMcpFiles, collectMcpFromClaudeJsonProjects, collectClaudeDesktopExtensionManifests, collectClaudeDesktopExtensionSettingsFiles, enrichClaudeDesktopExtensionsInstallationsUpload, determineFileTypeFromPath, } from '../collection/config_collector.js';
17
17
  import { collectWorkspaceVscdbs } from '../collection/mcp_tool_collector.js';
18
18
  import { collectCursorProjectWorkspaceMcpConfigs } from '../collection/cursor_project_mcp_collector.js';
19
19
  import { normalizePathSkipPrefixes } from '../paths/pattern_resolver.js';
@@ -98,6 +98,13 @@ async function collectAllConfigFiles(endpointBase) {
98
98
  hookRunLog(`merging disk-only Claude Desktop extensions into extensions-installations.json`);
99
99
  const diskExtMerge = enrichClaudeDesktopExtensionsInstallationsUpload(configFiles, HOME_DIR);
100
100
  hookRunLog(`claude desktop extensions: merged=${diskExtMerge.mergedCount} had_registry_upload=${diskExtMerge.hadRegistryUpload}`);
101
+ for (const entry of collectClaudeDesktopExtensionSettingsFiles(HOME_DIR)) {
102
+ const key = `${entry.file_type}\t${entry.file_path}`;
103
+ if (!existingPaths.has(key)) {
104
+ existingPaths.add(key);
105
+ configFiles.push(entry);
106
+ }
107
+ }
101
108
  if (!diskExtMerge.hadRegistryUpload && diskExtMerge.mergedCount > 0) {
102
109
  hookRunLog(`scanning Claude Desktop extension manifests on disk (no registry upload in batch)`);
103
110
  for (const entry of collectClaudeDesktopExtensionManifests(HOME_DIR)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "log-llm-config-staging",
3
- "version": "1.3.97",
3
+ "version": "1.3.99",
4
4
  "description": "CLI helpers for logging hardware UUIDs and posting startup payloads to Optimus Security.",
5
5
  "type": "module",
6
6
  "bin": {