greenrun-cli 0.2.16 → 0.3.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.
package/README.md CHANGED
@@ -78,9 +78,11 @@ Detect which tests are impacted by recent git changes and offer to run them.
78
78
  | `update_test` | Update test (auto-invalidates script on content change) |
79
79
  | `prepare_test_batch` | Fetch, filter, and start runs for a batch of tests |
80
80
  | `export_test_script` | Write a test's cached Playwright script to a local file (keeps scripts out of context) |
81
+ | `export_test_instructions` | Write a test's instructions to a local file (keeps instructions out of context) |
81
82
  | `sweep` | Find tests affected by specific pages |
82
83
  | `start_run` | Start a test run |
83
- | `complete_run` | Record test result |
84
+ | `complete_run` | Record a single test result |
85
+ | `batch_complete_runs` | Record results for multiple test runs in one call |
84
86
  | `get_run` | Get run details |
85
87
  | `list_runs` | List run history |
86
88
 
@@ -1,13 +1,112 @@
1
+ /** Configuration for connecting to the Greenrun API. */
1
2
  export interface ApiConfig {
2
3
  baseUrl: string;
3
4
  token: string;
4
5
  }
6
+ /** Credential set stored on a project. */
7
+ export interface Credential {
8
+ name: string;
9
+ email: string;
10
+ password: string;
11
+ }
12
+ /** Project data returned by the API. */
13
+ export interface ProjectResponse {
14
+ id: string;
15
+ name: string;
16
+ base_url?: string;
17
+ description?: string;
18
+ auth_mode?: string;
19
+ login_url?: string;
20
+ register_url?: string;
21
+ login_instructions?: string;
22
+ register_instructions?: string;
23
+ credentials?: Credential[];
24
+ tests_count?: number;
25
+ pages_count?: number;
26
+ }
27
+ /** Tag data as returned by the API (may be a string or object with name). */
28
+ interface TagData {
29
+ name?: string;
30
+ [key: string]: unknown;
31
+ }
32
+ /** Page data as returned by the API. */
33
+ interface PageData {
34
+ id: string;
35
+ url: string;
36
+ [key: string]: unknown;
37
+ }
38
+ /** Test data returned by the API. */
39
+ export interface TestResponse {
40
+ id: string;
41
+ name: string;
42
+ instructions?: string;
43
+ status?: string;
44
+ credential_name?: string;
45
+ has_script?: boolean;
46
+ script?: string;
47
+ script_generated_at?: string;
48
+ pages?: PageData[];
49
+ tags?: (string | TagData)[];
50
+ [key: string]: unknown;
51
+ }
52
+ /** Run data returned by the API. */
53
+ export interface RunResponse {
54
+ id: string;
55
+ status: string;
56
+ result?: string;
57
+ started_at?: string;
58
+ completed_at?: string;
59
+ duration_ms?: number;
60
+ [key: string]: unknown;
61
+ }
62
+ /** Summary of a test in a prepared batch. */
63
+ export interface BatchTestSummary {
64
+ test_id: string;
65
+ test_name: string;
66
+ run_id: string;
67
+ credential_name: string | null;
68
+ pages: {
69
+ id: string;
70
+ url: string;
71
+ }[];
72
+ tags: string[];
73
+ has_script: boolean;
74
+ }
75
+ /** Project summary included in a batch result, with auth fields filtered by relevance. */
76
+ export interface BatchProjectSummary {
77
+ id: string;
78
+ name: string;
79
+ base_url?: string;
80
+ auth_mode: string;
81
+ login_url?: string;
82
+ register_url?: string;
83
+ login_instructions?: string;
84
+ register_instructions?: string;
85
+ credentials?: Credential[];
86
+ }
87
+ /** Result of preparing a test batch for execution. */
88
+ export interface BatchResult {
89
+ project: BatchProjectSummary;
90
+ tests: BatchTestSummary[];
91
+ }
92
+ /** A single run result for batch completion. */
93
+ export interface RunResult {
94
+ run_id: string;
95
+ status: string;
96
+ result?: string;
97
+ }
98
+ /** HTTP client for the Greenrun API. */
5
99
  export declare class ApiClient {
6
100
  private baseUrl;
7
101
  private token;
8
102
  constructor(config: ApiConfig);
103
+ /** Make an authenticated request to the API. */
9
104
  private request;
10
- listProjects(): Promise<unknown>;
105
+ /** List all projects accessible to the authenticated user. */
106
+ listProjects(): Promise<{
107
+ projects: ProjectResponse[];
108
+ }>;
109
+ /** Create a new project. */
11
110
  createProject(data: {
12
111
  name: string;
13
112
  base_url?: string;
@@ -17,13 +116,15 @@ export declare class ApiClient {
17
116
  register_url?: string;
18
117
  login_instructions?: string;
19
118
  register_instructions?: string;
20
- credentials?: {
21
- name: string;
22
- email: string;
23
- password: string;
24
- }[];
25
- }): Promise<unknown>;
26
- getProject(id: string): Promise<unknown>;
119
+ credentials?: Credential[];
120
+ }): Promise<{
121
+ project: ProjectResponse;
122
+ }>;
123
+ /** Get a project by ID. */
124
+ getProject(id: string): Promise<{
125
+ project: ProjectResponse;
126
+ }>;
127
+ /** Update a project's settings. */
27
128
  updateProject(id: string, data: {
28
129
  name?: string;
29
130
  base_url?: string;
@@ -33,24 +134,41 @@ export declare class ApiClient {
33
134
  register_url?: string;
34
135
  login_instructions?: string;
35
136
  register_instructions?: string;
36
- credentials?: {
37
- name: string;
38
- email: string;
39
- password: string;
40
- }[];
41
- }): Promise<unknown>;
42
- deleteProject(id: string): Promise<unknown>;
43
- listPages(projectId: string): Promise<unknown>;
137
+ credentials?: Credential[];
138
+ }): Promise<{
139
+ project: ProjectResponse;
140
+ }>;
141
+ /** Delete a project by ID. */
142
+ deleteProject(id: string): Promise<{
143
+ message: string;
144
+ }>;
145
+ /** List all pages in a project. */
146
+ listPages(projectId: string): Promise<{
147
+ pages: PageData[];
148
+ }>;
149
+ /** Register a new page URL in a project. */
44
150
  createPage(projectId: string, data: {
45
151
  url: string;
46
152
  name?: string;
47
- }): Promise<unknown>;
153
+ }): Promise<{
154
+ page: PageData;
155
+ }>;
156
+ /** Update a page's URL or name. */
48
157
  updatePage(id: string, data: {
49
158
  url?: string;
50
159
  name?: string;
51
- }): Promise<unknown>;
52
- deletePage(id: string): Promise<unknown>;
53
- listTests(projectId: string, compact?: boolean): Promise<unknown>;
160
+ }): Promise<{
161
+ page: PageData;
162
+ }>;
163
+ /** Delete a page by ID. */
164
+ deletePage(id: string): Promise<{
165
+ message: string;
166
+ }>;
167
+ /** List tests in a project. Pass compact=true to omit instructions/script content. */
168
+ listTests(projectId: string, compact?: boolean): Promise<{
169
+ tests: TestResponse[];
170
+ }>;
171
+ /** Create a new test in a project. */
54
172
  createTest(projectId: string, data: {
55
173
  name: string;
56
174
  instructions: string;
@@ -58,8 +176,14 @@ export declare class ApiClient {
58
176
  status?: string;
59
177
  tags?: string[];
60
178
  credential_name?: string;
61
- }): Promise<unknown>;
62
- getTest(id: string): Promise<unknown>;
179
+ }): Promise<{
180
+ test: TestResponse;
181
+ }>;
182
+ /** Get full test details including instructions, pages, and recent runs. */
183
+ getTest(id: string): Promise<{
184
+ test: TestResponse;
185
+ }>;
186
+ /** Update a test's properties. */
63
187
  updateTest(id: string, data: {
64
188
  name?: string;
65
189
  instructions?: string;
@@ -69,39 +193,44 @@ export declare class ApiClient {
69
193
  credential_name?: string | null;
70
194
  script?: string | null;
71
195
  script_generated_at?: string | null;
72
- }): Promise<unknown>;
73
- deleteTest(id: string): Promise<unknown>;
196
+ }): Promise<{
197
+ test: TestResponse;
198
+ }>;
199
+ /** Delete a test by ID. */
200
+ deleteTest(id: string): Promise<{
201
+ message: string;
202
+ }>;
203
+ /** Find tests affected by specific pages (impact analysis). */
74
204
  sweep(projectId: string, params: {
75
205
  pages?: string[];
76
206
  url_pattern?: string;
77
207
  }): Promise<unknown>;
78
- startRun(testId: string): Promise<unknown>;
208
+ /** Start a new test run (sets status to running). */
209
+ startRun(testId: string): Promise<{
210
+ run: RunResponse;
211
+ }>;
212
+ /** Record the result of a single test run. */
79
213
  completeRun(runId: string, data: {
80
214
  status: string;
81
215
  result?: string;
82
- }): Promise<unknown>;
83
- getRun(runId: string): Promise<unknown>;
84
- listRuns(testId: string): Promise<unknown>;
85
- prepareTestBatch(projectId: string, filter?: string, testIds?: string[]): Promise<{
86
- project: {
87
- id: any;
88
- name: any;
89
- base_url: any;
90
- auth_mode: any;
91
- login_url: any;
92
- register_url: any;
93
- login_instructions: any;
94
- register_instructions: any;
95
- credentials: any;
96
- };
97
- tests: {
98
- test_id: any;
99
- test_name: any;
100
- run_id: any;
101
- credential_name: any;
102
- pages: any;
103
- tags: any;
104
- has_script: any;
105
- }[];
216
+ }): Promise<{
217
+ run: RunResponse;
218
+ }>;
219
+ /** Complete multiple test runs in a single batch call. */
220
+ batchCompleteRuns(runs: RunResult[]): Promise<{
221
+ completed: number;
106
222
  }>;
223
+ /** Get details of a specific test run. */
224
+ getRun(runId: string): Promise<{
225
+ run: RunResponse;
226
+ }>;
227
+ /** List run history for a test (newest first). */
228
+ listRuns(testId: string): Promise<unknown>;
229
+ /**
230
+ * Prepare a batch of tests for execution. Lists tests, applies filters,
231
+ * fetches project details, and starts runs — all in one call.
232
+ * Only includes credentials referenced by the batch's tests.
233
+ */
234
+ prepareTestBatch(projectId: string, filter?: string, testIds?: string[]): Promise<BatchResult>;
107
235
  }
236
+ export {};
@@ -1,3 +1,4 @@
1
+ /** HTTP client for the Greenrun API. */
1
2
  export class ApiClient {
2
3
  baseUrl;
3
4
  token;
@@ -5,6 +6,7 @@ export class ApiClient {
5
6
  this.baseUrl = config.baseUrl.replace(/\/+$/, '');
6
7
  this.token = config.token;
7
8
  }
9
+ /** Make an authenticated request to the API. */
8
10
  async request(method, path, body) {
9
11
  const url = `${this.baseUrl}/api/v1${path}`;
10
12
  const headers = {
@@ -31,53 +33,68 @@ export class ApiClient {
31
33
  }
32
34
  return response.json();
33
35
  }
34
- // Projects
36
+ // --- Projects ---
37
+ /** List all projects accessible to the authenticated user. */
35
38
  async listProjects() {
36
39
  return this.request('GET', '/projects');
37
40
  }
41
+ /** Create a new project. */
38
42
  async createProject(data) {
39
43
  return this.request('POST', '/projects', data);
40
44
  }
45
+ /** Get a project by ID. */
41
46
  async getProject(id) {
42
47
  return this.request('GET', `/projects/${id}`);
43
48
  }
49
+ /** Update a project's settings. */
44
50
  async updateProject(id, data) {
45
51
  return this.request('PUT', `/projects/${id}`, data);
46
52
  }
53
+ /** Delete a project by ID. */
47
54
  async deleteProject(id) {
48
55
  return this.request('DELETE', `/projects/${id}`);
49
56
  }
50
- // Pages
57
+ // --- Pages ---
58
+ /** List all pages in a project. */
51
59
  async listPages(projectId) {
52
60
  return this.request('GET', `/projects/${projectId}/pages`);
53
61
  }
62
+ /** Register a new page URL in a project. */
54
63
  async createPage(projectId, data) {
55
64
  return this.request('POST', `/projects/${projectId}/pages`, data);
56
65
  }
66
+ /** Update a page's URL or name. */
57
67
  async updatePage(id, data) {
58
68
  return this.request('PUT', `/pages/${id}`, data);
59
69
  }
70
+ /** Delete a page by ID. */
60
71
  async deletePage(id) {
61
72
  return this.request('DELETE', `/pages/${id}`);
62
73
  }
63
- // Tests
74
+ // --- Tests ---
75
+ /** List tests in a project. Pass compact=true to omit instructions/script content. */
64
76
  async listTests(projectId, compact) {
65
77
  const query = compact ? '?compact=1' : '';
66
78
  return this.request('GET', `/projects/${projectId}/tests${query}`);
67
79
  }
80
+ /** Create a new test in a project. */
68
81
  async createTest(projectId, data) {
69
82
  return this.request('POST', `/projects/${projectId}/tests`, data);
70
83
  }
84
+ /** Get full test details including instructions, pages, and recent runs. */
71
85
  async getTest(id) {
72
86
  return this.request('GET', `/tests/${id}`);
73
87
  }
88
+ /** Update a test's properties. */
74
89
  async updateTest(id, data) {
75
90
  return this.request('PUT', `/tests/${id}`, data);
76
91
  }
92
+ /** Delete a test by ID. */
77
93
  async deleteTest(id) {
78
94
  return this.request('DELETE', `/tests/${id}`);
79
95
  }
80
- // Sweep
96
+ // --- Sweep ---
97
+ /** Find tests affected by specific pages (impact analysis). */
81
98
  async sweep(projectId, params) {
82
99
  const searchParams = new URLSearchParams();
83
100
  if (params.pages) {
@@ -90,69 +107,112 @@ export class ApiClient {
90
107
  }
91
108
  return this.request('GET', `/projects/${projectId}/sweep?${searchParams.toString()}`);
92
109
  }
93
- // Test Runs
110
+ // --- Test Runs ---
111
+ /** Start a new test run (sets status to running). */
94
112
  async startRun(testId) {
95
113
  return this.request('POST', `/tests/${testId}/runs`);
96
114
  }
115
+ /** Record the result of a single test run. */
97
116
  async completeRun(runId, data) {
98
117
  return this.request('PUT', `/runs/${runId}`, data);
99
118
  }
119
+ /** Complete multiple test runs in a single batch call. */
120
+ async batchCompleteRuns(runs) {
121
+ return this.request('PUT', '/runs/batch', { runs });
122
+ }
123
+ /** Get details of a specific test run. */
100
124
  async getRun(runId) {
101
125
  return this.request('GET', `/runs/${runId}`);
102
126
  }
127
+ /** List run history for a test (newest first). */
103
128
  async listRuns(testId) {
104
129
  return this.request('GET', `/tests/${testId}/runs`);
105
130
  }
106
- // Batch operations
131
+ // --- Batch Operations ---
132
+ /**
133
+ * Prepare a batch of tests for execution. Lists tests, applies filters,
134
+ * fetches project details, and starts runs — all in one call.
135
+ * Only includes credentials referenced by the batch's tests.
136
+ */
107
137
  async prepareTestBatch(projectId, filter, testIds) {
108
138
  const [projectResult, testsResult] = await Promise.all([
109
139
  this.getProject(projectId),
110
140
  this.listTests(projectId, true),
111
141
  ]);
112
142
  const project = projectResult.project;
113
- let tests = (testsResult.tests || []).filter((t) => t.status === 'active');
114
- if (testIds && testIds.length > 0) {
115
- const idSet = new Set(testIds);
116
- tests = tests.filter((t) => idSet.has(t.id));
117
- }
118
- else if (filter) {
119
- if (filter.startsWith('tag:')) {
120
- const tag = filter.slice(4).toLowerCase();
121
- tests = tests.filter((t) => (t.tags || []).some((tg) => (tg.name || tg).toLowerCase() === tag));
122
- }
123
- else if (filter.startsWith('/')) {
124
- tests = tests.filter((t) => (t.pages || []).some((p) => (p.url || '').includes(filter)));
125
- }
126
- else {
127
- const term = filter.toLowerCase();
128
- tests = tests.filter((t) => (t.name || '').toLowerCase().includes(term));
129
- }
130
- }
131
- const projectSummary = {
132
- id: project.id, name: project.name, base_url: project.base_url,
133
- auth_mode: project.auth_mode ?? 'none',
134
- login_url: project.login_url ?? null,
135
- register_url: project.register_url ?? null,
136
- login_instructions: project.login_instructions ?? null,
137
- register_instructions: project.register_instructions ?? null,
138
- credentials: project.credentials ?? null,
139
- };
140
- if (tests.length === 0) {
143
+ const activeTests = filterTests(testsResult.tests || [], filter, testIds);
144
+ const projectSummary = buildProjectSummary(project, activeTests);
145
+ if (activeTests.length === 0) {
141
146
  return { project: projectSummary, tests: [] };
142
147
  }
143
- // Start runs in parallel (listTests already has full details, no need for getTest)
144
- const runs = await Promise.all(tests.map((t) => this.startRun(t.id)));
148
+ const runs = await startBatchRuns(activeTests, (testId) => this.startRun(testId));
145
149
  return {
146
150
  project: projectSummary,
147
- tests: tests.map((t, i) => ({
148
- test_id: t.id,
149
- test_name: t.name,
150
- run_id: runs[i].run.id,
151
- credential_name: t.credential_name ?? null,
152
- pages: (t.pages || []).map((p) => ({ id: p.id, url: p.url })),
153
- tags: (t.tags || []).map((tg) => tg.name || tg),
154
- has_script: t.has_script ?? !!t.script,
151
+ tests: activeTests.map((test, index) => ({
152
+ test_id: test.id,
153
+ test_name: test.name,
154
+ run_id: runs[index].run.id,
155
+ credential_name: test.credential_name ?? null,
156
+ pages: (test.pages || []).map((page) => ({ id: page.id, url: page.url })),
157
+ tags: (test.tags || []).map((tag) => typeof tag === 'string' ? tag : tag.name || ''),
158
+ has_script: test.has_script ?? !!test.script,
155
159
  })),
156
160
  };
157
161
  }
158
162
  }
163
+ /** Filter tests by test IDs, tag, page URL, or name substring. */
164
+ function filterTests(tests, filter, testIds) {
165
+ let activeTests = tests.filter((test) => test.status === 'active');
166
+ if (testIds && testIds.length > 0) {
167
+ const idSet = new Set(testIds);
168
+ activeTests = activeTests.filter((test) => idSet.has(test.id));
169
+ }
170
+ else if (filter) {
171
+ if (filter.startsWith('tag:')) {
172
+ const tagName = filter.slice(4).toLowerCase();
173
+ activeTests = activeTests.filter((test) => (test.tags || []).some((tag) => {
174
+ const name = typeof tag === 'string' ? tag : tag.name || '';
175
+ return name.toLowerCase() === tagName;
176
+ }));
177
+ }
178
+ else if (filter.startsWith('/')) {
179
+ activeTests = activeTests.filter((test) => (test.pages || []).some((page) => (page.url || '').includes(filter)));
180
+ }
181
+ else {
182
+ const term = filter.toLowerCase();
183
+ activeTests = activeTests.filter((test) => (test.name || '').toLowerCase().includes(term));
184
+ }
185
+ }
186
+ return activeTests;
187
+ }
188
+ /**
189
+ * Build a project summary for the batch response.
190
+ * Omits auth fields when auth_mode is 'none'.
191
+ * Only includes credentials referenced by the batch's tests.
192
+ */
193
+ function buildProjectSummary(project, tests) {
194
+ const authMode = project.auth_mode ?? 'none';
195
+ if (authMode === 'none') {
196
+ return { id: project.id, name: project.name, base_url: project.base_url, auth_mode: 'none' };
197
+ }
198
+ const referencedNames = new Set(tests.map((test) => test.credential_name).filter((name) => !!name));
199
+ const allCredentials = project.credentials ?? [];
200
+ const filteredCredentials = referencedNames.size > 0
201
+ ? allCredentials.filter((cred) => referencedNames.has(cred.name))
202
+ : allCredentials;
203
+ return {
204
+ id: project.id,
205
+ name: project.name,
206
+ base_url: project.base_url,
207
+ auth_mode: authMode,
208
+ login_url: project.login_url,
209
+ register_url: project.register_url,
210
+ login_instructions: project.login_instructions,
211
+ register_instructions: project.register_instructions,
212
+ credentials: filteredCredentials.length > 0 ? filteredCredentials : undefined,
213
+ };
214
+ }
215
+ /** Start runs for all tests in parallel. */
216
+ async function startBatchRuns(tests, startRun) {
217
+ return Promise.all(tests.map((test) => startRun(test.id)));
218
+ }
@@ -100,12 +100,14 @@ async function validateToken(token) {
100
100
  return { valid: true, projectCount: projects.length };
101
101
  }
102
102
  catch (err) {
103
- return { valid: false, error: err?.message || String(err) };
103
+ const message = err instanceof Error ? err.message : String(err);
104
+ return { valid: false, error: message };
104
105
  }
105
106
  }
106
107
  function getClaudeConfigPath() {
107
108
  return join(homedir(), '.claude.json');
108
109
  }
110
+ /** Read the Claude Code config from ~/.claude.json. */
109
111
  function readClaudeConfig() {
110
112
  const configPath = getClaudeConfigPath();
111
113
  if (!existsSync(configPath))
@@ -117,6 +119,7 @@ function readClaudeConfig() {
117
119
  return {};
118
120
  }
119
121
  }
122
+ /** Write the Claude Code config to ~/.claude.json. */
120
123
  function writeClaudeConfig(config) {
121
124
  writeFileSync(getClaudeConfigPath(), JSON.stringify(config, null, 2) + '\n');
122
125
  }
@@ -233,19 +236,8 @@ function installClaudeMd() {
233
236
  console.log(' Created CLAUDE.md with Greenrun instructions');
234
237
  }
235
238
  }
236
- function installSettings() {
237
- const settingsDir = join(process.cwd(), '.claude');
238
- mkdirSync(settingsDir, { recursive: true });
239
- const settingsPath = join(settingsDir, 'settings.local.json');
240
- let existing = {};
241
- if (existsSync(settingsPath)) {
242
- try {
243
- existing = JSON.parse(readFileSync(settingsPath, 'utf-8'));
244
- }
245
- catch {
246
- // overwrite invalid JSON
247
- }
248
- }
239
+ /** Build the list of MCP tool permissions needed for Greenrun and Playwright. */
240
+ function buildPermissionsList() {
249
241
  const greenrunTools = [
250
242
  'mcp__greenrun__list_projects',
251
243
  'mcp__greenrun__get_project',
@@ -259,10 +251,13 @@ function installSettings() {
259
251
  'mcp__greenrun__update_test',
260
252
  'mcp__greenrun__start_run',
261
253
  'mcp__greenrun__complete_run',
254
+ 'mcp__greenrun__batch_complete_runs',
262
255
  'mcp__greenrun__get_run',
263
256
  'mcp__greenrun__list_runs',
264
257
  'mcp__greenrun__sweep',
265
258
  'mcp__greenrun__prepare_test_batch',
259
+ 'mcp__greenrun__export_test_script',
260
+ 'mcp__greenrun__export_test_instructions',
266
261
  ];
267
262
  const browserTools = [
268
263
  'mcp__playwright__browser_navigate',
@@ -270,10 +265,6 @@ function installSettings() {
270
265
  'mcp__playwright__browser_click',
271
266
  'mcp__playwright__browser_type',
272
267
  'mcp__playwright__browser_handle_dialog',
273
- 'mcp__playwright__browser_tab_list',
274
- 'mcp__playwright__browser_tab_new',
275
- 'mcp__playwright__browser_tab_select',
276
- 'mcp__playwright__browser_tab_close',
277
268
  'mcp__playwright__browser_select_option',
278
269
  'mcp__playwright__browser_hover',
279
270
  'mcp__playwright__browser_drag',
@@ -291,11 +282,27 @@ function installSettings() {
291
282
  'mcp__playwright__browser_tabs',
292
283
  'mcp__playwright__browser_network_requests',
293
284
  ];
294
- const requiredTools = [...greenrunTools, ...browserTools];
295
- existing.permissions = existing.permissions || {};
296
- const currentAllow = existing.permissions.allow || [];
285
+ return [...greenrunTools, ...browserTools];
286
+ }
287
+ function installSettings() {
288
+ const settingsDir = join(process.cwd(), '.claude');
289
+ mkdirSync(settingsDir, { recursive: true });
290
+ const settingsPath = join(settingsDir, 'settings.local.json');
291
+ let existing = {};
292
+ if (existsSync(settingsPath)) {
293
+ try {
294
+ existing = JSON.parse(readFileSync(settingsPath, 'utf-8'));
295
+ }
296
+ catch {
297
+ // overwrite invalid JSON
298
+ }
299
+ }
300
+ const requiredTools = buildPermissionsList();
301
+ const permissions = (existing.permissions ?? {});
302
+ const currentAllow = (permissions.allow ?? []);
297
303
  const merged = [...new Set([...currentAllow, ...requiredTools])];
298
- existing.permissions.allow = merged;
304
+ permissions.allow = merged;
305
+ existing.permissions = permissions;
299
306
  writeFileSync(settingsPath, JSON.stringify(existing, null, 2) + '\n');
300
307
  console.log(' Updated .claude/settings.local.json with tool permissions');
301
308
  }
package/dist/server.js CHANGED
@@ -1,9 +1,46 @@
1
1
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
3
  import { z } from 'zod';
4
- import { writeFileSync, mkdirSync } from 'node:fs';
5
- import { dirname } from 'node:path';
4
+ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
5
+ import { dirname, join } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
6
7
  import { ApiClient } from './api-client.js';
8
+ const __filename = fileURLToPath(import.meta.url);
9
+ const __dirname = dirname(__filename);
10
+ /** Remove keys with null or undefined values from an object (shallow). */
11
+ function stripNulls(obj) {
12
+ const result = {};
13
+ for (const [key, value] of Object.entries(obj)) {
14
+ if (value != null) {
15
+ result[key] = value;
16
+ }
17
+ }
18
+ return result;
19
+ }
20
+ /** Build a compact MCP text response from data, stripping nulls and using minimal JSON. */
21
+ function jsonResponse(data) {
22
+ const cleaned = data && typeof data === 'object' && !Array.isArray(data)
23
+ ? stripNulls(data)
24
+ : data;
25
+ return { content: [{ type: 'text', text: JSON.stringify(cleaned) }] };
26
+ }
27
+ /** Read the package version from package.json. */
28
+ function readPackageVersion() {
29
+ try {
30
+ const pkgPath = join(__dirname, '..', 'package.json');
31
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
32
+ return pkg.version || '0.0.0';
33
+ }
34
+ catch {
35
+ return '0.0.0';
36
+ }
37
+ }
38
+ /** Shared zod schema for credential sets, used by create_project and update_project. */
39
+ const credentialSchema = z.object({
40
+ name: z.string().describe('Credential set name (e.g. "admin", "viewer")'),
41
+ email: z.string().describe('Login email'),
42
+ password: z.string().describe('Login password'),
43
+ });
7
44
  export async function startServer() {
8
45
  const GREENRUN_API_URL = process.env.GREENRUN_API_URL || 'https://app.greenrun.dev';
9
46
  const GREENRUN_API_TOKEN = process.env.GREENRUN_API_TOKEN;
@@ -17,12 +54,12 @@ export async function startServer() {
17
54
  });
18
55
  const server = new McpServer({
19
56
  name: 'greenrun',
20
- version: '0.1.0',
57
+ version: readPackageVersion(),
21
58
  });
22
59
  // --- Projects ---
23
60
  server.tool('list_projects', 'List all projects', {}, async () => {
24
61
  const result = await api.listProjects();
25
- return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
62
+ return jsonResponse(result);
26
63
  });
27
64
  server.tool('create_project', 'Create a new project', {
28
65
  name: z.string().describe('Project name'),
@@ -33,18 +70,14 @@ export async function startServer() {
33
70
  register_url: z.string().optional().describe('URL of registration page (for new_user auth mode)'),
34
71
  login_instructions: z.string().optional().describe('Steps to log in with existing credentials'),
35
72
  register_instructions: z.string().optional().describe('Steps to register a new user'),
36
- credentials: z.array(z.object({
37
- name: z.string().describe('Credential set name (e.g. "admin", "viewer")'),
38
- email: z.string().describe('Login email'),
39
- password: z.string().describe('Login password'),
40
- })).optional().describe('Named credential sets for test authentication (max 20)'),
73
+ credentials: z.array(credentialSchema).optional().describe('Named credential sets for test authentication (max 20)'),
41
74
  }, async (args) => {
42
75
  const result = await api.createProject(args);
43
- return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
76
+ return jsonResponse(result);
44
77
  });
45
78
  server.tool('get_project', 'Get project details', { project_id: z.string().describe('Project UUID') }, async (args) => {
46
79
  const result = await api.getProject(args.project_id);
47
- return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
80
+ return jsonResponse(result);
48
81
  });
49
82
  server.tool('update_project', 'Update project settings', {
50
83
  project_id: z.string().describe('Project UUID'),
@@ -56,20 +89,16 @@ export async function startServer() {
56
89
  register_url: z.string().optional().describe('URL of registration page (for new_user auth mode)'),
57
90
  login_instructions: z.string().optional().describe('Steps to log in with existing credentials'),
58
91
  register_instructions: z.string().optional().describe('Steps to register a new user'),
59
- credentials: z.array(z.object({
60
- name: z.string().describe('Credential set name (e.g. "admin", "viewer")'),
61
- email: z.string().describe('Login email'),
62
- password: z.string().describe('Login password'),
63
- })).optional().describe('Named credential sets for test authentication (max 20)'),
92
+ credentials: z.array(credentialSchema).optional().describe('Named credential sets for test authentication (max 20)'),
64
93
  }, async (args) => {
65
94
  const { project_id, ...data } = args;
66
95
  const result = await api.updateProject(project_id, data);
67
- return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
96
+ return jsonResponse(result);
68
97
  });
69
98
  // --- Pages ---
70
99
  server.tool('list_pages', 'List pages in a project', { project_id: z.string().describe('Project UUID') }, async (args) => {
71
100
  const result = await api.listPages(args.project_id);
72
- return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
101
+ return jsonResponse(result);
73
102
  });
74
103
  server.tool('create_page', 'Register a page URL in a project', {
75
104
  project_id: z.string().describe('Project UUID'),
@@ -77,16 +106,16 @@ export async function startServer() {
77
106
  name: z.string().optional().describe('Human-friendly page name'),
78
107
  }, async (args) => {
79
108
  const result = await api.createPage(args.project_id, { url: args.url, name: args.name });
80
- return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
109
+ return jsonResponse(result);
81
110
  });
82
111
  // --- Tests ---
83
112
  server.tool('list_tests', 'List tests in a project (includes latest run status)', { project_id: z.string().describe('Project UUID') }, async (args) => {
84
113
  const result = await api.listTests(args.project_id);
85
- return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
114
+ return jsonResponse(result);
86
115
  });
87
116
  server.tool('get_test', 'Get test details including instructions, pages, and recent runs', { test_id: z.string().describe('Test UUID') }, async (args) => {
88
117
  const result = await api.getTest(args.test_id);
89
- return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
118
+ return jsonResponse(result);
90
119
  });
91
120
  server.tool('create_test', 'Store a new test case in a project', {
92
121
  project_id: z.string().describe('Project UUID'),
@@ -105,7 +134,7 @@ export async function startServer() {
105
134
  tags: args.tags,
106
135
  credential_name: args.credential_name,
107
136
  });
108
- return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
137
+ return jsonResponse(result);
109
138
  });
110
139
  server.tool('update_test', 'Update test instructions, name, status, or page associations', {
111
140
  test_id: z.string().describe('Test UUID'),
@@ -119,8 +148,8 @@ export async function startServer() {
119
148
  script_generated_at: z.string().optional().nullable().describe('ISO timestamp when the script was generated'),
120
149
  }, async (args) => {
121
150
  const { test_id, ...data } = args;
122
- const result = await api.updateTest(test_id, data);
123
- return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
151
+ await api.updateTest(test_id, data);
152
+ return jsonResponse({ success: true });
124
153
  });
125
154
  server.tool('export_test_script', 'Fetch a test\'s cached Playwright script and write it directly to a file. The script content is never returned — only a confirmation. Use this to export scripts without consuming context.', {
126
155
  test_id: z.string().describe('Test UUID'),
@@ -135,6 +164,20 @@ export async function startServer() {
135
164
  writeFileSync(args.file_path, script, 'utf-8');
136
165
  return { content: [{ type: 'text', text: `Script written to ${args.file_path} (${script.length} chars)` }] };
137
166
  });
167
+ server.tool('export_test_instructions', 'Fetch a test\'s instructions and write them to a local file. The instructions are never returned — only a confirmation. Use this so agents can read instructions from disk instead of receiving them through MCP context.', {
168
+ test_id: z.string().describe('Test UUID'),
169
+ file_path: z.string().describe('Absolute file path to write the instructions to (e.g. /tmp/greenrun-tests/{test_id}.instructions.md)'),
170
+ }, async (args) => {
171
+ const result = await api.getTest(args.test_id);
172
+ const instructions = result.test?.instructions;
173
+ if (!instructions) {
174
+ return { content: [{ type: 'text', text: `No instructions found for test ${args.test_id}` }] };
175
+ }
176
+ mkdirSync(dirname(args.file_path), { recursive: true });
177
+ const header = result.test.name ? `# ${result.test.name}\n\n` : '';
178
+ writeFileSync(args.file_path, header + instructions, 'utf-8');
179
+ return { content: [{ type: 'text', text: `Instructions written to ${args.file_path} (${instructions.length} chars)` }] };
180
+ });
138
181
  // --- Sweep ---
139
182
  server.tool('sweep', 'Find tests affected by specific pages (impact analysis). Use after making changes to determine which tests to re-run.', {
140
183
  project_id: z.string().describe('Project UUID'),
@@ -145,7 +188,7 @@ export async function startServer() {
145
188
  pages: args.pages,
146
189
  url_pattern: args.url_pattern,
147
190
  });
148
- return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
191
+ return jsonResponse(result);
149
192
  });
150
193
  // --- Batch ---
151
194
  server.tool('prepare_test_batch', 'Prepare a batch of tests for execution: lists tests, filters, fetches full details, and starts runs — all in one call. Returns everything needed to execute tests.', {
@@ -155,35 +198,46 @@ export async function startServer() {
155
198
  }, async (args) => {
156
199
  try {
157
200
  const result = await api.prepareTestBatch(args.project_id, args.filter, args.test_ids);
158
- return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
201
+ return jsonResponse(result);
159
202
  }
160
203
  catch (error) {
161
- return { content: [{ type: 'text', text: `Error: ${error.message}` }], isError: true };
204
+ const message = error instanceof Error ? error.message : String(error);
205
+ return { content: [{ type: 'text', text: `Error: ${message}` }], isError: true };
162
206
  }
163
207
  });
164
208
  // --- Test Runs ---
165
209
  server.tool('start_run', 'Start a test run (sets status to running)', { test_id: z.string().describe('Test UUID') }, async (args) => {
166
210
  const result = await api.startRun(args.test_id);
167
- return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
211
+ return jsonResponse({ run_id: result.run.id });
168
212
  });
169
213
  server.tool('complete_run', 'Record the result of a test run', {
170
214
  run_id: z.string().describe('Run UUID'),
171
215
  status: z.enum(['passed', 'failed', 'error']).describe('Run result status'),
172
216
  result: z.string().optional().describe('Summary of what happened during the run'),
173
217
  }, async (args) => {
174
- const result = await api.completeRun(args.run_id, {
218
+ await api.completeRun(args.run_id, {
175
219
  status: args.status,
176
220
  result: args.result,
177
221
  });
178
- return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
222
+ return jsonResponse({ success: true });
223
+ });
224
+ server.tool('batch_complete_runs', 'Record results for multiple test runs in a single call. More efficient than calling complete_run individually.', {
225
+ runs: z.array(z.object({
226
+ run_id: z.string().describe('Run UUID'),
227
+ status: z.enum(['passed', 'failed', 'error']).describe('Run result status'),
228
+ result: z.string().optional().describe('Summary of what happened'),
229
+ })).describe('Array of run results to complete'),
230
+ }, async (args) => {
231
+ const result = await api.batchCompleteRuns(args.runs);
232
+ return jsonResponse(result);
179
233
  });
180
234
  server.tool('get_run', 'Get details of a specific test run', { run_id: z.string().describe('Run UUID') }, async (args) => {
181
235
  const result = await api.getRun(args.run_id);
182
- return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
236
+ return jsonResponse(result);
183
237
  });
184
238
  server.tool('list_runs', 'List run history for a test (newest first)', { test_id: z.string().describe('Test UUID') }, async (args) => {
185
239
  const result = await api.listRuns(args.test_id);
186
- return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
240
+ return jsonResponse(result);
187
241
  });
188
242
  // Start server
189
243
  const transport = new StdioServerTransport();
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "greenrun-cli",
3
- "version": "0.2.16",
3
+ "version": "0.3.0",
4
4
  "description": "CLI and MCP server for Greenrun - browser test management for Claude Code",
5
5
  "type": "module",
6
6
  "main": "dist/server.js",
7
7
  "bin": {
8
- "greenrun": "dist/cli.js"
8
+ "greenrun": "dist/cli.js",
9
+ "greenrun-cli": "dist/cli.js"
9
10
  },
10
11
  "files": [
11
12
  "dist",
@@ -12,9 +12,11 @@ The Greenrun MCP server provides these tools:
12
12
  - **list_projects** / **get_project** / **create_project** - Manage projects (includes auth configuration)
13
13
  - **list_pages** / **create_page** - Manage page URLs within a project
14
14
  - **list_tests** / **get_test** / **create_test** / **update_test** - Manage test cases
15
- - **start_run** / **complete_run** / **get_run** / **list_runs** - Execute and track test runs
15
+ - **start_run** / **complete_run** / **batch_complete_runs** / **get_run** / **list_runs** - Execute and track test runs
16
16
  - **sweep** - Impact analysis: find tests affected by changed pages
17
17
  - **prepare_test_batch** - Batch prepare tests for execution (lists, filters, fetches details, starts runs in one call)
18
+ - **export_test_script** - Write a test's cached Playwright script to a local file (keeps scripts out of context)
19
+ - **export_test_instructions** - Write a test's instructions to a local file (keeps instructions out of context)
18
20
 
19
21
  ### Running Tests
20
22
 
@@ -25,7 +25,7 @@ If auth fails (login form still visible after following instructions), report al
25
25
 
26
26
  You have a batch result from `prepare_test_batch` containing `project` (with `credentials` array) and `tests[]` (each with `test_id`, `test_name`, `run_id`, `credential_name`, `pages`, `tags`, `has_script`).
27
27
 
28
- Note: The batch does not include `instructions` or `script` content. Use `get_test(test_id)` to fetch these when needed.
28
+ Note: The batch does not include `instructions` or `script` content. Use `export_test_instructions(test_id, file_path)` to write instructions to disk — agents read from the file instead of receiving them through MCP context.
29
29
 
30
30
  If `tests` is empty, tell the user no matching active tests were found and stop.
31
31
 
@@ -44,7 +44,10 @@ If all tests are scripted, skip to Step 4.
44
44
 
45
45
  ### Step 3: Generate scripts for unscripted tests
46
46
 
47
- For each **unscripted** test, launch a Task agent sequentially (one at a time, wait for each to complete before starting the next). This keeps browser snapshot data out of the parent context.
47
+ For each **unscripted** test:
48
+
49
+ 1. Call `export_test_instructions(test_id, "/tmp/greenrun-tests/{test_id}.instructions.md")` to write instructions to disk
50
+ 2. Launch a Task agent sequentially (one at a time, wait for each to complete before starting the next). This keeps browser snapshot data out of the parent context.
48
51
 
49
52
  ```
50
53
  Task tool with:
@@ -68,7 +71,7 @@ Credentials: {credential_name} — email: {email}, password: {password}
68
71
 
69
72
  ## Task
70
73
 
71
- 1. Call `get_test("{test_id}")` to fetch the full test instructions
74
+ 1. Read the test instructions from `/tmp/greenrun-tests/{test_id}.instructions.md` (exported before agent launch)
72
75
  2. Authenticate: navigate to {login_url} and log in with the credential above using `browser_navigate`, `browser_snapshot`, `browser_click`, `browser_type`
73
76
  3. Do a scouting pass — follow the test instructions step by step in the browser:
74
77
  - Navigate to the test's starting page via `browser_navigate`
@@ -153,7 +156,7 @@ npx playwright test --config /tmp/greenrun-tests/playwright.config.ts
153
156
 
154
157
  **3. Parse results**: Read `/tmp/greenrun-tests/results.json`. Map each result back to a run ID via the filename: `{test_id}.spec.ts` → test_id → find the matching run_id from the batch.
155
158
 
156
- **4. Report results**: Call `complete_run(run_id, status, result_summary)` for each test. Map Playwright statuses: `passed` → `passed`, `failed`/`timedOut` → `failed`, other → `error`.
159
+ **4. Report results**: Call `batch_complete_runs` with all results at once. Map Playwright statuses: `passed` → `passed`, `failed`/`timedOut` → `failed`, other → `error`. Example: `batch_complete_runs({ runs: [{ run_id, status, result }] })`.
157
160
 
158
161
  **5. Clean up**: Call `browser_close` to reset the MCP browser context.
159
162
 
@@ -170,7 +173,10 @@ After parsing all native results, walk through them in completion order. Track c
170
173
 
171
174
  For tests that **failed** in native execution (and circuit breaker has not tripped), execute them one at a time via Task agents. This keeps snapshot data out of the parent context.
172
175
 
173
- For each failed test, launch a Task agent sequentially (wait for each to complete before the next):
176
+ For each failed test:
177
+
178
+ 1. Call `export_test_instructions(test_id, "/tmp/greenrun-tests/{test_id}.instructions.md")` to write instructions to disk
179
+ 2. Launch a Task agent sequentially (wait for each to complete before the next):
174
180
 
175
181
  ```
176
182
  Task tool with:
@@ -193,7 +199,7 @@ Native execution failed with: {failure_message}
193
199
 
194
200
  ## Task
195
201
 
196
- 1. Call `get_test("{test_id}")` to fetch the full test instructions
202
+ 1. Read the test instructions from `/tmp/greenrun-tests/{test_id}.instructions.md` (exported before agent launch)
197
203
  2. Start a new run: `start_run("{test_id}")` — note the run_id
198
204
  3. Authenticate: navigate to {login_url} and log in with the credential above
199
205
  4. Follow the test instructions step by step using Playwright MCP tools (`browser_navigate`, `browser_snapshot`, `browser_click`, `browser_type`)