@cspeach/cli 1.1.32 → 1.1.34

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 (101) hide show
  1. package/dist/agent/anthropic-provider.js +2 -0
  2. package/dist/agent/azure-profile.js +7 -0
  3. package/dist/agent/connection-profile.js +49 -0
  4. package/dist/agent/loop.js +61 -8
  5. package/dist/agent/provider-shape.js +36 -0
  6. package/dist/agent/providers/ai-hub-provider.js +6 -5
  7. package/dist/agent/providers/azure-provider.js +125 -0
  8. package/dist/agent/providers/byok-provider.js +8 -7
  9. package/dist/agent/providers/factory.js +8 -3
  10. package/dist/agent/providers/local-provider.js +18 -22
  11. package/dist/agent/risk-gate.js +202 -0
  12. package/dist/agent/summarise-via-provider.js +55 -8
  13. package/dist/agent/tool-dispatch.js +19 -0
  14. package/dist/agent/turn-error-ux.js +22 -0
  15. package/dist/auth/api-key.js +10 -5
  16. package/dist/auth/auth-file.js +35 -6
  17. package/dist/auth/azure/azure-key.js +21 -0
  18. package/dist/auth/azure/azure-log.js +45 -0
  19. package/dist/auth/azure/cache-store.js +171 -0
  20. package/dist/auth/azure/clear.js +28 -0
  21. package/dist/auth/azure/loopback.js +192 -0
  22. package/dist/auth/azure/msal-client.js +41 -0
  23. package/dist/auth/azure/sign-in.js +219 -0
  24. package/dist/auth/azure/startup-sign-in.js +64 -0
  25. package/dist/auth/azure/token-provider.js +116 -0
  26. package/dist/auth/key-resolver.js +258 -0
  27. package/dist/auth/me.js +11 -5
  28. package/dist/cli.js +21 -2
  29. package/dist/commands/compact.js +12 -1
  30. package/dist/commands/config-set.js +74 -3
  31. package/dist/commands/config-show.js +25 -0
  32. package/dist/commands/cost.js +25 -8
  33. package/dist/commands/help.js +2 -1
  34. package/dist/commands/login-azure.js +33 -0
  35. package/dist/commands/login.js +56 -8
  36. package/dist/commands/logout.js +34 -10
  37. package/dist/commands/plan-boundary.js +4 -4
  38. package/dist/commands/plan-model-tier.js +8 -3
  39. package/dist/commands/usage.js +112 -0
  40. package/dist/commands/whoami.js +113 -9
  41. package/dist/config/loader.js +5 -0
  42. package/dist/connection/azure-auth.js +135 -0
  43. package/dist/connection/azure-errors.js +103 -0
  44. package/dist/connection/azure-facts.js +21 -0
  45. package/dist/connection/azure-gate.js +40 -0
  46. package/dist/connection/azure-last-call.js +5 -0
  47. package/dist/connection/azure-turn-error.js +25 -0
  48. package/dist/connection/cache.js +70 -0
  49. package/dist/connection/fetch.js +33 -0
  50. package/dist/connection/guard.js +94 -0
  51. package/dist/connection/limit-gate.js +26 -0
  52. package/dist/connection/local-usage.js +71 -0
  53. package/dist/connection/login-step.js +62 -0
  54. package/dist/connection/startup.js +67 -0
  55. package/dist/connection/state.js +68 -0
  56. package/dist/connection/types.js +1 -0
  57. package/dist/cost/cost-log.js +3 -1
  58. package/dist/cost/credits-wire.js +1 -1
  59. package/dist/cost/credits.js +4 -0
  60. package/dist/cost/display-mode.js +7 -0
  61. package/dist/cost/month-usage.js +45 -0
  62. package/dist/cost/router-spend.js +3 -0
  63. package/dist/cost/session-cost.js +4 -0
  64. package/dist/doctor/checks/azure.js +100 -0
  65. package/dist/doctor/checks/credits.js +4 -3
  66. package/dist/doctor/checks/usage-reporting.js +22 -0
  67. package/dist/doctor/run.js +7 -0
  68. package/dist/licence/licence-check.js +205 -0
  69. package/dist/licence/paths.js +6 -0
  70. package/dist/licence/turn-gate.js +66 -0
  71. package/dist/models/model-identity.js +65 -0
  72. package/dist/models/resolve.js +8 -0
  73. package/dist/models/server-config.js +3 -2
  74. package/dist/one-shot.js +86 -2
  75. package/dist/renderer/footer-line.js +7 -1
  76. package/dist/renderer/status-footer.js +13 -6
  77. package/dist/repl/ask-or-no.js +18 -0
  78. package/dist/repl/builtin-commands.js +12 -1
  79. package/dist/repl/post-turn-status.js +3 -2
  80. package/dist/repl/safety-confirm.js +47 -2
  81. package/dist/repl/session-spend-line.js +4 -1
  82. package/dist/repl/slash-completer.js +1 -0
  83. package/dist/repl/startup-account.js +51 -0
  84. package/dist/repl.js +223 -48
  85. package/dist/router/byok-classifier.js +41 -14
  86. package/dist/router/classifier.js +5 -16
  87. package/dist/router/router-target.js +41 -0
  88. package/dist/sap/onboarding.js +70 -64
  89. package/dist/session/audit-export.js +11 -4
  90. package/dist/skills/manifest-client.js +37 -4
  91. package/dist/skills/skills-cache.js +115 -0
  92. package/dist/skills/source-authed.js +276 -0
  93. package/dist/tools/filesystem/glob.js +12 -3
  94. package/dist/tools/filesystem/grep.js +14 -3
  95. package/dist/tools/tool-loading.js +9 -10
  96. package/dist/usage/usage-queue.js +323 -0
  97. package/dist/usage/usage-reporter.js +270 -0
  98. package/package.json +3 -2
  99. package/dist/skills/bundled-skills.js +0 -286
  100. package/dist/skills/source-bundled.js +0 -20
  101. package/dist/skills/source-manifest.js +0 -36
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Azure tenant mode, Task 6b — the Microsoft sign-in that starts with the
3
+ * REPL. The question comes first (inquirer owns the keyboard then); only
4
+ * while the sign-in itself runs does Esc or Ctrl+C stop it, through the
5
+ * SAME signal ensureAzureSignIn hands to signInAzure, so the loopback
6
+ * listener closes at once (owner decision: the listener is always closed).
7
+ * The keyboard is handed back exactly as it was found.
8
+ */
9
+ import { ensureAzureSignIn, signInAzure } from './sign-in.js';
10
+ import { askOrNo } from '../../repl/ask-or-no.js';
11
+ const CTRL_C = 0x03;
12
+ const ESC = 0x1b;
13
+ /** Ctrl+C anywhere in the chunk, or a lone Esc (an arrow key is Esc + more bytes). */
14
+ export function isStopKey(chunk) {
15
+ const b = typeof chunk === 'string' ? Buffer.from(chunk, 'utf8') : chunk;
16
+ return b.includes(CTRL_C) || (b.length === 1 && b[0] === ESC);
17
+ }
18
+ /** Aborts `ac` on Esc / Ctrl+C (raw TTY) or SIGINT until released. */
19
+ export function armStopKeys(ac, input, signals) {
20
+ const stop = () => { if (!ac.signal.aborted)
21
+ ac.abort(); };
22
+ const onData = (chunk) => { if (isStopKey(chunk))
23
+ stop(); };
24
+ const raw = input.isTTY === true && typeof input.setRawMode === 'function';
25
+ const wasRaw = input.isRaw === true;
26
+ const wasFlowing = input.readableFlowing === true;
27
+ signals.on('SIGINT', stop);
28
+ if (raw) {
29
+ input.setRawMode(true);
30
+ input.on('data', onData);
31
+ input.resume();
32
+ }
33
+ return () => {
34
+ signals.removeListener('SIGINT', stop);
35
+ if (raw) {
36
+ input.removeListener('data', onData);
37
+ input.setRawMode(wasRaw);
38
+ // Hand the keyboard back as found: still flowing if something else was
39
+ // reading it, paused otherwise (readline / Ink resume it themselves).
40
+ if (!wasFlowing)
41
+ input.pause();
42
+ }
43
+ };
44
+ }
45
+ export async function azureSignInAtStart(conn, o) {
46
+ const ac = new AbortController();
47
+ const input = o.input ?? process.stdin;
48
+ const signals = o.signals ?? process;
49
+ const run = o.signIn ?? signInAzure;
50
+ return (o.ensure ?? ensureAzureSignIn)(conn, {
51
+ // Ctrl+C at the question (ExitPromptError) is a no.
52
+ interactive: true, ask: askOrNo(o.ask), print: o.print, open: o.open, signal: ac.signal,
53
+ // The keys are armed only while the sign-in runs, never during the question.
54
+ signIn: async (c, opts, deps) => {
55
+ const release = armStopKeys(ac, input, signals);
56
+ try {
57
+ return await run(c, opts, deps);
58
+ }
59
+ finally {
60
+ release();
61
+ }
62
+ },
63
+ });
64
+ }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Azure tenant mode — Entra tokens for the Foundry endpoint. Silent before
3
+ * EVERY request (MSAL serves the cached token, refreshing with the refresh
4
+ * token near expiry); a token with < 120 s left by this laptop's clock is
5
+ * force-refreshed (plan D12). Never interactive here: interaction required ⇒
6
+ * AzureSignInRequiredError ("Your Microsoft sign-in has ended."). A token
7
+ * from another tenant is refused. No token ever goes into an error or a log.
8
+ */
9
+ import { AZURE_FACTS } from '../../connection/azure-facts.js';
10
+ import { AzureSignInRequiredError } from '../../connection/azure-auth.js';
11
+ import { cspeachRoot } from '../../config/paths.js';
12
+ import { getAzureKey } from './azure-key.js';
13
+ import { openCacheStore } from './cache-store.js';
14
+ import { azureLog } from './azure-log.js';
15
+ import { createMsal, isInteractionRequired, tenantOf, AzureWrongTenantError } from './msal-client.js';
16
+ export const NEAR_EXPIRY_MS = 120_000;
17
+ const apps = new Map();
18
+ /** One MSAL client per CSPeach home and tenant per process, with that home's encrypted cache. */
19
+ export function msalFor(tenantId, o = {}) {
20
+ const slot = `${cspeachRoot()}|${tenantId.toLowerCase()}`;
21
+ let p = apps.get(slot);
22
+ if (!p) {
23
+ p = (async () => {
24
+ const { plugin } = await (o.store ?? (() => openCacheStore({ notice: o.notice })))();
25
+ return (o.factory ?? createMsal)({ clientId: AZURE_FACTS.clientId, tenantId, cachePlugin: plugin, log: azureLog });
26
+ })();
27
+ apps.set(slot, p);
28
+ p.catch(() => apps.delete(slot));
29
+ }
30
+ return p;
31
+ }
32
+ const appFor = (conn, deps) => (deps.msal ?? (() => msalFor(conn.entra_tenant_id)))();
33
+ /** Every cached account for this tenant (MSAL's raw objects). */
34
+ export async function accountsForTenant(app, tenantId) {
35
+ const t = tenantId.toLowerCase();
36
+ return (await app.getAllAccounts()).filter((a) => (a.tenantId ?? '').toLowerCase() === t);
37
+ }
38
+ /** More than one account for the company's tenant: never guess which one. A fresh `cspeach login azure` keeps only the one signed in. */
39
+ export class AzureSeveralAccountsError extends AzureSignInRequiredError {
40
+ constructor() {
41
+ super('entra');
42
+ this.message = 'More than one Microsoft account is signed in for your company on this computer.';
43
+ this.name = 'AzureSeveralAccountsError';
44
+ }
45
+ }
46
+ async function accountFor(app, tenantId) {
47
+ const found = await accountsForTenant(app, tenantId);
48
+ if (found.length > 1)
49
+ throw new AzureSeveralAccountsError();
50
+ return found[0] ?? null;
51
+ }
52
+ const scopes = () => [AZURE_FACTS.entraScope];
53
+ async function silent(app, account, forceRefresh) {
54
+ try {
55
+ return await app.acquireTokenSilent({ scopes: scopes(), account, ...(forceRefresh ? { forceRefresh: true } : {}) });
56
+ }
57
+ catch (e) {
58
+ if (isInteractionRequired(e))
59
+ throw new AzureSignInRequiredError('entra');
60
+ throw e;
61
+ }
62
+ }
63
+ export function createEntraAuthProvider(conn, deps = {}) {
64
+ const now = deps.now ?? Date.now;
65
+ return {
66
+ method: 'entra',
67
+ async getAuth(opts = {}) {
68
+ const a = await appFor(conn, deps);
69
+ const account = await accountFor(a, conn.entra_tenant_id);
70
+ if (!account)
71
+ throw new AzureSignInRequiredError('entra');
72
+ let r = await silent(a, account, opts.forceRefresh === true);
73
+ if (!opts.forceRefresh && r.expiresOn && r.expiresOn.getTime() - now() < NEAR_EXPIRY_MS)
74
+ r = await silent(a, account, true);
75
+ if (tenantOf(r) !== conn.entra_tenant_id.toLowerCase())
76
+ throw new AzureWrongTenantError();
77
+ return { kind: 'entra', token: r.accessToken };
78
+ },
79
+ };
80
+ }
81
+ /**
82
+ * SelectAuthDeps.entra: Entra when this machine has an Entra account (or
83
+ * several) for the tenant; when it has none and the company allows keys and a key is stored,
84
+ * null (use the key); otherwise Entra (which asks for sign-in).
85
+ */
86
+ export async function entraForSelect(conn, deps = {}) {
87
+ const provider = createEntraAuthProvider(conn, deps);
88
+ let hasAccount = false;
89
+ try {
90
+ hasAccount = (await accountsForTenant(await appFor(conn, deps), conn.entra_tenant_id)).length > 0;
91
+ }
92
+ catch {
93
+ hasAccount = false;
94
+ }
95
+ if (hasAccount)
96
+ return provider;
97
+ if (conn.sign_in.includes('key') && (await (deps.getKey ?? getAzureKey)().catch(() => null)))
98
+ return null;
99
+ return provider;
100
+ }
101
+ /** whoami / doctor: the account and token expiry, silently. */
102
+ export async function describeEntraSignIn(conn, deps = {}) {
103
+ try {
104
+ const a = await appFor(conn, deps);
105
+ const account = await accountFor(a, conn.entra_tenant_id);
106
+ if (!account)
107
+ return { state: 'needs-sign-in' };
108
+ const r = await silent(a, account, false);
109
+ if (tenantOf(r) !== conn.entra_tenant_id.toLowerCase())
110
+ return { state: 'needs-sign-in' };
111
+ return { state: 'signed-in', username: account.username, expiresOn: r.expiresOn };
112
+ }
113
+ catch {
114
+ return { state: 'needs-sign-in' };
115
+ }
116
+ }
@@ -0,0 +1,258 @@
1
+ /**
2
+ * Security batch item 6 — the ONE CSPeach key resolver. Every reader (REPL
3
+ * requests, banner, whoami, logout, licence check, usage reporter) reaches it
4
+ * through getStore().get() (api-key.ts).
5
+ *
6
+ * Where the key lives:
7
+ * - the OS credential store, service `cspeach`, one account per CSPeach
8
+ * home: `api-key` for the default ~/.cspeach, `api-key:<12 hex of
9
+ * sha256(home)>` for any other CSPEACH_HOME — two homes never share one;
10
+ * - auth.json `apiKey` only where no credential store works, or where an
11
+ * older CLI left it (moved at start: copy → read back → only then remove).
12
+ *
13
+ * When both hold DIFFERENT keys, the newer login wins:
14
+ * - auth.json carries `savedAt` (this CLI wrote it while the store was
15
+ * unavailable) newer than the last store write → auth.json;
16
+ * - auth.json has no `savedAt` (an older CLI wrote it) → auth.json only when
17
+ * key-store.json proves our store write came BEFORE the file changed;
18
+ * - otherwise the credential store — the key REPL requests already used
19
+ * before this batch (managed stays byte-identical) — plus one notice.
20
+ *
21
+ * key-store.json (beside auth.json) holds { account, storedAt, legacyChecked }:
22
+ * no secret.
23
+ */
24
+ import crypto from 'node:crypto';
25
+ import path from 'node:path';
26
+ import { promises as fs } from 'node:fs';
27
+ import { cspeachRoot, defaultCspeachRoot } from '../config/paths.js';
28
+ import { readAuthFile, writeAuthFile, deleteAuthFile, authFileMtimeMs, authFileDir } from './auth-file.js';
29
+ import { licenceCachePath } from '../licence/paths.js';
30
+ export const KEYCHAIN_SERVICE = 'cspeach';
31
+ export const DEFAULT_ACCOUNT = 'api-key';
32
+ async function defaultIo() {
33
+ const { default: keychain } = await import('./keychain.js');
34
+ return {
35
+ get: (a) => keychain.getPassword(KEYCHAIN_SERVICE, a),
36
+ set: (a, k) => keychain.setPassword(KEYCHAIN_SERVICE, a, k),
37
+ del: (a) => keychain.deletePassword(KEYCHAIN_SERVICE, a),
38
+ };
39
+ }
40
+ function normHome(p) {
41
+ const r = path.resolve(p).replace(/[\\/]+$/, '');
42
+ return process.platform === 'win32' ? r.toLowerCase() : r;
43
+ }
44
+ export function keychainAccountForHome(home = cspeachRoot(), defaultHome = defaultCspeachRoot()) {
45
+ const h = normHome(home);
46
+ if (h === normHome(defaultHome))
47
+ return DEFAULT_ACCOUNT;
48
+ return `${DEFAULT_ACCOUNT}:${crypto.createHash('sha256').update(h).digest('hex').slice(0, 12)}`;
49
+ }
50
+ const metaPath = () => path.join(authFileDir(), 'key-store.json');
51
+ async function readMeta() {
52
+ try {
53
+ const m = JSON.parse(await fs.readFile(metaPath(), 'utf-8'));
54
+ if (typeof m.account !== 'string')
55
+ return null;
56
+ return { account: m.account, storedAt: typeof m.storedAt === 'string' ? m.storedAt : null, legacyChecked: m.legacyChecked === true };
57
+ }
58
+ catch {
59
+ return null;
60
+ }
61
+ }
62
+ async function writeMeta(m) {
63
+ const p = metaPath();
64
+ await fs.mkdir(path.dirname(p), { recursive: true });
65
+ // Unique per write: two writes in one process must never share a temp file.
66
+ const tmp = `${p}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`;
67
+ await fs.writeFile(tmp, JSON.stringify(m, null, 2), { encoding: 'utf-8', mode: 0o600 });
68
+ try {
69
+ await fs.rename(tmp, p);
70
+ }
71
+ finally {
72
+ await fs.rm(tmp, { force: true }).catch(() => { });
73
+ }
74
+ }
75
+ /** Note that this CLI just wrote the credential store (drives "newer login wins"). */
76
+ export async function recordStoreWrite(account = keychainAccountForHome(), at = new Date().toISOString()) {
77
+ const prev = await readMeta();
78
+ await writeMeta({ account, storedAt: at, legacyChecked: prev?.legacyChecked ?? false });
79
+ }
80
+ export function pickKey(i) {
81
+ const store = i.store?.trim() || null;
82
+ const fileKey = i.auth?.apiKey?.trim() || null;
83
+ if (!store && !fileKey)
84
+ return { key: null, source: 'none', conflict: false };
85
+ if (store && !fileKey)
86
+ return { key: store, source: 'store', conflict: false };
87
+ if (!store && fileKey)
88
+ return { key: fileKey, source: 'auth-file', conflict: false };
89
+ if (store === fileKey)
90
+ return { key: store, source: 'store', conflict: false };
91
+ const storedAtMs = i.storedAt ? Date.parse(i.storedAt) : NaN;
92
+ const fileNewer = i.auth?.savedAt
93
+ ? !Number.isFinite(storedAtMs) || Date.parse(i.auth.savedAt) > storedAtMs
94
+ : Number.isFinite(storedAtMs) && i.authMtimeMs !== null && i.authMtimeMs > storedAtMs;
95
+ return fileNewer
96
+ ? { key: fileKey, source: 'auth-file', conflict: false }
97
+ : { key: store, source: 'store', conflict: true };
98
+ }
99
+ export async function resolveApiKey(deps = {}) {
100
+ let store = null;
101
+ try {
102
+ store = await (deps.io ?? (await defaultIo())).get(keychainAccountForHome());
103
+ }
104
+ catch {
105
+ store = null; // no credential store on this machine: auth.json is the fallback
106
+ }
107
+ const [auth, authMtimeMs, meta] = await Promise.all([readAuthFile(), authFileMtimeMs(), readMeta()]);
108
+ return pickKey({ store, auth, authMtimeMs, storedAt: meta?.storedAt ?? null });
109
+ }
110
+ const exists = async (p) => fs.access(p).then(() => true, () => false);
111
+ /**
112
+ * Run once at start (cli.ts). Idempotent. The common start is one file read
113
+ * and no keychain call: there is nothing to move.
114
+ */
115
+ export async function migrateSignIn(deps = {}) {
116
+ const now = deps.now ?? (() => new Date());
117
+ const account = keychainAccountForHome();
118
+ const auth = await readAuthFile();
119
+ const meta = await readMeta();
120
+ // (a) A non-default home that predates per-home accounts kept its key in the
121
+ // shared `api-key` entry. Copy it once — only for a home already in use, so a
122
+ // brand-new home never inherits another seat's key. Fix round 1 (F1): a home
123
+ // whose auth.json holds its own key has no claim on the shared key — that
124
+ // key is this home's sign-in, moved in (b).
125
+ const needsLegacyCheck = account !== DEFAULT_ACCOUNT && !meta?.legacyChecked && !auth?.apiKey
126
+ && (auth !== null || await exists(path.join(cspeachRoot(), 'config.toml')));
127
+ if (!auth?.apiKey && !needsLegacyCheck)
128
+ return 'none';
129
+ let io;
130
+ try {
131
+ io = deps.io ?? (await defaultIo());
132
+ }
133
+ catch {
134
+ return auth?.apiKey ? 'store-unavailable' : 'none';
135
+ }
136
+ let outcome = 'none';
137
+ try {
138
+ if (needsLegacyCheck) {
139
+ if (!(await io.get(account))) {
140
+ const shared = await io.get(DEFAULT_ACCOUNT);
141
+ if (shared) {
142
+ await io.set(account, shared);
143
+ if ((await io.get(account)) !== shared)
144
+ return 'copy-verify-failed';
145
+ // A copy is not a login: keep the previous storedAt, never "now" (F1).
146
+ await writeMeta({ account, storedAt: meta?.storedAt ?? null, legacyChecked: true });
147
+ outcome = 'copied-shared';
148
+ }
149
+ }
150
+ if (outcome !== 'copied-shared')
151
+ await writeMeta({ account, storedAt: meta?.storedAt ?? null, legacyChecked: true });
152
+ }
153
+ // (b) A key in auth.json: copy → read back → only then remove.
154
+ if (!auth?.apiKey)
155
+ return outcome;
156
+ const store = await io.get(account);
157
+ const pick = pickKey({ store, auth, authMtimeMs: await authFileMtimeMs(), storedAt: (await readMeta())?.storedAt ?? null });
158
+ if (pick.conflict)
159
+ return 'conflict';
160
+ const fileKey = auth.apiKey.trim();
161
+ if (store !== fileKey) {
162
+ await io.set(account, fileKey);
163
+ if ((await io.get(account)) !== fileKey)
164
+ return 'verify-failed'; // never remove the only copy
165
+ outcome = 'migrated';
166
+ }
167
+ else {
168
+ outcome = 'removed';
169
+ }
170
+ // key-store.json is bookkeeping (no secret): its failure never turns a good
171
+ // store write into a "no credential store" notice (F7).
172
+ // Final review (Task 4 N1): a non-default home now holds its own key, so
173
+ // the shared-account check has nothing left to do — mark it done.
174
+ await (account !== DEFAULT_ACCOUNT
175
+ ? writeMeta({ account, storedAt: now().toISOString(), legacyChecked: true })
176
+ : recordStoreWrite(account, now().toISOString())).catch(() => { });
177
+ }
178
+ catch {
179
+ // No key in auth.json (only the shared-account check ran): nothing stays
180
+ // behind, so no notice — the check simply runs again on the next start.
181
+ return auth?.apiKey ? 'store-unavailable' : 'none';
182
+ }
183
+ try {
184
+ await writeAuthFile({ customerId: auth.customerId, ...(auth.savedAt ? { savedAt: auth.savedAt } : {}) });
185
+ }
186
+ catch {
187
+ return 'failed'; // the store holds the key; the next start removes the copy ('removed')
188
+ }
189
+ return outcome;
190
+ }
191
+ /** The one start-up notice for an outcome the user must know about, else null. */
192
+ export function signInMigrationNotice(o) {
193
+ switch (o) {
194
+ case 'conflict':
195
+ return 'cspeach: auth.json in your CSPeach home holds a different CSPeach key than your OS credential store. CSPeach uses the credential store\'s key. Run cspeach login to sign in again and clear this up.';
196
+ case 'store-unavailable':
197
+ return 'cspeach: your OS credential store is not available, so your CSPeach key stays in auth.json in your CSPeach home (on Linux: install and unlock a Secret Service keyring such as gnome-keyring).';
198
+ case 'verify-failed':
199
+ return 'cspeach: your CSPeach key could not be confirmed in your OS credential store, so it stays in auth.json for now. CSPeach tries again on the next start.';
200
+ case 'copy-verify-failed':
201
+ return 'cspeach: the CSPeach key for this CSPeach home could not be confirmed in your OS credential store. CSPeach tries again on the next start, or run cspeach login to sign in this home.';
202
+ default:
203
+ return null;
204
+ }
205
+ }
206
+ /** `cspeach login`: the store first; auth.json keeps customerId + savedAt. */
207
+ export async function saveSignIn(a, deps = {}) {
208
+ const account = keychainAccountForHome();
209
+ const savedAt = (deps.now ?? (() => new Date()))().toISOString();
210
+ // Fix round 1 (F7): only the store calls decide store vs file.
211
+ let stored = false;
212
+ try {
213
+ const io = deps.io ?? (await defaultIo());
214
+ await io.set(account, a.apiKey);
215
+ stored = (await io.get(account)) === a.apiKey;
216
+ }
217
+ catch {
218
+ stored = false;
219
+ }
220
+ if (!stored) {
221
+ await writeAuthFile({ apiKey: a.apiKey, customerId: a.customerId, savedAt });
222
+ return 'file';
223
+ }
224
+ await recordStoreWrite(account, savedAt).catch(() => { }); // bookkeeping only, no secret
225
+ await writeAuthFile({ customerId: a.customerId, savedAt });
226
+ return 'store';
227
+ }
228
+ /**
229
+ * `cspeach logout`: the store entry, auth.json and licence.json. Fix round 1
230
+ * (F2): verified — `cleared` is false when a delete failed or a key still
231
+ * resolves afterwards, so logout never says "removed" while a key is left.
232
+ * (A start running its migration at the same moment can re-store the key it
233
+ * read before this delete; the verify below catches that too.)
234
+ */
235
+ export async function clearSignIn(deps = {}) {
236
+ const account = keychainAccountForHome();
237
+ const auth = await readAuthFile();
238
+ let hadStore = false;
239
+ let storeOk = true;
240
+ let io = null;
241
+ try {
242
+ io = deps.io ?? (await defaultIo());
243
+ hadStore = (await io.get(account)) !== null;
244
+ if (hadStore && (await io.del(account)) === false)
245
+ storeOk = false;
246
+ }
247
+ catch {
248
+ // No credential store: nothing to delete there. A store that held a key
249
+ // but threw on delete is caught by the verify below.
250
+ }
251
+ const fileOk = await deleteAuthFile();
252
+ await fs.rm(licenceCachePath(), { force: true }).catch(() => { });
253
+ // Keep legacyChecked so a signed-out non-default home is never re-filled
254
+ // from the shared account on the next start.
255
+ await writeMeta({ account, storedAt: null, legacyChecked: true }).catch(() => { });
256
+ const left = (await resolveApiKey(io ? { io } : {})).key;
257
+ return { hadSignIn: hadStore || auth !== null, cleared: storeOk && fileOk && left === null };
258
+ }
package/dist/auth/me.js CHANGED
@@ -18,20 +18,26 @@ export async function fetchMe(opts) {
18
18
  try {
19
19
  return await Promise.race([
20
20
  (async () => {
21
- const r = await fetch(url, { headers: { Authorization: `Bearer ${opts.apiKey}` }, signal });
21
+ // No extra headers → exactly `{ Authorization }`, as before the batch.
22
+ const r = await fetch(url, { headers: { ...(opts.headers ?? {}), Authorization: `Bearer ${opts.apiKey}` }, signal });
23
+ const serverDate = r.headers.get('date');
22
24
  if (!r.ok) {
23
25
  const body = await r.json().catch(() => ({}));
24
- return { ok: false, status: r.status, error: body.error ?? 'unknown' };
26
+ return {
27
+ ok: false, status: r.status, error: body?.error ?? 'unknown',
28
+ ...(typeof body?.reason === 'string' ? { reason: body.reason } : {}),
29
+ serverDate,
30
+ };
25
31
  }
26
32
  const profile = await r.json();
27
- return { ok: true, profile };
33
+ return { ok: true, profile, serverDate };
28
34
  })(),
29
35
  timedOut,
30
36
  ]);
31
37
  }
32
38
  catch (err) {
33
39
  if (signal.aborted)
34
- return { ok: false, status: 0, error: `timed out after ${timeoutMs} ms` };
35
- return { ok: false, status: 0, error: err instanceof Error ? err.message : String(err) };
40
+ return { ok: false, status: 0, error: `timed out after ${timeoutMs} ms`, serverDate: null };
41
+ return { ok: false, status: 0, error: err instanceof Error ? err.message : String(err), serverDate: null };
36
42
  }
37
43
  }
package/dist/cli.js CHANGED
@@ -27,6 +27,15 @@ export async function main(argv) {
27
27
  const legacyNotice = legacyByokKeyNotice(await migrateLegacyByokKey().catch(() => 'failed'));
28
28
  if (legacyNotice)
29
29
  console.error(legacyNotice);
30
+ // Security batch item 6 — move the CSPeach key from auth.json into this
31
+ // home's OS credential store entry (copy → read back → only then remove).
32
+ // Once; idempotent; one notice when it cannot.
33
+ {
34
+ const { migrateSignIn, signInMigrationNotice } = await import('./auth/key-resolver.js');
35
+ const notice = signInMigrationNotice(await migrateSignIn().catch(() => 'failed'));
36
+ if (notice)
37
+ console.error(notice);
38
+ }
30
39
  let args = argv.slice(2);
31
40
  // Standalone mode (2026-09-16) — `--no-sap` is accepted ANYWHERE in the args
32
41
  // for both the REPL and one-shot, and stripped like `--sap-alias` below.
@@ -162,6 +171,14 @@ export async function main(argv) {
162
171
  const { runDoctor } = await import('./doctor/run.js');
163
172
  return runDoctor();
164
173
  }
174
+ // Azure tenant mode (Task 6b) — the Microsoft sign-in alone, with the
175
+ // company settings already on this machine. Same opener as `cspeach login`:
176
+ // no shell, the authorize URL (full of &) as one argument.
177
+ if (args[0] === 'login' && args[1] === 'azure') {
178
+ const { runLoginAzureCommand } = await import('./commands/login-azure.js');
179
+ const { loginOpener } = await import('./util/open-in-browser.js');
180
+ return runLoginAzureCommand({ deviceCode: args.includes('--device-code'), print: (s) => console.log(s), open: loginOpener() });
181
+ }
165
182
  if (args[0] === 'login') {
166
183
  const { runLoginCommand } = await import('./commands/login.js');
167
184
  const { input } = await import('@inquirer/prompts');
@@ -176,6 +193,7 @@ export async function main(argv) {
176
193
  open,
177
194
  print: (s) => console.log(s),
178
195
  prompt: (q) => input({ message: q }),
196
+ deviceCode: args.includes('--device-code'),
179
197
  });
180
198
  }
181
199
  if (args[0] === 'logout') {
@@ -186,7 +204,7 @@ export async function main(argv) {
186
204
  const { runWhoamiCommand } = await import('./commands/whoami.js');
187
205
  const { loadConfig } = await import('./config/loader.js');
188
206
  const cfg = await loadConfig();
189
- return runWhoamiCommand({ proxyUrl: cfg.proxy_url, print: (s) => console.log(s) });
207
+ return runWhoamiCommand({ proxyUrl: cfg.proxy_url, print: (s) => console.log(s), mode: cfg.llm.mode });
190
208
  }
191
209
  // A team project — many laptops, one picture (spec 2026-09-18).
192
210
  if (args[0] === 'team') {
@@ -297,7 +315,8 @@ Usage:
297
315
  cspeach sap link [alias] [--project dir] Let a local CAP app reach its S/4 services (Ctrl+C stops)
298
316
  cspeach doctor Diagnose install + connectivity
299
317
  cspeach login Sign in via device-code flow
300
- cspeach logout Sign out (keep keychain key, if any)
318
+ cspeach login azure Sign in to Microsoft for your company's Azure (--device-code: use a code instead of the browser)
319
+ cspeach logout Sign out (removes the CSPeach key from this home)
301
320
  cspeach whoami Print current account info
302
321
  cspeach team discover Read the whole estate from SAP into this folder (read only)
303
322
  cspeach team start <packages> Start a team project in this folder: object list, first check, you own it all
@@ -33,6 +33,7 @@ import { paint } from '../renderer/theme.js';
33
33
  import { getGlyphs } from '../renderer/glyphs.js';
34
34
  import { saveSession } from '../session/store.js';
35
35
  import { isUserPrompt } from '../session/user-prompt.js';
36
+ import { getActiveDeployments } from '../models/model-identity.js';
36
37
  /** Keep this many recent user-prompt anchored turns verbatim. */
37
38
  export const KEEP_RECENT_TURNS = 5;
38
39
  /** Don't bother compacting if there's only a handful of older turns. */
@@ -200,6 +201,16 @@ export function renderTodoSection(todos) {
200
201
  * turns are where the list was built, and Haiku isn't trusted to
201
202
  * reproduce it verbatim.
202
203
  */
204
+ /**
205
+ * Azure tenant mode, Task 5a — the model the summary line names. With a
206
+ * company deployment table (azure only) compaction runs on the small
207
+ * deployment, so the line names it and its family; every other mode keeps
208
+ * the COMPACTION_MODEL text byte for byte.
209
+ */
210
+ function compactionModelLabel() {
211
+ const table = getActiveDeployments();
212
+ return table ? `${table.small.name} (${table.small.model})` : COMPACTION_MODEL;
213
+ }
203
214
  export function buildSummaryMessage(summary, droppedTurns, todos) {
204
215
  const ts = new Date().toISOString();
205
216
  const todoSection = renderTodoSection(todos);
@@ -207,7 +218,7 @@ export function buildSummaryMessage(summary, droppedTurns, todos) {
207
218
  role: 'user',
208
219
  content: [
209
220
  `[Context summary auto-generated by /compact at ${ts}.`,
210
- `Original turns (${droppedTurns}) were summarised by ${COMPACTION_MODEL} to`,
221
+ `Original turns (${droppedTurns}) were summarised by ${compactionModelLabel()} to`,
211
222
  `reduce cache cost on subsequent turns. The original session is backed up`,
212
223
  `to ~/.cspeach/sessions/${'<session-id>'}.pre-compact-<timestamp>.json]`,
213
224
  '',