@cspeach/cli 0.6.4 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (153) hide show
  1. package/dist/agent/intent-system-prompt.js +46 -0
  2. package/dist/agent/loop.js +180 -18
  3. package/dist/agent/parallel-write-guard.js +71 -0
  4. package/dist/agent/repair-partial.js +88 -1
  5. package/dist/agent/retry-cap.js +121 -0
  6. package/dist/agent/summarise-via-provider.js +51 -0
  7. package/dist/approvals/jwt.js +33 -12
  8. package/dist/commands/auto-compact.js +94 -0
  9. package/dist/commands/compact.js +265 -0
  10. package/dist/commands/cost.js +56 -0
  11. package/dist/commands/help.js +21 -14
  12. package/dist/config/loader.js +39 -0
  13. package/dist/cost/cost-log.js +113 -0
  14. package/dist/cost/pricing.js +91 -0
  15. package/dist/index.js +0 -0
  16. package/dist/one-shot.js +1 -1
  17. package/dist/renderer/markdown.js +7 -1
  18. package/dist/renderer/status-footer.js +37 -14
  19. package/dist/renderer/thinking-heartbeat.js +15 -3
  20. package/dist/renderer/tool-widget.js +6 -1
  21. package/dist/renderer/tty.js +27 -0
  22. package/dist/repl/cspeach-shell-detect.js +33 -0
  23. package/dist/repl/post-turn-status.js +68 -0
  24. package/dist/repl/slash-completer.js +2 -0
  25. package/dist/repl/slash-picker.js +2 -0
  26. package/dist/repl.js +371 -46
  27. package/dist/sap-errors/parse-adt-exception.js +149 -0
  28. package/dist/session/schema.js +2 -1
  29. package/dist/session/store.js +19 -0
  30. package/dist/tools/_filesystem-shared.js +9 -1
  31. package/dist/tools/ask-question.js +19 -0
  32. package/dist/tools/sap-read.js +56 -4
  33. package/dist/tools/sap-write.js +19 -0
  34. package/dist/tools/subagent/schedule_draft_create.js +137 -0
  35. package/dist/ui/alt-screen.js +120 -0
  36. package/dist/ui/app.js +39 -15
  37. package/dist/ui/ask-question-emitter.js +13 -0
  38. package/dist/ui/body.js +39 -23
  39. package/dist/ui/coaching-picker-classic.js +8 -1
  40. package/dist/ui/footer.js +6 -1
  41. package/dist/ui/header.js +19 -4
  42. package/dist/ui/sap-state-store.js +13 -1
  43. package/dist/ui/sidebar.js +5 -3
  44. package/dist/ui/status-emitter.js +19 -0
  45. package/dist/ui/status-row.js +30 -4
  46. package/dist/ui/widgets/ask-question-modal.js +132 -0
  47. package/dist/ui/widgets/coaching-picker.js +55 -8
  48. package/package.json +86 -82
  49. package/dist/agent/__tests__/crash-recover.integration.test.js +0 -254
  50. package/dist/agent/__tests__/loop-save-hook.test.js +0 -188
  51. package/dist/agent/__tests__/maybe-build-project-context.test.js +0 -247
  52. package/dist/agent/__tests__/project-context-injection.test.js +0 -161
  53. package/dist/agent/__tests__/repair-partial.test.js +0 -79
  54. package/dist/agent/__tests__/retry-key.test.js +0 -77
  55. package/dist/agent/__tests__/sap-connection-adapter.test.js +0 -132
  56. package/dist/agent/__tests__/skill-checkpoint.test.js +0 -154
  57. package/dist/agent/__tests__/turn-assistant-text.test.js +0 -73
  58. package/dist/agent/__tests__/turn-error-ux.test.js +0 -145
  59. package/dist/agent/__tests__/turn-stream.test.js +0 -100
  60. package/dist/agent/__tests__/turn-watchdog.test.js +0 -126
  61. package/dist/agent/providers/__tests__/ai-hub-provider.test.js +0 -30
  62. package/dist/agent/providers/__tests__/byok-provider.test.js +0 -32
  63. package/dist/agent/providers/__tests__/factory.test.js +0 -33
  64. package/dist/agent/providers/__tests__/local-provider.test.js +0 -55
  65. package/dist/approvals/__tests__/advisory-prompt.test.js +0 -57
  66. package/dist/approvals/__tests__/advisory-render.test.js +0 -54
  67. package/dist/auth/__tests__/auth-file.test.js +0 -42
  68. package/dist/auth/__tests__/me.test.js +0 -43
  69. package/dist/classifier/__tests__/client.test.js +0 -70
  70. package/dist/commands/__tests__/config-set-write-mode.test.js +0 -14
  71. package/dist/commands/__tests__/login.test.js +0 -152
  72. package/dist/commands/__tests__/logout.test.js +0 -39
  73. package/dist/commands/__tests__/project-context-impact.test.js +0 -173
  74. package/dist/commands/__tests__/spec-gap-status.test.js +0 -31
  75. package/dist/commands/__tests__/whoami.test.js +0 -91
  76. package/dist/config/__tests__/llm-config.test.js +0 -28
  77. package/dist/config/__tests__/shell-exec-config.test.js +0 -76
  78. package/dist/config/__tests__/write-mode.test.js +0 -36
  79. package/dist/doctor/__tests__/check-forge-rules.test.js +0 -134
  80. package/dist/doctor/__tests__/check-llm-mode.test.js +0 -21
  81. package/dist/doctor/__tests__/check-write-mode.test.js +0 -34
  82. package/dist/project-context/__tests__/conventions.test.js +0 -221
  83. package/dist/project-context/__tests__/detect.test.js +0 -177
  84. package/dist/project-context/__tests__/domain-abap-cloud.test.js +0 -119
  85. package/dist/project-context/__tests__/domain-abapgit.test.js +0 -161
  86. package/dist/project-context/__tests__/domain-cap.test.js +0 -168
  87. package/dist/project-context/__tests__/domain-fiori.test.js +0 -282
  88. package/dist/project-context/__tests__/git.test.js +0 -131
  89. package/dist/project-context/__tests__/index-files.test.js +0 -195
  90. package/dist/project-context/__tests__/index.test.js +0 -133
  91. package/dist/project-context/__tests__/render.test.js +0 -322
  92. package/dist/project-context/__tests__/types.test.js +0 -73
  93. package/dist/projects/__tests__/build.test.js +0 -52
  94. package/dist/projects/__tests__/canonicalize.test.js +0 -47
  95. package/dist/projects/__tests__/email-template.test.js +0 -71
  96. package/dist/projects/__tests__/expand-text-attachments.test.js +0 -105
  97. package/dist/projects/__tests__/extract-cca.test.js +0 -141
  98. package/dist/projects/__tests__/extract-design.test.js +0 -62
  99. package/dist/projects/__tests__/extract-estimate.test.js +0 -58
  100. package/dist/projects/__tests__/extract-modernize.test.js +0 -127
  101. package/dist/projects/__tests__/extract-spec-gap.test.js +0 -122
  102. package/dist/projects/__tests__/extract-test-coverage.test.js +0 -133
  103. package/dist/projects/__tests__/filename.test.js +0 -32
  104. package/dist/projects/__tests__/promote-command.test.js +0 -170
  105. package/dist/projects/__tests__/promote.test.js +0 -181
  106. package/dist/projects/__tests__/save-command.test.js +0 -216
  107. package/dist/projects/__tests__/save.test.js +0 -41
  108. package/dist/projects/__tests__/status.test.js +0 -208
  109. package/dist/projects/__tests__/types.test.js +0 -41
  110. package/dist/projects/__tests__/validate.test.js +0 -165
  111. package/dist/projects/__tests__/workspace.test.js +0 -337
  112. package/dist/repl/__tests__/current-transport.test.js +0 -43
  113. package/dist/repl/__tests__/diff-display.test.js +0 -59
  114. package/dist/repl/__tests__/file-picker.test.js +0 -97
  115. package/dist/repl/__tests__/rule8-detector.test.js +0 -114
  116. package/dist/repl/__tests__/safety-confirm.test.js +0 -130
  117. package/dist/repl/__tests__/safety-mode-state.test.js +0 -42
  118. package/dist/sap/__tests__/system-info.test.js +0 -344
  119. package/dist/session/__tests__/store-discovery.test.js +0 -114
  120. package/dist/session/__tests__/time-ago.test.js +0 -53
  121. package/dist/skills/__tests__/canonical.test.js +0 -20
  122. package/dist/skills/__tests__/manifest-client.test.js +0 -88
  123. package/dist/skills/__tests__/promotion-dispatch.test.js +0 -28
  124. package/dist/skills/__tests__/source-managed.test.js +0 -60
  125. package/dist/tools/__tests__/_background-shared.test.js +0 -69
  126. package/dist/tools/__tests__/_command-shared.test.js +0 -59
  127. package/dist/tools/__tests__/_filesystem-shared.test.js +0 -42
  128. package/dist/tools/__tests__/_flag.test.js +0 -66
  129. package/dist/tools/__tests__/_helpers.js +0 -17
  130. package/dist/tools/__tests__/_web-shared.test.js +0 -143
  131. package/dist/tools/__tests__/agent_run.test.js +0 -166
  132. package/dist/tools/__tests__/approval-advisory.test.js +0 -66
  133. package/dist/tools/__tests__/background_run.test.js +0 -113
  134. package/dist/tools/__tests__/convention_get.test.js +0 -53
  135. package/dist/tools/__tests__/file-edit.test.js +0 -72
  136. package/dist/tools/__tests__/file-read.test.js +0 -81
  137. package/dist/tools/__tests__/file-write.test.js +0 -68
  138. package/dist/tools/__tests__/glob.test.js +0 -128
  139. package/dist/tools/__tests__/grep.test.js +0 -71
  140. package/dist/tools/__tests__/monitor_emit.test.js +0 -96
  141. package/dist/tools/__tests__/playbook_get.test.js +0 -109
  142. package/dist/tools/__tests__/project_context_get.test.js +0 -71
  143. package/dist/tools/__tests__/registry-category.test.js +0 -60
  144. package/dist/tools/__tests__/sap-read-extras.test.js +0 -196
  145. package/dist/tools/__tests__/sap-write-extras.test.js +0 -573
  146. package/dist/tools/__tests__/schedule_create.test.js +0 -85
  147. package/dist/tools/__tests__/shell_exec.test.js +0 -135
  148. package/dist/tools/__tests__/transport-safety.test.js +0 -106
  149. package/dist/tools/__tests__/update-method-intercept.test.js +0 -128
  150. package/dist/tools/__tests__/web_fetch.test.js +0 -329
  151. package/dist/tools/__tests__/web_search.test.js +0 -215
  152. package/dist/tools/__tests__/write-mode.test.js +0 -32
  153. package/dist/ui/__tests__/login-banner.test.js +0 -69
@@ -1,152 +0,0 @@
1
- import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
- import { promises as fs } from 'node:fs';
3
- import * as os from 'node:os';
4
- import * as path from 'node:path';
5
- import { runLoginCommand } from '../login.js';
6
- import { __setAuthFilePathForTests, readAuthFile } from '../../auth/auth-file.js';
7
- const { fetchMock } = vi.hoisted(() => ({ fetchMock: vi.fn() }));
8
- vi.stubGlobal('fetch', fetchMock);
9
- const { openMock } = vi.hoisted(() => ({ openMock: vi.fn() }));
10
- let dir;
11
- let authPath;
12
- beforeEach(async () => {
13
- fetchMock.mockReset();
14
- openMock.mockReset();
15
- openMock.mockResolvedValue(undefined);
16
- dir = await fs.mkdtemp(path.join(os.tmpdir(), 'cspeach-login-'));
17
- authPath = path.join(dir, 'auth.json');
18
- __setAuthFilePathForTests(authPath);
19
- });
20
- afterEach(async () => {
21
- __setAuthFilePathForTests(null);
22
- await fs.rm(dir, { recursive: true, force: true });
23
- });
24
- describe('cspeach login', () => {
25
- it('happy path: start → open browser → poll → save auth file (non-BYOK)', async () => {
26
- fetchMock.mockImplementation(async (url) => {
27
- if (url.endsWith('/v1/auth/device/start')) {
28
- return new Response(JSON.stringify({
29
- device_code: 'dev-1', user_code: 'AAAA-BBBB',
30
- verification_url: 'https://cspeach.dev/device',
31
- verification_url_complete: 'https://cspeach.dev/device?code=AAAA-BBBB',
32
- expires_in: 600, interval: 0,
33
- }), { status: 200, headers: { 'content-type': 'application/json' } });
34
- }
35
- if (url.endsWith('/v1/auth/device/poll')) {
36
- return new Response(JSON.stringify({ api_key: 'csp_live_abc' + 'x'.repeat(29), customer_id: 'cust-1' }), { status: 200, headers: { 'content-type': 'application/json' } });
37
- }
38
- if (url.endsWith('/v1/me')) {
39
- return new Response(JSON.stringify({ email: 'c@a.com', plan: 'trial-managed', trial_mode: 'managed-capped' }), { status: 200, headers: { 'content-type': 'application/json' } });
40
- }
41
- throw new Error(`unexpected URL ${url}`);
42
- });
43
- const lines = [];
44
- const result = await runLoginCommand({
45
- proxyUrl: 'https://api.cspeach.dev',
46
- open: openMock,
47
- print: (s) => lines.push(s),
48
- prompt: async () => '',
49
- pollIntervalMs: 0,
50
- maxAttempts: 3,
51
- });
52
- expect(result).toBe(0);
53
- expect(openMock).toHaveBeenCalledWith('https://cspeach.dev/device?code=AAAA-BBBB');
54
- expect(lines.some((l) => /AAAA-BBBB/.test(l))).toBe(true);
55
- const saved = await readAuthFile();
56
- expect(saved?.apiKey).toBe('csp_live_abc' + 'x'.repeat(29));
57
- expect(saved?.customerId).toBe('cust-1');
58
- expect(saved?.byokAnthropicKey).toBeNull();
59
- });
60
- it('BYOK customer: prompts for Anthropic key and saves it', async () => {
61
- fetchMock.mockImplementation(async (url) => {
62
- if (url.endsWith('/v1/auth/device/start')) {
63
- return new Response(JSON.stringify({ device_code: 'dev-2', user_code: 'CCCC-DDDD', verification_url: 'x', verification_url_complete: 'x', expires_in: 600, interval: 0 }), { status: 200 });
64
- }
65
- if (url.endsWith('/v1/auth/device/poll')) {
66
- return new Response(JSON.stringify({ api_key: 'csp_live_byok' + 'y'.repeat(28), customer_id: 'cust-byok' }), { status: 200 });
67
- }
68
- if (url.endsWith('/v1/me')) {
69
- return new Response(JSON.stringify({ email: 'b@a.com', plan: 'trial-byok', trial_mode: 'byok' }), { status: 200 });
70
- }
71
- throw new Error('unexpected');
72
- });
73
- let promptCount = 0;
74
- const result = await runLoginCommand({
75
- proxyUrl: 'https://api.cspeach.dev', open: openMock, print: () => { },
76
- prompt: async (q) => { promptCount++; return q.includes('Anthropic') ? ('sk-ant-byok-' + 'x'.repeat(30)) : ''; },
77
- pollIntervalMs: 0, maxAttempts: 3,
78
- });
79
- expect(result).toBe(0);
80
- expect(promptCount).toBeGreaterThan(0);
81
- const saved = await readAuthFile();
82
- expect(saved?.byokAnthropicKey).toBe('sk-ant-byok-' + 'x'.repeat(30));
83
- });
84
- it('poll returns authorization_pending the first time, success the second', async () => {
85
- let pollCount = 0;
86
- fetchMock.mockImplementation(async (url) => {
87
- if (url.endsWith('/v1/auth/device/start')) {
88
- return new Response(JSON.stringify({ device_code: 'dev-3', user_code: 'EEEE-FFFF', verification_url: 'x', verification_url_complete: 'x', expires_in: 600, interval: 0 }), { status: 200 });
89
- }
90
- if (url.endsWith('/v1/auth/device/poll')) {
91
- pollCount++;
92
- if (pollCount === 1) {
93
- return new Response(JSON.stringify({ error: 'authorization_pending' }), { status: 400 });
94
- }
95
- return new Response(JSON.stringify({ api_key: 'csp_live_p' + 'p'.repeat(31), customer_id: 'cust-p' }), { status: 200 });
96
- }
97
- if (url.endsWith('/v1/me')) {
98
- return new Response(JSON.stringify({ email: 'p@a.com', plan: 'solo', trial_mode: null }), { status: 200 });
99
- }
100
- throw new Error('unexpected');
101
- });
102
- const result = await runLoginCommand({
103
- proxyUrl: 'https://api.cspeach.dev', open: openMock, print: () => { },
104
- prompt: async () => '', pollIntervalMs: 0, maxAttempts: 5,
105
- });
106
- expect(result).toBe(0);
107
- expect(pollCount).toBe(2);
108
- });
109
- it('returns non-zero when poll keeps returning pending past maxAttempts', async () => {
110
- fetchMock.mockImplementation(async (url) => {
111
- if (url.endsWith('/v1/auth/device/start')) {
112
- return new Response(JSON.stringify({ device_code: 'd', user_code: 'X', verification_url: 'x', verification_url_complete: 'x', expires_in: 600, interval: 0 }), { status: 200 });
113
- }
114
- return new Response(JSON.stringify({ error: 'authorization_pending' }), { status: 400 });
115
- });
116
- const result = await runLoginCommand({
117
- proxyUrl: 'https://api.cspeach.dev', open: openMock, print: () => { },
118
- prompt: async () => '', pollIntervalMs: 0, maxAttempts: 2,
119
- });
120
- expect(result).not.toBe(0);
121
- });
122
- it('returns non-zero when start endpoint fails', async () => {
123
- fetchMock.mockImplementation(async () => new Response('{"error":"server"}', { status: 500 }));
124
- const result = await runLoginCommand({
125
- proxyUrl: 'https://api.cspeach.dev', open: openMock, print: () => { },
126
- prompt: async () => '', pollIntervalMs: 0, maxAttempts: 2,
127
- });
128
- expect(result).not.toBe(0);
129
- });
130
- it('does not crash when open() throws (fallback to manual URL print)', async () => {
131
- openMock.mockRejectedValue(new Error('xdg-open not installed'));
132
- fetchMock.mockImplementation(async (url) => {
133
- if (url.endsWith('/v1/auth/device/start')) {
134
- return new Response(JSON.stringify({ device_code: 'd', user_code: 'M', verification_url: 'https://cspeach.dev/device', verification_url_complete: 'https://cspeach.dev/device?code=M', expires_in: 600, interval: 0 }), { status: 200 });
135
- }
136
- if (url.endsWith('/v1/auth/device/poll')) {
137
- return new Response(JSON.stringify({ api_key: 'csp_live_' + 'z'.repeat(32), customer_id: 'c' }), { status: 200 });
138
- }
139
- if (url.endsWith('/v1/me')) {
140
- return new Response(JSON.stringify({ email: 'z@a.com', plan: 'solo' }), { status: 200 });
141
- }
142
- throw new Error('unexpected');
143
- });
144
- const lines = [];
145
- const result = await runLoginCommand({
146
- proxyUrl: 'https://api.cspeach.dev', open: openMock, print: (s) => lines.push(s),
147
- prompt: async () => '', pollIntervalMs: 0, maxAttempts: 3,
148
- });
149
- expect(result).toBe(0);
150
- expect(lines.some((l) => l.includes('https://cspeach.dev/device'))).toBe(true);
151
- });
152
- });
@@ -1,39 +0,0 @@
1
- import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
- import { promises as fs } from 'node:fs';
3
- import * as os from 'node:os';
4
- import * as path from 'node:path';
5
- import { runLogoutCommand } from '../logout.js';
6
- import { __setAuthFilePathForTests, writeAuthFile } from '../../auth/auth-file.js';
7
- let dir;
8
- let authPath;
9
- beforeEach(async () => {
10
- dir = await fs.mkdtemp(path.join(os.tmpdir(), 'cspeach-logout-'));
11
- authPath = path.join(dir, 'auth.json');
12
- __setAuthFilePathForTests(authPath);
13
- });
14
- afterEach(async () => {
15
- __setAuthFilePathForTests(null);
16
- await fs.rm(dir, { recursive: true, force: true });
17
- });
18
- describe('cspeach logout', () => {
19
- it('deletes the auth file when present', async () => {
20
- await writeAuthFile({ apiKey: 'csp_live_x', customerId: 'c-1', byokAnthropicKey: null });
21
- const lines = [];
22
- const result = await runLogoutCommand({ print: (s) => lines.push(s) });
23
- expect(result).toBe(0);
24
- await expect(fs.access(authPath)).rejects.toBeTruthy();
25
- expect(lines.some((l) => /Signed out/i.test(l))).toBe(true);
26
- });
27
- it('prints "Already signed out" when file absent', async () => {
28
- const lines = [];
29
- const result = await runLogoutCommand({ print: (s) => lines.push(s) });
30
- expect(result).toBe(0);
31
- expect(lines.some((l) => /Already signed out/i.test(l))).toBe(true);
32
- });
33
- it('returns 0', async () => {
34
- // Both branches above already assert exit code 0; this test pins
35
- // the contract that logout never returns non-zero.
36
- const result = await runLogoutCommand({ print: () => { } });
37
- expect(result).toBe(0);
38
- });
39
- });
@@ -1,173 +0,0 @@
1
- import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
- import { promises as fs } from 'node:fs';
3
- import * as os from 'node:os';
4
- import * as path from 'node:path';
5
- import { analyzeProjectContextImpact, renderImpactReport, } from '../project-context-impact.js';
6
- let dir;
7
- beforeEach(async () => {
8
- dir = await fs.mkdtemp(path.join(os.tmpdir(), 'cspeach-impact-'));
9
- });
10
- afterEach(async () => {
11
- await fs.rm(dir, { recursive: true, force: true });
12
- });
13
- async function writeSession(t) {
14
- const turns = t.turns ?? 1;
15
- const messages = [];
16
- for (let i = 0; i < turns; i++) {
17
- const isFirst = i === 0;
18
- const content = isFirst && t.flagOn
19
- ? '<project_context type="abapgit">\n <root>/x</root>\n</project_context>\n\nthe ask'
20
- : 'follow-up';
21
- messages.push({ role: 'user', content });
22
- messages.push({ role: 'assistant', content: 'reply' });
23
- }
24
- const toolCalls = [];
25
- for (let i = 0; i < t.toolCallCount; i++) {
26
- toolCalls.push({
27
- tool_use_id: `tu${i}`,
28
- tool: 'sap_get_source',
29
- args: {},
30
- result: 'ok',
31
- is_error: t.hadErrorTool === true && i === 0,
32
- duration_ms: 100,
33
- completed_at: '2026-05-10T10:00:00Z',
34
- completed: true,
35
- });
36
- }
37
- const session = {
38
- schema_version: 1,
39
- id: t.id,
40
- started_at: t.startedAt ?? '2026-05-10T10:00:00Z',
41
- last_turn_at: t.startedAt ?? '2026-05-10T10:00:00Z',
42
- sap_system: null,
43
- skill: t.skill ?? 'abap-cca',
44
- model: 'claude-opus-4-7',
45
- messages,
46
- toolCalls,
47
- usage: { input_tokens: t.inputTokens, output_tokens: t.outputTokens, cache_read_input_tokens: 0 },
48
- last_skill: t.skill ?? 'abap-cca',
49
- last_object: null,
50
- lastUserPrompt: null,
51
- awaitingSkillAnswer: false,
52
- };
53
- await fs.writeFile(path.join(dir, `${t.id}.json`), JSON.stringify(session), 'utf-8');
54
- }
55
- describe('analyzeProjectContextImpact — empty state', () => {
56
- it('returns zero counts when sessionsDir does not exist', async () => {
57
- const report = await analyzeProjectContextImpact({ dir: path.join(dir, 'nope') });
58
- expect(report.totalSessions).toBe(0);
59
- expect(report.flagOn.count).toBe(0);
60
- expect(report.flagOff.count).toBe(0);
61
- });
62
- it('returns zero counts when dir is empty', async () => {
63
- const report = await analyzeProjectContextImpact({ dir });
64
- expect(report.totalSessions).toBe(0);
65
- });
66
- });
67
- describe('analyzeProjectContextImpact — classification', () => {
68
- it('classifies a session by whether the first user message contains <project_context', async () => {
69
- await writeSession({ id: 'a1', flagOn: false, inputTokens: 1000, outputTokens: 200, toolCallCount: 2 });
70
- await writeSession({ id: 'a2', flagOn: true, inputTokens: 1500, outputTokens: 250, toolCallCount: 1 });
71
- const report = await analyzeProjectContextImpact({ dir });
72
- expect(report.totalSessions).toBe(2);
73
- expect(report.flagOn.count).toBe(1);
74
- expect(report.flagOff.count).toBe(1);
75
- });
76
- it('only counts the FIRST user message — a later block doesn\'t flip the flag', async () => {
77
- // Edge case: the model echoes <project_context> in turn 3. The
78
- // session is still "flag off" because turn 1 had a plain message.
79
- const session = {
80
- schema_version: 1,
81
- id: 'echo',
82
- started_at: '2026-05-10T10:00:00Z',
83
- last_turn_at: '2026-05-10T10:00:00Z',
84
- sap_system: null,
85
- skill: null,
86
- model: 'm',
87
- messages: [
88
- { role: 'user', content: 'plain ask, flag was off' },
89
- { role: 'assistant', content: 'reply mentioning <project_context type=...>' },
90
- { role: 'user', content: 'follow-up' },
91
- ],
92
- toolCalls: [],
93
- usage: { input_tokens: 100, output_tokens: 50, cache_read_input_tokens: 0 },
94
- last_skill: null,
95
- last_object: null,
96
- lastUserPrompt: null,
97
- awaitingSkillAnswer: false,
98
- };
99
- await fs.writeFile(path.join(dir, 'echo.json'), JSON.stringify(session), 'utf-8');
100
- const report = await analyzeProjectContextImpact({ dir });
101
- expect(report.flagOff.count).toBe(1);
102
- expect(report.flagOn.count).toBe(0);
103
- });
104
- });
105
- describe('analyzeProjectContextImpact — averages + delta', () => {
106
- it('computes per-session averages correctly', async () => {
107
- // 2 flag-off sessions: 1000+2000 input, 200+400 output, 2+4 tools → averages 1500/300/3
108
- await writeSession({ id: 'off1', flagOn: false, inputTokens: 1000, outputTokens: 200, toolCallCount: 2 });
109
- await writeSession({ id: 'off2', flagOn: false, inputTokens: 2000, outputTokens: 400, toolCallCount: 4 });
110
- // 2 flag-on sessions: 1200+1800 input, 250+350 output, 1+3 tools → averages 1500/300/2
111
- await writeSession({ id: 'on1', flagOn: true, inputTokens: 1200, outputTokens: 250, toolCallCount: 1 });
112
- await writeSession({ id: 'on2', flagOn: true, inputTokens: 1800, outputTokens: 350, toolCallCount: 3 });
113
- const report = await analyzeProjectContextImpact({ dir });
114
- expect(report.flagOff.avgInputTokens).toBe(1500);
115
- expect(report.flagOff.avgOutputTokens).toBe(300);
116
- expect(report.flagOff.avgToolCalls).toBe(3);
117
- expect(report.flagOn.avgInputTokens).toBe(1500);
118
- expect(report.flagOn.avgOutputTokens).toBe(300);
119
- expect(report.flagOn.avgToolCalls).toBe(2);
120
- });
121
- it('computes error-tool rate as fraction of sessions with at least one error', async () => {
122
- await writeSession({ id: 'clean1', flagOn: false, inputTokens: 100, outputTokens: 50, toolCallCount: 2, hadErrorTool: false });
123
- await writeSession({ id: 'err1', flagOn: false, inputTokens: 100, outputTokens: 50, toolCallCount: 2, hadErrorTool: true });
124
- await writeSession({ id: 'err2', flagOn: false, inputTokens: 100, outputTokens: 50, toolCallCount: 2, hadErrorTool: true });
125
- const report = await analyzeProjectContextImpact({ dir });
126
- // 2 of 3 sessions had at least one error → 0.667 (rounded to 3 places).
127
- expect(report.flagOff.errorToolRate).toBeCloseTo(0.667, 2);
128
- });
129
- it('returns top 5 skills sorted by frequency', async () => {
130
- for (let i = 0; i < 3; i++)
131
- await writeSession({ id: `cca${i}`, flagOn: false, inputTokens: 100, outputTokens: 50, toolCallCount: 1, skill: 'abap-cca' });
132
- for (let i = 0; i < 2; i++)
133
- await writeSession({ id: `rev${i}`, flagOn: false, inputTokens: 100, outputTokens: 50, toolCallCount: 1, skill: 'abap-review' });
134
- await writeSession({ id: 'mod', flagOn: false, inputTokens: 100, outputTokens: 50, toolCallCount: 1, skill: 'abap-modernize' });
135
- const report = await analyzeProjectContextImpact({ dir });
136
- expect(report.flagOff.topSkills[0]).toEqual({ skill: 'abap-cca', n: 3 });
137
- expect(report.flagOff.topSkills[1]).toEqual({ skill: 'abap-review', n: 2 });
138
- expect(report.flagOff.topSkills[2]).toEqual({ skill: 'abap-modernize', n: 1 });
139
- });
140
- });
141
- describe('analyzeProjectContextImpact — fault tolerance + filtering', () => {
142
- it('skips malformed JSON files and reports them in skippedSessions', async () => {
143
- await writeSession({ id: 'good', flagOn: false, inputTokens: 100, outputTokens: 50, toolCallCount: 1 });
144
- await fs.writeFile(path.join(dir, 'broken.json'), '{ not json', 'utf-8');
145
- const report = await analyzeProjectContextImpact({ dir });
146
- expect(report.totalSessions).toBe(1);
147
- expect(report.skippedSessions).toBe(1);
148
- });
149
- it('--since cutoff excludes sessions older than the date', async () => {
150
- await writeSession({ id: 'old', flagOn: false, inputTokens: 100, outputTokens: 50, toolCallCount: 1, startedAt: '2024-01-01T00:00:00Z' });
151
- await writeSession({ id: 'new', flagOn: false, inputTokens: 100, outputTokens: 50, toolCallCount: 1, startedAt: '2026-04-01T00:00:00Z' });
152
- const report = await analyzeProjectContextImpact({ dir, since: '2026-01-01' });
153
- expect(report.totalSessions).toBe(1);
154
- });
155
- });
156
- describe('renderImpactReport', () => {
157
- it('renders an empty-bucket message when one side has no sessions', async () => {
158
- await writeSession({ id: 'only', flagOn: false, inputTokens: 100, outputTokens: 50, toolCallCount: 1 });
159
- const report = await analyzeProjectContextImpact({ dir });
160
- const text = renderImpactReport(report);
161
- expect(text).toContain('Need at least one session in each bucket');
162
- });
163
- it('renders a comparison table when both buckets have sessions', async () => {
164
- await writeSession({ id: 'off', flagOn: false, inputTokens: 1000, outputTokens: 200, toolCallCount: 3 });
165
- await writeSession({ id: 'on', flagOn: true, inputTokens: 1500, outputTokens: 250, toolCallCount: 2 });
166
- const report = await analyzeProjectContextImpact({ dir });
167
- const text = renderImpactReport(report);
168
- expect(text).toContain('Per-session averages');
169
- expect(text).toContain('input tokens');
170
- expect(text).toContain('tool calls');
171
- expect(text).toContain('Top skills');
172
- });
173
- });
@@ -1,31 +0,0 @@
1
- import { describe, it, expect, vi } from 'vitest';
2
- import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
3
- import { tmpdir } from 'node:os';
4
- import { join } from 'node:path';
5
- import { runSpecGapStatus } from '../spec-gap-status.js';
6
- import { buildEnvelope } from '../../projects/build.js';
7
- describe('runSpecGapStatus', () => {
8
- it('reads files and prints summary', async () => {
9
- const tmp = mkdtempSync(join(tmpdir(), 'cspeach-cmd-'));
10
- try {
11
- const env = buildEnvelope({
12
- artefactType: 'spec-gap',
13
- extract: { title: 'Test', content: { inputType: 'User Story', inferredDomain: 'X', sections: [] }, items: [] },
14
- author: { name: 'L', role: 'consultant' },
15
- source: { skill: 'abap-spec-gap', skillVersion: '1.0', input: '', tokensUsed: 0, model: 'm' },
16
- });
17
- const path = join(tmp, 'a.cspeach.json');
18
- writeFileSync(path, JSON.stringify(env));
19
- const log = vi.fn();
20
- await runSpecGapStatus({ files: [path], log });
21
- expect(log.mock.calls.flat().join('\n')).toContain('Test — spec-gap');
22
- }
23
- finally {
24
- rmSync(tmp, { recursive: true, force: true });
25
- }
26
- });
27
- it('errors gracefully on missing file', async () => {
28
- const log = vi.fn();
29
- await expect(runSpecGapStatus({ files: ['/does/not/exist.cspeach.json'], log })).rejects.toThrow();
30
- });
31
- });
@@ -1,91 +0,0 @@
1
- import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
- import { promises as fs } from 'node:fs';
3
- import * as os from 'node:os';
4
- import * as path from 'node:path';
5
- import { runWhoamiCommand } from '../whoami.js';
6
- import { __setAuthFilePathForTests, writeAuthFile } from '../../auth/auth-file.js';
7
- const { fetchMock } = vi.hoisted(() => ({ fetchMock: vi.fn() }));
8
- vi.stubGlobal('fetch', fetchMock);
9
- let dir;
10
- let authPath;
11
- beforeEach(async () => {
12
- fetchMock.mockReset();
13
- dir = await fs.mkdtemp(path.join(os.tmpdir(), 'cspeach-whoami-'));
14
- authPath = path.join(dir, 'auth.json');
15
- __setAuthFilePathForTests(authPath);
16
- });
17
- afterEach(async () => {
18
- __setAuthFilePathForTests(null);
19
- await fs.rm(dir, { recursive: true, force: true });
20
- });
21
- describe('cspeach whoami', () => {
22
- it('returns 0 + prints "Not signed in" message when no auth file', async () => {
23
- const lines = [];
24
- const result = await runWhoamiCommand({
25
- proxyUrl: 'https://api.cspeach.dev',
26
- print: (s) => lines.push(s),
27
- });
28
- expect(result).toBe(0);
29
- expect(lines.some((l) => /Not signed in/i.test(l))).toBe(true);
30
- });
31
- it('returns 0 + prints email/plan/trial when /v1/me succeeds', async () => {
32
- await writeAuthFile({ apiKey: 'csp_live_x', customerId: 'c-1', byokAnthropicKey: null });
33
- fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({
34
- email: 'alice@acme.com', company: 'Acme', plan: 'trial-managed',
35
- trial_mode: 'managed-capped', trial_ends_at: '2099-01-01T00:00:00Z',
36
- trial_token_cap_input: 200_000, trial_token_cap_output: 50_000,
37
- usage_input_tokens: 47_000, usage_output_tokens: 8_000,
38
- entitlements: { enabled_skills: [], agent_mode: 'skill', enabled_features: [] },
39
- }), { status: 200 }));
40
- const lines = [];
41
- const result = await runWhoamiCommand({
42
- proxyUrl: 'https://api.cspeach.dev',
43
- print: (s) => lines.push(s),
44
- });
45
- expect(result).toBe(0);
46
- expect(lines.some((l) => /alice@acme\.com/.test(l))).toBe(true);
47
- expect(lines.some((l) => /trial-managed/.test(l))).toBe(true);
48
- expect(lines.some((l) => /managed-capped/.test(l))).toBe(true);
49
- expect(lines.some((l) => /47000.*200000/.test(l))).toBe(true);
50
- });
51
- it('returns 1 + prints "/v1/me returned 401" when auth file present but stale', async () => {
52
- await writeAuthFile({ apiKey: 'csp_live_stale', customerId: 'c-1', byokAnthropicKey: null });
53
- fetchMock.mockResolvedValueOnce(new Response('{"error":"invalid_api_key"}', { status: 401 }));
54
- const lines = [];
55
- const result = await runWhoamiCommand({
56
- proxyUrl: 'https://api.cspeach.dev',
57
- print: (s) => lines.push(s),
58
- });
59
- expect(result).toBe(1);
60
- expect(lines.some((l) => /401/.test(l))).toBe(true);
61
- expect(lines.some((l) => /cspeach login/.test(l))).toBe(true);
62
- });
63
- it('includes skills line only when enabled_skills is non-empty', async () => {
64
- await writeAuthFile({ apiKey: 'csp_live_x', customerId: 'c-1', byokAnthropicKey: null });
65
- // First call: empty skills → no Skills: line.
66
- fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({
67
- email: 'a@b.com', company: null, plan: 'solo',
68
- trial_mode: null, trial_ends_at: null,
69
- trial_token_cap_input: null, trial_token_cap_output: null,
70
- usage_input_tokens: 0, usage_output_tokens: 0,
71
- entitlements: { enabled_skills: [], agent_mode: 'skill', enabled_features: [] },
72
- }), { status: 200 }));
73
- const linesEmpty = [];
74
- await runWhoamiCommand({ proxyUrl: 'https://api.cspeach.dev', print: (s) => linesEmpty.push(s) });
75
- expect(linesEmpty.some((l) => /^Skills:/.test(l))).toBe(false);
76
- // Second call: non-empty skills → Skills: line present.
77
- fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({
78
- email: 'a@b.com', company: null, plan: 'solo',
79
- trial_mode: null, trial_ends_at: null,
80
- trial_token_cap_input: null, trial_token_cap_output: null,
81
- usage_input_tokens: 0, usage_output_tokens: 0,
82
- entitlements: { enabled_skills: ['abap-cca', 'abap-radar'], agent_mode: 'skill', enabled_features: [] },
83
- }), { status: 200 }));
84
- const linesFull = [];
85
- await runWhoamiCommand({ proxyUrl: 'https://api.cspeach.dev', print: (s) => linesFull.push(s) });
86
- const skillsLine = linesFull.find((l) => /^Skills:/.test(l));
87
- expect(skillsLine).toBeTruthy();
88
- expect(skillsLine).toMatch(/abap-cca/);
89
- expect(skillsLine).toMatch(/abap-radar/);
90
- });
91
- });
@@ -1,28 +0,0 @@
1
- import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
- import fs from 'node:fs/promises';
3
- import os from 'node:os';
4
- import path from 'node:path';
5
- const pathsHolder = { tmpHome: '' };
6
- vi.mock('../paths.js', () => ({
7
- cspeachRoot: () => pathsHolder.tmpHome,
8
- configFile: () => path.join(pathsHolder.tmpHome, 'config.toml'),
9
- }));
10
- const { loadConfig } = await import('../loader.js');
11
- describe('CspeachConfig.llm', () => {
12
- beforeEach(async () => {
13
- pathsHolder.tmpHome = await fs.mkdtemp(path.join(os.tmpdir(), 'cspeach-test-'));
14
- });
15
- afterEach(async () => {
16
- await fs.rm(pathsHolder.tmpHome, { recursive: true, force: true });
17
- });
18
- it('defaults to mode=managed', async () => {
19
- const cfg = await loadConfig();
20
- expect(cfg.llm.mode).toBe('managed');
21
- });
22
- it('accepts mode values: managed, byok, ai-hub, local', () => {
23
- const valid = [
24
- 'managed', 'byok', 'ai-hub', 'local',
25
- ];
26
- expect(valid.length).toBe(4);
27
- });
28
- });
@@ -1,76 +0,0 @@
1
- import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
- import { promises as fs } from 'node:fs';
3
- import * as os from 'node:os';
4
- import * as path from 'node:path';
5
- let tempHome;
6
- let origHome;
7
- let origUserProfile;
8
- beforeEach(async () => {
9
- tempHome = await fs.mkdtemp(path.join(os.tmpdir(), 'cspeach-shell-cfg-'));
10
- origHome = process.env.HOME;
11
- origUserProfile = process.env.USERPROFILE;
12
- process.env.HOME = tempHome;
13
- if (process.platform === 'win32')
14
- process.env.USERPROFILE = tempHome;
15
- vi.resetModules();
16
- });
17
- afterEach(async () => {
18
- if (origHome === undefined)
19
- delete process.env.HOME;
20
- else
21
- process.env.HOME = origHome;
22
- if (process.platform === 'win32') {
23
- if (origUserProfile === undefined)
24
- delete process.env.USERPROFILE;
25
- else
26
- process.env.USERPROFILE = origUserProfile;
27
- }
28
- await fs.rm(tempHome, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
29
- });
30
- describe('CspeachConfig — shell_exec block', () => {
31
- it('loads an empty shell_exec block as undefined when config file is missing', async () => {
32
- const { loadConfig } = await import('../loader.js');
33
- const cfg = await loadConfig();
34
- expect(cfg.shell_exec).toBeUndefined();
35
- });
36
- it('parses shell_exec.allow as a string array', async () => {
37
- const cspeachDir = path.join(tempHome, '.cspeach');
38
- await fs.mkdir(cspeachDir, { recursive: true });
39
- await fs.writeFile(path.join(cspeachDir, 'config.toml'), `[shell_exec]\nallow = ["docker", "make"]\n`, 'utf-8');
40
- const { loadConfig } = await import('../loader.js');
41
- const cfg = await loadConfig();
42
- expect(cfg.shell_exec?.allow).toEqual(['docker', 'make']);
43
- });
44
- it('treats shell_exec block as optional — empty TOML leaves it undefined', async () => {
45
- const cspeachDir = path.join(tempHome, '.cspeach');
46
- await fs.mkdir(cspeachDir, { recursive: true });
47
- await fs.writeFile(path.join(cspeachDir, 'config.toml'), `default_model = "claude-opus-4-7"\n`, 'utf-8');
48
- const { loadConfig } = await import('../loader.js');
49
- const cfg = await loadConfig();
50
- expect(cfg.shell_exec).toBeUndefined();
51
- });
52
- it('coerces non-array allow values to empty array (defensive)', async () => {
53
- const cspeachDir = path.join(tempHome, '.cspeach');
54
- await fs.mkdir(cspeachDir, { recursive: true });
55
- await fs.writeFile(path.join(cspeachDir, 'config.toml'), `[shell_exec]\nallow = "docker"\n`, 'utf-8');
56
- const { loadConfig } = await import('../loader.js');
57
- const cfg = await loadConfig();
58
- expect(cfg.shell_exec?.allow).toEqual([]);
59
- });
60
- it('rejects when allow contains a mixed-type TOML array (@iarna/toml throws at parse time)', async () => {
61
- // @iarna/toml enforces strict single-type inline arrays — ["docker", 42, "make"] is
62
- // a TomlError at parse time, before our sanitiser ever runs. loadConfig re-throws
63
- // (only ENOENT is silently absorbed). The runtime filter in loadConfig (Array.isArray
64
- // + typeof === 'string' guard) is dead-code as far as TOML inputs go — it only fires
65
- // if a non-TOML caller passes a hand-built Partial<CspeachConfig>. We assert the
66
- // throw here so a corrupt config.toml surfaces as a startup error rather than
67
- // silently producing garbage.
68
- const cspeachDir = path.join(tempHome, '.cspeach');
69
- await fs.mkdir(cspeachDir, { recursive: true });
70
- await fs.writeFile(path.join(cspeachDir, 'config.toml'), `[shell_exec]\nallow = ["docker", 42, "make"]\n`, 'utf-8');
71
- const { loadConfig } = await import('../loader.js');
72
- // @iarna/toml throws on mixed-type inline arrays; loadConfig should re-throw
73
- // (non-ENOENT errors bubble up — user has a corrupt config.toml).
74
- await expect(loadConfig()).rejects.toThrow();
75
- });
76
- });
@@ -1,36 +0,0 @@
1
- import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
- import fs from 'node:fs/promises';
3
- import os from 'node:os';
4
- import path from 'node:path';
5
- // paths.ts offers no env-var override, so mock it: each test sets `tmpHome`
6
- // and the mocked configFile() resolves to tmpHome/config.toml. Using a
7
- // mutable holder keeps the mock stable while letting each test point at a
8
- // fresh temp directory.
9
- const pathsHolder = { tmpHome: '' };
10
- vi.mock('../paths.js', () => ({
11
- cspeachRoot: () => pathsHolder.tmpHome,
12
- configFile: () => path.join(pathsHolder.tmpHome, 'config.toml'),
13
- }));
14
- const { loadConfig } = await import('../loader.js');
15
- describe('write_mode in CspeachConfig', () => {
16
- beforeEach(async () => {
17
- pathsHolder.tmpHome = await fs.mkdtemp(path.join(os.tmpdir(), 'cspeach-test-'));
18
- });
19
- afterEach(async () => {
20
- await fs.rm(pathsHolder.tmpHome, { recursive: true, force: true });
21
- });
22
- it('defaults to approval-gated when no config file exists', async () => {
23
- const cfg = await loadConfig();
24
- expect(cfg.write_mode).toBe('approval-gated');
25
- });
26
- it('accepts a valid value from disk', async () => {
27
- await fs.writeFile(path.join(pathsHolder.tmpHome, 'config.toml'), 'write_mode = "advisory-only"\n');
28
- const cfg = await loadConfig();
29
- expect(cfg.write_mode).toBe('advisory-only');
30
- });
31
- it('falls back to default when disk value is not one of the three literals', async () => {
32
- await fs.writeFile(path.join(pathsHolder.tmpHome, 'config.toml'), 'write_mode = "yolo"\n');
33
- const cfg = await loadConfig();
34
- expect(cfg.write_mode).toBe('approval-gated');
35
- });
36
- });