@cspeach/cli 0.6.4 → 0.6.5

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 (107) hide show
  1. package/dist/index.js +0 -0
  2. package/package.json +86 -82
  3. package/dist/agent/__tests__/crash-recover.integration.test.js +0 -254
  4. package/dist/agent/__tests__/loop-save-hook.test.js +0 -188
  5. package/dist/agent/__tests__/maybe-build-project-context.test.js +0 -247
  6. package/dist/agent/__tests__/project-context-injection.test.js +0 -161
  7. package/dist/agent/__tests__/repair-partial.test.js +0 -79
  8. package/dist/agent/__tests__/retry-key.test.js +0 -77
  9. package/dist/agent/__tests__/sap-connection-adapter.test.js +0 -132
  10. package/dist/agent/__tests__/skill-checkpoint.test.js +0 -154
  11. package/dist/agent/__tests__/turn-assistant-text.test.js +0 -73
  12. package/dist/agent/__tests__/turn-error-ux.test.js +0 -145
  13. package/dist/agent/__tests__/turn-stream.test.js +0 -100
  14. package/dist/agent/__tests__/turn-watchdog.test.js +0 -126
  15. package/dist/agent/providers/__tests__/ai-hub-provider.test.js +0 -30
  16. package/dist/agent/providers/__tests__/byok-provider.test.js +0 -32
  17. package/dist/agent/providers/__tests__/factory.test.js +0 -33
  18. package/dist/agent/providers/__tests__/local-provider.test.js +0 -55
  19. package/dist/approvals/__tests__/advisory-prompt.test.js +0 -57
  20. package/dist/approvals/__tests__/advisory-render.test.js +0 -54
  21. package/dist/auth/__tests__/auth-file.test.js +0 -42
  22. package/dist/auth/__tests__/me.test.js +0 -43
  23. package/dist/classifier/__tests__/client.test.js +0 -70
  24. package/dist/commands/__tests__/config-set-write-mode.test.js +0 -14
  25. package/dist/commands/__tests__/login.test.js +0 -152
  26. package/dist/commands/__tests__/logout.test.js +0 -39
  27. package/dist/commands/__tests__/project-context-impact.test.js +0 -173
  28. package/dist/commands/__tests__/spec-gap-status.test.js +0 -31
  29. package/dist/commands/__tests__/whoami.test.js +0 -91
  30. package/dist/config/__tests__/llm-config.test.js +0 -28
  31. package/dist/config/__tests__/shell-exec-config.test.js +0 -76
  32. package/dist/config/__tests__/write-mode.test.js +0 -36
  33. package/dist/doctor/__tests__/check-forge-rules.test.js +0 -134
  34. package/dist/doctor/__tests__/check-llm-mode.test.js +0 -21
  35. package/dist/doctor/__tests__/check-write-mode.test.js +0 -34
  36. package/dist/project-context/__tests__/conventions.test.js +0 -221
  37. package/dist/project-context/__tests__/detect.test.js +0 -177
  38. package/dist/project-context/__tests__/domain-abap-cloud.test.js +0 -119
  39. package/dist/project-context/__tests__/domain-abapgit.test.js +0 -161
  40. package/dist/project-context/__tests__/domain-cap.test.js +0 -168
  41. package/dist/project-context/__tests__/domain-fiori.test.js +0 -282
  42. package/dist/project-context/__tests__/git.test.js +0 -131
  43. package/dist/project-context/__tests__/index-files.test.js +0 -195
  44. package/dist/project-context/__tests__/index.test.js +0 -133
  45. package/dist/project-context/__tests__/render.test.js +0 -322
  46. package/dist/project-context/__tests__/types.test.js +0 -73
  47. package/dist/projects/__tests__/build.test.js +0 -52
  48. package/dist/projects/__tests__/canonicalize.test.js +0 -47
  49. package/dist/projects/__tests__/email-template.test.js +0 -71
  50. package/dist/projects/__tests__/expand-text-attachments.test.js +0 -105
  51. package/dist/projects/__tests__/extract-cca.test.js +0 -141
  52. package/dist/projects/__tests__/extract-design.test.js +0 -62
  53. package/dist/projects/__tests__/extract-estimate.test.js +0 -58
  54. package/dist/projects/__tests__/extract-modernize.test.js +0 -127
  55. package/dist/projects/__tests__/extract-spec-gap.test.js +0 -122
  56. package/dist/projects/__tests__/extract-test-coverage.test.js +0 -133
  57. package/dist/projects/__tests__/filename.test.js +0 -32
  58. package/dist/projects/__tests__/promote-command.test.js +0 -170
  59. package/dist/projects/__tests__/promote.test.js +0 -181
  60. package/dist/projects/__tests__/save-command.test.js +0 -216
  61. package/dist/projects/__tests__/save.test.js +0 -41
  62. package/dist/projects/__tests__/status.test.js +0 -208
  63. package/dist/projects/__tests__/types.test.js +0 -41
  64. package/dist/projects/__tests__/validate.test.js +0 -165
  65. package/dist/projects/__tests__/workspace.test.js +0 -337
  66. package/dist/repl/__tests__/current-transport.test.js +0 -43
  67. package/dist/repl/__tests__/diff-display.test.js +0 -59
  68. package/dist/repl/__tests__/file-picker.test.js +0 -97
  69. package/dist/repl/__tests__/rule8-detector.test.js +0 -114
  70. package/dist/repl/__tests__/safety-confirm.test.js +0 -130
  71. package/dist/repl/__tests__/safety-mode-state.test.js +0 -42
  72. package/dist/sap/__tests__/system-info.test.js +0 -344
  73. package/dist/session/__tests__/store-discovery.test.js +0 -114
  74. package/dist/session/__tests__/time-ago.test.js +0 -53
  75. package/dist/skills/__tests__/canonical.test.js +0 -20
  76. package/dist/skills/__tests__/manifest-client.test.js +0 -88
  77. package/dist/skills/__tests__/promotion-dispatch.test.js +0 -28
  78. package/dist/skills/__tests__/source-managed.test.js +0 -60
  79. package/dist/tools/__tests__/_background-shared.test.js +0 -69
  80. package/dist/tools/__tests__/_command-shared.test.js +0 -59
  81. package/dist/tools/__tests__/_filesystem-shared.test.js +0 -42
  82. package/dist/tools/__tests__/_flag.test.js +0 -66
  83. package/dist/tools/__tests__/_helpers.js +0 -17
  84. package/dist/tools/__tests__/_web-shared.test.js +0 -143
  85. package/dist/tools/__tests__/agent_run.test.js +0 -166
  86. package/dist/tools/__tests__/approval-advisory.test.js +0 -66
  87. package/dist/tools/__tests__/background_run.test.js +0 -113
  88. package/dist/tools/__tests__/convention_get.test.js +0 -53
  89. package/dist/tools/__tests__/file-edit.test.js +0 -72
  90. package/dist/tools/__tests__/file-read.test.js +0 -81
  91. package/dist/tools/__tests__/file-write.test.js +0 -68
  92. package/dist/tools/__tests__/glob.test.js +0 -128
  93. package/dist/tools/__tests__/grep.test.js +0 -71
  94. package/dist/tools/__tests__/monitor_emit.test.js +0 -96
  95. package/dist/tools/__tests__/playbook_get.test.js +0 -109
  96. package/dist/tools/__tests__/project_context_get.test.js +0 -71
  97. package/dist/tools/__tests__/registry-category.test.js +0 -60
  98. package/dist/tools/__tests__/sap-read-extras.test.js +0 -196
  99. package/dist/tools/__tests__/sap-write-extras.test.js +0 -573
  100. package/dist/tools/__tests__/schedule_create.test.js +0 -85
  101. package/dist/tools/__tests__/shell_exec.test.js +0 -135
  102. package/dist/tools/__tests__/transport-safety.test.js +0 -106
  103. package/dist/tools/__tests__/update-method-intercept.test.js +0 -128
  104. package/dist/tools/__tests__/web_fetch.test.js +0 -329
  105. package/dist/tools/__tests__/web_search.test.js +0 -215
  106. package/dist/tools/__tests__/write-mode.test.js +0 -32
  107. 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
- });