@aixle/insights 0.2.0 → 0.2.1
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/README.md +91 -15
- package/dist/auth/credentials.d.ts +7 -1
- package/dist/auth/credentials.js +90 -17
- package/dist/auth/flow.js +1 -0
- package/dist/cli.js +1 -1
- package/dist/collect-cursor-payloads.d.ts +1 -0
- package/dist/collect-cursor-payloads.js +8 -5
- package/dist/cursor-payload-contract.js +6 -0
- package/dist/cursor-settings.d.ts +9 -4
- package/dist/cursor-settings.js +80 -10
- package/dist/health.d.ts +1 -1
- package/dist/health.js +1 -1
- package/dist/hooks/cursor-hooks-reader.d.ts +2 -0
- package/dist/hooks/cursor-hooks-reader.js +10 -3
- package/dist/lib/client.d.ts +7 -0
- package/dist/lib/client.js +17 -0
- package/dist/lib/config.d.ts +2 -2
- package/dist/lib/config.js +32 -19
- package/dist/lib/parse-error.d.ts +21 -0
- package/dist/lib/parse-error.js +25 -0
- package/dist/lib/project-resolver.d.ts +5 -4
- package/dist/lib/project-resolver.js +57 -11
- package/dist/lib/repo-path-safety.d.ts +35 -0
- package/dist/lib/repo-path-safety.js +102 -0
- package/dist/lib/spawn-arg-safety.d.ts +25 -0
- package/dist/lib/spawn-arg-safety.js +49 -0
- package/dist/readers/claude.d.ts +59 -6
- package/dist/readers/claude.js +184 -3
- package/dist/readers/cursor.d.ts +6 -3
- package/dist/readers/cursor.js +125 -18
- package/dist/risk-scanner.js +7 -0
- package/dist/server.d.ts +3 -3
- package/dist/server.js +77 -66
- package/dist/state.js +42 -34
- package/dist/sync.d.ts +11 -0
- package/dist/sync.js +93 -60
- package/package.json +7 -3
package/README.md
CHANGED
|
@@ -13,7 +13,7 @@ Two channels are published. Pick one deliberately — they are **not** interchan
|
|
|
13
13
|
| | Production | Staging (QA) |
|
|
14
14
|
|---|---|---|
|
|
15
15
|
| Install | `npm i -g @aixle/insights` | `npm i -g @aixle/insights@staging` |
|
|
16
|
-
| Version looks like | `0.2.
|
|
16
|
+
| Version looks like | `0.2.1` | `0.2.6-staging` |
|
|
17
17
|
| Points at | the production API | the staging API |
|
|
18
18
|
| Who should use it | **everyone** | QA validating unreleased work |
|
|
19
19
|
| Stability | released, supported | may change or break without notice |
|
|
@@ -28,7 +28,7 @@ npx -y @aixle/insights init \
|
|
|
28
28
|
|
|
29
29
|
# Or global install:
|
|
30
30
|
npm i -g @aixle/insights
|
|
31
|
-
aixle-insights --version # e.g. 0.2.
|
|
31
|
+
aixle-insights --version # e.g. 0.2.1 (no suffix)
|
|
32
32
|
```
|
|
33
33
|
|
|
34
34
|
### Staging — QA only
|
|
@@ -44,7 +44,7 @@ npx -y @aixle/insights@staging init \
|
|
|
44
44
|
|
|
45
45
|
# Or global install:
|
|
46
46
|
npm i -g @aixle/insights@staging
|
|
47
|
-
aixle-insights --version # e.g. 0.2.
|
|
47
|
+
aixle-insights --version # e.g. 0.2.6-staging (note the suffix)
|
|
48
48
|
```
|
|
49
49
|
|
|
50
50
|
Point a staging build at the **staging** API host. Sending staging telemetry to production
|
|
@@ -60,7 +60,7 @@ npm view @aixle/insights dist-tags # what each channel currently resolves
|
|
|
60
60
|
Expected output — `latest` and `staging` move independently:
|
|
61
61
|
|
|
62
62
|
```
|
|
63
|
-
{ latest: '0.2.
|
|
63
|
+
{ latest: '0.2.1', staging: '0.2.6-staging' }
|
|
64
64
|
```
|
|
65
65
|
|
|
66
66
|
### Switching back to production
|
|
@@ -73,8 +73,8 @@ Then re-run `init` against the production host, since credentials and the MCP en
|
|
|
73
73
|
per-host.
|
|
74
74
|
|
|
75
75
|
> **Why `npm install` never surprises you with a staging build:** `-staging` versions are semver
|
|
76
|
-
> prereleases, and no ordinary version range resolves to a prerelease. `*`, `^0.2.
|
|
77
|
-
> and `>=0.1.0` all select `0.2.
|
|
76
|
+
> prereleases, and no ordinary version range resolves to a prerelease. `*`, `^0.2.1`, `~0.2.1`
|
|
77
|
+
> and `>=0.1.0` all select `0.2.1` even when `0.2.6-staging` exists. Staging builds are
|
|
78
78
|
> reachable only by exact version or the `staging` dist-tag.
|
|
79
79
|
|
|
80
80
|
Maintainers: see [`../RELEASING.md`](../RELEASING.md) for how each channel is cut.
|
|
@@ -98,7 +98,7 @@ After `init` succeeds:
|
|
|
98
98
|
|
|
99
99
|
1. **Restart Claude Code** — it discovers the new MCP server on the next launch.
|
|
100
100
|
2. Open Claude Code; confirm `/mcp` lists **aixle-insights**.
|
|
101
|
-
3. During a Claude session invoke the **`
|
|
101
|
+
3. During a Claude session invoke the **`aixle_insights_status`** MCP tool (or `aixle-insights health` from the shell) to see connectivity + last sync metadata.
|
|
102
102
|
|
|
103
103
|
## Multi-org
|
|
104
104
|
|
|
@@ -154,29 +154,54 @@ Optional `~/.aixle-insights/config.json` accepts Cursor line-cost overrides (per
|
|
|
154
154
|
}
|
|
155
155
|
```
|
|
156
156
|
|
|
157
|
+
The file is optional — an absent `config.json` is the normal case and is silent. A file that is
|
|
158
|
+
present but unusable is ignored entirely (every override falls back to its default) and a
|
|
159
|
+
`config_parse_failed` line is written to `mcp.log`. That covers malformed JSON and valid JSON that
|
|
160
|
+
isn't an object, including a **top-level array** — a common mistake when writing per-model rates.
|
|
161
|
+
Nothing is printed to the terminal, so check the log if an override appears to have no effect.
|
|
162
|
+
|
|
157
163
|
## Security
|
|
158
164
|
|
|
159
165
|
`@aixle/insights` enforces HTTPS for any remote host. Plaintext `http://` is allowed only for loopback (`localhost`, `127.0.0.0/8`, `[::1]`) so that local-dev flows against `make up` continue to work without friction.
|
|
160
166
|
|
|
161
|
-
###
|
|
167
|
+
### Three gates
|
|
162
168
|
|
|
163
169
|
| Gate | Where it fires | What it checks |
|
|
164
170
|
|---|---|---|
|
|
165
171
|
| CLI `--host` gate | `runInit()` at the top of `aixle-insights init`, before any network call to Keycloak | The `--host` value the user typed |
|
|
166
172
|
| Post-exchange `ingestHost` gate | `auth/flow.ts`, immediately after the OIDC-for-ingest-token exchange returns, before persisting credentials to the keychain | The `ingestHost` returned by the server, in case it differs from `--host` |
|
|
173
|
+
| Runtime send/lookup gate | `lib/client.ts`'s `postEvent` and `lib/project-resolver.ts`'s `lookupProjectByRemote`, immediately before every ingest POST and project-attribution GET | The `host` loaded from stored credentials, on **every** sync cycle — not just at `init` |
|
|
174
|
+
|
|
175
|
+
All three gates use the same pure utility, `evaluateTransportSecurity()` in `src/lib/transport-security.ts`. The first two rejecting aborts `init` with exit code 1 and a single-line error naming the offending host. The third rejecting drops that send/lookup (logged via `console.error`, no retry) without aborting the whole sync cycle — a single tampered credential shouldn't crash background sync, it should just refuse to leak the token.
|
|
167
176
|
|
|
168
|
-
|
|
177
|
+
The runtime gate exists because `init`'s two gates only run once, at login time. If `~/.aixle-insights/credentials.json` is edited afterward (by hand, by malware, or by disk corruption) to point at a plaintext `http://` remote, nothing previously re-checked the scheme before every subsequent sync sent the bearer token — DB90DV-539 closed that gap.
|
|
169
178
|
|
|
170
|
-
### `--insecure` (init-only)
|
|
179
|
+
### `--insecure` (init-only, consent persists to runtime)
|
|
171
180
|
|
|
172
|
-
`aixle-insights init --insecure --host http://<remote>` downgrades
|
|
181
|
+
`aixle-insights init --insecure --host http://<remote>` downgrades the first two gates from "reject" to "warn + continue." It is intended only for trusted non-production test endpoints (e.g. a self-hosted staging on a private network without a TLS cert).
|
|
173
182
|
|
|
174
|
-
`--insecure` is rejected on the `run` subcommand by design — the long-running MCP server should never run insecurely
|
|
183
|
+
`--insecure` is rejected on the `run` subcommand by design — the long-running MCP server should never run insecurely, and since `run` is normally spawned non-interactively (by Claude Code, via `~/.claude.json`), there is no ergonomic way to pass a flag to it per-cycle anyway. Instead, when `init --insecure` is used, that consent is recorded as `insecureHttpAllowed: true` on the stored credential (`StoredCredentials.insecureHttpAllowed` in `auth/credentials.ts`) and every later `run` reads it back — so a legitimately-approved trusted HTTP endpoint keeps syncing normally. A `credentials.json` that has an `http://` remote host **without** this flag set (e.g. because someone hand-edited the file after the fact, bypassing `init` entirely) is rejected by the runtime gate on every send.
|
|
175
184
|
|
|
176
185
|
### What is NOT gated
|
|
177
186
|
|
|
178
187
|
The Keycloak issuer URL (`--keycloak-url` / `KEYCLOAK_ISSUER`) is **not** TLS-gated by this package. Same threat model, different ticket — tracked separately. For now, use HTTPS for any remote Keycloak issuer; the OIDC device-flow library will fail the request if the cert is invalid, but it will not refuse to attempt plaintext.
|
|
179
188
|
|
|
189
|
+
### Local store integrity
|
|
190
|
+
|
|
191
|
+
Transport security covers data in flight. The other half of the threat model is what the package
|
|
192
|
+
reads back off the local machine: credentials (keychain or file), `config.json`, and state files are
|
|
193
|
+
all attacker-writable if the account is compromised, so none of them is trusted on read.
|
|
194
|
+
|
|
195
|
+
Each is validated every time it is loaded. A payload that fails to parse, or that parses but does
|
|
196
|
+
not match the expected shape, is **rejected** and the caller falls back to its documented default —
|
|
197
|
+
no credentials, no config overrides, fresh state. Every rejection is recorded in `mcp.log`, so a
|
|
198
|
+
corrupted or tampered store is distinguishable from one that was never created; before this, both
|
|
199
|
+
were silent and looked identical to a fresh install. See
|
|
200
|
+
[Diagnostics](#diagnostics) for the event names.
|
|
201
|
+
|
|
202
|
+
Log fields carry only the file path (or the keychain service name) and a short machine-readable
|
|
203
|
+
reason. File contents, keychain payloads, and tokens are never logged.
|
|
204
|
+
|
|
180
205
|
## Cursor hook forwarder (opt-in)
|
|
181
206
|
|
|
182
207
|
`aixle-insights init --hooks --tool-name cursor` installs a Node script as a Cursor hook (`~/.cursor/hooks.json`). The script appends redacted hook payloads to `~/.aixle-insights/hooks-queue.ndjson`; the background sync drains the queue on its next cycle and POSTs the events with accurate per-turn model attribution. Requires a Cursor restart after install. To remove, run `aixle-insights uninstall-hooks` and restart Cursor again.
|
|
@@ -184,9 +209,25 @@ The Keycloak issuer URL (`--keycloak-url` / `KEYCLOAK_ISSUER`) is **not** TLS-ga
|
|
|
184
209
|
## State + credentials
|
|
185
210
|
|
|
186
211
|
- **App home directory**: `~/.aixle-insights/` (override with `AIXLE_INSIGHTS_HOME`).
|
|
187
|
-
- **Credentials**: OS keychain
|
|
212
|
+
- **Credentials**: the OS keychain is the source of truth. On read, the keychain is consulted **first**; the file is only a fallback when the keychain is unavailable, empty, or explicitly disabled (`DB90_MCP_DISABLE_KEYTAR`). On write, credentials go to the keychain and the file is removed when the keychain write succeeds.
|
|
188
213
|
- **State files**: `state-<hostname>-<token-hash>.json` per credential, plus `state.lock` advisory lock, `mcp.log` rotating diagnostic log, optional `hooks-queue.ndjson`.
|
|
189
214
|
|
|
215
|
+
### Credential storage by OS
|
|
216
|
+
|
|
217
|
+
The secure store is the platform's native keychain, accessed via `keytar` (service `aixle-insights`):
|
|
218
|
+
|
|
219
|
+
| OS | Secure store | Availability | Fallback file protection |
|
|
220
|
+
| --- | --- | --- | --- |
|
|
221
|
+
| **macOS** | Keychain | reliably present | `credentials.json` written `chmod 0600` |
|
|
222
|
+
| **Windows** | Credential Manager | reliably present | `credentials.json` best-effort locked via `icacls` (Node `chmod` cannot set NTFS ACLs) |
|
|
223
|
+
| **Linux** | Secret Service (libsecret / GNOME Keyring / KWallet) | **often absent** on headless servers, minimal Docker images, and CI — no D-Bus secret service | `credentials.json` written `chmod 0600` |
|
|
224
|
+
|
|
225
|
+
Notes:
|
|
226
|
+
|
|
227
|
+
- **Windows**: don't set `DB90_MCP_DISABLE_KEYTAR` — Credential Manager is reliably present, and the plaintext fallback file cannot be locked down as tightly as the keychain. The `icacls` hardening is best-effort defense-in-depth.
|
|
228
|
+
- **Linux**: when no Secret Service is running (common in headless/CI/container contexts), the tool degrades to the `chmod 0600` fallback file **by design** — this is the one environment where the file path is routinely exercised, and POSIX permissions protect it there.
|
|
229
|
+
- A stale `credentials.json` sitting alongside a populated keychain entry is logged as drift (`credentials_file_shadowed_by_keychain` in `mcp.log`) and ignored in favour of the keychain.
|
|
230
|
+
|
|
190
231
|
The internal state-file shape is implementation-detail; don't depend on it from outside this package.
|
|
191
232
|
|
|
192
233
|
## Diagnostics
|
|
@@ -196,18 +237,51 @@ aixle-insights health # connectivity + last sync metadata
|
|
|
196
237
|
aixle-insights verify-hooks # JSON: hooks installed + queue depth
|
|
197
238
|
```
|
|
198
239
|
|
|
199
|
-
`mcp.log` (rotates at 5 MiB to `mcp.log.1`) under the app home directory captures operational events. Inside Claude Code, the **`
|
|
240
|
+
`mcp.log` (rotates at 5 MiB to `mcp.log.1`) under the app home directory captures operational events. Inside Claude Code, the **`aixle_insights_status`** MCP tool returns the same diagnostic structure as `aixle-insights health`.
|
|
241
|
+
|
|
242
|
+
### Local-store integrity events
|
|
243
|
+
|
|
244
|
+
These four are the only signal that a local store was present but unusable — `health` and
|
|
245
|
+
`aixle_insights_status` do **not** report them, so `mcp.log` is the sole surface:
|
|
246
|
+
|
|
247
|
+
| Event | Fires when |
|
|
248
|
+
|---|---|
|
|
249
|
+
| `credentials_parse_failed` | `credentials.json` exists but was rejected |
|
|
250
|
+
| `credentials_keytar_parse_failed` | the OS keychain entry exists but was rejected |
|
|
251
|
+
| `config_parse_failed` | `config.json` exists but was rejected |
|
|
252
|
+
| `state_parse_failed` | a state file exists but was rejected |
|
|
253
|
+
|
|
254
|
+
Each carries a `reason` distinguishing the two failure modes:
|
|
255
|
+
|
|
256
|
+
- `invalid_json` — the payload did not parse at all.
|
|
257
|
+
- `invalid_shape` — it parsed, but validation rejected it: credentials with no usable token, a
|
|
258
|
+
`config.json` that is a JSON array, a state file missing `version` / `sessions`, and so on.
|
|
259
|
+
|
|
260
|
+
Three properties are worth relying on:
|
|
261
|
+
|
|
262
|
+
- An **absent** file never warns. That is the everyday case (most users never create a
|
|
263
|
+
`config.json`, and every machine starts with no state file), so a warning always means something
|
|
264
|
+
is actually there and wrong.
|
|
265
|
+
- A **missing or disabled OS keychain** never warns either — falling back to the file is expected
|
|
266
|
+
on headless Linux, CI, and containers, not an error.
|
|
267
|
+
- All four are written to the log **only**, never mirrored to stderr, because stray output on the
|
|
268
|
+
stdio transport corrupts the MCP protocol. Emitting a warning never changes the fallback the
|
|
269
|
+
caller returns.
|
|
200
270
|
|
|
201
271
|
## Troubleshooting
|
|
202
272
|
|
|
203
273
|
| Symptom | Most likely cause | Fix |
|
|
204
274
|
|---|---|---|
|
|
205
275
|
| `Error: DB90 API host <name> uses remote plaintext HTTP.` | You passed `--host http://<remote>` without `--insecure`. | Use `https://...`, or add `--insecure` if you know the endpoint is trusted and non-production. |
|
|
276
|
+
| `Blocked event send — DB90 ingest host <name> uses remote plaintext HTTP.` (or `Blocked project lookup — ...`) in `mcp.log` / console during `run` | `credentials.json` (or the keychain entry) has an `http://` remote `host` without a recorded `--insecure` consent — most likely because it was edited outside of `init`. | Re-run `aixle-insights init --host https://... ` (or `init --insecure --host http://...` if the endpoint is genuinely trusted non-prod) to re-establish credentials with an explicit, recorded decision. |
|
|
206
277
|
| `Auth failed: fetch failed` during `init` | The `--keycloak-url` host doesn't resolve (NXDOMAIN), is behind a VPN, or the TLS cert is bad. | Verify with `curl -sS https://<host>/realms/<realm>/.well-known/openid-configuration`. For DB90 staging, the canonical Keycloak URL is embedded in the SPA — `curl https://<APP_HOST> \| grep keycloakUrl` extracts the current value. |
|
|
207
278
|
| `Failed to post event: HTTP 401 Unauthorized` repeated for every turn | Your saved ingest token has been rotated, revoked, or invalidated by a server redeploy. The ingest token is distinct from the Keycloak access token that `health` reports as `authenticated: true`. | Reset the keychain entry and re-run `init`: `security delete-generic-password -s "aixle-insights" -a "aixle-insights-ingest-credential"` then `rm -f ~/.aixle-insights/credentials.json` then `aixle-insights init --host ... --keycloak-url ...`. State files are **not** deleted, so already-sent sessions stay deduped. |
|
|
208
279
|
| `health` shows `authenticated: true` but `last_result` is `sent: 0, failed: N` cycle after cycle | Same as the 401 row above. `authenticated` only proves the OIDC token was acquired, not that the ingest token still validates server-side. | Re-init as above. |
|
|
209
280
|
| `last_result` reports `sent: N` but the Events UI shows nothing | The Temporal worker is not running. The ingest endpoint returns HTTP 202 (queued) regardless of worker state. | `make worker` (or check `docker ps` for `db90-worker`). See [LOCAL-DEV.md](./LOCAL-DEV.md) §1. |
|
|
210
281
|
| `sync_lock_skip {reason: "advisory_lock_held"}` in the log | Another sync cycle is still holding `~/.aixle-insights/state.lock`. | Wait for it to finish; only delete the lock file (`rm -f ~/.aixle-insights/state.lock`) after confirming no `aixle-insights run` process is alive (`pgrep -fa aixle-insights`). |
|
|
282
|
+
| `health` reports `authenticated: false` right after a successful `init`, and `credentials_parse_failed` or `credentials_keytar_parse_failed` is in the log | The credential store exists but was rejected, so it is treated as absent. The `reason` field says whether it failed to parse (`invalid_json`) or parsed into the wrong shape (`invalid_shape`). | Re-run `init`. If you hand-edited `credentials.json` for local testing, remember the keychain is read **first** — see [State + credentials](#state--credentials). |
|
|
283
|
+
| A `config.json` override has no effect, and `config_parse_failed` is in the log | The file is malformed, or is valid JSON that is not an object — a top-level array is the usual mistake. | Fix it to match the shape under [Environment](#environment); until it parses, every override is ignored. |
|
|
284
|
+
| Sync re-sends history that was already delivered, and `state_parse_failed` is in the log | A state file was present but rejected, so sync fell back to fresh state and lost its dedup checkpoints. | This is recovery, not a loop — the next successful cycle writes valid state. Ingest upserts by session, so duplicates are absorbed. Worth investigating what wrote the bad file. |
|
|
211
285
|
| `aixle-insights --help` doesn't list `--insecure` | You're running an older published version of the package, not the local source. | `which aixle-insights` shows the path. To run local source: `cd packages/tools/aixle-insights && npm run build && npm link`. To return to the published version: `npm unlink -g @aixle/insights && npm install -g @aixle/insights@latest`. |
|
|
212
286
|
| Not sure whether `aixle-insights` is a `npm link` or a real install | Real installs are regular files; `npm link` is a symlink chain into the repo. | `readlink "$(which aixle-insights)"` shows the link target if any. A linked install will trace back to a path under your monorepo checkout. |
|
|
213
287
|
|
|
@@ -246,7 +320,9 @@ After the script reports success: **quit and reopen Claude Code / Cursor** so ea
|
|
|
246
320
|
|
|
247
321
|
## Requirements
|
|
248
322
|
|
|
249
|
-
- Node.js
|
|
323
|
+
- Node.js >= 20.19.0 — matches `engines.node` in `package.json`, and
|
|
324
|
+
`src/test/supply-chain-contract.test.ts` fails if the two drift apart. Older
|
|
325
|
+
20.x patch releases are not supported.
|
|
250
326
|
- macOS / Linux / Windows. On Windows, the package writes a `cmd /c npx …` wrapper in `~/.claude.json` so Claude Code can spawn the MCP server reliably.
|
|
251
327
|
- `better-sqlite3` is a native module. After a Node upgrade, if SQLite reads start failing, rebuild it from the tools workspace:
|
|
252
328
|
|
|
@@ -5,6 +5,12 @@ export interface StoredCredentials {
|
|
|
5
5
|
host: string;
|
|
6
6
|
organizationId?: string;
|
|
7
7
|
accounts: Partial<Record<TelemetryToolId, string>>;
|
|
8
|
+
/**
|
|
9
|
+
* Set only when the user explicitly passed `init --insecure` for this host.
|
|
10
|
+
* There is no `run --insecure` flag (by design — see README § Security), so
|
|
11
|
+
* runtime sync honors this persisted consent instead of re-prompting.
|
|
12
|
+
*/
|
|
13
|
+
insecureHttpAllowed?: boolean;
|
|
8
14
|
}
|
|
9
15
|
export declare const KEYTAR_SERVICE = "aixle-insights";
|
|
10
16
|
/** Returns true when at least one tool has a non-empty token. */
|
|
@@ -12,7 +18,7 @@ export declare function credentialsHaveAnyToken(creds: StoredCredentials): boole
|
|
|
12
18
|
export declare function pickProjectLookupToken(creds: StoredCredentials): string | null;
|
|
13
19
|
/** Read credentials from disk only (tests / fallback). */
|
|
14
20
|
export declare function loadCredentialsFromFileOnly(appDir?: string): StoredCredentials | null;
|
|
15
|
-
/** Prefer OS keychain when keytar works;
|
|
21
|
+
/** Prefer the OS keychain when keytar works; fall back to `credentials.json`. */
|
|
16
22
|
export declare function loadCredentials(appDir?: string): Promise<StoredCredentials | null>;
|
|
17
23
|
/**
|
|
18
24
|
* Persist multi-tool ingest tokens for one host namespace.
|
package/dist/auth/credentials.js
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
1
2
|
import { chmodSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { userInfo } from "node:os";
|
|
2
4
|
import { join } from "node:path";
|
|
3
5
|
import { getAppDir } from "../state.js";
|
|
6
|
+
import { mcpLog } from "../log.js";
|
|
7
|
+
import { describeReadFailure } from "../lib/parse-error.js";
|
|
4
8
|
export const KEYTAR_SERVICE = "aixle-insights";
|
|
5
9
|
const KEYTAR_ACCOUNT = "aixle-insights-ingest-credential";
|
|
6
10
|
function credentialsPath(appDir) {
|
|
@@ -45,6 +49,7 @@ function normalizeLoadedCredentials(raw) {
|
|
|
45
49
|
host,
|
|
46
50
|
accounts: out,
|
|
47
51
|
organizationId: typeof org === "string" ? org : undefined,
|
|
52
|
+
...(o.insecureHttpAllowed === true ? { insecureHttpAllowed: true } : {}),
|
|
48
53
|
};
|
|
49
54
|
}
|
|
50
55
|
const token = o.token;
|
|
@@ -58,28 +63,54 @@ export function loadCredentialsFromFileOnly(appDir = getAppDir()) {
|
|
|
58
63
|
const filePath = credentialsPath(appDir);
|
|
59
64
|
if (!existsSync(filePath))
|
|
60
65
|
return null;
|
|
66
|
+
let raw;
|
|
61
67
|
try {
|
|
62
|
-
|
|
63
|
-
return normalizeLoadedCredentials(raw);
|
|
68
|
+
raw = JSON.parse(readFileSync(filePath, "utf-8"));
|
|
64
69
|
}
|
|
65
|
-
catch {
|
|
70
|
+
catch (err) {
|
|
71
|
+
// File exists (checked above) but is not readable/valid JSON — distinguishes tampering from "never created".
|
|
72
|
+
mcpLog.warn("credentials_parse_failed", { path: filePath, ...describeReadFailure(err) }, false);
|
|
66
73
|
return null;
|
|
67
74
|
}
|
|
75
|
+
const normalized = normalizeLoadedCredentials(raw);
|
|
76
|
+
if (normalized === null) {
|
|
77
|
+
// Valid JSON, but not a credential shape we accept. `normalizeLoadedCredentials` signals
|
|
78
|
+
// rejection by returning null and never throws, so this cannot surface in the catch
|
|
79
|
+
// above — without this branch a plausible-looking replacement file stays silent. (DB90DV-699)
|
|
80
|
+
mcpLog.warn("credentials_parse_failed", { path: filePath, reason: "invalid_shape" }, false);
|
|
81
|
+
}
|
|
82
|
+
return normalized;
|
|
68
83
|
}
|
|
69
84
|
async function tryKeytarGet() {
|
|
70
85
|
if (keytarDisabled())
|
|
71
86
|
return null;
|
|
87
|
+
let raw;
|
|
72
88
|
try {
|
|
73
89
|
const keytar = await import("keytar");
|
|
74
|
-
|
|
75
|
-
if (!raw)
|
|
76
|
-
return null;
|
|
77
|
-
const parsed = JSON.parse(raw);
|
|
78
|
-
return normalizeLoadedCredentials(parsed);
|
|
90
|
+
raw = await keytar.default.getPassword(KEYTAR_SERVICE, KEYTAR_ACCOUNT);
|
|
79
91
|
}
|
|
80
92
|
catch {
|
|
93
|
+
// Keytar unavailable (native module missing/unbuilt, no Secret Service, etc.) — silent fallback to file, same as tryKeytarSet.
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
if (!raw)
|
|
97
|
+
return null;
|
|
98
|
+
let parsed;
|
|
99
|
+
try {
|
|
100
|
+
parsed = JSON.parse(raw);
|
|
101
|
+
}
|
|
102
|
+
catch (err) {
|
|
103
|
+
// Keychain entry exists (checked above) but is not valid JSON — distinguishes tampering from "no entry".
|
|
104
|
+
mcpLog.warn("credentials_keytar_parse_failed", { keytarService: KEYTAR_SERVICE, ...describeReadFailure(err) }, false);
|
|
81
105
|
return null;
|
|
82
106
|
}
|
|
107
|
+
const normalized = normalizeLoadedCredentials(parsed);
|
|
108
|
+
if (normalized === null) {
|
|
109
|
+
// Same shape-rejection hole as the file path above. Fields stay service-only — never the
|
|
110
|
+
// keychain payload. (DB90DV-699)
|
|
111
|
+
mcpLog.warn("credentials_keytar_parse_failed", { keytarService: KEYTAR_SERVICE, reason: "invalid_shape" }, false);
|
|
112
|
+
}
|
|
113
|
+
return normalized;
|
|
83
114
|
}
|
|
84
115
|
async function tryKeytarSet(payload) {
|
|
85
116
|
if (keytarDisabled())
|
|
@@ -100,8 +131,32 @@ async function tryKeytarDelete() {
|
|
|
100
131
|
const keytar = await import("keytar");
|
|
101
132
|
await keytar.default.deletePassword(KEYTAR_SERVICE, KEYTAR_ACCOUNT);
|
|
102
133
|
}
|
|
134
|
+
catch (err) {
|
|
135
|
+
// Non-fatal, but a stale keychain entry can mislead later loads — record it.
|
|
136
|
+
mcpLog.warn("keytar_delete_failed", { keytarService: KEYTAR_SERVICE, error: err instanceof Error ? err.message : String(err) }, false);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Best-effort NTFS ACL lock-down for the fallback credentials file on Windows.
|
|
141
|
+
*
|
|
142
|
+
* Node's file `mode` / `chmodSync` only toggle the read-only bit on Windows — they do NOT
|
|
143
|
+
* map to NTFS ACLs — so the POSIX `0o600` we set on other platforms has no real effect there.
|
|
144
|
+
* We shell out to the built-in `icacls` to drop inherited ACEs and grant only the current user
|
|
145
|
+
* full control. Mirrors the POSIX `chmod` best-effort: any failure is swallowed (the user-profile
|
|
146
|
+
* directory already blocks cross-user reads, and Windows Credential Manager — the preferred store
|
|
147
|
+
* via keytar — makes this fallback file rare in the first place).
|
|
148
|
+
*/
|
|
149
|
+
function restrictWindowsAclBestEffort(filePath) {
|
|
150
|
+
try {
|
|
151
|
+
const user = userInfo().username;
|
|
152
|
+
if (!user)
|
|
153
|
+
return;
|
|
154
|
+
execFileSync("icacls", [filePath, "/inheritance:r", "/grant:r", `${user}:F`], {
|
|
155
|
+
stdio: "ignore",
|
|
156
|
+
});
|
|
157
|
+
}
|
|
103
158
|
catch {
|
|
104
|
-
//
|
|
159
|
+
// best-effort, non-fatal — same posture as the POSIX chmod
|
|
105
160
|
}
|
|
106
161
|
}
|
|
107
162
|
function writeFileCredential(appDir, creds) {
|
|
@@ -112,12 +167,16 @@ function writeFileCredential(appDir, creds) {
|
|
|
112
167
|
host: creds.host,
|
|
113
168
|
organizationId: creds.organizationId,
|
|
114
169
|
accounts: { ...creds.accounts },
|
|
170
|
+
...(creds.insecureHttpAllowed ? { insecureHttpAllowed: true } : {}),
|
|
115
171
|
};
|
|
116
172
|
writeFileSync(filePath, `${JSON.stringify(body, null, 2)}\n`, {
|
|
117
173
|
encoding: "utf-8",
|
|
118
174
|
mode: 0o600,
|
|
119
175
|
});
|
|
120
|
-
if (process.platform
|
|
176
|
+
if (process.platform === "win32") {
|
|
177
|
+
restrictWindowsAclBestEffort(filePath);
|
|
178
|
+
}
|
|
179
|
+
else {
|
|
121
180
|
try {
|
|
122
181
|
chmodSync(filePath, 0o600);
|
|
123
182
|
}
|
|
@@ -132,19 +191,27 @@ function removeFileCredential(appDir) {
|
|
|
132
191
|
try {
|
|
133
192
|
unlinkSync(filePath);
|
|
134
193
|
}
|
|
135
|
-
catch {
|
|
136
|
-
//
|
|
194
|
+
catch (err) {
|
|
195
|
+
// A stale plaintext credentials.json left behind here is exactly the drift loadCredentials warns about.
|
|
196
|
+
mcpLog.warn("credentials_file_remove_failed", { path: filePath, error: err instanceof Error ? err.message : String(err) }, false);
|
|
137
197
|
}
|
|
138
198
|
}
|
|
139
199
|
}
|
|
140
|
-
/** Prefer OS keychain when keytar works;
|
|
200
|
+
/** Prefer the OS keychain when keytar works; fall back to `credentials.json`. */
|
|
141
201
|
export async function loadCredentials(appDir = getAppDir()) {
|
|
202
|
+
const fromKeytar = await tryKeytarGet();
|
|
203
|
+
if (fromKeytar) {
|
|
204
|
+
// Keychain is the source of truth; a lingering file is stale and silently shadowed it before this fix.
|
|
205
|
+
// Warn-only (log file, no stderr mirror): drift is recorded without spamming the background sync loop,
|
|
206
|
+
// and the next saveStoredCredentials removes the file. We do not mutate disk on a read.
|
|
207
|
+
if (existsSync(credentialsPath(appDir))) {
|
|
208
|
+
mcpLog.warn("credentials_file_shadowed_by_keychain", { path: credentialsPath(appDir), keytarService: KEYTAR_SERVICE }, false);
|
|
209
|
+
}
|
|
210
|
+
return fromKeytar;
|
|
211
|
+
}
|
|
142
212
|
const fromFile = loadCredentialsFromFileOnly(appDir);
|
|
143
213
|
if (fromFile)
|
|
144
214
|
return fromFile;
|
|
145
|
-
const fromKeytar = await tryKeytarGet();
|
|
146
|
-
if (fromKeytar)
|
|
147
|
-
return fromKeytar;
|
|
148
215
|
return null;
|
|
149
216
|
}
|
|
150
217
|
/**
|
|
@@ -154,7 +221,13 @@ export async function saveStoredCredentials(creds, appDir = getAppDir()) {
|
|
|
154
221
|
if (!credentialsHaveAnyToken(creds)) {
|
|
155
222
|
throw new Error("saveStoredCredentials requires at least one account token");
|
|
156
223
|
}
|
|
157
|
-
const payload = JSON.stringify({
|
|
224
|
+
const payload = JSON.stringify({
|
|
225
|
+
version: 2,
|
|
226
|
+
host: creds.host,
|
|
227
|
+
organizationId: creds.organizationId,
|
|
228
|
+
accounts: { ...creds.accounts },
|
|
229
|
+
...(creds.insecureHttpAllowed ? { insecureHttpAllowed: true } : {}),
|
|
230
|
+
});
|
|
158
231
|
const keytarOk = await tryKeytarSet(payload);
|
|
159
232
|
if (keytarOk) {
|
|
160
233
|
removeFileCredential(appDir);
|
package/dist/auth/flow.js
CHANGED
|
@@ -61,6 +61,7 @@ export async function loginAndPersistCredentials(opts) {
|
|
|
61
61
|
host: exchanged.ingestHost,
|
|
62
62
|
organizationId: exchanged.organizationId,
|
|
63
63
|
accounts: existing?.host === exchanged.ingestHost ? { ...existing.accounts } : {},
|
|
64
|
+
...(opts.allowInsecureHttp === true ? { insecureHttpAllowed: true } : {}),
|
|
64
65
|
};
|
|
65
66
|
for (const tid of ["claude_code", "cursor"]) {
|
|
66
67
|
const acc = exchanged.accounts[tid];
|
package/dist/cli.js
CHANGED
|
@@ -348,7 +348,7 @@ export async function runOnce(deps, options) {
|
|
|
348
348
|
let projectId = null;
|
|
349
349
|
let projectIdSource = "none";
|
|
350
350
|
if (lookupToken) {
|
|
351
|
-
const resolution = await runtime.resolveProjectId(undefined, undefined, creds.host, lookupToken, false);
|
|
351
|
+
const resolution = await runtime.resolveProjectId(undefined, undefined, creds.host, lookupToken, false, creds.insecureHttpAllowed === true);
|
|
352
352
|
projectId = resolution.projectId;
|
|
353
353
|
projectIdSource = resolution.source;
|
|
354
354
|
mcpLog.info("project_attribution_resolved", { project_id: resolution.projectId, source: resolution.source }, false);
|
|
@@ -6,7 +6,7 @@ import { readEvents as readCursorEvents, readDailyStatsWithDedupe, readRecentCom
|
|
|
6
6
|
* Read local Cursor stores, apply watermarks/dedupe, and group payloads for sync posting.
|
|
7
7
|
*/
|
|
8
8
|
export async function prepareCursorSliceGroups(options) {
|
|
9
|
-
const { stateBefore, fullScan = false, projectId = null, projectIdSource, host, token, projectLookupToken, verbose = false, cursorBaseDir, cursorTranscriptProjectDirs, cursorPricing = DEFAULT_CURSOR_PRICING, } = options;
|
|
9
|
+
const { stateBefore, fullScan = false, projectId = null, projectIdSource, host, token, projectLookupToken, allowInsecureHttp, verbose = false, cursorBaseDir, cursorTranscriptProjectDirs, cursorPricing = DEFAULT_CURSOR_PRICING, } = options;
|
|
10
10
|
const useCommitHashDedup = !fullScan;
|
|
11
11
|
const eventsSince = fullScan
|
|
12
12
|
? null
|
|
@@ -22,7 +22,9 @@ export async function prepareCursorSliceGroups(options) {
|
|
|
22
22
|
console.log("[verbose][cursor] Full scan — ignoring saved watermarks and commit hash dedupe");
|
|
23
23
|
}
|
|
24
24
|
const baseDir = cursorBaseDir;
|
|
25
|
-
const
|
|
25
|
+
const activeModelResolution = readCursorActiveModel(baseDir);
|
|
26
|
+
const activeModel = activeModelResolution.model ?? undefined;
|
|
27
|
+
const modelResolution = activeModelResolution.source;
|
|
26
28
|
const transcriptTurns = await readCursorTranscriptSessions(baseDir, cursorTranscriptProjectDirs, verbose);
|
|
27
29
|
const rawEvents = readCursorEvents(eventsSince, baseDir, verbose);
|
|
28
30
|
const { raw: dailyStatsRaw, deduped: dailyStats } = readDailyStatsWithDedupe(dailyStatsSince, baseDir, verbose);
|
|
@@ -41,7 +43,7 @@ export async function prepareCursorSliceGroups(options) {
|
|
|
41
43
|
return known.contentHash !== turn.contentHash;
|
|
42
44
|
return known.fileSize !== turn.fileSize;
|
|
43
45
|
})
|
|
44
|
-
.map((turn) => mapCursorTranscriptTurn(turn, projectIdOpt, cursorPricing, activeModel))
|
|
46
|
+
.map((turn) => mapCursorTranscriptTurn(turn, projectIdOpt, cursorPricing, activeModel, modelResolution))
|
|
45
47
|
.sort((a, b) => a.occurred_at.localeCompare(b.occurred_at));
|
|
46
48
|
const skippedTranscriptCount = transcriptTurns.length - transcriptPayloads.length;
|
|
47
49
|
const transcriptModeEnabled = transcriptTurnsById.size > 0;
|
|
@@ -49,7 +51,7 @@ export async function prepareCursorSliceGroups(options) {
|
|
|
49
51
|
.map(({ row, workspacePath }) => mapCursorEvent(row, workspacePath, projectIdOpt, cursorPricing))
|
|
50
52
|
.filter((e) => e !== null);
|
|
51
53
|
const allMappedFromStats = dailyStats
|
|
52
|
-
.flatMap((entry) => mapDailyStats(entry, projectIdOpt, cursorPricing, activeModel));
|
|
54
|
+
.flatMap((entry) => mapDailyStats(entry, projectIdOpt, cursorPricing, activeModel, modelResolution));
|
|
53
55
|
// When transcripts are present they cover the same chat activity — suppress the daily aggregates
|
|
54
56
|
// to prevent double-counting. Logged at info level in sync.ts via counts.suppressedComposer.
|
|
55
57
|
const mappedFromEvents = allMappedFromEvents.filter((payload) => !transcriptModeEnabled || payload.event_type !== "chat");
|
|
@@ -59,7 +61,7 @@ export async function prepareCursorSliceGroups(options) {
|
|
|
59
61
|
allMappedFromStats.filter((p) => p.event_type === "chat").length
|
|
60
62
|
: 0;
|
|
61
63
|
let mappedFromCommits = recentCommitSnapshots
|
|
62
|
-
.map((snapshot) => mapRecentCommit(snapshot, projectIdOpt, cursorPricing, activeModel))
|
|
64
|
+
.map((snapshot) => mapRecentCommit(snapshot, projectIdOpt, cursorPricing, activeModel, modelResolution))
|
|
63
65
|
.filter((payload) => payload !== null)
|
|
64
66
|
.sort((a, b) => a.occurred_at.localeCompare(b.occurred_at));
|
|
65
67
|
if (useCommitHashDedup) {
|
|
@@ -72,6 +74,7 @@ export async function prepareCursorSliceGroups(options) {
|
|
|
72
74
|
projectIdSource,
|
|
73
75
|
host,
|
|
74
76
|
token: lookupToken,
|
|
77
|
+
allowInsecureHttp,
|
|
75
78
|
verbose,
|
|
76
79
|
});
|
|
77
80
|
}
|
|
@@ -19,6 +19,7 @@ const METADATA_BASE_KEYS = new Set([
|
|
|
19
19
|
"cost_model",
|
|
20
20
|
"scannable",
|
|
21
21
|
"risk_level",
|
|
22
|
+
"model_resolution",
|
|
22
23
|
]);
|
|
23
24
|
const METADATA_COMMIT_KEYS = new Set([
|
|
24
25
|
...METADATA_BASE_KEYS,
|
|
@@ -42,6 +43,7 @@ const METADATA_TRANSCRIPT_KEYS = new Set([
|
|
|
42
43
|
"composer_name",
|
|
43
44
|
"prompt_text",
|
|
44
45
|
"assistant_text",
|
|
46
|
+
"model_resolution",
|
|
45
47
|
]);
|
|
46
48
|
const METADATA_HOOK_KEYS = new Set([
|
|
47
49
|
"cursor_session_id",
|
|
@@ -136,6 +138,10 @@ export function validateCursorPayload(payload) {
|
|
|
136
138
|
if (typeof meta.workspace !== "string" || meta.workspace.length === 0) {
|
|
137
139
|
errors.push("metadata.workspace must be a non-empty string");
|
|
138
140
|
}
|
|
141
|
+
if (meta.model_resolution !== undefined &&
|
|
142
|
+
!["settings_json", "state_vscdb", "unresolved"].includes(meta.model_resolution)) {
|
|
143
|
+
errors.push('metadata.model_resolution must be "settings_json", "state_vscdb", or "unresolved" when present');
|
|
144
|
+
}
|
|
139
145
|
if (path !== "mcp_transcript" && path !== "cursor_hook") {
|
|
140
146
|
if (meta.workspace_scope !== "global" && meta.workspace_scope !== "workspace") {
|
|
141
147
|
errors.push('metadata.workspace_scope must be "global" or "workspace"');
|
|
@@ -1,6 +1,11 @@
|
|
|
1
|
+
export type CursorModelResolutionSource = "settings_json" | "state_vscdb" | "unresolved";
|
|
2
|
+
export interface CursorActiveModelResolution {
|
|
3
|
+
model: string | null;
|
|
4
|
+
source: CursorModelResolutionSource;
|
|
5
|
+
}
|
|
1
6
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
7
|
+
* Resolves Cursor's active model, chaining settings.json (pre-1.6 location) then
|
|
8
|
+
* state.vscdb (1.6+ location). Reports which source supplied the model, or "unresolved"
|
|
9
|
+
* when neither has it, so downstream payload metadata can record where the tool looked.
|
|
5
10
|
*/
|
|
6
|
-
export declare function readCursorActiveModel(baseDir?: string):
|
|
11
|
+
export declare function readCursorActiveModel(baseDir?: string): CursorActiveModelResolution;
|
package/dist/cursor-settings.js
CHANGED
|
@@ -1,22 +1,15 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { cursorUserDir } from "./readers/cursor.js";
|
|
4
|
-
|
|
5
|
-
// generic fallbacks. "model" is last to avoid capturing unrelated workspace
|
|
6
|
-
// settings that happen to have a "model" key.
|
|
4
|
+
import { openCursorSqliteReadonly } from "./readers/cursor-sqlite.js";
|
|
7
5
|
const SETTINGS_MODEL_KEYS = [
|
|
8
6
|
"cursor.aiModel",
|
|
9
7
|
"aiModel",
|
|
10
8
|
"cursor.general.preferredModel",
|
|
11
9
|
"model",
|
|
12
10
|
];
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
* Returns null on any error (file absent, unreadable, no matching key).
|
|
16
|
-
* Never throws.
|
|
17
|
-
*/
|
|
18
|
-
export function readCursorActiveModel(baseDir) {
|
|
19
|
-
const settingsPath = join(baseDir ?? cursorUserDir(), "settings.json");
|
|
11
|
+
function readModelFromSettingsJson(dir) {
|
|
12
|
+
const settingsPath = join(dir, "settings.json");
|
|
20
13
|
try {
|
|
21
14
|
if (!existsSync(settingsPath))
|
|
22
15
|
return null;
|
|
@@ -36,3 +29,80 @@ export function readCursorActiveModel(baseDir) {
|
|
|
36
29
|
return null;
|
|
37
30
|
}
|
|
38
31
|
}
|
|
32
|
+
function safeParseJson(raw) {
|
|
33
|
+
try {
|
|
34
|
+
return JSON.parse(raw);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
function isNonEmptyString(v) {
|
|
41
|
+
return typeof v === "string" && v.trim().length > 0;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Reads the active model from Cursor's global state.vscdb (Cursor 1.6+), where settings.json
|
|
45
|
+
* no longer carries it. The model lives in the cursorDiskKV table (verified against a real
|
|
46
|
+
* Cursor install, 2026-07-10 — NOT ItemTable.aiSettings/featureModelConfigs, which don't exist
|
|
47
|
+
* on current Cursor versions), one row per conversation keyed composerData:<composerId>. The
|
|
48
|
+
* table is UNIQUE ON CONFLICT REPLACE, so every update to a composer re-inserts it with a fresh
|
|
49
|
+
* (higher) rowid — ORDER BY rowid DESC LIMIT 1 gives the most recently touched composer without
|
|
50
|
+
* needing to parse and rank every row's timestamp. Opens the DB only via openCursorSqliteReadonly
|
|
51
|
+
* (read-only, root-contained) — never a raw new Database() call.
|
|
52
|
+
*/
|
|
53
|
+
function readCursorActiveModelFromStateDb(baseDir) {
|
|
54
|
+
const dbPath = join(baseDir, "globalStorage", "state.vscdb");
|
|
55
|
+
const opened = openCursorSqliteReadonly(dbPath, { rootDir: baseDir });
|
|
56
|
+
if (!opened.ok)
|
|
57
|
+
return null;
|
|
58
|
+
const { db } = opened;
|
|
59
|
+
try {
|
|
60
|
+
const hasTable = db
|
|
61
|
+
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='cursorDiskKV'")
|
|
62
|
+
.get();
|
|
63
|
+
if (!hasTable)
|
|
64
|
+
return null;
|
|
65
|
+
const row = db
|
|
66
|
+
.prepare("SELECT value FROM cursorDiskKV WHERE key LIKE 'composerData:%' ORDER BY rowid DESC LIMIT 1")
|
|
67
|
+
.get();
|
|
68
|
+
if (!row)
|
|
69
|
+
return null;
|
|
70
|
+
const parsed = safeParseJson(row.value);
|
|
71
|
+
if (typeof parsed !== "object" || parsed === null)
|
|
72
|
+
return null;
|
|
73
|
+
const modelConfig = parsed.modelConfig;
|
|
74
|
+
if (typeof modelConfig !== "object" || modelConfig === null)
|
|
75
|
+
return null;
|
|
76
|
+
const mc = modelConfig;
|
|
77
|
+
if (isNonEmptyString(mc.modelName))
|
|
78
|
+
return mc.modelName.trim();
|
|
79
|
+
const selectedModels = mc.selectedModels;
|
|
80
|
+
if (Array.isArray(selectedModels) && selectedModels.length > 0) {
|
|
81
|
+
const first = selectedModels[0];
|
|
82
|
+
if (isNonEmptyString(first?.modelId))
|
|
83
|
+
return first.modelId.trim();
|
|
84
|
+
}
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
finally {
|
|
91
|
+
db.close();
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Resolves Cursor's active model, chaining settings.json (pre-1.6 location) then
|
|
96
|
+
* state.vscdb (1.6+ location). Reports which source supplied the model, or "unresolved"
|
|
97
|
+
* when neither has it, so downstream payload metadata can record where the tool looked.
|
|
98
|
+
*/
|
|
99
|
+
export function readCursorActiveModel(baseDir) {
|
|
100
|
+
const dir = baseDir ?? cursorUserDir();
|
|
101
|
+
const fromSettings = readModelFromSettingsJson(dir);
|
|
102
|
+
if (fromSettings)
|
|
103
|
+
return { model: fromSettings, source: "settings_json" };
|
|
104
|
+
const fromStateDb = readCursorActiveModelFromStateDb(dir);
|
|
105
|
+
if (fromStateDb)
|
|
106
|
+
return { model: fromStateDb, source: "state_vscdb" };
|
|
107
|
+
return { model: null, source: "unresolved" };
|
|
108
|
+
}
|