@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
@@ -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,25 +81,33 @@ 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
- if (s.byModel.length > 1) {
98
+ if (s.byModel.length > 1 && !inCredits) {
87
99
  emit('');
88
100
  emit(chalk.bold('By model:'));
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
  }
96
- else if (s.byModel[0]) {
108
+ else if (s.byModel[0] && !inCredits) {
109
+ // 2026-10-09 (owner): no model line for managed customers — they pay in
110
+ // credits and cannot change the model here; it only invites questions.
97
111
  emit('');
98
112
  emit(panelRow('Model', modelText(s.byModel[0].model)));
99
113
  }
@@ -115,8 +129,9 @@ export async function runCostCommand(sessionId, credits = globalCredits.snapshot
115
129
  ? (typeof e.credits === 'number'
116
130
  ? formatCredits(e.credits, credits?.unitLabel ?? 'credits')
117
131
  : CREDITS_PENDING)
118
- : formatCost(e.cost);
119
- 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));
120
135
  }
121
136
  if (inCredits && perTurn.unknown > 0) {
122
137
  emit('');
@@ -124,6 +139,8 @@ export async function runCostCommand(sessionId, credits = globalCredits.snapshot
124
139
  + `their usage is included in a later turn's figure.`).trimEnd());
125
140
  }
126
141
  emit('');
142
+ if (inCredits)
143
+ emit(formatInfo('This month and your top skills: /usage').trimEnd());
127
144
  emit(formatInfo(`Detailed JSONL: ~/.cspeach/sessions/${sessionId}-cost.jsonl`).trimEnd());
128
145
  emit('');
129
146
  }
@@ -1,6 +1,6 @@
1
1
  import chalk from 'chalk';
2
2
  import { SKILL_CATALOG } from '../skill-catalog.js';
3
- import { costCommandDescription } 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,6 +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: usageCommandDescription },
55
56
  { name: '/context', description: fixed("Show what fills the model's context window") },
56
57
  { name: '/compact', description: fixed('Shrink a long session and keep the facts') },
57
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) {
@@ -67,7 +67,9 @@ const NO_OVERRIDE = { model: null, notice: null };
67
67
  *
68
68
  * Anything ambiguous resolves to "no override". The returned model is
69
69
  * claude-sonnet-4-6 or nothing — Haiku is unreachable by construction
70
- * (pinned in plan-model-tier.test.ts).
70
+ * (pinned in plan-model-tier.test.ts). Azure tenant mode: `tierModel`
71
+ * replaces the target with the company's review deployment, or turns
72
+ * tiering off (null).
71
73
  */
72
74
  export function selectPlanPhaseModel(args) {
73
75
  if (!args.enabled)
@@ -76,8 +78,11 @@ export function selectPlanPhaseModel(args) {
76
78
  return NO_OVERRIDE;
77
79
  if (!/opus/i.test(args.defaultModel))
78
80
  return NO_OVERRIDE;
81
+ if (args.tierModel === null)
82
+ return NO_OVERRIDE;
83
+ const target = args.tierModel ?? PLAN_TIER_SONNET_MODEL;
79
84
  return {
80
- model: PLAN_TIER_SONNET_MODEL,
81
- notice: `phase ${args.phaseId} → ${PLAN_TIER_SONNET_MODEL} (plan model tiering)`,
85
+ model: target,
86
+ notice: `phase ${args.phaseId} → ${target} (plan model tiering)`,
82
87
  };
83
88
  }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * `/usage` slash command (2026-10-09) — this month at a glance, in credits:
3
+ * a bar of what this month used against what was available, the balance,
4
+ * the top skills and this session. `/cost` stays the per-turn detail.
5
+ *
6
+ * Managed only: the numbers come from the proxy's credit ledger
7
+ * (GET /v1/me/usage). Own key / local mode: no CSPeach ledger exists, so the
8
+ * panel says where monthly usage lives instead. Never a dollar figure.
9
+ * Colours: the panels palette only (panelHeading / panelRow / paint).
10
+ */
11
+ import { loadConfig } from '../config/loader.js';
12
+ import { getStore } from '../auth/api-key.js';
13
+ import { creditsActive, formatCredits, globalCredits } from '../cost/credits.js';
14
+ import { fetchMonthUsage } from '../cost/month-usage.js';
15
+ import { panelHeading, panelRow } from '../renderer/panel-rows.js';
16
+ import { formatInfo } from '../renderer/notice-shape.js';
17
+ import { paint } from '../renderer/theme.js';
18
+ import { sanitizeControlChars } from '../renderer/sanitize.js';
19
+ import { shouldUseUnicodeBoxChars } from '../renderer/tty.js';
20
+ const BAR_WIDTH = 20;
21
+ const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
22
+ /** used / (used + left), as filled cells of BAR_WIDTH; a non-zero use shows at least one cell. */
23
+ export function usageBar(used, left, unicode = shouldUseUnicodeBoxChars()) {
24
+ const total = used + Math.max(0, left ?? 0);
25
+ let filled = total > 0 ? Math.round((BAR_WIDTH * used) / total) : 0;
26
+ if (used > 0 && filled === 0)
27
+ filled = 1;
28
+ filled = Math.min(BAR_WIDTH, filled);
29
+ const [on, off] = unicode ? ['█', '░'] : ['#', '-'];
30
+ return paint('brand', on.repeat(filled)) + paint('frame', off.repeat(BAR_WIDTH - filled));
31
+ }
32
+ function monthName(iso) {
33
+ const d = iso ? new Date(iso) : new Date();
34
+ return MONTHS[(Number.isNaN(d.getTime()) ? new Date() : d).getUTCMonth()];
35
+ }
36
+ async function defaultDeps() {
37
+ const cfg = await loadConfig();
38
+ return {
39
+ mode: cfg.llm?.mode ?? 'managed',
40
+ fetchUsage: () => fetchMonthUsage(cfg, async () => (await (await getStore()).get()) ?? ''),
41
+ // Credits seen this run (balance deltas); unknown outside credits mode.
42
+ sessionCredits: creditsActive() ? globalCredits.snapshot().sessionTotal : null,
43
+ };
44
+ }
45
+ export async function runUsageCommand(emit = (l) => console.log(l), deps) {
46
+ // Review fix: a config that fails to load mid-session must not throw out of
47
+ // the REPL handler — fall back to the "could not reach" line.
48
+ let d;
49
+ try {
50
+ d = deps ?? await defaultDeps();
51
+ }
52
+ catch {
53
+ d = { mode: 'managed', fetchUsage: async () => null, sessionCredits: null };
54
+ }
55
+ emit('');
56
+ if (d.mode === 'azure') {
57
+ emit(panelHeading('Usage'));
58
+ emit(formatInfo("CSPeach on your Azure shows tokens only. /cost shows this session's tokens. Your company's usage report is in the CSPeach portal. Your Azure invoice is the record.").trimEnd());
59
+ emit('');
60
+ return;
61
+ }
62
+ if (d.mode !== 'managed') {
63
+ emit(panelHeading('Usage'));
64
+ emit(formatInfo('You are on your own model key, so CSPeach keeps no monthly usage. It is in your provider account. /cost shows this session.').trimEnd());
65
+ emit('');
66
+ return;
67
+ }
68
+ const u = await d.fetchUsage();
69
+ if (!u) {
70
+ emit(panelHeading('Usage'));
71
+ emit(formatInfo('Could not reach CSPeach for this month’s usage. Try again in a moment. /cost shows this session.').trimEnd());
72
+ emit('');
73
+ return;
74
+ }
75
+ if (!u.metered) {
76
+ emit(panelHeading('Usage'));
77
+ emit(formatInfo('This account is not billed in credits, so there is no monthly credit usage to show. /cost shows this session.').trimEnd());
78
+ emit('');
79
+ return;
80
+ }
81
+ const unit = u.unitLabel;
82
+ const used = u.monthUsed ?? 0;
83
+ emit(panelHeading('Usage', `this month (${monthName(u.monthStart)}, UTC)`));
84
+ emit('');
85
+ emit(panelRow('This month', `${usageBar(used, u.balance)} ${formatCredits(used, unit)} used`, 14));
86
+ if (u.balance !== null) {
87
+ const split = u.subscription !== null && u.purchased !== null
88
+ ? paint('dim', ` monthly ${Math.round(u.subscription).toLocaleString('en-US')} · bought ${Math.round(u.purchased).toLocaleString('en-US')}`)
89
+ : '';
90
+ emit(panelRow('Balance', `${formatCredits(u.balance, unit)} left${split}`, 14));
91
+ }
92
+ if (u.topSkills.length > 0) {
93
+ emit('');
94
+ emit(panelHeading('Top skills this month'));
95
+ const width = Math.max(14, ...u.topSkills.map((s) => sanitizeControlChars(s.skill).length + 2));
96
+ const valueWidth = Math.max(...u.topSkills.map((s) => Math.round(s.credits).toLocaleString('en-US').length));
97
+ for (const s of u.topSkills) {
98
+ emit(panelRow(sanitizeControlChars(s.skill), Math.round(s.credits).toLocaleString('en-US').padStart(valueWidth), width));
99
+ }
100
+ if (u.otherSkills > 0) {
101
+ emit(panelRow('other skills', Math.round(u.otherSkills).toLocaleString('en-US').padStart(valueWidth), width));
102
+ }
103
+ }
104
+ emit('');
105
+ if (d.sessionCredits !== null) {
106
+ emit(panelRow('This session', `${formatCredits(d.sessionCredits, unit)}${paint('dim', ' details: /cost')}`, 14));
107
+ }
108
+ else {
109
+ emit(formatInfo('Details for this session: /cost').trimEnd());
110
+ }
111
+ emit('');
112
+ }