@agents-at-scale/ark 0.1.35-rc2 → 0.1.36-rc1

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 (37) hide show
  1. package/dist/arkServices.js +7 -7
  2. package/dist/commands/agents/index.js +14 -0
  3. package/dist/commands/completion/index.js +1 -61
  4. package/dist/commands/dev/tool/shared.js +3 -1
  5. package/dist/commands/generate/generators/agent.js +2 -2
  6. package/dist/commands/generate/generators/team.js +2 -2
  7. package/dist/commands/install/index.js +1 -6
  8. package/dist/commands/models/index.js +15 -0
  9. package/dist/commands/models/index.spec.js +20 -0
  10. package/dist/commands/query/index.js +9 -116
  11. package/dist/commands/query/index.spec.d.ts +1 -0
  12. package/dist/commands/query/index.spec.js +53 -0
  13. package/dist/commands/status/index.d.ts +2 -3
  14. package/dist/commands/status/index.js +36 -17
  15. package/dist/commands/targets/index.js +26 -19
  16. package/dist/commands/targets/index.spec.js +95 -46
  17. package/dist/commands/teams/index.js +15 -0
  18. package/dist/commands/uninstall/index.js +0 -5
  19. package/dist/components/statusChecker.d.ts +2 -2
  20. package/dist/index.js +1 -3
  21. package/dist/lib/chatClient.js +70 -76
  22. package/dist/lib/config.d.ts +0 -2
  23. package/dist/lib/executeQuery.d.ts +20 -0
  24. package/dist/lib/executeQuery.js +135 -0
  25. package/dist/lib/executeQuery.spec.d.ts +1 -0
  26. package/dist/lib/executeQuery.spec.js +170 -0
  27. package/dist/lib/nextSteps.js +1 -1
  28. package/dist/lib/queryRunner.d.ts +22 -0
  29. package/dist/lib/queryRunner.js +142 -0
  30. package/dist/lib/startup.d.ts +1 -1
  31. package/dist/lib/startup.js +25 -31
  32. package/dist/lib/startup.spec.js +29 -45
  33. package/dist/lib/types.d.ts +70 -0
  34. package/dist/lib/versions.d.ts +23 -0
  35. package/dist/lib/versions.js +51 -0
  36. package/dist/ui/MainMenu.js +15 -11
  37. package/package.json +1 -2
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Shared query execution logic for both universal and resource-specific query commands
3
+ */
4
+ import { execa } from 'execa';
5
+ import ora from 'ora';
6
+ import output from './output.js';
7
+ /**
8
+ * Execute a query against any ARK target (model, agent, team)
9
+ * This is the shared implementation used by all query commands
10
+ */
11
+ export async function executeQuery(options) {
12
+ const spinner = ora('Creating query...').start();
13
+ // Generate a unique query name
14
+ const timestamp = Date.now();
15
+ const queryName = `cli-query-${timestamp}`;
16
+ // Create the Query resource
17
+ const queryManifest = {
18
+ apiVersion: 'ark.mckinsey.com/v1alpha1',
19
+ kind: 'Query',
20
+ metadata: {
21
+ name: queryName,
22
+ },
23
+ spec: {
24
+ input: options.message,
25
+ targets: [
26
+ {
27
+ type: options.targetType,
28
+ name: options.targetName,
29
+ },
30
+ ],
31
+ },
32
+ };
33
+ try {
34
+ // Apply the query
35
+ spinner.text = 'Submitting query...';
36
+ await execa('kubectl', ['apply', '-f', '-'], {
37
+ input: JSON.stringify(queryManifest),
38
+ stdio: ['pipe', 'pipe', 'pipe'],
39
+ });
40
+ // Watch for query completion
41
+ spinner.text = 'Query status: initializing';
42
+ let queryComplete = false;
43
+ let attempts = 0;
44
+ const maxAttempts = 300; // 5 minutes with 1 second intervals
45
+ while (!queryComplete && attempts < maxAttempts) {
46
+ attempts++;
47
+ try {
48
+ const { stdout } = await execa('kubectl', ['get', 'query', queryName, '-o', 'json'], { stdio: 'pipe' });
49
+ const query = JSON.parse(stdout);
50
+ const phase = query.status?.phase;
51
+ // Update spinner with current phase
52
+ if (phase) {
53
+ spinner.text = `Query status: ${phase}`;
54
+ }
55
+ // Check if query is complete based on phase
56
+ if (phase === 'done') {
57
+ queryComplete = true;
58
+ spinner.succeed('Query completed');
59
+ // Extract and display the response from responses array
60
+ if (query.status?.responses && query.status.responses.length > 0) {
61
+ const response = query.status.responses[0];
62
+ console.log('\n' + (response.content || response));
63
+ }
64
+ else {
65
+ output.warning('No response received');
66
+ }
67
+ }
68
+ else if (phase === 'error') {
69
+ queryComplete = true;
70
+ spinner.fail('Query failed');
71
+ // Try to get error message from conditions or status
72
+ const errorCondition = query.status?.conditions?.find((c) => {
73
+ return c.type === 'Complete' && c.status === 'False';
74
+ });
75
+ if (errorCondition?.message) {
76
+ output.error(errorCondition.message);
77
+ }
78
+ else if (query.status?.error) {
79
+ output.error(query.status.error);
80
+ }
81
+ else {
82
+ output.error('Query failed with unknown error');
83
+ }
84
+ }
85
+ else if (phase === 'canceled') {
86
+ queryComplete = true;
87
+ spinner.warn('Query canceled');
88
+ // Try to get cancellation reason if available
89
+ if (query.status?.message) {
90
+ output.warning(query.status.message);
91
+ }
92
+ }
93
+ }
94
+ catch {
95
+ // Query might not exist yet, continue waiting
96
+ spinner.text = 'Query status: waiting for query to be created';
97
+ }
98
+ if (!queryComplete) {
99
+ await new Promise((resolve) => setTimeout(resolve, 1000)); // Wait 1 second
100
+ }
101
+ }
102
+ if (!queryComplete) {
103
+ spinner.fail('Query timed out');
104
+ output.error('Query did not complete within 5 minutes');
105
+ }
106
+ }
107
+ catch (error) {
108
+ spinner.fail('Query failed');
109
+ output.error(error instanceof Error ? error.message : 'Unknown error');
110
+ process.exit(1);
111
+ }
112
+ finally {
113
+ // Clean up the query resource
114
+ try {
115
+ await execa('kubectl', ['delete', 'query', queryName], { stdio: 'pipe' });
116
+ }
117
+ catch {
118
+ // Ignore cleanup errors
119
+ }
120
+ }
121
+ }
122
+ /**
123
+ * Parse a target string like "model/default" or "agent/weather"
124
+ * Returns QueryTarget or null if invalid
125
+ */
126
+ export function parseTarget(target) {
127
+ const parts = target.split('/');
128
+ if (parts.length !== 2) {
129
+ return null;
130
+ }
131
+ return {
132
+ type: parts[0],
133
+ name: parts[1],
134
+ };
135
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,170 @@
1
+ import { jest } from '@jest/globals';
2
+ const mockExeca = jest.fn();
3
+ jest.unstable_mockModule('execa', () => ({
4
+ execa: mockExeca,
5
+ }));
6
+ const mockSpinner = {
7
+ start: jest.fn(),
8
+ succeed: jest.fn(),
9
+ fail: jest.fn(),
10
+ warn: jest.fn(),
11
+ text: '',
12
+ };
13
+ const mockOra = jest.fn(() => mockSpinner);
14
+ jest.unstable_mockModule('ora', () => ({
15
+ default: mockOra,
16
+ }));
17
+ const mockOutput = {
18
+ warning: jest.fn(),
19
+ error: jest.fn(),
20
+ };
21
+ jest.unstable_mockModule('./output.js', () => ({
22
+ default: mockOutput,
23
+ }));
24
+ const mockExit = jest.spyOn(process, 'exit').mockImplementation((() => {
25
+ throw new Error('process.exit called');
26
+ }));
27
+ const mockConsoleLog = jest.spyOn(console, 'log').mockImplementation(() => { });
28
+ const { executeQuery, parseTarget } = await import('./executeQuery.js');
29
+ describe('executeQuery', () => {
30
+ beforeEach(() => {
31
+ jest.clearAllMocks();
32
+ mockSpinner.start.mockReturnValue(mockSpinner);
33
+ });
34
+ describe('parseTarget', () => {
35
+ it('should parse valid target strings', () => {
36
+ expect(parseTarget('model/default')).toEqual({
37
+ type: 'model',
38
+ name: 'default',
39
+ });
40
+ expect(parseTarget('agent/weather-agent')).toEqual({
41
+ type: 'agent',
42
+ name: 'weather-agent',
43
+ });
44
+ expect(parseTarget('team/my-team')).toEqual({
45
+ type: 'team',
46
+ name: 'my-team',
47
+ });
48
+ });
49
+ it('should return null for invalid target strings', () => {
50
+ expect(parseTarget('invalid')).toBeNull();
51
+ expect(parseTarget('')).toBeNull();
52
+ expect(parseTarget('model/default/extra')).toBeNull();
53
+ });
54
+ });
55
+ describe('executeQuery', () => {
56
+ it('should create and apply a query manifest', async () => {
57
+ const mockQueryResponse = {
58
+ status: {
59
+ phase: 'done',
60
+ responses: [{ content: 'Test response' }],
61
+ },
62
+ };
63
+ mockExeca.mockImplementation(async (command, args) => {
64
+ if (args.includes('apply')) {
65
+ return { stdout: '', stderr: '', exitCode: 0 };
66
+ }
67
+ if (args.includes('get') && args.includes('query')) {
68
+ return {
69
+ stdout: JSON.stringify(mockQueryResponse),
70
+ stderr: '',
71
+ exitCode: 0,
72
+ };
73
+ }
74
+ if (args.includes('delete')) {
75
+ return { stdout: '', stderr: '', exitCode: 0 };
76
+ }
77
+ return { stdout: '', stderr: '', exitCode: 0 };
78
+ });
79
+ await executeQuery({
80
+ targetType: 'model',
81
+ targetName: 'default',
82
+ message: 'Hello',
83
+ });
84
+ expect(mockSpinner.start).toHaveBeenCalled();
85
+ expect(mockSpinner.succeed).toHaveBeenCalledWith('Query completed');
86
+ expect(mockConsoleLog).toHaveBeenCalledWith('\nTest response');
87
+ });
88
+ it('should handle query error phase', async () => {
89
+ const mockQueryResponse = {
90
+ status: {
91
+ phase: 'error',
92
+ error: 'Query failed with test error',
93
+ },
94
+ };
95
+ mockExeca.mockImplementation(async (command, args) => {
96
+ if (args.includes('apply')) {
97
+ return { stdout: '', stderr: '', exitCode: 0 };
98
+ }
99
+ if (args.includes('get') && args.includes('query')) {
100
+ return {
101
+ stdout: JSON.stringify(mockQueryResponse),
102
+ stderr: '',
103
+ exitCode: 0,
104
+ };
105
+ }
106
+ if (args.includes('delete')) {
107
+ return { stdout: '', stderr: '', exitCode: 0 };
108
+ }
109
+ return { stdout: '', stderr: '', exitCode: 0 };
110
+ });
111
+ await executeQuery({
112
+ targetType: 'model',
113
+ targetName: 'default',
114
+ message: 'Hello',
115
+ });
116
+ expect(mockSpinner.fail).toHaveBeenCalledWith('Query failed');
117
+ expect(mockOutput.error).toHaveBeenCalledWith('Query failed with test error');
118
+ });
119
+ it('should handle query canceled phase', async () => {
120
+ const mockQueryResponse = {
121
+ status: {
122
+ phase: 'canceled',
123
+ message: 'Query was canceled',
124
+ },
125
+ };
126
+ mockExeca.mockImplementation(async (command, args) => {
127
+ if (args.includes('apply')) {
128
+ return { stdout: '', stderr: '', exitCode: 0 };
129
+ }
130
+ if (args.includes('get') && args.includes('query')) {
131
+ return {
132
+ stdout: JSON.stringify(mockQueryResponse),
133
+ stderr: '',
134
+ exitCode: 0,
135
+ };
136
+ }
137
+ if (args.includes('delete')) {
138
+ return { stdout: '', stderr: '', exitCode: 0 };
139
+ }
140
+ return { stdout: '', stderr: '', exitCode: 0 };
141
+ });
142
+ await executeQuery({
143
+ targetType: 'agent',
144
+ targetName: 'test-agent',
145
+ message: 'Hello',
146
+ });
147
+ expect(mockSpinner.warn).toHaveBeenCalledWith('Query canceled');
148
+ expect(mockOutput.warning).toHaveBeenCalledWith('Query was canceled');
149
+ });
150
+ it('should clean up query resource even on failure', async () => {
151
+ mockExeca.mockImplementation(async (command, args) => {
152
+ if (args.includes('apply')) {
153
+ throw new Error('Failed to apply');
154
+ }
155
+ if (args.includes('delete')) {
156
+ return { stdout: '', stderr: '', exitCode: 0 };
157
+ }
158
+ return { stdout: '', stderr: '', exitCode: 0 };
159
+ });
160
+ await expect(executeQuery({
161
+ targetType: 'model',
162
+ targetName: 'default',
163
+ message: 'Hello',
164
+ })).rejects.toThrow('process.exit called');
165
+ expect(mockSpinner.fail).toHaveBeenCalledWith('Query failed');
166
+ expect(mockOutput.error).toHaveBeenCalledWith('Failed to apply');
167
+ expect(mockExit).toHaveBeenCalledWith(1);
168
+ });
169
+ });
170
+ });
@@ -13,7 +13,7 @@ export function printNextSteps() {
13
13
  console.log(` ${chalk.gray('open dashboard:')} ${chalk.white.bold('ark dashboard')}`);
14
14
  console.log(` ${chalk.gray('show agents:')} ${chalk.white.bold('kubectl get agents')}`);
15
15
  console.log(` ${chalk.gray('run a query:')} ${chalk.white.bold('ark query model/default "What are large language models?"')}`);
16
- console.log(` ${chalk.gray('interactive chat:')} ${chalk.white.bold('ark')} ${chalk.gray('# then choose \'Chat\'')}`);
16
+ console.log(` ${chalk.gray('interactive chat:')} ${chalk.white.bold('ark')} ${chalk.gray("# then choose 'Chat'")}`);
17
17
  console.log(` ${chalk.gray('new project:')} ${chalk.white.bold('ark generate project my-agents')}`);
18
18
  console.log(` ${chalk.gray('install fark:')} ${chalk.blue('https://mckinsey.github.io/agents-at-scale-ark/developer-guide/cli-tools/')}`);
19
19
  console.log();
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Shared query execution logic for both universal and resource-specific query commands
3
+ */
4
+ export interface QueryOptions {
5
+ targetType: string;
6
+ targetName: string;
7
+ message: string;
8
+ verbose?: boolean;
9
+ }
10
+ /**
11
+ * Execute a query against any ARK target (model, agent, team)
12
+ * This is the shared implementation used by all query commands
13
+ */
14
+ export declare function executeQuery(options: QueryOptions): Promise<void>;
15
+ /**
16
+ * Parse a target string like "model/default" or "agent/weather"
17
+ * Returns { targetType, targetName } or null if invalid
18
+ */
19
+ export declare function parseTarget(target: string): {
20
+ targetType: string;
21
+ targetName: string;
22
+ } | null;
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Shared query execution logic for both universal and resource-specific query commands
3
+ */
4
+ import { execa } from 'execa';
5
+ import ora from 'ora';
6
+ import output from './output.js';
7
+ /**
8
+ * Execute a query against any ARK target (model, agent, team)
9
+ * This is the shared implementation used by all query commands
10
+ */
11
+ export async function executeQuery(options) {
12
+ const spinner = ora('Creating query...').start();
13
+ // Generate a unique query name
14
+ const timestamp = Date.now();
15
+ const queryName = `cli-query-${timestamp}`;
16
+ // Create the Query resource
17
+ const queryManifest = {
18
+ apiVersion: 'ark.mckinsey.com/v1alpha1',
19
+ kind: 'Query',
20
+ metadata: {
21
+ name: queryName,
22
+ },
23
+ spec: {
24
+ input: options.message,
25
+ targets: [
26
+ {
27
+ type: options.targetType,
28
+ name: options.targetName,
29
+ },
30
+ ],
31
+ },
32
+ };
33
+ try {
34
+ // Apply the query
35
+ spinner.text = 'Submitting query...';
36
+ await execa('kubectl', ['apply', '-f', '-'], {
37
+ input: JSON.stringify(queryManifest),
38
+ stdio: ['pipe', 'pipe', 'pipe'],
39
+ });
40
+ // Watch for query completion
41
+ spinner.text = 'Query status: initializing';
42
+ let queryComplete = false;
43
+ let attempts = 0;
44
+ const maxAttempts = 300; // 5 minutes with 1 second intervals
45
+ while (!queryComplete && attempts < maxAttempts) {
46
+ attempts++;
47
+ try {
48
+ const { stdout } = await execa('kubectl', [
49
+ 'get',
50
+ 'query',
51
+ queryName,
52
+ '-o',
53
+ 'json',
54
+ ], { stdio: 'pipe' });
55
+ const query = JSON.parse(stdout);
56
+ const phase = query.status?.phase;
57
+ // Update spinner with current phase
58
+ if (phase) {
59
+ spinner.text = `Query status: ${phase}`;
60
+ }
61
+ // Check if query is complete based on phase
62
+ if (phase === 'done') {
63
+ queryComplete = true;
64
+ spinner.succeed('Query completed');
65
+ // Extract and display the response from responses array
66
+ if (query.status?.responses && query.status.responses.length > 0) {
67
+ const response = query.status.responses[0];
68
+ console.log('\n' + (response.content || response));
69
+ }
70
+ else {
71
+ output.warning('No response received');
72
+ }
73
+ }
74
+ else if (phase === 'error') {
75
+ queryComplete = true;
76
+ spinner.fail('Query failed');
77
+ // Try to get error message from conditions or status
78
+ const errorCondition = query.status?.conditions?.find((c) => {
79
+ const condition = c;
80
+ return condition.type === 'Complete' && condition.status === 'False';
81
+ });
82
+ if (errorCondition?.message) {
83
+ output.error(errorCondition.message);
84
+ }
85
+ else if (query.status?.error) {
86
+ output.error(query.status.error);
87
+ }
88
+ else {
89
+ output.error('Query failed with unknown error');
90
+ }
91
+ }
92
+ else if (phase === 'canceled') {
93
+ queryComplete = true;
94
+ spinner.warn('Query canceled');
95
+ // Try to get cancellation reason if available
96
+ if (query.status?.message) {
97
+ output.warning(query.status.message);
98
+ }
99
+ }
100
+ }
101
+ catch {
102
+ // Query might not exist yet, continue waiting
103
+ spinner.text = 'Query status: waiting for query to be created';
104
+ }
105
+ if (!queryComplete) {
106
+ await new Promise(resolve => setTimeout(resolve, 1000)); // Wait 1 second
107
+ }
108
+ }
109
+ if (!queryComplete) {
110
+ spinner.fail('Query timed out');
111
+ output.error('Query did not complete within 5 minutes');
112
+ }
113
+ }
114
+ catch (error) {
115
+ spinner.fail('Query failed');
116
+ output.error(error instanceof Error ? error.message : 'Unknown error');
117
+ process.exit(1);
118
+ }
119
+ finally {
120
+ // Clean up the query resource
121
+ try {
122
+ await execa('kubectl', ['delete', 'query', queryName], { stdio: 'pipe' });
123
+ }
124
+ catch {
125
+ // Ignore cleanup errors
126
+ }
127
+ }
128
+ }
129
+ /**
130
+ * Parse a target string like "model/default" or "agent/weather"
131
+ * Returns { targetType, targetName } or null if invalid
132
+ */
133
+ export function parseTarget(target) {
134
+ const parts = target.split('/');
135
+ if (parts.length !== 2) {
136
+ return null;
137
+ }
138
+ return {
139
+ targetType: parts[0],
140
+ targetName: parts[1],
141
+ };
142
+ }
@@ -4,6 +4,6 @@ import type { ArkConfig } from './config.js';
4
4
  */
5
5
  export declare function showNoClusterError(): void;
6
6
  /**
7
- * Initialize the CLI by checking requirements and loading config
7
+ * Initialize the CLI with minimal checks for fast startup
8
8
  */
9
9
  export declare function startup(): Promise<ArkConfig>;
@@ -1,8 +1,7 @@
1
1
  import chalk from 'chalk';
2
+ import { execa } from 'execa';
2
3
  import { checkCommandExists } from './commands.js';
3
4
  import { loadConfig } from './config.js';
4
- import { getArkVersion } from './arkStatus.js';
5
- import { getClusterInfo } from './cluster.js';
6
5
  const REQUIRED_COMMANDS = [
7
6
  {
8
7
  name: 'kubectl',
@@ -48,46 +47,41 @@ export function showNoClusterError() {
48
47
  console.log(chalk.blue(' https://mckinsey.github.io/agents-at-scale-ark/quickstart/'));
49
48
  }
50
49
  /**
51
- * Fetch version information (non-blocking)
50
+ * Check if a Kubernetes context is configured
51
+ * This is a fast local check that doesn't hit the cluster
52
52
  */
53
- async function fetchVersionInfo(config) {
54
- // Fetch latest version from GitHub
53
+ async function hasKubernetesContext() {
55
54
  try {
56
- const response = await fetch('https://api.github.com/repos/mckinsey/agents-at-scale-ark/releases/latest');
57
- if (response.ok) {
58
- const data = (await response.json());
59
- // Remove 'v' prefix if present for consistent comparison
60
- config.latestVersion = data.tag_name.replace(/^v/, '');
61
- }
62
- }
63
- catch {
64
- // Silently fail - latestVersion will remain undefined
65
- }
66
- // Fetch current installed version (already without 'v' from helm)
67
- try {
68
- const currentVersion = await getArkVersion();
69
- if (currentVersion) {
70
- config.currentVersion = currentVersion;
71
- }
55
+ const { stdout } = await execa('kubectl', ['config', 'current-context']);
56
+ return stdout.trim().length > 0;
72
57
  }
73
58
  catch {
74
- // Silently fail - currentVersion will remain undefined
59
+ return false;
75
60
  }
76
61
  }
77
62
  /**
78
- * Initialize the CLI by checking requirements and loading config
63
+ * Initialize the CLI with minimal checks for fast startup
79
64
  */
80
65
  export async function startup() {
81
- // Check required commands
66
+ // Check required commands (kubectl, helm) - fast local checks
82
67
  await checkRequirements();
83
- // Load config
68
+ // Load config from disk (fast - just file I/O)
84
69
  const config = loadConfig();
85
- // Get cluster info - if no error, we have cluster access
86
- const clusterInfo = await getClusterInfo();
87
- if (!clusterInfo.error) {
88
- config.clusterInfo = clusterInfo;
70
+ // Check if we have a kubernetes context configured (fast local check)
71
+ // We don't check cluster connectivity here - that's expensive
72
+ const hasContext = await hasKubernetesContext();
73
+ if (hasContext) {
74
+ try {
75
+ const { stdout } = await execa('kubectl', ['config', 'current-context']);
76
+ config.clusterInfo = {
77
+ type: 'unknown', // We don't detect cluster type here - too slow
78
+ context: stdout.trim(),
79
+ // We don't fetch namespace or cluster details here - too slow
80
+ };
81
+ }
82
+ catch {
83
+ // Ignore - no context
84
+ }
89
85
  }
90
- // Fetch version info synchronously so it's available immediately
91
- await fetchVersionInfo(config);
92
86
  return config;
93
87
  }
@@ -16,21 +16,26 @@ jest.unstable_mockModule('./commands.js', () => ({
16
16
  jest.unstable_mockModule('./config.js', () => ({
17
17
  loadConfig: jest.fn(),
18
18
  }));
19
+ // Mock execa module
20
+ jest.unstable_mockModule('execa', () => ({
21
+ execa: jest.fn(),
22
+ }));
19
23
  // Dynamic imports after mocks
20
24
  const { checkCommandExists } = await import('./commands.js');
21
25
  const { loadConfig } = await import('./config.js');
26
+ const { execa } = await import('execa');
22
27
  const { startup } = await import('./startup.js');
23
28
  // Type the mocks
24
29
  const mockCheckCommandExists = checkCommandExists;
25
30
  const mockLoadConfig = loadConfig;
26
- // Mock fetch globally
27
- globalThis.fetch = jest.fn();
31
+ const mockExeca = execa;
28
32
  describe('startup', () => {
29
33
  let mockExit;
30
34
  let mockConsoleError;
31
35
  beforeEach(() => {
32
36
  jest.clearAllMocks();
33
- globalThis.fetch.mockClear();
37
+ // Mock execa to reject by default (no kubectl context)
38
+ mockExeca.mockRejectedValue(new Error('No context'));
34
39
  mockExit = jest.spyOn(process, 'exit').mockImplementation(() => {
35
40
  throw new Error('process.exit');
36
41
  });
@@ -120,49 +125,28 @@ describe('startup', () => {
120
125
  expect(checkCallOrder).toBeLessThan(loadCallOrder);
121
126
  expect(config).toEqual(expectedConfig);
122
127
  });
123
- describe('version fetching', () => {
124
- beforeEach(() => {
125
- // Setup successful requirements check and config
126
- mockCheckCommandExists.mockResolvedValue(true);
127
- mockLoadConfig.mockReturnValue({ chat: { streaming: true } });
128
- });
129
- it('fetches latest version from GitHub API', async () => {
130
- globalThis.fetch.mockResolvedValue({
131
- ok: true,
132
- json: async () => ({ tag_name: 'v0.1.35' }),
133
- });
134
- const config = await startup();
135
- expect(globalThis.fetch).toHaveBeenCalledWith('https://api.github.com/repos/mckinsey/agents-at-scale-ark/releases/latest');
136
- // Wait for async fetch to complete
137
- await new Promise((resolve) => setTimeout(resolve, 50));
138
- expect(config.latestVersion).toBe('0.1.35');
139
- });
140
- it('handles GitHub API failure gracefully', async () => {
141
- globalThis.fetch.mockRejectedValue(new Error('Network error'));
142
- const config = await startup();
143
- // Wait for async fetch attempt
144
- await new Promise((resolve) => setTimeout(resolve, 50));
145
- // Should not have latestVersion set
146
- expect(config.latestVersion).toBeUndefined();
147
- });
148
- it('handles non-OK response from GitHub API', async () => {
149
- globalThis.fetch.mockResolvedValue({
150
- ok: false,
151
- status: 403,
152
- });
153
- const config = await startup();
154
- // Wait for async fetch to complete
155
- await new Promise((resolve) => setTimeout(resolve, 50));
156
- // Should not have latestVersion set
157
- expect(config.latestVersion).toBeUndefined();
128
+ it('includes cluster context when available', async () => {
129
+ mockCheckCommandExists.mockResolvedValue(true);
130
+ mockLoadConfig.mockReturnValue({ chat: { streaming: true } });
131
+ // Mock successful kubectl context check
132
+ mockExeca.mockResolvedValue({
133
+ stdout: 'minikube',
134
+ stderr: '',
158
135
  });
159
- it('continues startup even if version fetch fails', async () => {
160
- globalThis.fetch.mockRejectedValue(new Error('API Error'));
161
- const config = await startup();
162
- // Startup should complete successfully
163
- expect(config).toBeDefined();
164
- expect(config.chat).toBeDefined();
165
- expect(mockExit).not.toHaveBeenCalled();
136
+ const config = await startup();
137
+ expect(config.clusterInfo).toEqual({
138
+ type: 'unknown',
139
+ context: 'minikube',
166
140
  });
141
+ expect(mockExeca).toHaveBeenCalledWith('kubectl', ['config', 'current-context']);
142
+ });
143
+ it('handles missing kubectl context gracefully', async () => {
144
+ mockCheckCommandExists.mockResolvedValue(true);
145
+ const expectedConfig = { chat: { streaming: false } };
146
+ mockLoadConfig.mockReturnValue(expectedConfig);
147
+ // mockExeca already mocked to reject in beforeEach
148
+ const config = await startup();
149
+ expect(config).toEqual(expectedConfig);
150
+ expect(config.clusterInfo).toBeUndefined();
167
151
  });
168
152
  });