@cspeach/cli 1.1.33 → 1.1.35

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 (119) 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 +182 -39
  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 +19 -6
  33. package/dist/commands/help.js +2 -2
  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 +6 -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/router-spend.js +3 -0
  62. package/dist/cost/session-cost.js +4 -0
  63. package/dist/doctor/checks/azure.js +100 -0
  64. package/dist/doctor/checks/credits.js +4 -3
  65. package/dist/doctor/checks/usage-reporting.js +22 -0
  66. package/dist/doctor/run.js +7 -0
  67. package/dist/lib/graceful-exit.js +17 -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/projects/answer-blockers.js +25 -12
  76. package/dist/renderer/change-card.js +28 -4
  77. package/dist/renderer/footer-line.js +7 -1
  78. package/dist/renderer/notices.js +27 -13
  79. package/dist/renderer/progress-chatter.js +3 -1
  80. package/dist/renderer/status-footer.js +13 -6
  81. package/dist/renderer/tool-labels.js +8 -2
  82. package/dist/renderer/verify-chain.js +41 -18
  83. package/dist/renderer/wrap-rows.js +87 -0
  84. package/dist/repl/ask-or-no.js +18 -0
  85. package/dist/repl/builtin-commands.js +10 -2
  86. package/dist/repl/post-turn-status.js +3 -2
  87. package/dist/repl/safety-confirm.js +47 -2
  88. package/dist/repl/session-spend-line.js +4 -1
  89. package/dist/repl/startup-account.js +51 -0
  90. package/dist/repl.js +217 -48
  91. package/dist/router/byok-classifier.js +41 -14
  92. package/dist/router/classifier.js +5 -16
  93. package/dist/router/router-target.js +41 -0
  94. package/dist/sap/connection-manager.js +15 -0
  95. package/dist/sap/onboarding.js +70 -64
  96. package/dist/sap/session-end.js +66 -0
  97. package/dist/session/audit-export.js +11 -4
  98. package/dist/skills/manifest-client.js +37 -4
  99. package/dist/skills/skills-cache.js +115 -0
  100. package/dist/skills/source-authed.js +276 -0
  101. package/dist/tools/ask-question.js +1 -1
  102. package/dist/tools/filesystem/glob.js +12 -3
  103. package/dist/tools/filesystem/grep.js +14 -3
  104. package/dist/tools/sap-read.js +22 -22
  105. package/dist/tools/sap-write.js +249 -18
  106. package/dist/tools/snapshot.js +26 -0
  107. package/dist/tools/tool-loading.js +9 -10
  108. package/dist/ui/app.js +19 -9
  109. package/dist/ui/approval-modal.js +37 -5
  110. package/dist/ui/live-area.js +14 -1
  111. package/dist/ui/widgets/ask-card.js +183 -107
  112. package/dist/ui/widgets/coaching-picker.js +22 -1
  113. package/dist/ui/widgets/confirm-card.js +102 -76
  114. package/dist/usage/usage-queue.js +323 -0
  115. package/dist/usage/usage-reporter.js +270 -0
  116. package/package.json +4 -3
  117. package/dist/skills/bundled-skills.js +0 -286
  118. package/dist/skills/source-bundled.js +0 -20
  119. package/dist/skills/source-manifest.js +0 -36
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
  '',
@@ -20,6 +20,7 @@ const MUTABLE_KEYS = new Set([
20
20
  'write_mode',
21
21
  'plan_mode',
22
22
  'plan_audit',
23
+ 'usage_reporting',
23
24
  'approvals',
24
25
  'no_sap',
25
26
  'llm.mode',
@@ -31,6 +32,7 @@ const MUTABLE_KEYS = new Set([
31
32
  // Virtual keys that route to keychain (not saved to config.toml):
32
33
  'llm.byok_key',
33
34
  'llm.ai_hub_token',
35
+ 'llm.azure_key',
34
36
  ]);
35
37
  // SAP-scoped key pattern: sap.<alias>.auto_approve
36
38
  const SAP_AUTO_APPROVE_RE = /^sap\.([A-Z0-9_-]{2,32})\.auto_approve$/;
@@ -70,9 +72,10 @@ const EFFORT_VALUES = ['low', 'medium', 'high', 'xhigh', 'max'];
70
72
  const TELEMETRY_VALUES = ['minimal', 'full'];
71
73
  const AUTO_APPROVE_VALUES = ['never', 'low', 'medium'];
72
74
  const SYSTEM_ROLE_VALUES = ['dev', 'qas', 'prd'];
73
- const LLM_MODE_VALUES = ['managed', 'byok', 'ai-hub', 'local'];
75
+ const LLM_MODE_VALUES = ['managed', 'byok', 'ai-hub', 'local', 'azure'];
74
76
  const PLAN_MODE_VALUES = ['step', 'guarded'];
75
77
  const PLAN_AUDIT_VALUES = ['on', 'off'];
78
+ const USAGE_REPORTING_VALUES = ['on', 'off'];
76
79
  const APPROVALS_VALUES = ['plan', 'per-write'];
77
80
  /**
78
81
  * Thrown on validation failure. The outer CLI dispatcher converts to
@@ -105,7 +108,7 @@ const INTERNAL_KEYS = new Set(['default_model_set', 'effort_set', 'file_keys']);
105
108
  * unset` cannot remove them — that is a keychain operation — so they are
106
109
  * rejected with a pointer rather than silently no-op'd.
107
110
  */
108
- const VIRTUAL_KEYCHAIN_KEYS = new Set(['llm.byok_key', 'llm.ai_hub_token']);
111
+ const VIRTUAL_KEYCHAIN_KEYS = new Set(['llm.byok_key', 'llm.ai_hub_token', 'llm.azure_key']);
109
112
  /**
110
113
  * MUTABLE_KEYS that `config unset` can act on (excludes keychain virtuals and
111
114
  * the deprecated `local_files` alias — the canonical name is `local_build`).
@@ -392,6 +395,38 @@ export async function runConfigSet(args) {
392
395
  console.log(chalk.green(`Set ${key} = ${value}`));
393
396
  return;
394
397
  }
398
+ // Azure tenant mode (Task 5a) — the admin-issued Azure key, only when the
399
+ // company allows keys. Stored in the OS credential store per CSPEACH_HOME.
400
+ if (key === 'llm.azure_key') {
401
+ const { readConnectionCache } = await import('../connection/cache.js');
402
+ const company = await readConnectionCache();
403
+ if (!company || company.removed)
404
+ err('Your company has no Azure settings in CSPeach. Run cspeach login first.');
405
+ if (!company.connection.sign_in.includes('key'))
406
+ err('Your company does not allow Azure keys in CSPeach. Use cspeach login azure.');
407
+ let secret = value;
408
+ if (secret === '-') {
409
+ if (process.stdin.isTTY) {
410
+ const { password } = await import('@inquirer/prompts');
411
+ secret = await password({ message: 'Azure key (input masked):', mask: '*' });
412
+ }
413
+ else {
414
+ const chunks = [];
415
+ for await (const c of process.stdin)
416
+ chunks.push(c);
417
+ secret = Buffer.concat(chunks).toString('utf8').trim();
418
+ }
419
+ }
420
+ else {
421
+ console.log(chalk.yellow('WARN: the key was passed as a shell argument and is now in your shell history. Prefer: cspeach config set llm.azure_key -'));
422
+ }
423
+ if (!secret || !secret.trim())
424
+ err('No key was given.');
425
+ const { setAzureKey } = await import('../auth/azure/azure-key.js');
426
+ await setAzureKey(secret.trim());
427
+ console.log(chalk.green('✓ Azure key stored in your OS credential store'));
428
+ return;
429
+ }
395
430
  // Virtual keys that store secrets in the OS keychain instead of config.toml.
396
431
  if (key === 'llm.byok_key' || key === 'llm.ai_hub_token') {
397
432
  // Support stdin piping for secrets: `echo "key" | cspeach config set llm.byok_key -`
@@ -557,6 +592,25 @@ export async function runConfigSet(args) {
557
592
  }
558
593
  cfg.plan_audit = value;
559
594
  break;
595
+ case 'usage_reporting':
596
+ // Security batch item 3 — usage counts from non-managed turns.
597
+ if (!USAGE_REPORTING_VALUES.includes(value)) {
598
+ err(`Invalid usage_reporting "${value}". Allowed: ${USAGE_REPORTING_VALUES.join(', ')}`);
599
+ }
600
+ // Azure tenant mode, Task 8b (plan D11) — the company decides: when its
601
+ // settings require usage counts, or this seat has a monthly limit (which
602
+ // needs the counts; fix round 1, ruling 1), "off" is refused and nothing
603
+ // changes. Every other mode keeps today's switch exactly.
604
+ if (value === 'off' && cfg.llm?.mode === 'azure') {
605
+ const { companyUsageRule } = await import('../usage/usage-reporter.js');
606
+ const rule = await companyUsageRule(cfg);
607
+ if (rule === 'required')
608
+ err('Your company requires usage counts for CSPeach on Azure');
609
+ if (rule === 'limit')
610
+ err('Your company sets a monthly CSPeach limit for you, so usage counts stay on.');
611
+ }
612
+ cfg.usage_reporting = value;
613
+ break;
560
614
  case 'approvals':
561
615
  // 'plan' = the plan's one approval covers its listed objects (default);
562
616
  // 'per-write' = a card before every write.
@@ -565,12 +619,21 @@ export async function runConfigSet(args) {
565
619
  }
566
620
  cfg.approvals = value;
567
621
  break;
568
- case 'llm.mode':
622
+ case 'llm.mode': {
569
623
  if (!LLM_MODE_VALUES.includes(value)) {
570
624
  err(`Invalid llm.mode "${value}". Allowed: ${LLM_MODE_VALUES.join(', ')}`);
571
625
  }
626
+ // Azure tenant mode — the org setting wins (spec "How settings reach the
627
+ // CLI"). The cache is this CSPeach home's connection.json.
628
+ const { readConnectionCache } = await import('../connection/cache.js');
629
+ const company = await readConnectionCache();
630
+ if (value !== 'azure' && company && !company.removed)
631
+ err('Your company set CSPeach to use your Azure. Run cspeach logout to leave it.');
632
+ if (value === 'azure' && !company)
633
+ err('Your company has no Azure settings on this machine. Run cspeach login to load them.');
572
634
  cfg.llm.mode = value;
573
635
  break;
636
+ }
574
637
  case 'llm.ai_hub_base_url':
575
638
  try {
576
639
  new URL(value);
@@ -613,6 +676,11 @@ export async function runConfigSet(args) {
613
676
  // value-diff can't see — e.g. `config set default_model claude-opus-4-8`).
614
677
  markExplicit(cfg, key);
615
678
  await saveConfig(cfg);
679
+ // F15 — off means off: the counts already waiting are deleted too.
680
+ if (key === 'usage_reporting' && value === 'off') {
681
+ const { deleteUsageQueue } = await import('../usage/usage-queue.js');
682
+ await deleteUsageQueue().catch(() => { });
683
+ }
616
684
  console.log(chalk.green(`Set ${key} = ${value}`));
617
685
  }
618
686
  /**
@@ -689,6 +757,8 @@ export function validateValue(key, value) {
689
757
  return PLAN_MODE_VALUES.includes(value);
690
758
  case 'plan_audit':
691
759
  return PLAN_AUDIT_VALUES.includes(value);
760
+ case 'usage_reporting':
761
+ return USAGE_REPORTING_VALUES.includes(value);
692
762
  case 'approvals':
693
763
  return APPROVALS_VALUES.includes(value);
694
764
  case 'no_sap':
@@ -711,6 +781,7 @@ export function validateValue(key, value) {
711
781
  return isModelAlias(value);
712
782
  case 'llm.byok_key':
713
783
  case 'llm.ai_hub_token':
784
+ case 'llm.azure_key':
714
785
  return value.length > 0; // Secrets pass through if non-empty; no format check
715
786
  default:
716
787
  return false;
@@ -51,6 +51,7 @@ const LLM_REQUIRED_KEYS_BY_MODE = {
51
51
  byok: [],
52
52
  'ai-hub': ['ai_hub_base_url', 'ai_hub_model_alias'],
53
53
  local: ['local_base_url', 'local_model'],
54
+ azure: [],
54
55
  };
55
56
  function printLlmMissingHints(llm) {
56
57
  if (typeof llm !== 'object' || llm === null)
@@ -134,6 +135,24 @@ function resolveDottedKey(cfg, keyPath) {
134
135
  }
135
136
  return current;
136
137
  }
138
+ /** Azure tenant mode (Task 7) — the company's settings, read-only, never a secret. */
139
+ export function formatAzureBlock(c) {
140
+ const d = c.connection.deployments;
141
+ const entry = (e) => (e ? `${e.name} (${e.model})` : '<none: plan reviews run on main>');
142
+ return [
143
+ '',
144
+ `${chalk.bold('[azure]')} ${chalk.gray('# set by your company · read-only · change it through your CSPeach admin')}`,
145
+ `endpoint = "${c.connection.endpoint}"`,
146
+ `tenant = "${c.connection.entra_tenant_id}"`,
147
+ `main = "${entry(d.main)}"`,
148
+ `small = "${entry(d.small)}"`,
149
+ `review = "${entry(d.review)}"`,
150
+ `sign_in = "${c.connection.sign_in.join(', ')}"`,
151
+ `usage_counts = "${c.connection.usage_counts}"`,
152
+ ...(c.pending ? [chalk.yellow(`# a changed endpoint is waiting for you: ${c.pending.endpoint} (start cspeach to review it)`)] : []),
153
+ ...(c.removed ? [chalk.yellow('# your company removed these settings: run cspeach login')] : []),
154
+ ];
155
+ }
137
156
  /**
138
157
  * Executes `cspeach config show [key]`.
139
158
  * Without argument: prints full config as TOML (secrets redacted).
@@ -153,6 +172,12 @@ export async function runConfigShow(args) {
153
172
  if (customer?.model && customer.enforced) {
154
173
  console.log(chalk.gray(`# model set by your account admin: ${customer.model} (enforced)`));
155
174
  }
175
+ if (cfg.llm.mode === 'azure') {
176
+ const { readConnectionCache } = await import('../connection/cache.js');
177
+ const c = await readConnectionCache();
178
+ for (const l of c ? formatAzureBlock(c) : ['', chalk.yellow("# [azure] your company's settings are not on this machine: run cspeach login")])
179
+ console.log(l);
180
+ }
156
181
  return;
157
182
  }
158
183
  const keyPath = args[0];
@@ -11,7 +11,7 @@
11
11
  import chalk from 'chalk';
12
12
  import { readCostLog, summarise, turnRows } from '../cost/cost-log.js';
13
13
  import { formatCost } from '../cost/pricing.js';
14
- import { creditsActive, formatCredits, globalCredits, CREDITS_PENDING, } from '../cost/credits.js';
14
+ import { creditsActive, tokensOnlyActive, formatCredits, globalCredits, CREDITS_PENDING, } from '../cost/credits.js';
15
15
  import { panelHeading, panelRow } from '../renderer/panel-rows.js';
16
16
  import { formatInfo } from '../renderer/notice-shape.js';
17
17
  import { countLabel } from '../renderer/checklist-format.js';
@@ -54,6 +54,9 @@ export async function runCostCommand(sessionId, credits = globalCredits.snapshot
54
54
  // 2026-10-02 (owner): tokens go too. Not a COGS leak on their own, but beside
55
55
  // credits they invite "credits per token" maths — credits only for managed.
56
56
  const inCredits = creditsActive();
57
+ // Azure (Task 5b): tokens only — CSPeach has no price for the customer's
58
+ // deployments, so no formatCost() figure is printed anywhere below.
59
+ const tokensOnly = tokensOnlyActive();
57
60
  const perTurn = summariseCredits(entries);
58
61
  const turnsNote = `${s.turns} turn${s.turns === 1 ? '' : 's'}`;
59
62
  emit('');
@@ -66,6 +69,9 @@ export async function runCostCommand(sessionId, credits = globalCredits.snapshot
66
69
  if (credits?.balance != null)
67
70
  emit(panelRow('Balance', formatCredits(credits.balance, unit)));
68
71
  }
72
+ else if (tokensOnly) {
73
+ emit(panelHeading('Session tokens so far', turnsNote));
74
+ }
69
75
  else {
70
76
  emit(panelHeading(`Session cost so far: ${formatCost(s.totalCost)}`, turnsNote));
71
77
  }
@@ -75,13 +81,19 @@ export async function runCostCommand(sessionId, credits = globalCredits.snapshot
75
81
  emit(panelRow('input', s.totalTokens.input.toLocaleString().padStart(12)));
76
82
  emit(panelRow('output', s.totalTokens.output.toLocaleString().padStart(12)));
77
83
  emit(panelRow('cache read', s.totalTokens.cacheRead.toLocaleString().padStart(12)));
78
- emit(panelRow('cache create', s.totalTokens.cacheCreate.toLocaleString().padStart(12)));
84
+ // Azure (Task 7, ruling 7): the spec's wording; other modes unchanged.
85
+ emit(panelRow(tokensOnly ? 'cache write' : 'cache create', s.totalTokens.cacheCreate.toLocaleString().padStart(12)));
86
+ }
87
+ if (tokensOnly) {
88
+ emit('');
89
+ emit(panelRow('Cost', 'billed on your Azure invoice · CSPeach has no price for your deployments'));
79
90
  }
80
91
  // Task 18 fix M3 — reader subagent spend (already inside the total above).
81
92
  // USD only: in credits mode the balance delta already carries it.
82
93
  if (s.readerCalls > 0 && !inCredits) {
83
94
  emit('');
84
- emit(panelRow('Readers', `${s.readerCalls} run${s.readerCalls === 1 ? '' : 's'} ${formatCost(s.readerCost)}`));
95
+ const readerTail = tokensOnly ? '' : ` ${formatCost(s.readerCost)}`;
96
+ emit(panelRow('Readers', `${s.readerCalls} run${s.readerCalls === 1 ? '' : 's'}${readerTail}`));
85
97
  }
86
98
  if (s.byModel.length > 1 && !inCredits) {
87
99
  emit('');
@@ -89,7 +101,7 @@ export async function runCostCommand(sessionId, credits = globalCredits.snapshot
89
101
  for (const m of s.byModel) {
90
102
  // Credits mode: turn counts only. A per-model dollar split is a direct
91
103
  // read on our per-model COGS.
92
- const tail = inCredits ? '' : ` ${formatCost(m.cost)}`;
104
+ const tail = inCredits || tokensOnly ? '' : ` ${formatCost(m.cost)}`;
93
105
  emit(panelRow(modelText(m.model), `${m.turns} turn${m.turns === 1 ? '' : 's'}${tail}`, 24));
94
106
  }
95
107
  }
@@ -117,8 +129,9 @@ export async function runCostCommand(sessionId, credits = globalCredits.snapshot
117
129
  ? (typeof e.credits === 'number'
118
130
  ? formatCredits(e.credits, credits?.unitLabel ?? 'credits')
119
131
  : CREDITS_PENDING)
120
- : formatCost(e.cost);
121
- emit(panelRow(`turn ${String(e.turn).padStart(3)}`, inCredits ? tail : `${tok.padStart(10)} tok ${tail}`, 8));
132
+ : tokensOnly ? '' : formatCost(e.cost);
133
+ const row = inCredits ? tail : tokensOnly ? `${tok.padStart(10)} tok` : `${tok.padStart(10)} tok ${tail}`;
134
+ emit(panelRow(`turn ${String(e.turn).padStart(3)}`, row, 8));
122
135
  }
123
136
  if (inCredits && perTurn.unknown > 0) {
124
137
  emit('');
@@ -1,6 +1,6 @@
1
1
  import chalk from 'chalk';
2
2
  import { SKILL_CATALOG } from '../skill-catalog.js';
3
- import { costCommandDescription, USAGE_DESCRIPTION } from '../repl/builtin-commands.js';
3
+ import { costCommandDescription, usageCommandDescription } from '../repl/builtin-commands.js';
4
4
  import { panelHeading } from '../renderer/panel-rows.js';
5
5
  import { paint } from '../renderer/theme.js';
6
6
  import { uiWidth, truncateToWidth } from '../renderer/ui-width.js';
@@ -52,7 +52,7 @@ export const HELP_GROUPS = [
52
52
  { name: '/new', description: fixed('Next prompt picks a skill afresh (also /reset)') },
53
53
  { name: '/recap', description: fixed('Show the recap (last session, open tasks, plan)') },
54
54
  { name: '/cost', description: costCommandDescription },
55
- { name: '/usage', description: fixed(USAGE_DESCRIPTION) },
55
+ { name: '/usage', description: usageCommandDescription },
56
56
  { name: '/context', description: fixed("Show what fills the model's context window") },
57
57
  { name: '/compact', description: fixed('Shrink a long session and keep the facts') },
58
58
  { name: '/help', description: fixed('This screen (also /skills)') },
@@ -0,0 +1,33 @@
1
+ /**
2
+ * `cspeach login azure [--device-code]` — the Microsoft sign-in alone (token
3
+ * expired, password changed, a new policy). Uses the company settings already
4
+ * on this machine; `cspeach login` fetches them.
5
+ */
6
+ import { formatFailure } from '../renderer/notice-shape.js';
7
+ import { paint } from '../renderer/theme.js';
8
+ import { getGlyphs } from '../renderer/glyphs.js';
9
+ import { sanitizeControlChars } from '../renderer/sanitize.js';
10
+ import { readConnectionCache } from '../connection/cache.js';
11
+ import { signInAzure } from '../auth/azure/sign-in.js';
12
+ export async function runLoginAzureCommand(deps) {
13
+ const cached = await (deps.read ?? readConnectionCache)();
14
+ if (!cached || cached.removed) {
15
+ deps.print(formatFailure({ what: "Your company's Azure settings are not on this machine.", next: 'cspeach login' }).trimEnd());
16
+ return 2;
17
+ }
18
+ const conn = cached.connection;
19
+ if (!conn.sign_in.includes('entra')) {
20
+ deps.print(formatFailure({ what: 'Your company uses Azure keys, not Microsoft sign-in.', next: 'ask IT for your key, then cspeach config set llm.azure_key -' }).trimEnd());
21
+ return 2;
22
+ }
23
+ const r = await (deps.signIn ?? signInAzure)(conn, { deviceCode: deps.deviceCode }, { open: deps.open, print: deps.print });
24
+ if (!r.ok) {
25
+ deps.print(formatFailure({ what: r.what, next: r.next }).trimEnd());
26
+ return 2;
27
+ }
28
+ const until = r.expiresOn ? ` · token valid until ${r.expiresOn.toISOString()}` : '';
29
+ // The account name comes from Microsoft: one line, no control characters.
30
+ const who = sanitizeControlChars(r.username).replace(/\s+/g, ' ').trim();
31
+ deps.print(`${paint('ok', getGlyphs().done)} Signed in to Microsoft as ${who}${until}`);
32
+ return 0;
33
+ }
@@ -6,23 +6,30 @@
6
6
  * 2) Open the browser to verification_url_complete (or print URL if open fails)
7
7
  * 3) Print user_code prominently so customer can read it
8
8
  * 4) Poll /v1/auth/device/poll every interval until verified or maxAttempts
9
- * 5) Save api_key + customer_id to ~/.cspeach/auth.json
9
+ * 5) Save api_key to the OS credential store (this CSPeach home's account);
10
+ * auth.json keeps customer_id + savedAt (the key only without a store)
10
11
  * 6) Call GET /v1/me — if trial_mode === 'byok', pull the org's Anthropic key,
11
12
  * or prompt for the user's own
12
13
  * 7) Store that key in the OS keychain (cspeach.anthropic/api-key) — never
13
14
  * in auth.json (2026-10-02)
15
+ * 8) GET /v1/me/connection — Azure tenant mode: the company's Azure
16
+ * settings are shown and saved only on a yes (a no exits 6)
17
+ * 9) Azure with Entra: the Microsoft sign-in (browser, or a device code
18
+ * with --device-code); a failure exits 7 and keeps the CSPeach sign-in
14
19
  *
15
20
  * Dependencies (open, print, prompt) are injected so the command is
16
21
  * unit-testable without stubbing platform globals.
17
22
  */
18
23
  import keychain from '../auth/keychain.js';
19
- import { writeAuthFile } from '../auth/auth-file.js';
24
+ import { saveSignIn } from '../auth/key-resolver.js';
20
25
  import { fetchOrgAnthropicKey } from '../auth/org-anthropic-key.js';
21
26
  import { formatFailure, formatAttention, formatInfo } from '../renderer/notice-shape.js';
22
27
  import { panelRow } from '../renderer/panel-rows.js';
23
28
  import { paint } from '../renderer/theme.js';
24
29
  import { getGlyphs } from '../renderer/glyphs.js';
25
30
  import { sanitizeControlChars } from '../renderer/sanitize.js';
31
+ import { runConnectionStep } from '../connection/login-step.js';
32
+ import { clientIdConfigured } from '../connection/azure-facts.js';
26
33
  const ANTHROPIC_KEYCHAIN_SERVICE = 'cspeach.anthropic';
27
34
  const ANTHROPIC_KEYCHAIN_ACCOUNT = 'api-key';
28
35
  /** Server-supplied text (user code, URL, email, plan) is one line with no control characters. */
@@ -92,12 +99,13 @@ export async function runLoginCommand(deps) {
92
99
  deps.print(formatFailure({ what: 'Timed out waiting for the browser sign-in', next: 'cspeach login' }).trimEnd());
93
100
  return 5;
94
101
  }
95
- // 5) Save (non-BYOK part).
96
- await writeAuthFile({
97
- apiKey: polled.api_key,
98
- customerId: polled.customer_id,
99
- });
100
- deps.print(done('Saved your CSPeach key to auth.json in your CSPeach home'));
102
+ // 5) Save (non-BYOK part). Security batch item 6 — the OS credential store
103
+ // first; auth.json keeps customerId + savedAt, and the key only when no
104
+ // credential store works on this machine.
105
+ const savedTo = await (deps.saveSignIn ?? saveSignIn)({ apiKey: polled.api_key, customerId: polled.customer_id });
106
+ deps.print(done(savedTo === 'store'
107
+ ? 'Saved your CSPeach key to your OS credential store'
108
+ : 'Saved your CSPeach key to auth.json in your CSPeach home (no OS credential store is available)'));
101
109
  // 6) /v1/me — figure out if BYOK is required.
102
110
  try {
103
111
  const r = await fetch(`${proxy}/v1/me`, { headers: { Authorization: `Bearer ${polled.api_key}` } });
@@ -138,6 +146,46 @@ export async function runLoginCommand(deps) {
138
146
  catch (err) {
139
147
  deps.print(formatInfo(`Could not read your account details (${err instanceof Error ? err.message : String(err)}) · sign-in still worked`).trimEnd());
140
148
  }
149
+ // 8) Azure tenant mode — the company's connection settings (no secrets).
150
+ // { kind: 'none' } changes nothing; an Azure answer is confirmed on screen.
151
+ const step = await (deps.connectionStep ?? runConnectionStep)({
152
+ proxyUrl: proxy, apiKey: polled.api_key, print: deps.print,
153
+ confirm: async (q) => /^y(es)?$/i.test((await deps.prompt(`${q} [y/n] `)).trim()),
154
+ });
155
+ if (step.result === 'declined')
156
+ return 6;
157
+ // Azure tenant mode (Task 6b) — with Entra the Microsoft sign-in follows
158
+ // the settings. A failed one keeps the CSPeach sign-in (exit 7).
159
+ if (step.result === 'azure') {
160
+ if (step.connection.sign_in.includes('entra')) {
161
+ // Only when this build can sign in to Microsoft at all; otherwise the
162
+ // sign-in's own refusal is the one line.
163
+ if (clientIdConfigured())
164
+ deps.print(formatInfo("Now sign in to Microsoft for your company's Azure").trimEnd());
165
+ const signIn = deps.azureSignIn ?? (await import('../auth/azure/sign-in.js')).signInAzure;
166
+ const r = await signIn(step.connection, { deviceCode: deps.deviceCode === true }, { open: deps.open, print: deps.print });
167
+ if (!r.ok) {
168
+ deps.print(formatFailure({ what: r.what, next: r.next }).trimEnd());
169
+ deps.print(formatInfo('Your CSPeach sign-in worked · when the Microsoft sign-in is sorted out, run cspeach login azure').trimEnd());
170
+ return 7;
171
+ }
172
+ deps.print(done(`Signed in to Microsoft as ${serverText(r.username)}`));
173
+ }
174
+ else {
175
+ deps.print(formatInfo('Your company uses Azure keys · ask IT for yours, then run cspeach config set llm.azure_key -').trimEnd());
176
+ }
177
+ }
178
+ // Fix round 1 (I3): still set to azure (e.g. after cspeach logout) but the
179
+ // company has no settings. Never suggest managed: that would move prompts
180
+ // to our service without the company's say.
181
+ // Ruling 1 (Task 7): only a real { kind: 'none' } answer says so; a service
182
+ // without the settings route ('no_route') stays silent.
183
+ if (step.result === 'none') {
184
+ const { loadConfig } = await import('../config/loader.js');
185
+ const mode = await loadConfig().then((c) => c.llm?.mode).catch(() => undefined);
186
+ if (mode === 'azure')
187
+ deps.print(formatInfo('Your company has no Azure settings in CSPeach · ask your CSPeach admin which setup to use').trimEnd());
188
+ }
141
189
  deps.print(` ${paint('dim', 'Next:')} run cspeach to start`);
142
190
  return 0;
143
191
  }
@@ -1,21 +1,45 @@
1
1
  /**
2
- * `cspeach logout` — delete the device-code-flow auth file. The REPL
3
- * will fall back to the keytar-stored API key if one is present.
2
+ * `cspeach logout` — security batch item 6: delete this home's credential
3
+ * store entry, auth.json and licence.json; Azure tenant mode adds the
4
+ * company's Azure entries in this home (clearAzureSignIn). Other CSPeach
5
+ * homes are untouched.
4
6
  */
5
- import { deleteAuthFile, readAuthFile } from '../auth/auth-file.js';
6
- import { formatInfo } from '../renderer/notice-shape.js';
7
+ import { clearSignIn } from '../auth/key-resolver.js';
8
+ import { clearAzureSignIn } from '../auth/azure/clear.js';
9
+ import { formatFailure, formatInfo } from '../renderer/notice-shape.js';
7
10
  import { paint } from '../renderer/theme.js';
8
11
  import { getGlyphs } from '../renderer/glyphs.js';
9
12
  export async function runLogoutCommand(deps) {
10
- const before = await readAuthFile();
11
- await deleteAuthFile();
12
- if (before) {
13
- deps.print(`${paint('ok', getGlyphs().done)} Signed out · removed auth.json from your CSPeach home`);
14
- deps.print(` ${paint('dim', 'The REPL uses the keychain-stored key if you have one.')}`);
13
+ const { hadSignIn, cleared } = await (deps.clear ?? (() => clearSignIn()))();
14
+ // Fix round 1 (F2): never say "removed" while a key is still in this home.
15
+ if (cleared === false) {
16
+ deps.print(formatFailure({
17
+ what: 'Could not sign out completely',
18
+ detail: ['a CSPeach key is still in this CSPeach home (OS credential store or auth.json)'],
19
+ next: 'close other cspeach windows, then run cspeach logout again',
20
+ }).trimEnd());
21
+ return 1;
22
+ }
23
+ const az = await (deps.clearAzure ?? (() => clearAzureSignIn()))()
24
+ .catch(() => ({ removed: [], failed: ['your Azure sign-in details'] }));
25
+ if (hadSignIn) {
26
+ deps.print(`${paint('ok', getGlyphs().done)} Signed out · removed your CSPeach key from this CSPeach home`);
15
27
  deps.print(` ${paint('dim', 'Next:')} cspeach login (to sign in again)`);
16
28
  }
17
29
  else {
18
- deps.print(formatInfo('Already signed out · no auth.json present').trimEnd());
30
+ deps.print(formatInfo('Already signed out · no CSPeach key in this CSPeach home').trimEnd());
31
+ }
32
+ for (const what of az.removed)
33
+ deps.print(`${paint('ok', getGlyphs().done)} Removed ${what} from this CSPeach home`);
34
+ // Fix round 1: outside azure mode an Azure clean-up error (e.g. EPERM on a
35
+ // stale connection.json) neither prints nor changes the exit code.
36
+ const mode = az.failed.length > 0
37
+ ? await (deps.mode ?? (async () => (await (await import('../config/loader.js')).loadConfig()).llm?.mode))().catch(() => undefined)
38
+ : undefined;
39
+ // An unreadable mode still reports the failure.
40
+ if (az.failed.length > 0 && (mode === undefined || mode === 'azure')) {
41
+ deps.print(formatFailure({ what: `Could not remove ${az.failed.join(', ')}`, next: 'close other cspeach windows, then run cspeach logout again' }).trimEnd());
42
+ return 1;
19
43
  }
20
44
  return 0;
21
45
  }
@@ -67,12 +67,12 @@ const COMPACT_CALL = {
67
67
  maxTokens: 4000,
68
68
  headers: { 'X-CSForge-Skill': 'compact', 'X-CSPeach-Model-Role': 'compact' },
69
69
  };
70
- export function makePhaseBoundarySummariser(provider, model) {
71
- return (msgs) => summariseViaProvider(provider, buildSummarisationPrompt(), buildPhaseBoundaryPrompt() + '\n\n' + serialiseForSummariser(msgs), { model, maxTokens: COMPACT_CALL.maxTokens, headers: { ...COMPACT_CALL.headers } });
70
+ export function makePhaseBoundarySummariser(provider, model, sessionId) {
71
+ return (msgs) => summariseViaProvider(provider, buildSummarisationPrompt(), buildPhaseBoundaryPrompt() + '\n\n' + serialiseForSummariser(msgs), { model, maxTokens: COMPACT_CALL.maxTokens, headers: { ...COMPACT_CALL.headers }, ...(sessionId ? { sessionId } : {}) });
72
72
  }
73
73
  /** The standard /compact summarisation (the compact skill carries the instructions). */
74
- export function makeAutoCompactSummariser(provider, model) {
75
- return (msgs) => summariseViaProvider(provider, buildSummarisationPrompt(), serialiseForSummariser(msgs), { model, maxTokens: COMPACT_CALL.maxTokens, headers: { ...COMPACT_CALL.headers } });
74
+ export function makeAutoCompactSummariser(provider, model, sessionId) {
75
+ return (msgs) => summariseViaProvider(provider, buildSummarisationPrompt(), serialiseForSummariser(msgs), { model, maxTokens: COMPACT_CALL.maxTokens, headers: { ...COMPACT_CALL.headers }, ...(sessionId ? { sessionId } : {}) });
76
76
  }
77
77
  /** Auto-compact after a plan turn whose revision is already saved and that queued no next step. */
78
78
  export async function maybeAutoCompactAfterPlanTurn(a) {