@cspeach/cli 1.1.32 → 1.1.33

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.
@@ -83,7 +83,7 @@ export async function runCostCommand(sessionId, credits = globalCredits.snapshot
83
83
  emit('');
84
84
  emit(panelRow('Readers', `${s.readerCalls} run${s.readerCalls === 1 ? '' : 's'} ${formatCost(s.readerCost)}`));
85
85
  }
86
- if (s.byModel.length > 1) {
86
+ if (s.byModel.length > 1 && !inCredits) {
87
87
  emit('');
88
88
  emit(chalk.bold('By model:'));
89
89
  for (const m of s.byModel) {
@@ -93,7 +93,9 @@ export async function runCostCommand(sessionId, credits = globalCredits.snapshot
93
93
  emit(panelRow(modelText(m.model), `${m.turns} turn${m.turns === 1 ? '' : 's'}${tail}`, 24));
94
94
  }
95
95
  }
96
- else if (s.byModel[0]) {
96
+ else if (s.byModel[0] && !inCredits) {
97
+ // 2026-10-09 (owner): no model line for managed customers — they pay in
98
+ // credits and cannot change the model here; it only invites questions.
97
99
  emit('');
98
100
  emit(panelRow('Model', modelText(s.byModel[0].model)));
99
101
  }
@@ -124,6 +126,8 @@ export async function runCostCommand(sessionId, credits = globalCredits.snapshot
124
126
  + `their usage is included in a later turn's figure.`).trimEnd());
125
127
  }
126
128
  emit('');
129
+ if (inCredits)
130
+ emit(formatInfo('This month and your top skills: /usage').trimEnd());
127
131
  emit(formatInfo(`Detailed JSONL: ~/.cspeach/sessions/${sessionId}-cost.jsonl`).trimEnd());
128
132
  emit('');
129
133
  }
@@ -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, USAGE_DESCRIPTION } 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: fixed(USAGE_DESCRIPTION) },
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,106 @@
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 !== 'managed') {
57
+ emit(panelHeading('Usage'));
58
+ 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());
59
+ emit('');
60
+ return;
61
+ }
62
+ const u = await d.fetchUsage();
63
+ if (!u) {
64
+ emit(panelHeading('Usage'));
65
+ emit(formatInfo('Could not reach CSPeach for this month’s usage. Try again in a moment. /cost shows this session.').trimEnd());
66
+ emit('');
67
+ return;
68
+ }
69
+ if (!u.metered) {
70
+ emit(panelHeading('Usage'));
71
+ emit(formatInfo('This account is not billed in credits, so there is no monthly credit usage to show. /cost shows this session.').trimEnd());
72
+ emit('');
73
+ return;
74
+ }
75
+ const unit = u.unitLabel;
76
+ const used = u.monthUsed ?? 0;
77
+ emit(panelHeading('Usage', `this month (${monthName(u.monthStart)}, UTC)`));
78
+ emit('');
79
+ emit(panelRow('This month', `${usageBar(used, u.balance)} ${formatCredits(used, unit)} used`, 14));
80
+ if (u.balance !== null) {
81
+ const split = u.subscription !== null && u.purchased !== null
82
+ ? paint('dim', ` monthly ${Math.round(u.subscription).toLocaleString('en-US')} · bought ${Math.round(u.purchased).toLocaleString('en-US')}`)
83
+ : '';
84
+ emit(panelRow('Balance', `${formatCredits(u.balance, unit)} left${split}`, 14));
85
+ }
86
+ if (u.topSkills.length > 0) {
87
+ emit('');
88
+ emit(panelHeading('Top skills this month'));
89
+ const width = Math.max(14, ...u.topSkills.map((s) => sanitizeControlChars(s.skill).length + 2));
90
+ const valueWidth = Math.max(...u.topSkills.map((s) => Math.round(s.credits).toLocaleString('en-US').length));
91
+ for (const s of u.topSkills) {
92
+ emit(panelRow(sanitizeControlChars(s.skill), Math.round(s.credits).toLocaleString('en-US').padStart(valueWidth), width));
93
+ }
94
+ if (u.otherSkills > 0) {
95
+ emit(panelRow('other skills', Math.round(u.otherSkills).toLocaleString('en-US').padStart(valueWidth), width));
96
+ }
97
+ }
98
+ emit('');
99
+ if (d.sessionCredits !== null) {
100
+ emit(panelRow('This session', `${formatCredits(d.sessionCredits, unit)}${paint('dim', ' details: /cost')}`, 14));
101
+ }
102
+ else {
103
+ emit(formatInfo('Details for this session: /cost').trimEnd());
104
+ }
105
+ emit('');
106
+ }
@@ -0,0 +1,45 @@
1
+ export const MONTH_USAGE_TIMEOUT_MS = 5_000;
2
+ function num(v) {
3
+ return typeof v === 'number' && Number.isFinite(v) ? v : null;
4
+ }
5
+ export async function fetchMonthUsage(cfg, getBearer, fetchFn = fetch) {
6
+ const controller = new AbortController();
7
+ const timer = setTimeout(() => controller.abort(), MONTH_USAGE_TIMEOUT_MS);
8
+ try {
9
+ const key = await getBearer();
10
+ if (!key)
11
+ return null;
12
+ const r = await fetchFn(`${cfg.proxy_url.replace(/\/$/, '')}/v1/me/usage`, {
13
+ headers: { Authorization: `Bearer ${key}` },
14
+ signal: controller.signal,
15
+ });
16
+ if (!r.ok)
17
+ return null;
18
+ const b = await r.json();
19
+ if (typeof b !== 'object' || b === null)
20
+ return null;
21
+ const o = b;
22
+ const skills = Array.isArray(o.top_skills) ? o.top_skills : [];
23
+ return {
24
+ metered: o.metered === true,
25
+ unitLabel: typeof o.unit_label === 'string' && o.unit_label ? o.unit_label : 'credits',
26
+ monthStart: typeof o.month_start === 'string' ? o.month_start : null,
27
+ monthUsed: num(o.month_used_credits),
28
+ topSkills: skills.flatMap((s) => {
29
+ const e = s;
30
+ const credits = num(e?.credits);
31
+ return typeof e?.skill === 'string' && credits !== null ? [{ skill: e.skill, credits }] : [];
32
+ }),
33
+ otherSkills: num(o.other_skills_credits) ?? 0,
34
+ balance: num(o.balance_credits),
35
+ subscription: num(o.subscription_credits),
36
+ purchased: num(o.purchased_credits),
37
+ };
38
+ }
39
+ catch {
40
+ return null;
41
+ }
42
+ finally {
43
+ clearTimeout(timer);
44
+ }
45
+ }
@@ -14,6 +14,8 @@ import { BUNDLED_RUNS } from '../runs/bundled-runs.js';
14
14
  export const COST_DESCRIPTION_USD = 'Show this session\'s spend so far ($ and tokens)';
15
15
  export const COST_DESCRIPTION_CREDITS = 'Show the credits this session has used so far';
16
16
  /** Piece 2 §10: managed customers see credits, never "$" (cost/display-mode.ts). */
17
+ /** /usage (2026-10-09): this month in credits — see commands/usage.ts. */
18
+ export const USAGE_DESCRIPTION = 'Show this month’s credits, your balance and the top skills';
17
19
  export function costCommandDescription() {
18
20
  return creditsActive() ? COST_DESCRIPTION_CREDITS : COST_DESCRIPTION_USD;
19
21
  }
@@ -21,6 +23,7 @@ export const BUILTIN_ENTRIES = [
21
23
  // Piece 2: /cancel is not listed — it exists only in the classic loop
22
24
  // (repl.tsx), and Esc / Ctrl+C already cancel under Ink.
23
25
  { name: '/cost', get description() { return costCommandDescription(); } },
26
+ { name: '/usage', description: USAGE_DESCRIPTION },
24
27
  { name: '/compact', description: 'Shrink a long session and keep the facts (later turns cost far less)' },
25
28
  { name: '/export', description: 'Export the signable session audit record — /export audit [filename]' },
26
29
  { name: '/context', description: 'Show what fills the model context window — a composition grid of bars' },
@@ -22,6 +22,7 @@ const BUILTIN_COMMANDS = [
22
22
  '/cancel',
23
23
  '/compact',
24
24
  '/cost',
25
+ '/usage',
25
26
  '/context',
26
27
  '/export',
27
28
  '/exit',
package/dist/repl.js CHANGED
@@ -1068,6 +1068,12 @@ export async function runRepl(opts) {
1068
1068
  printHelp((line) => chunkEmitter.emit('chunk', line + '\n'));
1069
1069
  return;
1070
1070
  }
1071
+ // /usage (2026-10-09) — this month in credits, from the proxy's ledger.
1072
+ if (trimmed === '/usage') {
1073
+ const { runUsageCommand } = await import('./commands/usage.js');
1074
+ await runUsageCommand((l) => chunkEmitter.emit('chunk', l + '\n'));
1075
+ return;
1076
+ }
1071
1077
  // Phase 1 cost tracking (2026-05-16) — /cost prints session $ summary
1072
1078
  // from the JSONL log written each turn. Read-only, no network.
1073
1079
  if (trimmed === '/cost') {
@@ -2992,6 +2998,12 @@ export async function runRepl(opts) {
2992
2998
  printHelp();
2993
2999
  continue;
2994
3000
  }
3001
+ // /usage (2026-10-09) — classic mirror of the Ink handler above.
3002
+ if (trimmed === '/usage') {
3003
+ const { runUsageCommand } = await import('./commands/usage.js');
3004
+ await runUsageCommand();
3005
+ continue;
3006
+ }
2995
3007
  // Phase 1 cost tracking (2026-05-16) — classic-mode /cost.
2996
3008
  // Mirrors the Ink-mode handler above. /cost prints session $ summary
2997
3009
  // from the JSONL log written each turn. Read-only, no network.
@@ -1,6 +1,6 @@
1
1
  // GENERATED FILE — bundled skills for local mode.
2
2
  // Source: https://manifest.cspeach.dev/v1.json
3
- // Generated: 2026-10-09T13:56:07.835Z
3
+ // Generated: 2026-10-10T00:40:56.198Z
4
4
  // Manifest signature verified at build time.
5
5
  export const BUNDLED_SKILLS = {
6
6
  "abap-app": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cspeach/cli",
3
- "version": "1.1.32",
3
+ "version": "1.1.33",
4
4
  "description": "CSPeach — AI-assisted ABAP development CLI. Direct SAP access, 34 skills, safety gates.",
5
5
  "keywords": [
6
6
  "abap",