@letta-ai/dreams 0.0.1 → 0.0.4

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 CHANGED
@@ -15,50 +15,120 @@ npx @letta-ai/dreams@latest onboard --json
15
15
 
16
16
  1. verifies Node >= 22.13 through the bin preflight
17
17
  2. installs the exact CLI version under `~/.dreams/cli/<version>/`
18
- 3. creates or preserves the stable launcher at `~/.dreams/bin/dreams`
18
+ 3. activates it through the stable launcher at `~/.dreams/bin/dreams`
19
19
  4. creates or preserves the installation id in `~/.dreams/installation.json`
20
- 5. installs `~/.claude/skills/dreams-setup/SKILL.md`
20
+ 5. installs `~/.claude/skills/dreams-setup/` (skill + init snapshot template)
21
21
  6. initializes and migrates `~/.dreams/dreams.db`
22
- 7. returns the next agent action based on authentication state
22
+ 7. returns a skill handoff (`status: "ready"`) with absolute `cli_path` and skill path
23
23
 
24
- It does not start login or `orient` itself. An unauthenticated response directs
25
- the agent to run `auth login` and then rerun `onboard`; an authenticated
26
- response directs the agent to run `orient`. Repeated calls are safe and return
27
- the same action until authentication state changes.
24
+ It does **not** check authentication or choose login/orient next steps. The
25
+ installed `dreams-setup` skill owns sequencing (authenticate detect sources
26
+ init snapshot approve directories + install consented hooks optional
27
+ background history import). SessionStart hooks call `dreams wake`; Stop hooks
28
+ call `dreams sync`. Repeated `onboard` calls are safe and repair local setup
29
+ only.
28
30
 
29
31
  ### Commands
30
32
 
31
- - `dreams onboard [--json]` — install or repair local setup and return the next
32
- setup action.
33
- - `dreams status [--json]` — installation, durable CLI, SQLite, setup skill,
34
- and authentication state.
33
+ - `dreams onboard [--json]` — install or repair local setup and hand off to the
34
+ setup skill.
35
+ - `dreams rollback [--json]` — reactivate the previous completed CLI version.
36
+ - `dreams status [--json]` — installation, active and previous CLI versions,
37
+ SQLite, setup skill, and authentication state.
35
38
  - `dreams auth login [--json]` — OAuth device code sign-in (see Auth below).
36
- - `dreams auth status [--json]` — authentication state; tokens are masked
39
+ - `dreams auth status [--json]` — authentication state; lists every discovered credential (`env` / `keychain` / `file`), which won and why, plus a hooks note that live sync inherits the harness process env; tokens are masked
37
40
  (`at-let-…abcd`), never printed.
38
41
  - `dreams auth logout [--json]` — best-effort revoke of Dreams' refresh token,
39
42
  then clear stored credentials.
43
+ - `dreams dev reset local [--confirm] [--json]` — preview or remove only local
44
+ SQLite and blob state.
45
+ - `dreams dev reset identity [--confirm] [--json]` — preview or remove local
46
+ state, stored Dreams credentials, and the installation identity.
40
47
 
41
48
  Local state lives in `~/.dreams/`. Requires Node >= 22.13, where built-in
42
49
  `node:sqlite` no longer requires the `--experimental-sqlite` flag.
43
50
 
44
- ## Local sources (Claude Code)
51
+ ### CLI upgrades and rollback
52
+
53
+ Installed CLI versions are immutable under `~/.dreams/cli/<version>/`. The
54
+ atomic `~/.dreams/cli/activation.json` record selects the active version and
55
+ retains one previous version. The stable launcher reads that record rather than
56
+ pointing directly at a version.
57
+
58
+ Run `dreams rollback` to reactivate the previous completed version. The launcher
59
+ also rolls back automatically when an active CLI cannot reach its startup
60
+ readiness marker, then retries the original command once. Normal command errors
61
+ after readiness, including invalid arguments, authentication failures, and
62
+ network failures, never trigger rollback.
63
+
64
+ ### Development reset
65
+
66
+ Development resets are preview-only until `--confirm` is provided. `local`
67
+ removes `dreams.db`, its SQLite sidecars, and `~/.dreams/blobs/`; it preserves
68
+ credentials and the installation identity. `identity` removes those local files
69
+ plus Dreams' stored credentials and `installation.json`, so the next `onboard`
70
+ mints a new device identity. Both scopes preserve installed CLI versions, the
71
+ stable launcher, the setup skill, and every Cloud resource. Confirmed resets
72
+ hold the same local-state lock as onboarding, auth, source, and upload commands,
73
+ so they fail instead of deleting files another process is using. If
74
+ `DREAMS_CREDENTIALS_BACKEND=file` is forced, identity reset clears the file and
75
+ explicitly reports that the OS keychain was not checked.
76
+
77
+ Test-workspace cleanup is intentionally not available through this local CLI.
78
+ It is a separate internal Cloud operation with different credentials and data
79
+ cleanup semantics. `dreams dev reset test-workspace` fails without deleting
80
+ anything.
81
+
82
+ ## Local sources (Claude Code and Codex)
45
83
 
46
84
  The CLI maintains a crash-safe local SQLite store (`~/.dreams/dreams.db`) that
47
- discovers Claude Code transcripts and prepares immutable upload segments before
48
- they are sent to Letta Cloud.
49
-
50
- - `dreams source approve-root <path> [--json]` approve a work root. Sources
51
- are **deny-by-default**: no transcript bytes are queued until their working
52
- directory is under an approved root.
53
- - `dreams source scan [--json]` discover top-level Claude Code sessions
54
- (`~/.claude/projects/<project>/<sessionId>.jsonl`), read only complete new
55
- lines after the durable cursor, and enqueue deterministic upload segments.
56
- Repeated scans are idempotent; `.dreamignore` and unapproved roots are honored
57
- before anything is enqueued.
58
- - `dreams source list [--json]` show the DB path, approved roots, sources, and
59
- the pending upload queue.
60
- - `dreams upload [--json]` — authenticate, register the source, and upload due
61
- queue entries to Letta Cloud.
85
+ discovers coding-agent transcripts and prepares immutable upload segments before
86
+ they are sent to Letta Cloud. Harness behavior is selected with
87
+ `--source <type>` (`claude-code` default; `codex`). One invocation always
88
+ targets a single source it never processes all sources at once.
89
+
90
+ - `dreams source detect [--source <type>] [--json]` — detect a harness from
91
+ filesystem facts only (`~/.claude` or `~/.codex`; no transcript reads) and
92
+ register an installation-scoped Cloud source in status `detected`. Idempotent;
93
+ does not scan or upload.
94
+ - `dreams source approve-dir <path> [--json]` (alias: `approve-root`) approve a
95
+ work directory. Sources are **deny-by-default**: no transcript bytes are queued
96
+ until their working directory is under an approved folder (`.dreamignore`
97
+ still applies).
98
+ - `dreams source scan [--source <type>] [--json]` — discover sessions for the
99
+ selected source (Claude: `~/.claude/projects/...`; Codex:
100
+ `~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl`), read only complete new lines
101
+ after the durable cursor, and enqueue deterministic upload segments. Repeated
102
+ scans are idempotent.
103
+ - `dreams source list [--json]` — show the DB path, approved directories, sources,
104
+ and the pending upload queue.
105
+ - `dreams snapshots create --type <type> --file <path> --client-request-id <id> [--workspace-id <id>] [--json]`
106
+ — submit a skill-authored JSON snapshot (256 KiB UTF-8 object cap) to
107
+ `POST /v1/dreams/workspaces/{workspaceId}/snapshots`. Workspace defaults to the
108
+ local binding from `source detect`.
109
+ - `dreams hooks install [--source <type>] [--dry-run] [--json]` — install or
110
+ preview Claude Code / Codex live-sync hooks. SessionStart → `wake`, Stop →
111
+ `sync`. Prefer `--dry-run` for consent before writing.
112
+ - `dreams wake [--source <type>] [--json]` — SessionStart entrypoint: quiet
113
+ managed CLI/skill repair, then force-kick the sync worker for pending
114
+ live/backfill drain (no stdin session hint). Always exits 0 for hook safety.
115
+ - `dreams sync [--source <type>] [--force] [--json]` — Stop / live-turn
116
+ scheduler for one source: records a durable sync trigger (optional stdin
117
+ `session_id`) and runs a detached worker that live-scans tracked sessions and
118
+ uploads with a per-pass budget (~3h cadence unless `--force` or retry-due).
119
+ - `dreams sync pause|resume [--json]` — global pause makes hook wakes no-ops;
120
+ resume clears pause and requests one immediate sync (not per-source).
121
+ - `dreams sync status [--source <type>] [--json]` — per-source diagnostics
122
+ (`effective_state`, cadence/retry, worker). Codex status also reports
123
+ discovery health (`discovery.status` / reason codes) when rollout formats
124
+ drift.
125
+ - `dreams backfill start --since <RFC3339> [--source <type>] [--json]` — approve
126
+ a frozen `[since, until=now]` historical window for one source and run one
127
+ bounded local scan/queue pass (session frontier, newest unfinished sessions
128
+ first; no upload). Policies are source-isolated. Re-running the same active
129
+ `--since` is idempotent; a different active policy fails closed. After
130
+ `backfill start`, kick with `dreams wake --source …` so draining does not wait
131
+ for the next session.
62
132
 
63
133
  Local cursors track a `queued_through_offset` (advanced as segments are queued)
64
134
  separately from an `acked_offset` (advanced only after durable Cloud
@@ -96,6 +166,12 @@ masks them. `DREAMS_CREDENTIALS_BACKEND=file|keychain` forces a backend
96
166
  when it is missing or expires within 5 minutes.
97
167
  3. Otherwise: a structured not-authenticated error (`dreams auth login`).
98
168
 
169
+ `dreams auth status --json` reports that inventory explicitly: top-level
170
+ `source` / `access_token` remain the winner for this process; `credentials[]`
171
+ lists each backend with `present`, `selected`, and `reason`; `active` restates
172
+ the winner; `hooks` notes that coding-agent hooks inherit the harness
173
+ environment (a harness-set `LETTA_API_KEY` shadows stored OAuth).
174
+
99
175
  **Refresh rotation.** Refreshes use `refresh_token_mode: "new"`: the server
100
176
  always returns a *new* refresh token that revokes the old one for this
101
177
  (user, client, device). The CLI persists the rotated token immediately and
@@ -127,20 +203,20 @@ npm test # build + node:test suite (uses a temp HOME)
127
203
  ## Testing the npx bootstrap via tarball (no publish)
128
204
 
129
205
  ```sh
130
- npm pack # produces letta-ai-dreams-0.0.1.tgz
206
+ npm pack # produces letta-ai-dreams-0.0.3.tgz
131
207
  cd "$(mktemp -d)"
132
- npx -y -p /absolute/path/to/dreams/letta-ai-dreams-0.0.1.tgz dreams onboard
208
+ npx -y -p /absolute/path/to/dreams/letta-ai-dreams-0.0.3.tgz dreams onboard
133
209
  ```
134
210
 
135
211
  (The `-p <tarball> dreams` form is required: a bare `npx <tarball>` treats the
136
212
  path as a script to execute, not a package to install.)
137
213
 
138
214
  `onboard` writes to `~/.dreams/` and installs
139
- `~/.claude/skills/dreams-setup/SKILL.md`. Set `HOME` to a temporary directory
140
- for a sandboxed run.
215
+ `~/.claude/skills/dreams-setup/` (including the init snapshot template). Set
216
+ `HOME` to a temporary directory for a sandboxed run.
141
217
 
142
218
  ## Deferred
143
219
 
144
- - `orient` and its Cloud-backed setup actions
145
- - Shared-memory generation and review
146
- - Automated source configuration and historical backfill
220
+ - Shared-memory repository generation and review
221
+ - Hook installation and default Dream schedule creation
222
+ - `dreams backfill start` (skill optional background history import)
@@ -0,0 +1,91 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Codex SessionStart/Stop hook launcher for Dreams.
4
+ *
5
+ * Codex Stop requires exactly one valid JSON object on stdout when exiting 0.
6
+ * Dreams CLI JSON must never leak into that stream. This wrapper:
7
+ * 1. Forwards hook stdin to `dreams <wake|sync> --source codex`
8
+ * 2. Suppresses all CLI stdout/stderr
9
+ * 3. Always prints `{}` and exits 0 (scheduling failures must not interrupt Codex)
10
+ *
11
+ * Usage:
12
+ * node dreams-codex-hook.cjs <cli_path> [wake|sync]
13
+ * Default mode is `sync` (Stop / live turn). SessionStart should pass `wake`.
14
+ */
15
+ "use strict";
16
+
17
+ var spawnSync = require("node:child_process").spawnSync;
18
+ var fs = require("node:fs");
19
+ var path = require("node:path");
20
+
21
+ function readStdinSync() {
22
+ try {
23
+ return fs.readFileSync(0);
24
+ } catch {
25
+ return Buffer.alloc(0);
26
+ }
27
+ }
28
+
29
+ function quoteCmdArg(value) {
30
+ return '"' + String(value).replace(/"/g, '""') + '"';
31
+ }
32
+
33
+ function resolveMode(raw) {
34
+ if (raw === "wake" || raw === "sync") return raw;
35
+ return "sync";
36
+ }
37
+
38
+ /**
39
+ * Resolve how to invoke the durable Dreams CLI.
40
+ * On Windows the stable launcher is `dreams.cmd`, which cannot be spawnSync'd
41
+ * with shell:false. Prefer the sibling Node controller (`launcher.cjs`) when
42
+ * present (managed install layout); otherwise fall back to ComSpec with
43
+ * explicitly quoted paths so spaces work.
44
+ */
45
+ function resolveInvocation(cliPath, mode) {
46
+ var args = [mode, "--source", "codex"];
47
+ if (process.platform === "win32" && /\.(cmd|bat)$/i.test(cliPath)) {
48
+ var sibling = path.join(path.dirname(cliPath), "launcher.cjs");
49
+ try {
50
+ if (fs.statSync(sibling).isFile()) {
51
+ return { command: process.execPath, args: [sibling].concat(args), shell: false };
52
+ }
53
+ } catch {
54
+ // fall through to ComSpec
55
+ }
56
+ var cmdline = [quoteCmdArg(cliPath)].concat(args).join(" ");
57
+ return {
58
+ command: process.env.ComSpec || "cmd.exe",
59
+ args: ["/d", "/s", "/c", cmdline],
60
+ shell: false,
61
+ };
62
+ }
63
+ return { command: cliPath, args: args, shell: false };
64
+ }
65
+
66
+ function main() {
67
+ var cliPath = process.argv[2];
68
+ var mode = resolveMode(process.argv[3]);
69
+ var stdin = mode === "wake" ? Buffer.alloc(0) : readStdinSync();
70
+
71
+ if (typeof cliPath === "string" && cliPath.trim() !== "") {
72
+ try {
73
+ var invocation = resolveInvocation(cliPath, mode);
74
+ spawnSync(invocation.command, invocation.args, {
75
+ input: stdin,
76
+ encoding: "buffer",
77
+ stdio: ["pipe", "pipe", "pipe"],
78
+ windowsHide: true,
79
+ shell: invocation.shell,
80
+ env: process.env,
81
+ });
82
+ } catch {
83
+ // Tolerate missing launcher / spawn failures — hook must not interrupt Codex.
84
+ }
85
+ }
86
+
87
+ process.stdout.write("{}\n");
88
+ process.exit(0);
89
+ }
90
+
91
+ main();
package/bin/dreams.cjs CHANGED
@@ -16,4 +16,25 @@ if (major < REQUIRED_MAJOR || (major === REQUIRED_MAJOR && minor < REQUIRED_MINO
16
16
  process.exit(1);
17
17
  }
18
18
 
19
+ // node:sqlite still emits ExperimentalWarning on load; suppress only that noise
20
+ // before requiring the CLI (which imports node:sqlite).
21
+ (function suppressSqliteExperimentalWarning() {
22
+ var originalEmit = process.emit;
23
+ process.emit = function (event) {
24
+ if (event === 'warning' && arguments.length > 1) {
25
+ var warning = arguments[1];
26
+ if (
27
+ warning &&
28
+ typeof warning === 'object' &&
29
+ warning.name === 'ExperimentalWarning' &&
30
+ typeof warning.message === 'string' &&
31
+ /SQLite is an experimental feature|node:sqlite/i.test(warning.message)
32
+ ) {
33
+ return false;
34
+ }
35
+ }
36
+ return originalEmit.apply(process, arguments);
37
+ };
38
+ })();
39
+
19
40
  require('../dist/cli.js').main();
@@ -7,8 +7,8 @@
7
7
  * {"status":"authenticated",…} object once the user approves in the browser.
8
8
  */
9
9
  Object.defineProperty(exports, "__esModule", { value: true });
10
- exports.commandAuthLogin = commandAuthLogin;
11
10
  exports.renderAuthStatus = renderAuthStatus;
11
+ exports.commandAuthLogin = commandAuthLogin;
12
12
  exports.commandAuthStatus = commandAuthStatus;
13
13
  exports.commandAuthLogout = commandAuthLogout;
14
14
  const browser_1 = require("./browser");
@@ -17,12 +17,13 @@ const index_1 = require("./index");
17
17
  const credentials_1 = require("./credentials");
18
18
  const oauth_refresh_1 = require("./oauth-refresh");
19
19
  const oauth_1 = require("./oauth");
20
+ const state_lock_1 = require("../local/state-lock");
20
21
  /** `auth login` short-circuits when the stored token is valid for >7 days. */
21
22
  const REAUTH_MARGIN_MS = 7 * 24 * 60 * 60 * 1000;
22
23
  function printJsonLine(value) {
23
24
  console.log(JSON.stringify(value));
24
25
  }
25
- async function commandAuthLogin(json) {
26
+ async function commandAuthLoginUnlocked(json) {
26
27
  const stored = (0, credentials_1.loadCredentials)();
27
28
  if (stored && stored.credentials.access_token && !(0, credentials_1.expiresWithin)(stored.credentials, REAUTH_MARGIN_MS)) {
28
29
  if (json) {
@@ -96,22 +97,42 @@ async function commandAuthLogin(json) {
96
97
  }
97
98
  }
98
99
  function renderAuthStatus(info) {
100
+ const candidateLines = info.credentials.map((c) => {
101
+ const mark = c.selected ? "*" : " ";
102
+ const detail = c.present
103
+ ? `${c.access_token ?? "(no access token)"}` + (c.expires_at ? ` exp=${c.expires_at}` : "")
104
+ : "(absent)";
105
+ return ` ${mark} ${c.source.padEnd(9)} ${detail}\n ${c.reason}`;
106
+ });
99
107
  if (!info.authenticated) {
100
108
  return [
101
109
  `Not authenticated — run \`dreams auth login\` to connect this device.`,
102
110
  ``,
103
111
  ` Device id: ${info.device_id}`,
112
+ ``,
113
+ `Credentials discovered:`,
114
+ ...candidateLines,
115
+ ``,
116
+ `Hooks: ${info.hooks.note}`,
104
117
  ].join("\n");
105
118
  }
106
119
  return [
107
120
  `Authenticated via ${info.source}`,
121
+ info.active ? ` Why: ${info.active.reason}` : ``,
108
122
  ``,
109
123
  ` Access token: ${info.access_token ?? "(none — will refresh on next use)"}`,
110
124
  ` Expires at: ${info.expires_at ?? "(n/a — LETTA_API_KEY)"}`,
111
125
  ` Device id: ${info.device_id}`,
112
- ].join("\n");
126
+ ``,
127
+ `Credentials discovered (* = selected for this process):`,
128
+ ...candidateLines,
129
+ ``,
130
+ `Hooks: ${info.hooks.note}`,
131
+ ]
132
+ .filter((line) => line !== undefined)
133
+ .join("\n");
113
134
  }
114
- function commandAuthStatus(json) {
135
+ function commandAuthStatusUnlocked(json) {
115
136
  const info = (0, index_1.authStatusInfo)();
116
137
  if (json) {
117
138
  console.log(JSON.stringify(info, null, 2));
@@ -121,7 +142,7 @@ function commandAuthStatus(json) {
121
142
  }
122
143
  return 0;
123
144
  }
124
- async function commandAuthLogout(json) {
145
+ async function commandAuthLogoutUnlocked(json) {
125
146
  const stored = (0, credentials_1.loadCredentials)();
126
147
  if (!stored) {
127
148
  if (json)
@@ -139,3 +160,44 @@ async function commandAuthLogout(json) {
139
160
  console.log("Logged out: refresh token revoked and local credentials cleared.");
140
161
  return 0;
141
162
  }
163
+ function stateBusy(json) {
164
+ const message = "Another Dreams command is using local state. Try again after it finishes.";
165
+ if (json)
166
+ printJsonLine({ status: "error", code: "local_state_busy", message });
167
+ else
168
+ process.stderr.write(message + "\n");
169
+ return 1;
170
+ }
171
+ async function commandAuthLogin(json) {
172
+ const lock = (0, state_lock_1.acquireLocalStateLock)();
173
+ if (lock === null)
174
+ return stateBusy(json);
175
+ try {
176
+ return await commandAuthLoginUnlocked(json);
177
+ }
178
+ finally {
179
+ (0, state_lock_1.releaseLocalStateLock)(lock);
180
+ }
181
+ }
182
+ function commandAuthStatus(json) {
183
+ const lock = (0, state_lock_1.acquireLocalStateLock)();
184
+ if (lock === null)
185
+ return stateBusy(json);
186
+ try {
187
+ return commandAuthStatusUnlocked(json);
188
+ }
189
+ finally {
190
+ (0, state_lock_1.releaseLocalStateLock)(lock);
191
+ }
192
+ }
193
+ async function commandAuthLogout(json) {
194
+ const lock = (0, state_lock_1.acquireLocalStateLock)();
195
+ if (lock === null)
196
+ return stateBusy(json);
197
+ try {
198
+ return await commandAuthLogoutUnlocked(json);
199
+ }
200
+ finally {
201
+ (0, state_lock_1.releaseLocalStateLock)(lock);
202
+ }
203
+ }
@@ -47,6 +47,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
47
47
  exports.EXPIRY_MARGIN_MS = exports.NotAuthenticatedError = void 0;
48
48
  exports.credentialsFilePath = credentialsFilePath;
49
49
  exports.loadCredentials = loadCredentials;
50
+ exports.probeStoredCredentialBackends = probeStoredCredentialBackends;
50
51
  exports.saveCredentials = saveCredentials;
51
52
  exports.clearCredentials = clearCredentials;
52
53
  exports.expiresWithin = expiresWithin;
@@ -108,15 +109,26 @@ function keychainRead(account) {
108
109
  return null; // no entry, or keychain unusable — fall through to file
109
110
  }
110
111
  }
111
- function keychainDelete(account) {
112
+ function keychainDelete(account, strict) {
112
113
  const ctor = keyringCtor();
113
- if (!ctor)
114
- return;
114
+ if (!ctor) {
115
+ if (backendOverride() === "file")
116
+ return "not_checked_file_backend";
117
+ if (strict) {
118
+ throw new Error("Could not verify OS keychain cleanup because @napi-rs/keyring is unavailable. " +
119
+ "Rerun where keychain support is available.");
120
+ }
121
+ return "unavailable";
122
+ }
115
123
  try {
116
124
  new ctor(KEYCHAIN_SERVICE, account).deleteCredential();
125
+ return "cleared";
117
126
  }
118
- catch {
119
- // already gone or keychain unusable
127
+ catch (error) {
128
+ if (strict) {
129
+ throw new Error(`Could not clear Dreams credentials from the OS keychain: ${error instanceof Error ? error.message : String(error)}`);
130
+ }
131
+ return "unavailable";
120
132
  }
121
133
  }
122
134
  // --- file backend ------------------------------------------------------------
@@ -155,6 +167,23 @@ function warnKeychainUnavailable() {
155
167
  process.stderr.write("Warning: OS keychain unavailable — storing credentials in ~/.dreams/credentials.json (mode 0600).\n");
156
168
  }
157
169
  // --- public API ---------------------------------------------------------------
170
+ function credentialsFromKeychainRaw(refresh, accessRaw) {
171
+ let access_token = "";
172
+ let expires_at = EPOCH_ISO;
173
+ if (accessRaw) {
174
+ try {
175
+ const parsed = JSON.parse(accessRaw);
176
+ if (typeof parsed.access_token === "string")
177
+ access_token = parsed.access_token;
178
+ if (typeof parsed.expires_at === "string")
179
+ expires_at = parsed.expires_at;
180
+ }
181
+ catch {
182
+ // stale/foreign entry shape — treat access token as expired
183
+ }
184
+ }
185
+ return { access_token, refresh_token: refresh, expires_at };
186
+ }
158
187
  /**
159
188
  * Load stored credentials: keychain first, then the file fallback (covers
160
189
  * installs where an earlier save fell back to the file).
@@ -162,25 +191,66 @@ function warnKeychainUnavailable() {
162
191
  function loadCredentials() {
163
192
  const refresh = keychainRead(REFRESH_ACCOUNT);
164
193
  if (refresh) {
165
- let access_token = "";
166
- let expires_at = EPOCH_ISO;
167
- const accessRaw = keychainRead(ACCESS_ACCOUNT);
168
- if (accessRaw) {
169
- try {
170
- const parsed = JSON.parse(accessRaw);
171
- if (typeof parsed.access_token === "string")
172
- access_token = parsed.access_token;
173
- if (typeof parsed.expires_at === "string")
174
- expires_at = parsed.expires_at;
194
+ return {
195
+ credentials: credentialsFromKeychainRaw(refresh, keychainRead(ACCESS_ACCOUNT)),
196
+ source: "keychain",
197
+ };
198
+ }
199
+ const fromFile = fileRead();
200
+ return fromFile ? { credentials: fromFile, source: "file" } : null;
201
+ }
202
+ /**
203
+ * Inspect each stored backend independently for `auth status` inventory.
204
+ * Unlike `loadCredentials()`, this does not stop after keychain wins — file is
205
+ * still reported when both exist. Never throws (forced-keychain misconfig →
206
+ * unavailable).
207
+ */
208
+ function probeStoredCredentialBackends() {
209
+ let keychain;
210
+ if (backendOverride() === "file") {
211
+ keychain = { status: "not_checked", credentials: null };
212
+ }
213
+ else {
214
+ try {
215
+ const ctor = requireKeyring();
216
+ if (!ctor) {
217
+ keychain = { status: "unavailable", credentials: null };
175
218
  }
176
- catch {
177
- // stale/foreign entry shape — treat access token as expired
219
+ else {
220
+ let refresh = null;
221
+ let accessRaw = null;
222
+ try {
223
+ refresh = new ctor(KEYCHAIN_SERVICE, REFRESH_ACCOUNT).getPassword() ?? null;
224
+ }
225
+ catch {
226
+ refresh = null;
227
+ }
228
+ try {
229
+ accessRaw = new ctor(KEYCHAIN_SERVICE, ACCESS_ACCOUNT).getPassword() ?? null;
230
+ }
231
+ catch {
232
+ accessRaw = null;
233
+ }
234
+ if (refresh) {
235
+ keychain = {
236
+ status: "present",
237
+ credentials: credentialsFromKeychainRaw(refresh, accessRaw),
238
+ };
239
+ }
240
+ else {
241
+ keychain = { status: "absent", credentials: null };
242
+ }
178
243
  }
179
244
  }
180
- return { credentials: { access_token, refresh_token: refresh, expires_at }, source: "keychain" };
245
+ catch {
246
+ keychain = { status: "unavailable", credentials: null };
247
+ }
181
248
  }
182
249
  const fromFile = fileRead();
183
- return fromFile ? { credentials: fromFile, source: "file" } : null;
250
+ const file = fromFile
251
+ ? { status: "present", credentials: fromFile }
252
+ : { status: "absent", credentials: null };
253
+ return { keychain, file };
184
254
  }
185
255
  /** Persist credentials: keychain if usable, else the 0600 file (with a warning). */
186
256
  function saveCredentials(credentials) {
@@ -202,10 +272,19 @@ function saveCredentials(credentials) {
202
272
  fileWrite(credentials);
203
273
  }
204
274
  /** Clear both backends (login/logout/invalid_grant paths). */
205
- function clearCredentials() {
206
- keychainDelete(REFRESH_ACCOUNT);
207
- keychainDelete(ACCESS_ACCOUNT);
275
+ function clearCredentials(options = {}) {
276
+ const strict = options.strictKeychain === true;
277
+ const refresh = keychainDelete(REFRESH_ACCOUNT, strict);
278
+ const access = keychainDelete(ACCESS_ACCOUNT, strict);
208
279
  fs.rmSync(credentialsFilePath(), { force: true });
280
+ return {
281
+ file: "cleared",
282
+ keychain: refresh === "not_checked_file_backend" || access === "not_checked_file_backend"
283
+ ? "not_checked_file_backend"
284
+ : refresh === "cleared" && access === "cleared"
285
+ ? "cleared"
286
+ : "unavailable",
287
+ };
209
288
  }
210
289
  function expiresWithin(credentials, marginMs = exports.EXPIRY_MARGIN_MS) {
211
290
  const at = Date.parse(credentials.expires_at);