@optimuslabs/harness-map-staging 1.5.16

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.
Files changed (88) hide show
  1. package/README.md +46 -0
  2. package/dist/apply_deferred_vscdb.js +38 -0
  3. package/dist/bootstrap_constants.js +15 -0
  4. package/dist/cli/bash_script_generator.js +95 -0
  5. package/dist/cli.js +144 -0
  6. package/dist/cli_invocation_match.js +30 -0
  7. package/dist/compliance_check_runner.js +57 -0
  8. package/dist/compliance_prompt_gate.js +396 -0
  9. package/dist/dialog_prefs_cli.js +109 -0
  10. package/dist/endpoint_client/compress_payload.js +25 -0
  11. package/dist/endpoint_client/http_transport.js +90 -0
  12. package/dist/endpoint_client/index.js +3 -0
  13. package/dist/endpoint_client/registry_api.js +41 -0
  14. package/dist/endpoint_client/startup_api.js +44 -0
  15. package/dist/endpoint_client/types.js +4 -0
  16. package/dist/execute_trusted_restarts.js +60 -0
  17. package/dist/log_config_files/auth/auth_flow.js +22 -0
  18. package/dist/log_config_files/auth/auth_key_store.js +14 -0
  19. package/dist/log_config_files/collection/claude_desktop_extensions_collector.js +248 -0
  20. package/dist/log_config_files/collection/claude_known_projects_collector.js +42 -0
  21. package/dist/log_config_files/collection/claude_token_usage_collector.js +203 -0
  22. package/dist/log_config_files/collection/codex_token_usage_collector.js +342 -0
  23. package/dist/log_config_files/collection/config_collector.js +245 -0
  24. package/dist/log_config_files/collection/copilot_token_usage_collector.js +167 -0
  25. package/dist/log_config_files/collection/cowork_desktop_version_collector.js +60 -0
  26. package/dist/log_config_files/collection/cowork_session_whitelist.js +89 -0
  27. package/dist/log_config_files/collection/cursor_project_mcp_collector.js +230 -0
  28. package/dist/log_config_files/collection/cursor_project_workspace_activity.js +73 -0
  29. package/dist/log_config_files/collection/cursor_token_usage_collector.js +192 -0
  30. package/dist/log_config_files/collection/directory_collector.js +126 -0
  31. package/dist/log_config_files/collection/enrichment_helpers.js +53 -0
  32. package/dist/log_config_files/collection/ensure_cursor_user_settings_snapshot.js +50 -0
  33. package/dist/log_config_files/collection/file_type_rules.js +47 -0
  34. package/dist/log_config_files/collection/grok_cli_version_collector.js +53 -0
  35. package/dist/log_config_files/collection/hermes_token_usage_collector.js +241 -0
  36. package/dist/log_config_files/collection/mcp_tool_collector.js +106 -0
  37. package/dist/log_config_files/collection/metadata_merge.js +43 -0
  38. package/dist/log_config_files/collection/openclaw_helpers.js +55 -0
  39. package/dist/log_config_files/collection/openclaw_token_usage_collector.js +223 -0
  40. package/dist/log_config_files/collection/opencode_token_usage_collector.js +177 -0
  41. package/dist/log_config_files/collection/pi_token_usage_collector.js +239 -0
  42. package/dist/log_config_files/collection/plugin_collector.js +223 -0
  43. package/dist/log_config_files/collection/plugin_version_helpers.js +37 -0
  44. package/dist/log_config_files/collection/skills_cli_collector.js +162 -0
  45. package/dist/log_config_files/index.js +19 -0
  46. package/dist/log_config_files/paths/path_constants_helpers.js +71 -0
  47. package/dist/log_config_files/paths/pattern_resolver.js +326 -0
  48. package/dist/log_config_files/readers/composer_shadow_merge.js +67 -0
  49. package/dist/log_config_files/readers/cursor_shadow_merge_ruleset.js +46 -0
  50. package/dist/log_config_files/readers/file_readers.js +196 -0
  51. package/dist/log_config_files/readers/vscdb_config_builder.js +148 -0
  52. package/dist/log_config_files/readers/vscdb_reactive_storage.js +62 -0
  53. package/dist/log_config_files/readers/vscdb_reader.js +337 -0
  54. package/dist/log_config_files/runtime/client_event_reporter.js +347 -0
  55. package/dist/log_config_files/runtime/compliance_check.js +1013 -0
  56. package/dist/log_config_files/runtime/compliance_session_log.js +378 -0
  57. package/dist/log_config_files/runtime/dialog_preferences.js +31 -0
  58. package/dist/log_config_files/runtime/hardware_uuid.js +36 -0
  59. package/dist/log_config_files/runtime/hook_logger.js +267 -0
  60. package/dist/log_config_files/runtime/hook_type_for_request.js +30 -0
  61. package/dist/log_config_files/runtime/log_metadata.js +122 -0
  62. package/dist/log_config_files/runtime/main_runner.js +376 -0
  63. package/dist/log_config_files/runtime/management_storage.js +144 -0
  64. package/dist/log_config_files/runtime/ops_target_path.js +18 -0
  65. package/dist/log_config_files/runtime/remediation_apply_tracking.js +152 -0
  66. package/dist/log_config_files/runtime/remediation_config_path.js +140 -0
  67. package/dist/log_config_files/runtime/remediation_sync.js +1705 -0
  68. package/dist/log_config_files/runtime/secret_regex_scan.js +126 -0
  69. package/dist/log_config_files/runtime/sqlite_binary.js +124 -0
  70. package/dist/log_config_files/runtime/trusted_restarts.js +113 -0
  71. package/dist/log_config_files/runtime/workspace_repo.js +170 -0
  72. package/dist/log_config_files/runtime/worktree_absent.js +50 -0
  73. package/dist/log_config_files/runtime/worktree_scanner.js +137 -0
  74. package/dist/log_config_files/sender/batch_sender.js +283 -0
  75. package/dist/log_config_files/sender/endpoint_config.js +24 -0
  76. package/dist/log_config_files/sender/signing.js +1 -0
  77. package/dist/log_sensitive_paths_audit.js +89 -0
  78. package/dist/log_uuid/auth_key_store.js +37 -0
  79. package/dist/log_uuid/hardware_uuid.js +35 -0
  80. package/dist/log_uuid/index.js +11 -0
  81. package/dist/log_uuid/log_uuid_helper.js +30 -0
  82. package/dist/log_uuid/startup_sender.js +131 -0
  83. package/dist/log_uuid/user_profile.js +192 -0
  84. package/dist/remediation_change_preview.js +182 -0
  85. package/dist/tofu.js +22 -0
  86. package/dist/tofu_environment.js +36 -0
  87. package/dist/types/config_file_types.js +1 -0
  88. package/package.json +67 -0
@@ -0,0 +1,167 @@
1
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+ /**
5
+ * Token-usage / session / CLI-version aggregator for GitHub Copilot CLI.
6
+ *
7
+ * Copilot CLI writes one transcript per session under ~/.copilot/session-state/<id>/events.jsonl.
8
+ * Each transcript's `session.start` carries `copilotVersion` + `selectedModel`; its
9
+ * `session.shutdown` carries `tokenDetails` ({input,output,cache_read}.tokenCount),
10
+ * `totalPremiumRequests`, and per-model `modelMetrics`. We aggregate the *numbers* (and version /
11
+ * model ids) on the endpoint and emit only those — never transcript content.
12
+ *
13
+ * Per-model split uses each model's `modelMetrics.<model>.tokenDetails` — NOT its sibling
14
+ * `modelMetrics.<model>.usage` — because `usage.{input,output,cacheRead}Tokens` counts a larger,
15
+ * differently-scoped quantity (confirmed on a real transcript: usage.inputTokens was ~25x the
16
+ * session's own top-level tokenDetails.input.tokenCount for the same shutdown event, apparently
17
+ * including reprocessed/cumulative context rather than the exchange delta). Each model's nested
18
+ * `tokenDetails` mirrors the top-level `tokenDetails` shape and, for a single-model session,
19
+ * its exact values — so summing nested tokenDetails per model stays consistent with the
20
+ * top-level `totals` this same function computes, unlike `usage`.
21
+ *
22
+ * Bounded by mtime recency so a large session history is not fully re-parsed on every hook run.
23
+ */
24
+ const TOKEN_USAGE_RECENCY_DAYS = 30;
25
+ const TOKEN_USAGE_FILE_TYPE = 'copilot_token_usage';
26
+ function emptyTotals() {
27
+ return { input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0 };
28
+ }
29
+ function num(value) {
30
+ return typeof value === 'number' && Number.isFinite(value) ? value : 0;
31
+ }
32
+ function asObject(value) {
33
+ return value && typeof value === 'object' ? value : {};
34
+ }
35
+ /** session.shutdown.tokenDetails → {input,output,cache_read}.tokenCount */
36
+ function addTokenDetails(totals, tokenDetails) {
37
+ totals.input_tokens += num(asObject(tokenDetails.input).tokenCount);
38
+ totals.output_tokens += num(asObject(tokenDetails.output).tokenCount);
39
+ totals.cache_read_input_tokens += num(asObject(tokenDetails.cache_read).tokenCount);
40
+ }
41
+ /** Recursively collect events.jsonl files whose mtime is within the recency window. */
42
+ function recentEventFiles(dir, cutoffMs) {
43
+ const out = [];
44
+ let entries;
45
+ try {
46
+ entries = readdirSync(dir, { withFileTypes: true });
47
+ }
48
+ catch {
49
+ return out;
50
+ }
51
+ for (const entry of entries) {
52
+ const full = join(dir, entry.name);
53
+ if (entry.isDirectory()) {
54
+ out.push(...recentEventFiles(full, cutoffMs));
55
+ }
56
+ else if (entry.isFile() && entry.name === 'events.jsonl') {
57
+ try {
58
+ if (statSync(full).mtimeMs >= cutoffMs)
59
+ out.push(full);
60
+ }
61
+ catch {
62
+ /* unreadable — skip */
63
+ }
64
+ }
65
+ }
66
+ return out;
67
+ }
68
+ /**
69
+ * Aggregate Copilot token usage / session count / version across recent session transcripts.
70
+ * Returns a single-element array (one per-machine aggregate) or [] when there is no recent data.
71
+ */
72
+ function collectCopilotTokenUsage(home = homedir()) {
73
+ const sessionsDir = join(home, '.copilot', 'session-state');
74
+ if (!existsSync(sessionsDir))
75
+ return [];
76
+ const cutoffMs = Date.now() - TOKEN_USAGE_RECENCY_DAYS * 24 * 60 * 60 * 1000;
77
+ const files = recentEventFiles(sessionsDir, cutoffMs);
78
+ if (files.length === 0)
79
+ return [];
80
+ const totals = emptyTotals();
81
+ const sessions = new Set();
82
+ const models = new Set();
83
+ const modelTokenSplit = {};
84
+ let premiumRequests = 0;
85
+ let version = '';
86
+ let versionAt = -1;
87
+ let sawData = false;
88
+ for (const file of files) {
89
+ let content;
90
+ try {
91
+ content = readFileSync(file, 'utf8');
92
+ }
93
+ catch {
94
+ continue;
95
+ }
96
+ for (const line of content.split('\n')) {
97
+ const trimmed = line.trim();
98
+ if (!trimmed)
99
+ continue;
100
+ let entry;
101
+ try {
102
+ entry = JSON.parse(trimmed);
103
+ }
104
+ catch {
105
+ continue;
106
+ }
107
+ sawData = true;
108
+ const type = entry.type;
109
+ const data = asObject(entry.data);
110
+ const sessionId = data.sessionId;
111
+ if (typeof sessionId === 'string' && sessionId)
112
+ sessions.add(sessionId);
113
+ if (type === 'session.start') {
114
+ // Keep the copilotVersion from the most recently started session.
115
+ const startTime = num(data.startTime);
116
+ const v = data.copilotVersion;
117
+ if (typeof v === 'string' && v && startTime >= versionAt) {
118
+ version = v;
119
+ versionAt = startTime;
120
+ }
121
+ if (typeof data.selectedModel === 'string' && data.selectedModel) {
122
+ models.add(data.selectedModel);
123
+ }
124
+ }
125
+ else if (type === 'assistant.message') {
126
+ if (typeof data.model === 'string' && data.model)
127
+ models.add(data.model);
128
+ }
129
+ else if (type === 'session.shutdown') {
130
+ premiumRequests += num(data.totalPremiumRequests);
131
+ addTokenDetails(totals, asObject(data.tokenDetails));
132
+ const modelMetrics = asObject(data.modelMetrics);
133
+ for (const [model, metrics] of Object.entries(modelMetrics)) {
134
+ models.add(model);
135
+ // tokenDetails, not usage — see the module docstring's note on why usage.* isn't
136
+ // comparable to the top-level tokenDetails-derived `totals`.
137
+ const modelTokenDetails = asObject(asObject(metrics).tokenDetails);
138
+ const sum = num(asObject(modelTokenDetails.input).tokenCount) +
139
+ num(asObject(modelTokenDetails.output).tokenCount) +
140
+ num(asObject(modelTokenDetails.cache_read).tokenCount);
141
+ if (sum > 0)
142
+ modelTokenSplit[model] = (modelTokenSplit[model] ?? 0) + sum;
143
+ }
144
+ }
145
+ }
146
+ }
147
+ if (!sawData)
148
+ return [];
149
+ // Sessions without a sessionId in data still count via their transcript file.
150
+ const sessionCount = Math.max(sessions.size, files.length);
151
+ return [
152
+ {
153
+ file_type: TOKEN_USAGE_FILE_TYPE,
154
+ file_path: sessionsDir, // stable synthetic path → one aggregate row per machine
155
+ raw_content: {
156
+ version,
157
+ session_count: sessionCount,
158
+ models: [...models].sort(),
159
+ premium_requests: premiumRequests,
160
+ model_token_split: modelTokenSplit,
161
+ window_days: TOKEN_USAGE_RECENCY_DAYS,
162
+ totals,
163
+ },
164
+ },
165
+ ];
166
+ }
167
+ export { collectCopilotTokenUsage, TOKEN_USAGE_RECENCY_DAYS, TOKEN_USAGE_FILE_TYPE };
@@ -0,0 +1,60 @@
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
+ * Claude Desktop app version, surfaced as Cowork's Agent Profile `agent_version`.
7
+ *
8
+ * Cowork has no per-session version signal — its `local_<sessionId>.json` session files
9
+ * (see cowork_session_whitelist.ts) carry no version field, confirmed against a live install.
10
+ * Cowork runs inside the Claude Desktop app rather than as a standalone CLI, so the app's own
11
+ * bundle version is the only real "version" available for it: `CFBundleShortVersionString` from
12
+ * Claude.app's Info.plist, read via `plutil` (a stock macOS binary — no new dependency, same
13
+ * shell-out pattern as the sqlite3-based collectors).
14
+ *
15
+ * Gated on the Cowork enablement marker (~/Library/Application Support/Claude/
16
+ * cowork-enabled-cli-ops.json) so a machine with Claude Desktop installed but Cowork never
17
+ * enabled doesn't get an agent=cowork version row. macOS only — Claude Desktop/Cowork ships on
18
+ * macOS today.
19
+ */
20
+ const FILE_TYPE = 'cowork_desktop_version';
21
+ const BUNDLE_VERSION_KEY = 'CFBundleShortVersionString';
22
+ const CLAUDE_APP_INFO_PLIST = '/Applications/Claude.app/Contents/Info.plist';
23
+ function coworkEnabledMarkerPath(home) {
24
+ return join(home, 'Library', 'Application Support', 'Claude', 'cowork-enabled-cli-ops.json');
25
+ }
26
+ /** `plutil -extract CFBundleShortVersionString raw <path>`, or '' when plutil/the plist is unavailable. */
27
+ function readBundleVersion(plistPath) {
28
+ try {
29
+ return execFileSync('/usr/bin/plutil', ['-extract', BUNDLE_VERSION_KEY, 'raw', plistPath], {
30
+ encoding: 'utf8',
31
+ stdio: ['ignore', 'pipe', 'ignore'],
32
+ }).trim();
33
+ }
34
+ catch {
35
+ return '';
36
+ }
37
+ }
38
+ /**
39
+ * Emit the Claude Desktop app version as a single-element aggregate, or [] when this isn't
40
+ * macOS, Cowork isn't enabled on this machine, or Claude.app/plutil is unavailable.
41
+ */
42
+ function collectCoworkDesktopVersion(home = homedir(), plistPath = CLAUDE_APP_INFO_PLIST) {
43
+ if (process.platform !== 'darwin')
44
+ return [];
45
+ if (!existsSync(coworkEnabledMarkerPath(home)))
46
+ return [];
47
+ if (!existsSync(plistPath))
48
+ return [];
49
+ const version = readBundleVersion(plistPath);
50
+ if (!version)
51
+ return [];
52
+ return [
53
+ {
54
+ file_type: FILE_TYPE,
55
+ file_path: plistPath,
56
+ raw_content: { version },
57
+ },
58
+ ];
59
+ }
60
+ export { collectCoworkDesktopVersion, FILE_TYPE as COWORK_DESKTOP_VERSION_FILE_TYPE };
@@ -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
+ }
@@ -0,0 +1,230 @@
1
+ import { existsSync, readdirSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+ import { readJSONFile } from '../readers/file_readers.js';
5
+ import { normalizePathSkipPrefixes } from '../paths/pattern_resolver.js';
6
+ import { getCursorProjectsPath } from '../paths/path_constants_helpers.js';
7
+ import { readVscdbItemTableJson } from '../readers/vscdb_reader.js';
8
+ import { isStaleCursorProjectWorkspace } from './cursor_project_workspace_activity.js';
9
+ /** Synthetic manifest path segment — not repo `.cursor/mcp.json`. */
10
+ export const CURSOR_WORKSPACE_MCP_MANIFEST = 'cursor-workspace-mcp-servers.json';
11
+ function fileUriToPath(uri) {
12
+ const trimmed = uri.trim();
13
+ if (!trimmed.startsWith('file://'))
14
+ return null;
15
+ let p = trimmed.slice('file://'.length);
16
+ if (!p.startsWith('/'))
17
+ p = `/${p}`;
18
+ try {
19
+ return decodeURIComponent(p);
20
+ }
21
+ catch {
22
+ return p;
23
+ }
24
+ }
25
+ function normalizeSlugPart(s) {
26
+ return s.replace(/\\/g, '/').replace(/_/g, '-').toLowerCase();
27
+ }
28
+ /** Best-effort: map a repo absolute path to Cursor's ~/.cursor/projects/<slug> dir name. */
29
+ function cursorProjectSlugFromRepoPath(repoPath, home) {
30
+ const normHome = home.replace(/\\/g, '/').replace(/\/+$/, '');
31
+ const normPath = repoPath.replace(/\\/g, '/').replace(/\/+$/, '');
32
+ if (!normPath.startsWith(`${normHome}/`))
33
+ return '';
34
+ const homeSegs = normHome.split('/').filter(Boolean);
35
+ const tail = normPath.slice(normHome.length + 1);
36
+ const prefix = homeSegs.join('-');
37
+ const tailSlug = tail.split('/').join('-').replace(/_/g, '-');
38
+ return tailSlug ? `${prefix}-${tailSlug}` : prefix;
39
+ }
40
+ function loadWorkspaceMetadataEntries(spec, home) {
41
+ if (!spec?.global_vscdb_path_segments?.length || !spec.global_workspace_list_key)
42
+ return [];
43
+ const globalDbPath = join(home, ...spec.global_vscdb_path_segments);
44
+ if (!existsSync(globalDbPath))
45
+ return [];
46
+ const globalResult = readVscdbItemTableJson(globalDbPath, spec.global_workspace_list_key);
47
+ if (!globalResult)
48
+ return [];
49
+ const rawValue = globalResult[spec.global_workspace_list_key];
50
+ const entries = Array.isArray(rawValue)
51
+ ? rawValue
52
+ : Array.isArray(rawValue?.entries)
53
+ ? rawValue.entries
54
+ : [];
55
+ const out = [];
56
+ for (const entry of entries) {
57
+ if (!entry || typeof entry !== 'object')
58
+ continue;
59
+ const e = entry;
60
+ const row = {};
61
+ if (typeof e.displayPath === 'string')
62
+ row.displayPath = e.displayPath;
63
+ if (typeof e.folderUri === 'string')
64
+ row.folderUri = e.folderUri;
65
+ if (typeof e.workspaceId === 'string')
66
+ row.workspaceId = e.workspaceId;
67
+ out.push(row);
68
+ }
69
+ return out;
70
+ }
71
+ function workspaceMetaForSlug(slug, metadataEntries, home) {
72
+ const slugNorm = normalizeSlugPart(slug);
73
+ for (const entry of metadataEntries) {
74
+ if (entry.folderUri) {
75
+ const repoPath = fileUriToPath(entry.folderUri);
76
+ if (repoPath && normalizeSlugPart(cursorProjectSlugFromRepoPath(repoPath, home)) === slugNorm) {
77
+ return entry;
78
+ }
79
+ }
80
+ if (entry.displayPath) {
81
+ const expanded = entry.displayPath.startsWith('~/')
82
+ ? join(home, entry.displayPath.slice(2))
83
+ : entry.displayPath;
84
+ if (expanded && normalizeSlugPart(cursorProjectSlugFromRepoPath(expanded, home)) === slugNorm) {
85
+ return entry;
86
+ }
87
+ }
88
+ }
89
+ return undefined;
90
+ }
91
+ function mcpServerLabelFromMcpsDir(serverDirName, meta) {
92
+ const fromMeta = (typeof meta?.serverName === 'string' && meta.serverName) ||
93
+ (typeof meta?.serverIdentifier === 'string' && meta.serverIdentifier) ||
94
+ serverDirName;
95
+ if (fromMeta.startsWith('user-'))
96
+ return fromMeta.slice('user-'.length);
97
+ return fromMeta;
98
+ }
99
+ function isEphemeralCursorProjectDir(name) {
100
+ if (/^\d+$/.test(name))
101
+ return true;
102
+ return false;
103
+ }
104
+ /** Cursor uses this slug when no repo folder is open; mcps/ there are app-built-in only. */
105
+ function isInternalCursorWorkspaceSlug(slug) {
106
+ return normalizeSlugPart(slug) === 'empty-window';
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
+ }
143
+ /**
144
+ * Cursor stores per-workspace MCP state under ~/.cursor/projects/<slug>/ (mcp-cache.json, mcps/*).
145
+ * This is not the repo file at <repo>/.cursor/mcp.json. Upload one mcp_config per slug with
146
+ * mcpServers from mcps/ only; mcp-cache.json supplies supplementary tool schemas when names match.
147
+ */
148
+ function collectCursorProjectWorkspaceMcpConfigs(pathSkipPrefixes = [], constants, workspaceVscdbSpec, home = homedir()) {
149
+ const skipPrefixes = normalizePathSkipPrefixes(pathSkipPrefixes);
150
+ const results = [];
151
+ const cursorProjectsPath = getCursorProjectsPath(home, constants);
152
+ const metadataEntries = loadWorkspaceMetadataEntries(workspaceVscdbSpec, home);
153
+ if (!existsSync(cursorProjectsPath))
154
+ return results;
155
+ let projectDirs;
156
+ try {
157
+ projectDirs = readdirSync(cursorProjectsPath, { withFileTypes: true }).filter((d) => d.isDirectory());
158
+ }
159
+ catch {
160
+ return results;
161
+ }
162
+ for (const projectDir of projectDirs) {
163
+ if (skipPrefixes.some((p) => projectDir.name.startsWith(p)))
164
+ continue;
165
+ if (isEphemeralCursorProjectDir(projectDir.name))
166
+ continue;
167
+ if (isInternalCursorWorkspaceSlug(projectDir.name))
168
+ continue;
169
+ const projectPath = join(cursorProjectsPath, projectDir.name);
170
+ if (isStaleCursorProjectWorkspace(projectPath))
171
+ continue;
172
+ const mcpServers = {};
173
+ const cachePath = join(projectPath, 'mcp-cache.json');
174
+ const mcpToolCache = readMcpToolCache(projectPath);
175
+ const mcpsPath = join(projectPath, 'mcps');
176
+ if (existsSync(mcpsPath)) {
177
+ try {
178
+ for (const serverDir of readdirSync(mcpsPath, { withFileTypes: true }).filter((d) => d.isDirectory())) {
179
+ const metaPath = join(mcpsPath, serverDir.name, 'SERVER_METADATA.json');
180
+ const meta = existsSync(metaPath) ? readJSONFile(metaPath) : null;
181
+ const metaObj = meta && typeof meta === 'object' ? meta : null;
182
+ const label = mcpServerLabelFromMcpsDir(serverDir.name, metaObj);
183
+ if (!label)
184
+ continue;
185
+ if (label in mcpServers)
186
+ continue;
187
+ const entry = {
188
+ cursor_source: 'mcps',
189
+ server_identifier: serverDir.name,
190
+ };
191
+ if (metaObj) {
192
+ entry.cursor_server_metadata = metaObj;
193
+ }
194
+ const cacheTools = cursorMcpCacheToolsForServer(mcpToolCache, label, serverDir.name);
195
+ if (cacheTools !== undefined) {
196
+ entry.cursor_mcp_cache_tools = cacheTools;
197
+ }
198
+ mcpServers[label] = entry;
199
+ }
200
+ }
201
+ catch {
202
+ /* ignore */
203
+ }
204
+ }
205
+ if (Object.keys(mcpServers).length === 0)
206
+ continue;
207
+ const linked = workspaceMetaForSlug(projectDir.name, metadataEntries, home);
208
+ const linkedRepoPath = linked?.folderUri ? fileUriToPath(linked.folderUri) : null;
209
+ const manifestPath = existsSync(cachePath)
210
+ ? cachePath
211
+ : join(projectPath, CURSOR_WORKSPACE_MCP_MANIFEST);
212
+ results.push({
213
+ file_type: 'mcp_config',
214
+ file_path: manifestPath,
215
+ raw_content: {
216
+ mcpServers,
217
+ cursor_workspace: {
218
+ slug: projectDir.name,
219
+ collection_root: projectPath,
220
+ display_path: linked?.displayPath,
221
+ folder_uri: linked?.folderUri,
222
+ linked_repo_path: linkedRepoPath ?? undefined,
223
+ note: 'Cursor ~/.cursor/projects workspace MCP; not repo .cursor/mcp.json',
224
+ },
225
+ },
226
+ });
227
+ }
228
+ return results;
229
+ }
230
+ export { collectCursorProjectWorkspaceMcpConfigs, cursorProjectSlugFromRepoPath, fileUriToPath, isInternalCursorWorkspaceSlug, workspaceMetaForSlug, };
@@ -0,0 +1,73 @@
1
+ import { existsSync, readdirSync, statSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ /** Align with backend `AGENT_ACTIVITY_RECENCY_DAYS` — skip workspace MCP when idle longer. */
4
+ export const CURSOR_WORKSPACE_MCP_RECENCY_DAYS = 7;
5
+ const MS_PER_DAY = 24 * 60 * 60 * 1000;
6
+ function maxMtimeInTree(root, maxDepth) {
7
+ if (!existsSync(root))
8
+ return null;
9
+ let best = null;
10
+ const visit = (dir, depth) => {
11
+ if (depth > maxDepth)
12
+ return;
13
+ let entries;
14
+ try {
15
+ entries = readdirSync(dir, { withFileTypes: true });
16
+ }
17
+ catch {
18
+ return;
19
+ }
20
+ for (const entry of entries) {
21
+ const fullPath = join(dir, entry.name);
22
+ try {
23
+ const st = statSync(fullPath);
24
+ const ms = st.mtimeMs;
25
+ if (best === null || ms > best)
26
+ best = ms;
27
+ if (entry.isDirectory())
28
+ visit(fullPath, depth + 1);
29
+ }
30
+ catch {
31
+ /* ignore */
32
+ }
33
+ }
34
+ };
35
+ visit(root, 0);
36
+ return best;
37
+ }
38
+ function pushMtime(candidates, path) {
39
+ if (!existsSync(path))
40
+ return;
41
+ try {
42
+ candidates.push(statSync(path).mtimeMs);
43
+ }
44
+ catch {
45
+ /* ignore */
46
+ }
47
+ }
48
+ /**
49
+ * Latest filesystem activity under a Cursor ~/.cursor/projects/<slug> directory.
50
+ * Uses mcp-cache, mcps/, agent-tools/, terminals/, and the project root — not hook ingest time.
51
+ */
52
+ function cursorProjectWorkspaceLatestActivityMs(projectPath) {
53
+ const candidates = [];
54
+ pushMtime(candidates, projectPath);
55
+ pushMtime(candidates, join(projectPath, 'mcp-cache.json'));
56
+ pushMtime(candidates, join(projectPath, 'terminals'));
57
+ for (const subdir of ['mcps', 'agent-tools']) {
58
+ const nested = maxMtimeInTree(join(projectPath, subdir), 5);
59
+ if (nested !== null)
60
+ candidates.push(nested);
61
+ }
62
+ if (candidates.length === 0)
63
+ return null;
64
+ return Math.max(...candidates);
65
+ }
66
+ function isStaleCursorProjectWorkspace(projectPath, recencyDays = CURSOR_WORKSPACE_MCP_RECENCY_DAYS, nowMs = Date.now()) {
67
+ const latest = cursorProjectWorkspaceLatestActivityMs(projectPath);
68
+ if (latest === null)
69
+ return true;
70
+ const cutoff = nowMs - recencyDays * MS_PER_DAY;
71
+ return latest < cutoff;
72
+ }
73
+ export { cursorProjectWorkspaceLatestActivityMs, isStaleCursorProjectWorkspace, maxMtimeInTree, };