callkaro-cli-unofficial 0.1.0

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 (42) hide show
  1. package/README.md +196 -0
  2. package/dist/commands/accounts.js +275 -0
  3. package/dist/commands/agentPhone.js +50 -0
  4. package/dist/commands/agentVersions.js +228 -0
  5. package/dist/commands/agents.js +455 -0
  6. package/dist/commands/analytics.js +77 -0
  7. package/dist/commands/analyzers.js +130 -0
  8. package/dist/commands/auditStrategies.js +178 -0
  9. package/dist/commands/batches.js +189 -0
  10. package/dist/commands/calls.js +193 -0
  11. package/dist/commands/chatAgents.js +251 -0
  12. package/dist/commands/jsonInput.js +56 -0
  13. package/dist/commands/kpi.js +681 -0
  14. package/dist/commands/models.js +99 -0
  15. package/dist/commands/numbers.js +154 -0
  16. package/dist/commands/ongoing.js +39 -0
  17. package/dist/commands/secrets.js +124 -0
  18. package/dist/commands/setJson.js +42 -0
  19. package/dist/commands/simulations.js +139 -0
  20. package/dist/commands/skills.js +318 -0
  21. package/dist/commands/transcribers.js +95 -0
  22. package/dist/commands/update.js +86 -0
  23. package/dist/commands/voices.js +211 -0
  24. package/dist/data/catalog.json +1978 -0
  25. package/dist/endpoints.json +8 -0
  26. package/dist/index.js +684 -0
  27. package/dist/lib/agentImport.js +60 -0
  28. package/dist/lib/agentUpdate.js +262 -0
  29. package/dist/lib/api.js +287 -0
  30. package/dist/lib/browserAuth.js +93 -0
  31. package/dist/lib/catalog.js +136 -0
  32. package/dist/lib/config.js +354 -0
  33. package/dist/lib/dashboardApi.js +197 -0
  34. package/dist/lib/dashboardAuth.js +339 -0
  35. package/dist/lib/dbFields.js +30 -0
  36. package/dist/lib/logQuery.js +140 -0
  37. package/dist/lib/looseJson.js +198 -0
  38. package/dist/lib/session.js +17 -0
  39. package/dist/lib/types.js +15 -0
  40. package/dist/lib/ui.js +91 -0
  41. package/dist/lib/versions.js +19 -0
  42. package/package.json +52 -0
@@ -0,0 +1,93 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.browserAuth = browserAuth;
4
+ const child_process_1 = require("child_process");
5
+ const api_1 = require("./api");
6
+ const config_1 = require("./config");
7
+ const ui_1 = require("./ui");
8
+ const types_1 = require("./types");
9
+ /** Open a URL in the user's default browser (no extra dependency). */
10
+ function openBrowser(url) {
11
+ const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
12
+ const args = process.platform === 'win32' ? ['', url] : [url];
13
+ try {
14
+ (0, child_process_1.spawn)(cmd, args, { stdio: 'ignore', detached: true, shell: process.platform === 'win32' }).unref();
15
+ }
16
+ catch {
17
+ /* fall back to the printed link below */
18
+ }
19
+ }
20
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
21
+ /**
22
+ * Browser sign-in handshake — no password is ever typed into the terminal.
23
+ *
24
+ * We start a session, open the web app at /cli-login?code=…, then poll until the
25
+ * page approves it. The poll secret never leaves this process, so the visible
26
+ * code alone can't be used to grab the token.
27
+ *
28
+ * `mode` only changes where we land the user first: sign-in vs sign-up (the web
29
+ * page offers Google and email on both).
30
+ */
31
+ async function browserAuth(mode = 'login') {
32
+ const { api } = (0, config_1.getClient)();
33
+ let session;
34
+ try {
35
+ session = await api.browserStart();
36
+ }
37
+ catch (err) {
38
+ (0, ui_1.fail)(err instanceof Error ? err.message : 'Could not start browser login');
39
+ return null;
40
+ }
41
+ // Always land on /cli-login — that page owns the routing (it sends the user to
42
+ // /login or /signup if needed and returns here), so there is exactly one entry
43
+ // URL and no redirect-param handling duplicated in the CLI.
44
+ const url = `${(0, config_1.webUrl)()}/cli-login?code=${encodeURIComponent(session.code)}` +
45
+ (mode === 'signup' ? '&mode=signup' : '');
46
+ (0, ui_1.info)('Opening your browser to finish signing in…');
47
+ (0, ui_1.info)(`If it doesn't open, paste this link:\n ${url}\n`);
48
+ openBrowser(url);
49
+ const deadline = Date.now() + (session.expiresInSeconds || 600) * 1000;
50
+ (0, ui_1.info)('Waiting for you to complete sign-in in the browser… (Ctrl+C to cancel)');
51
+ let transientFailures = 0;
52
+ while (Date.now() < deadline) {
53
+ await sleep(2000);
54
+ try {
55
+ const res = await api.browserPoll(session.code, session.pollSecret);
56
+ if (res.status === 'approved') {
57
+ // Stored under its own email only — never merged into the active
58
+ // account. No email, no save: a token filed under the wrong account
59
+ // would silently replace that account's session.
60
+ let user = res.user;
61
+ if (!user?.email && res.token) {
62
+ try {
63
+ const me = await new api_1.Api((0, config_1.backendUrl)(), (0, config_1.callUrl)(), res.token).me();
64
+ user = me?.user || me;
65
+ }
66
+ catch {
67
+ /* reported below */
68
+ }
69
+ }
70
+ if (!res.token || !user?.email) {
71
+ (0, ui_1.fail)('Sign-in was approved but the server did not return the account email, so nothing was saved. Run the command again.');
72
+ return null;
73
+ }
74
+ (0, ui_1.success)((0, config_1.saveLogin)(user.email, { token: res.token, user }));
75
+ return user;
76
+ }
77
+ transientFailures = 0; // a clean "pending" clears the streak
78
+ }
79
+ catch (err) {
80
+ const status = err instanceof types_1.ApiError ? err.status : -1;
81
+ // A dropped connection or a server hiccup shouldn't end a sign-in the user
82
+ // is still completing — keep polling. Only definitive answers are fatal:
83
+ // 404 expired, 403 wrong secret, 400 already used.
84
+ const transient = status === 0 || status >= 500;
85
+ if (transient && ++transientFailures < 10)
86
+ continue;
87
+ (0, ui_1.fail)(err instanceof Error ? err.message : 'Browser login failed');
88
+ return null;
89
+ }
90
+ }
91
+ (0, ui_1.fail)('Timed out waiting for sign-in. Run the command again.');
92
+ return null;
93
+ }
@@ -0,0 +1,136 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.LLM_MODELS = exports.MURF_STYLES = exports.TRANSCRIBER_PROVIDERS = exports.VOICE_PROVIDERS = void 0;
7
+ exports.applyLiveCatalog = applyLiveCatalog;
8
+ exports.findVoiceProvider = findVoiceProvider;
9
+ exports.findTranscriberProvider = findTranscriberProvider;
10
+ exports.langMatches = langMatches;
11
+ exports.visibleVoiceProviders = visibleVoiceProviders;
12
+ exports.visibleTranscriberProviders = visibleTranscriberProviders;
13
+ const catalog_json_1 = __importDefault(require("../data/catalog.json"));
14
+ const hydrate = (p) => ({
15
+ name: p.name,
16
+ models: (p.models || []).map((m) => ({ name: m.name, languages: m.languages || [] })),
17
+ voiceSource: p.voiceSource === 'api' ? 'api' : p.voiceSource === 'static' ? 'static' : null,
18
+ languageKind: p.languageKind === 'array' ? 'array' : p.languageKind === 'string' ? 'string' : null,
19
+ languages: p.languages || [],
20
+ fields: p.fields || [],
21
+ gate: p.gate === 'admin' ? 'admin' : null,
22
+ notes: p.notes || null,
23
+ voices: p.voices || [],
24
+ });
25
+ const raw = catalog_json_1.default;
26
+ exports.VOICE_PROVIDERS = raw.voice.map(hydrate);
27
+ exports.TRANSCRIBER_PROVIDERS = raw.transcriber.map(hydrate);
28
+ exports.MURF_STYLES = raw.murfStyles;
29
+ /**
30
+ * Per-slot LLM model lists (`agent`, `post-call`, ...), keyed by slot name.
31
+ *
32
+ * These are scoped, not flat: `post-call` excludes `gpt-4o` and `o4-mini` while
33
+ * `agent` includes both, so a single list validates one field wrongly or the
34
+ * other. Kept beside the provider tables so `npm run catalog:check` compares all
35
+ * three against the server rather than leaving LLM models to a hand-written list.
36
+ */
37
+ exports.LLM_MODELS = raw.llmModels || {};
38
+ const norm = (s) => s.toLowerCase().replace(/[^a-z0-9]/g, '');
39
+ /** Live model entries arrive as plain strings; be tolerant of `{name}` too. */
40
+ const modelName = (m) => typeof m === 'string' ? m : String(m?.name ?? m?.model ?? m?.id ?? '');
41
+ /**
42
+ * Overlay the server's own model lists onto the built-in providers, in place.
43
+ *
44
+ * Mutating rather than reassigning matters: `agentUpdate.ts` and the voice and
45
+ * transcriber commands hold references to these exact Provider objects, so
46
+ * replacing the arrays would leave them validating against the stale copy.
47
+ *
48
+ * Only `models` is taken from the live payload. Everything else the CLI needs —
49
+ * `fields`, `gate`, `languageKind`, static `voices` — exists only in the bundle,
50
+ * so a provider present in both keeps that metadata and gains the live models.
51
+ *
52
+ * A provider the server no longer lists (Groq) is left in place but flagged, so
53
+ * `cku voices` can say it is not currently served rather than silently offering
54
+ * a dead choice.
55
+ */
56
+ function applyLiveCatalog(catalogs) {
57
+ const summary = { voices: 0, transcribers: 0 };
58
+ const overlay = (targets, live) => {
59
+ if (!Array.isArray(live) || !live.length)
60
+ return; // leave this list entirely alone
61
+ const byName = new Map(targets.map((p) => [norm(p.name), p]));
62
+ for (const entry of live) {
63
+ const name = String(entry?.provider ?? '');
64
+ if (!name)
65
+ continue;
66
+ const provider = byName.get(norm(name));
67
+ if (!provider)
68
+ continue; // a provider the bundle never saw; do not invent metadata
69
+ const models = (Array.isArray(entry.models) ? entry.models : [])
70
+ .map(modelName)
71
+ .filter(Boolean)
72
+ .map((n) => ({ name: n, languages: provider.models.find((m) => norm(m.name) === norm(n))?.languages || [] }));
73
+ // Keep the live list even when empty: Azure and Open AI legitimately have
74
+ // no selectable models, and falling back would resurrect withdrawn ones.
75
+ provider.models = models;
76
+ provider.servedBy = true;
77
+ }
78
+ // Anything not named in a list we actually received is no longer served.
79
+ // Runs here rather than at the end for both lists, so a call that omits one
80
+ // of them leaves that one untouched instead of flagging everything in it.
81
+ for (const p of targets) {
82
+ if (p.servedBy === undefined)
83
+ p.servedBy = false;
84
+ }
85
+ };
86
+ overlay(exports.VOICE_PROVIDERS, catalogs?.ttsProviders);
87
+ overlay(exports.TRANSCRIBER_PROVIDERS, catalogs?.sttProviders);
88
+ summary.voices = exports.VOICE_PROVIDERS.filter((p) => p.servedBy === true).length;
89
+ summary.transcribers = exports.TRANSCRIBER_PROVIDERS.filter((p) => p.servedBy === true).length;
90
+ return summary;
91
+ }
92
+ /**
93
+ * Case- and punctuation-insensitive lookup, so "elevenlabs", "Eleven Labs" and
94
+ * "eleven-labs" all resolve. Providers store the canonical spelling.
95
+ */
96
+ function findVoiceProvider(name) {
97
+ const n = norm(name);
98
+ return exports.VOICE_PROVIDERS.find((p) => norm(p.name) === n);
99
+ }
100
+ function findTranscriberProvider(name) {
101
+ const n = norm(name);
102
+ return exports.TRANSCRIBER_PROVIDERS.find((p) => norm(p.name) === n);
103
+ }
104
+ /**
105
+ * Language match. Provider tables mix bare codes ('hi') and locales ('hi-IN'),
106
+ * so accept either direction: they agree, or one is the other's base code.
107
+ */
108
+ function langMatches(query, providerLangs) {
109
+ const q = norm(query);
110
+ if (!q)
111
+ return true;
112
+ return providerLangs.some((lang) => {
113
+ const l = norm(lang);
114
+ return l === q || l.split('-')[0] === q || q.split('-')[0] === l.split('-')[0];
115
+ });
116
+ }
117
+ /**
118
+ * Providers visible for an account. `opts.all` includes admin-gated ones;
119
+ * otherwise they are hidden unless the account is authorized for them.
120
+ */
121
+ function visibleVoiceProviders(opts = {}) {
122
+ if (opts.allowed && opts.allowed.length) {
123
+ return exports.VOICE_PROVIDERS.filter((p) => opts.allowed.some((a) => norm(a) === norm(p.name)));
124
+ }
125
+ if (opts.all)
126
+ return exports.VOICE_PROVIDERS;
127
+ return exports.VOICE_PROVIDERS.filter((p) => p.gate !== 'admin');
128
+ }
129
+ function visibleTranscriberProviders(opts = {}) {
130
+ if (opts.allowed && opts.allowed.length) {
131
+ return exports.TRANSCRIBER_PROVIDERS.filter((p) => opts.allowed.some((a) => norm(a) === norm(p.name)));
132
+ }
133
+ if (opts.all)
134
+ return exports.TRANSCRIBER_PROVIDERS;
135
+ return exports.TRANSCRIBER_PROVIDERS.filter((p) => p.gate !== 'admin');
136
+ }
@@ -0,0 +1,354 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.accountsDir = exports.OFFICIAL_SKILLS_REPO = exports.configPath = exports.CONFIG_DIR = void 0;
7
+ exports.backendUrl = backendUrl;
8
+ exports.callUrl = callUrl;
9
+ exports.webUrl = webUrl;
10
+ exports.skillsRepo = skillsRepo;
11
+ exports.skillsRepoFor = skillsRepoFor;
12
+ exports.skillsBranch = skillsBranch;
13
+ exports.slugFor = slugFor;
14
+ exports.activeAccount = activeAccount;
15
+ exports.projectAccount = projectAccount;
16
+ exports.setActiveAccount = setActiveAccount;
17
+ exports.clearActiveAccount = clearActiveAccount;
18
+ exports.bindProjectAccount = bindProjectAccount;
19
+ exports.unbindProjectAccount = unbindProjectAccount;
20
+ exports.accountSource = accountSource;
21
+ exports.listAccounts = listAccounts;
22
+ exports.loadAccount = loadAccount;
23
+ exports.saveAccount = saveAccount;
24
+ exports.saveDashboardSession = saveDashboardSession;
25
+ exports.clearDashboardSession = clearDashboardSession;
26
+ exports.removeAccount = removeAccount;
27
+ exports.loadConfig = loadConfig;
28
+ exports.clearSession = clearSession;
29
+ exports.saveLogin = saveLogin;
30
+ exports.getClient = getClient;
31
+ const fs_1 = require("fs");
32
+ const os_1 = require("os");
33
+ const path_1 = require("path");
34
+ const api_1 = require("./api");
35
+ const endpoints_json_1 = __importDefault(require("../endpoints.json"));
36
+ // Endpoints live in a committed, shipped file (NOT .env) — the platform's
37
+ // packaging doesn't allow .env. Edit endpoints.json to change where the CLI
38
+ // points: `backendUrl` for everything, `callUrl` for placing calls (the call
39
+ // cloud may move independently later).
40
+ //
41
+ // The config dir is `callkaro-unofficial`, not `callkaro`, so this fork and
42
+ // @callkaro-official/cli keep separate sessions. Sharing one file means the
43
+ // last `ck` or `cku login` overwrites the other's token, and `ck logout` would
44
+ // clear the fork's session too.
45
+ //
46
+ // CK_CONFIG_DIR relocates the whole thing, which is what the tests use to stay
47
+ // out of the real ~/.config. It is read once at load: changing it mid-process
48
+ // would leave `accountsDir` and `activePath` pointing at the old location.
49
+ exports.CONFIG_DIR = process.env.CK_CONFIG_DIR || (0, path_1.join)((0, os_1.homedir)(), '.config', 'callkaro-unofficial');
50
+ exports.configPath = (0, path_1.join)(exports.CONFIG_DIR, 'config.json');
51
+ const trim = (u) => (u || '').replace(/\/+$/, '');
52
+ /** Base URL for all backend operations (from endpoints.json). */
53
+ function backendUrl() {
54
+ return trim(process.env.CK_BACKEND_URL || endpoints_json_1.default.backendUrl);
55
+ }
56
+ /** Base URL used to place calls — the "call cloud" (from endpoints.json). */
57
+ function callUrl() {
58
+ return trim(endpoints_json_1.default.callUrl || endpoints_json_1.default.backendUrl);
59
+ }
60
+ /** Web app URL — where the browser is sent for sign-in (from endpoints.json). */
61
+ function webUrl() {
62
+ return trim(endpoints_json_1.default.webUrl || endpoints_json_1.default.backendUrl);
63
+ }
64
+ /** GitHub repo holding this fork's skill pack — skills ship separately from the CLI. */
65
+ function skillsRepo() {
66
+ return endpoints_json_1.default.skillsRepo || 'TejasLamba2006/callkaro-skills';
67
+ }
68
+ /** The upstream pack, for `cku skills install --official`. */
69
+ exports.OFFICIAL_SKILLS_REPO = 'Mail-Daddy-AI/callkaro-skills';
70
+ /** Which pack to fetch. `official` opts into upstream; default is this fork's. */
71
+ function skillsRepoFor(official) {
72
+ return official ? exports.OFFICIAL_SKILLS_REPO : skillsRepo();
73
+ }
74
+ function skillsBranch() {
75
+ return endpoints_json_1.default.skillsBranch || 'main';
76
+ }
77
+ // ---------------------------------------------------------------------------
78
+ // Multi-account
79
+ // ---------------------------------------------------------------------------
80
+ //
81
+ // One file per account, so signing into a second account never disturbs the
82
+ // first. The *selected* account is the only mutable state, and it lives in
83
+ // `active.json` — a pointer, not a credential. Losing that file downgrades to
84
+ // the single-account behaviour rather than losing sessions.
85
+ //
86
+ // ~/.config/callkaro-unofficial/
87
+ // accounts/
88
+ // <slug>.json one session each
89
+ // active.json { "account": "<slug>" }
90
+ //
91
+ // A slug is the email with non-alphanumerics collapsed to '-', so
92
+ // tejas@acme.com -> tejas-acme-com. The local part alone is not unique enough.
93
+ exports.accountsDir = (0, path_1.join)(exports.CONFIG_DIR, 'accounts');
94
+ const activePath = (0, path_1.join)(exports.CONFIG_DIR, 'active.json');
95
+ /** Per-project binding file. Checked into git on purpose — a shared repo then
96
+ * gives every clone the same account, without anyone running a setup step. */
97
+ const PROJECT_CONFIG = '.cku.json';
98
+ /** Filesystem-safe slug for an email address. */
99
+ function slugFor(email) {
100
+ const slug = email.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
101
+ return slug || 'account';
102
+ }
103
+ function readJson(path, fallback) {
104
+ if (!(0, fs_1.existsSync)(path))
105
+ return fallback;
106
+ try {
107
+ return JSON.parse((0, fs_1.readFileSync)(path, 'utf8'));
108
+ }
109
+ catch {
110
+ return fallback;
111
+ }
112
+ }
113
+ function writeJson(path, value) {
114
+ (0, fs_1.mkdirSync)((0, path_1.join)(path, '..'), { recursive: true });
115
+ (0, fs_1.writeFileSync)(path, JSON.stringify(value, null, 2), { mode: 0o600 });
116
+ try {
117
+ (0, fs_1.chmodSync)(path, 0o600);
118
+ }
119
+ catch {
120
+ /* best effort on platforms without POSIX perms */
121
+ }
122
+ }
123
+ /**
124
+ * The account a command acts on. Resolution order:
125
+ * 1. CK_ACCOUNT env var — explicit, per-invocation
126
+ * 2. .cku.json in the current directory (or the nearest parent) — per-project
127
+ * 3. the global pointer in active.json
128
+ * 4. undefined — no session
129
+ *
130
+ * Step 2 is what makes parallel agents safe. Two agents working in two folders
131
+ * bound to two accounts each resolve their own binding, so neither can change
132
+ * what the other sees. The global pointer is only a fallback for when no
133
+ * project is bound, and it is the one thing two agents must not fight over —
134
+ * prefer `cku accounts bind` over `cku accounts use` for agent work.
135
+ *
136
+ * Callers should treat `undefined` as "no session" and say so, rather than
137
+ * assuming a single default account exists.
138
+ */
139
+ function activeAccount() {
140
+ const override = process.env.CK_ACCOUNT;
141
+ if (override)
142
+ return slugFor(override);
143
+ return projectAccount() || globalAccount();
144
+ }
145
+ /** The account bound to the current directory, searching upwards for .cku.json. */
146
+ function projectAccount() {
147
+ let dir = process.cwd();
148
+ // Stop at the filesystem root; also stop early if we hit the user's home, so
149
+ // a stray ~/.cku.json can't silently capture every project.
150
+ const stop = (0, os_1.homedir)();
151
+ for (;;) {
152
+ const candidate = (0, path_1.join)(dir, PROJECT_CONFIG);
153
+ if ((0, fs_1.existsSync)(candidate)) {
154
+ const { account } = readJson(candidate, {});
155
+ if (account)
156
+ return account;
157
+ }
158
+ if (dir === stop)
159
+ return undefined;
160
+ const parent = (0, path_1.dirname)(dir);
161
+ if (parent === dir)
162
+ return undefined; // reached the root
163
+ dir = parent;
164
+ }
165
+ }
166
+ /** Only the global pointer (active.json), ignoring CK_ACCOUNT and .cku.json. */
167
+ function globalAccount() {
168
+ const { account } = readJson(activePath, {});
169
+ return account || undefined;
170
+ }
171
+ function setActiveAccount(email) {
172
+ const slug = slugFor(email);
173
+ writeJson(activePath, { account: slug });
174
+ return slug;
175
+ }
176
+ function clearActiveAccount() {
177
+ if ((0, fs_1.existsSync)(activePath))
178
+ writeJson(activePath, {});
179
+ }
180
+ /**
181
+ * Bind an account to the current directory only. Writes `.cku.json` here, so
182
+ * this folder — and anything under it — uses that account regardless of what
183
+ * the global pointer says.
184
+ */
185
+ function bindProjectAccount(email, cwd = process.cwd()) {
186
+ const slug = slugFor(email);
187
+ writeJson((0, path_1.join)(cwd, PROJECT_CONFIG), { account: slug });
188
+ return slug;
189
+ }
190
+ /** Remove this directory's binding, falling back to the global pointer. */
191
+ function unbindProjectAccount(cwd = process.cwd()) {
192
+ const path = (0, path_1.join)(cwd, PROJECT_CONFIG);
193
+ if (!(0, fs_1.existsSync)(path))
194
+ return false;
195
+ try {
196
+ (0, fs_1.unlinkSync)(path);
197
+ }
198
+ catch {
199
+ return false;
200
+ }
201
+ return true;
202
+ }
203
+ /** Where the current directory's binding came from — for `accounts status`. */
204
+ function accountSource() {
205
+ if (process.env.CK_ACCOUNT)
206
+ return 'env';
207
+ if (projectAccount())
208
+ return 'project';
209
+ if (globalAccount())
210
+ return 'global';
211
+ return 'none';
212
+ }
213
+ /** Every stored account, newest first — signed-out ones included (no `token`),
214
+ * so `logout` doesn't make an account vanish from `use`/`bind`/`remove`. */
215
+ function listAccounts() {
216
+ if (!(0, fs_1.existsSync)(exports.accountsDir))
217
+ return [];
218
+ return (0, fs_1.readdirSync)(exports.accountsDir)
219
+ .filter((f) => f.endsWith('.json'))
220
+ .map((f) => readJson((0, path_1.join)(exports.accountsDir, f), {}))
221
+ .filter((a) => Boolean(a.email))
222
+ .sort((a, b) => String(b.savedAt || '').localeCompare(String(a.savedAt || '')));
223
+ }
224
+ function accountPath(slug) {
225
+ return (0, path_1.join)(exports.accountsDir, `${slug}.json`);
226
+ }
227
+ function loadAccount(slug) {
228
+ if (!slug)
229
+ return {};
230
+ return readJson(accountPath(slug), {});
231
+ }
232
+ /** Store a session for `email` and make it the active account. */
233
+ function saveAccount(email, session) {
234
+ const slug = slugFor(email);
235
+ // Carry the dashboard session across. `saveLogin` passes only { token, user },
236
+ // so without this a later `cku login` would silently drop a dashboard session
237
+ // the account already holds.
238
+ const dashboard = session.dashboard ?? loadAccount(slug).dashboard;
239
+ writeJson(accountPath(slug), { ...session, email, savedAt: new Date().toISOString(), ...(dashboard ? { dashboard } : {}) });
240
+ setActiveAccount(email);
241
+ return slug;
242
+ }
243
+ /**
244
+ * Store the dashboard session on an account, keeping whatever else it holds.
245
+ * Merging matters: signing in to the dashboard must not disturb a `ck_` token
246
+ * already on that account, and adding a `ck_` token must not drop the
247
+ * dashboard session.
248
+ */
249
+ function saveDashboardSession(email, dashboard) {
250
+ const slug = slugFor(email);
251
+ const current = loadAccount(slug);
252
+ // Built field by field rather than spread. A caller that accidentally passes a
253
+ // password (or anything else) must not have it written to disk — the guarantee
254
+ // that no password is ever persisted has to hold here, not at the call site.
255
+ const stored = {
256
+ email: dashboard.email || email,
257
+ token: dashboard.token,
258
+ savedAt: dashboard.savedAt || new Date().toISOString(),
259
+ ...(dashboard.sessionToken ? { sessionToken: dashboard.sessionToken } : {}),
260
+ ...(dashboard.userId ? { userId: dashboard.userId } : {}),
261
+ ...(dashboard.expiresAt ? { expiresAt: dashboard.expiresAt } : {}),
262
+ };
263
+ writeJson(accountPath(slug), { ...current, email, dashboard: stored, savedAt: new Date().toISOString() });
264
+ return slug;
265
+ }
266
+ /** Forget only the dashboard session; the `ck_` token is left alone. */
267
+ function clearDashboardSession(email) {
268
+ const slug = slugFor(email);
269
+ const current = loadAccount(slug);
270
+ if (!current.dashboard)
271
+ return false;
272
+ const { dashboard, ...rest } = current;
273
+ writeJson(accountPath(slug), rest);
274
+ return true;
275
+ }
276
+ function removeAccount(slug) {
277
+ const path = accountPath(slug);
278
+ if (!(0, fs_1.existsSync)(path))
279
+ return false;
280
+ // Unlink rather than write an empty file: a tokenless account would still
281
+ // show up in the account list.
282
+ try {
283
+ (0, fs_1.unlinkSync)(path);
284
+ }
285
+ catch {
286
+ return false;
287
+ }
288
+ // The global pointer only: activeAccount() may be this folder's binding, and
289
+ // clearing on that would wipe an unrelated default (or miss a dangling one).
290
+ if (globalAccount() === slug)
291
+ clearActiveAccount();
292
+ return true;
293
+ }
294
+ // ---------------------------------------------------------------------------
295
+ // Session resolution
296
+ // ---------------------------------------------------------------------------
297
+ /**
298
+ * The session in effect: CK_TOKEN wins outright (automation), otherwise the
299
+ * active account's stored token.
300
+ */
301
+ function loadConfig() {
302
+ const envToken = process.env.CK_TOKEN;
303
+ if (envToken)
304
+ return { token: envToken };
305
+ return loadAccount(activeAccount());
306
+ }
307
+ /**
308
+ * Drop the effective account's token (and identity), keeping the account slot
309
+ * so it stays listed as signed out. Returns that account's email, if any.
310
+ *
311
+ * Under CK_TOKEN no stored account is in effect, so none is touched — clearing
312
+ * whichever one happens to be active would sign out an unrelated session.
313
+ */
314
+ function clearSession() {
315
+ if (process.env.CK_TOKEN)
316
+ return undefined;
317
+ const slug = activeAccount();
318
+ if (!slug)
319
+ return undefined;
320
+ const path = accountPath(slug);
321
+ if (!(0, fs_1.existsSync)(path))
322
+ return undefined;
323
+ const { email, savedAt, dashboard } = loadAccount(slug);
324
+ // Dropping the ck_ token is what logout means. A dashboard session is a
325
+ // separate credential and is left in place — `cku firebase logout` removes it.
326
+ writeJson(path, { email, savedAt, ...(dashboard ? { dashboard } : {}) });
327
+ return email;
328
+ }
329
+ /**
330
+ * Store a fresh sign-in as its own account and return the line to print. Every
331
+ * login path goes through here: it saves ONLY the new session (never merging
332
+ * the active account's fields), and says honestly which account is in effect —
333
+ * saveAccount moves the global pointer, but CK_TOKEN, CK_ACCOUNT or a bound
334
+ * folder still win over it.
335
+ */
336
+ function saveLogin(email, session) {
337
+ const slug = saveAccount(email, session);
338
+ const saved = `Signed in as ${email} — saved and set as the global default.\n`;
339
+ if (process.env.CK_TOKEN) {
340
+ return saved + ' CK_TOKEN is set and overrides every stored account; unset it to use this one.';
341
+ }
342
+ const effective = activeAccount();
343
+ if (effective === slug)
344
+ return `Signed in as ${email}. This is now the active account.`;
345
+ const current = loadAccount(effective).email || effective;
346
+ return accountSource() === 'env'
347
+ ? saved + ` This shell still uses ${current} via CK_ACCOUNT. Unset it, or set CK_ACCOUNT=${email}, to switch.`
348
+ : saved + ` This folder still uses ${current} via .cku.json. Run \`cku accounts bind ${email}\` to switch.`;
349
+ }
350
+ /** Build the API client from the endpoint URLs + resolved token. */
351
+ function getClient() {
352
+ const cfg = loadConfig();
353
+ return { api: new api_1.Api(backendUrl(), callUrl(), cfg.token), cfg };
354
+ }