@continuedev/continuous-ai 1.0.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 +170 -0
- package/bin/cai.js +2 -0
- package/bin/continuous-ai.js +2 -0
- package/package.json +56 -0
- package/src/commands/approve.js +52 -0
- package/src/commands/diff.js +40 -0
- package/src/commands/logs.js +42 -0
- package/src/commands/reject.js +48 -0
- package/src/commands/status.js +58 -0
- package/src/commands/wait.js +77 -0
- package/src/index.js +25 -0
- package/src/lib/api-client.js +149 -0
- package/src/lib/auth.js +71 -0
- package/src/lib/formatters.js +126 -0
- package/src/lib/pr-parser.js +34 -0
package/README.md
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# @continuedev/continuous-ai
|
|
2
|
+
|
|
3
|
+
CLI tool for interacting with Continue agent checks on pull requests.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install -g @continuedev/continuous-ai
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
### Setup
|
|
14
|
+
|
|
15
|
+
Get your API key from [hub.continue.dev/settings/api-keys](https://hub.continue.dev/settings/api-keys) and set it:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
export CONTINUE_API_KEY="con-xxx"
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Or pass it with the `--token` flag to each command.
|
|
22
|
+
|
|
23
|
+
### Commands
|
|
24
|
+
|
|
25
|
+
#### `cai status <pr-url>`
|
|
26
|
+
|
|
27
|
+
List all agent checks for a pull request with their current status.
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
cai status https://github.com/owner/repo/pull/123
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Options:
|
|
34
|
+
- `--org <slug>` - Organization context
|
|
35
|
+
- `--token <key>` - API key (or use CONTINUE_API_KEY env var)
|
|
36
|
+
- `--format json|table` - Output format (default: table)
|
|
37
|
+
|
|
38
|
+
#### `cai wait <pr-url>`
|
|
39
|
+
|
|
40
|
+
Block until all agent checks complete or fail.
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
cai wait https://github.com/owner/repo/pull/123
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Options:
|
|
47
|
+
- `--org <slug>` - Organization context
|
|
48
|
+
- `--token <key>` - API key
|
|
49
|
+
- `--timeout <seconds>` - Max wait time (default: 600)
|
|
50
|
+
- `--fail-fast` - Exit immediately on first failure
|
|
51
|
+
- `--format json|table` - Output format
|
|
52
|
+
|
|
53
|
+
#### `cai logs <session-id>`
|
|
54
|
+
|
|
55
|
+
View logs/state for a specific agent session.
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
cai logs A0042
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Options:
|
|
62
|
+
- `--org <slug>` - Organization context
|
|
63
|
+
- `--token <key>` - API key
|
|
64
|
+
- `--tail <lines>` - Show last N lines (default: all)
|
|
65
|
+
|
|
66
|
+
#### `cai diff <session-id>`
|
|
67
|
+
|
|
68
|
+
View code diff for an agent session.
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
cai diff A0042
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Options:
|
|
75
|
+
- `--org <slug>` - Organization context
|
|
76
|
+
- `--token <key>` - API key
|
|
77
|
+
- `--format unified|json` - Output format (default: unified)
|
|
78
|
+
|
|
79
|
+
#### `cai approve <pr-url>`
|
|
80
|
+
|
|
81
|
+
Approve changes and optionally merge the PR.
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
cai approve https://github.com/owner/repo/pull/123 --merge
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Options:
|
|
88
|
+
- `--org <slug>` - Organization context
|
|
89
|
+
- `--token <key>` - API key
|
|
90
|
+
- `--merge` - Also merge the PR (default: just mark ready)
|
|
91
|
+
- `--merge-method squash|merge|rebase` - Merge method (default: squash)
|
|
92
|
+
|
|
93
|
+
#### `cai reject <pr-url>`
|
|
94
|
+
|
|
95
|
+
Reject changes and close the PR.
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
cai reject https://github.com/owner/repo/pull/123 --reason "Does not meet requirements"
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Options:
|
|
102
|
+
- `--org <slug>` - Organization context
|
|
103
|
+
- `--token <key>` - API key
|
|
104
|
+
- `--reason <text>` - Optional rejection reason
|
|
105
|
+
|
|
106
|
+
## Exit Codes
|
|
107
|
+
|
|
108
|
+
- `0` - Success
|
|
109
|
+
- `1` - General error (API error, not found, etc.)
|
|
110
|
+
- `2` - No sessions found for PR
|
|
111
|
+
- `124` - Timeout reached
|
|
112
|
+
|
|
113
|
+
## Configuration
|
|
114
|
+
|
|
115
|
+
You can save your API key in `~/.continue/cli-config.json`:
|
|
116
|
+
|
|
117
|
+
```json
|
|
118
|
+
{
|
|
119
|
+
"apiKey": "con-xxx",
|
|
120
|
+
"apiUrl": "https://api.continue.dev"
|
|
121
|
+
}
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
### Using Staging Environment
|
|
125
|
+
|
|
126
|
+
For internal testing with the staging environment, configure the staging API URL:
|
|
127
|
+
|
|
128
|
+
```json
|
|
129
|
+
{
|
|
130
|
+
"apiKey": "con-xxx",
|
|
131
|
+
"apiUrl": "https://api.continue-stage.tools"
|
|
132
|
+
}
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Get your staging API key from [hub.continue-stage.tools/settings/api-keys](https://hub.continue-stage.tools/settings/api-keys).
|
|
136
|
+
|
|
137
|
+
You can also use environment variables:
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
export CONTINUE_API_KEY="con-your-staging-key"
|
|
141
|
+
export CONTINUE_API_URL="https://api.continue-stage.tools"
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
## Example Workflow
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
#!/bin/bash
|
|
148
|
+
# Wait for agents, then approve and merge if successful
|
|
149
|
+
|
|
150
|
+
export CONTINUE_API_KEY="con-xxx"
|
|
151
|
+
PR_URL="https://github.com/owner/repo/pull/123"
|
|
152
|
+
|
|
153
|
+
# Wait for completion with 5 minute timeout
|
|
154
|
+
cai wait "$PR_URL" --timeout 300 --fail-fast
|
|
155
|
+
|
|
156
|
+
# If successful, approve and merge
|
|
157
|
+
if [ $? -eq 0 ]; then
|
|
158
|
+
echo "All agents passed! Merging PR..."
|
|
159
|
+
cai approve "$PR_URL" --merge --merge-method squash
|
|
160
|
+
else
|
|
161
|
+
echo "Agents failed. Checking logs..."
|
|
162
|
+
# Get first failed session ID from status output
|
|
163
|
+
SESSION_ID=$(cai status "$PR_URL" --format json | jq -r '.sessions[] | select(.status == "failed") | .id' | head -1)
|
|
164
|
+
cai logs "$SESSION_ID"
|
|
165
|
+
fi
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
## License
|
|
169
|
+
|
|
170
|
+
MIT
|
package/bin/cai.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@continuedev/continuous-ai",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "CLI tool for interacting with Continue agent checks on pull requests",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"cai": "./bin/cai.js",
|
|
8
|
+
"continuous-ai": "./bin/continuous-ai.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"bin",
|
|
12
|
+
"src",
|
|
13
|
+
"README.md",
|
|
14
|
+
"CHANGELOG.md"
|
|
15
|
+
],
|
|
16
|
+
"scripts": {
|
|
17
|
+
"build": "echo 'No build step needed - continuous-ai uses plain JavaScript'",
|
|
18
|
+
"test": "echo \"Error: no test specified\" && exit 1"
|
|
19
|
+
},
|
|
20
|
+
"keywords": [
|
|
21
|
+
"continue",
|
|
22
|
+
"agent",
|
|
23
|
+
"cli",
|
|
24
|
+
"github",
|
|
25
|
+
"pull-request"
|
|
26
|
+
],
|
|
27
|
+
"author": "Continue",
|
|
28
|
+
"license": "Apache-2.0",
|
|
29
|
+
"repository": {
|
|
30
|
+
"type": "git",
|
|
31
|
+
"url": "https://github.com/continuedev/remote-config-server",
|
|
32
|
+
"directory": "packages/continuous-ai"
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"commander": "^12.0.0",
|
|
36
|
+
"chalk": "^5.3.0",
|
|
37
|
+
"ora": "^8.0.1",
|
|
38
|
+
"node-fetch": "^3.3.2"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@semantic-release/changelog": "^6.0.3",
|
|
42
|
+
"@semantic-release/commit-analyzer": "^13.0.1",
|
|
43
|
+
"@semantic-release/git": "^10.0.1",
|
|
44
|
+
"@semantic-release/github": "^11.0.3",
|
|
45
|
+
"@semantic-release/npm": "^12.0.2",
|
|
46
|
+
"@semantic-release/release-notes-generator": "^14.0.3",
|
|
47
|
+
"semantic-release": "^24.2.7",
|
|
48
|
+
"semantic-release-monorepo": "^8.0.2"
|
|
49
|
+
},
|
|
50
|
+
"engines": {
|
|
51
|
+
"node": ">=18.0.0"
|
|
52
|
+
},
|
|
53
|
+
"publishConfig": {
|
|
54
|
+
"access": "public"
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { getApiKey, getApiUrl, requireApiKey } from '../lib/auth.js';
|
|
3
|
+
import { ApiClient } from '../lib/api-client.js';
|
|
4
|
+
import { parsePrUrl } from '../lib/pr-parser.js';
|
|
5
|
+
import { createSpinner } from '../lib/formatters.js';
|
|
6
|
+
|
|
7
|
+
export function approveCommand(program) {
|
|
8
|
+
program
|
|
9
|
+
.command('approve <pr-url>')
|
|
10
|
+
.description('Approve changes and optionally merge the PR')
|
|
11
|
+
.option('--org <slug>', 'Organization context')
|
|
12
|
+
.option('--token <key>', 'API key')
|
|
13
|
+
.option('--merge', 'Also merge the PR', false)
|
|
14
|
+
.option('--merge-method <method>', 'Merge method (squash|merge|rebase)', 'squash')
|
|
15
|
+
.action(async (prUrl, options) => {
|
|
16
|
+
const spinner = createSpinner('Approving pull request...');
|
|
17
|
+
try {
|
|
18
|
+
// Get API key
|
|
19
|
+
const apiKey = getApiKey(options.token);
|
|
20
|
+
requireApiKey(apiKey);
|
|
21
|
+
|
|
22
|
+
// Parse PR URL
|
|
23
|
+
const pr = parsePrUrl(prUrl);
|
|
24
|
+
|
|
25
|
+
// Create API client
|
|
26
|
+
const client = new ApiClient(getApiUrl(), apiKey);
|
|
27
|
+
|
|
28
|
+
spinner.start();
|
|
29
|
+
|
|
30
|
+
// Approve PR
|
|
31
|
+
const result = await client.approvePr(pr.url, {
|
|
32
|
+
organizationSlug: options.org,
|
|
33
|
+
autoMerge: options.merge,
|
|
34
|
+
mergeMethod: options.mergeMethod,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
spinner.stop();
|
|
38
|
+
|
|
39
|
+
if (result.merged) {
|
|
40
|
+
console.log(chalk.green('✓') + ' Pull request approved and merged!');
|
|
41
|
+
console.log(` ${prUrl}`);
|
|
42
|
+
} else {
|
|
43
|
+
console.log(chalk.green('✓') + ' Pull request marked as ready for review!');
|
|
44
|
+
console.log(` ${prUrl}`);
|
|
45
|
+
}
|
|
46
|
+
} catch (error) {
|
|
47
|
+
spinner.stop();
|
|
48
|
+
console.error('ERROR:', error.message);
|
|
49
|
+
process.exit(1);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { getApiKey, getApiUrl, requireApiKey } from '../lib/auth.js';
|
|
2
|
+
import { ApiClient } from '../lib/api-client.js';
|
|
3
|
+
|
|
4
|
+
export function diffCommand(program) {
|
|
5
|
+
program
|
|
6
|
+
.command('diff <session-id>')
|
|
7
|
+
.description('View code diff for an agent session')
|
|
8
|
+
.option('--token <key>', 'API key')
|
|
9
|
+
.option('--format <type>', 'Output format (unified|json)', 'unified')
|
|
10
|
+
.action(async (sessionId, options) => {
|
|
11
|
+
try {
|
|
12
|
+
// Get API key
|
|
13
|
+
const apiKey = getApiKey(options.token);
|
|
14
|
+
requireApiKey(apiKey);
|
|
15
|
+
|
|
16
|
+
// Create API client
|
|
17
|
+
const client = new ApiClient(getApiUrl(), apiKey);
|
|
18
|
+
|
|
19
|
+
// Get session diff
|
|
20
|
+
const diff = await client.getSessionDiff(sessionId);
|
|
21
|
+
|
|
22
|
+
// Format output
|
|
23
|
+
if (options.format === 'json') {
|
|
24
|
+
console.log(JSON.stringify(diff, null, 2));
|
|
25
|
+
} else if (typeof diff === 'string') {
|
|
26
|
+
// Already a unified diff string
|
|
27
|
+
console.log(diff);
|
|
28
|
+
} else if (diff.diff) {
|
|
29
|
+
// Diff is in a property
|
|
30
|
+
console.log(diff.diff);
|
|
31
|
+
} else {
|
|
32
|
+
// Pretty print whatever we got
|
|
33
|
+
console.log(JSON.stringify(diff, null, 2));
|
|
34
|
+
}
|
|
35
|
+
} catch (error) {
|
|
36
|
+
console.error('ERROR:', error.message);
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { getApiKey, getApiUrl, requireApiKey } from '../lib/auth.js';
|
|
2
|
+
import { ApiClient } from '../lib/api-client.js';
|
|
3
|
+
|
|
4
|
+
export function logsCommand(program) {
|
|
5
|
+
program
|
|
6
|
+
.command('logs <session-id>')
|
|
7
|
+
.description('View logs/state for a specific agent session')
|
|
8
|
+
.option('--token <key>', 'API key')
|
|
9
|
+
.option('--tail <lines>', 'Show last N lines', parseInt)
|
|
10
|
+
.action(async (sessionId, options) => {
|
|
11
|
+
try {
|
|
12
|
+
// Get API key
|
|
13
|
+
const apiKey = getApiKey(options.token);
|
|
14
|
+
requireApiKey(apiKey);
|
|
15
|
+
|
|
16
|
+
// Create API client
|
|
17
|
+
const client = new ApiClient(getApiUrl(), apiKey);
|
|
18
|
+
|
|
19
|
+
// Get session state
|
|
20
|
+
const state = await client.getSessionState(sessionId);
|
|
21
|
+
|
|
22
|
+
// Format state as readable output
|
|
23
|
+
if (typeof state === 'string') {
|
|
24
|
+
console.log(state);
|
|
25
|
+
} else {
|
|
26
|
+
// Pretty print JSON state
|
|
27
|
+
const stateStr = JSON.stringify(state, null, 2);
|
|
28
|
+
|
|
29
|
+
if (options.tail) {
|
|
30
|
+
const lines = stateStr.split('\n');
|
|
31
|
+
const tailLines = lines.slice(-options.tail);
|
|
32
|
+
console.log(tailLines.join('\n'));
|
|
33
|
+
} else {
|
|
34
|
+
console.log(stateStr);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
} catch (error) {
|
|
38
|
+
console.error('ERROR:', error.message);
|
|
39
|
+
process.exit(1);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { getApiKey, getApiUrl, requireApiKey } from '../lib/auth.js';
|
|
3
|
+
import { ApiClient } from '../lib/api-client.js';
|
|
4
|
+
import { parsePrUrl } from '../lib/pr-parser.js';
|
|
5
|
+
import { createSpinner } from '../lib/formatters.js';
|
|
6
|
+
|
|
7
|
+
export function rejectCommand(program) {
|
|
8
|
+
program
|
|
9
|
+
.command('reject <pr-url>')
|
|
10
|
+
.description('Reject changes and close the PR')
|
|
11
|
+
.option('--org <slug>', 'Organization context')
|
|
12
|
+
.option('--token <key>', 'API key')
|
|
13
|
+
.option('--reason <text>', 'Optional rejection reason')
|
|
14
|
+
.action(async (prUrl, options) => {
|
|
15
|
+
const spinner = createSpinner('Rejecting pull request...');
|
|
16
|
+
try {
|
|
17
|
+
// Get API key
|
|
18
|
+
const apiKey = getApiKey(options.token);
|
|
19
|
+
requireApiKey(apiKey);
|
|
20
|
+
|
|
21
|
+
// Parse PR URL
|
|
22
|
+
const pr = parsePrUrl(prUrl);
|
|
23
|
+
|
|
24
|
+
// Create API client
|
|
25
|
+
const client = new ApiClient(getApiUrl(), apiKey);
|
|
26
|
+
|
|
27
|
+
spinner.start();
|
|
28
|
+
|
|
29
|
+
// Reject PR
|
|
30
|
+
const result = await client.rejectPr(pr.url, {
|
|
31
|
+
organizationSlug: options.org,
|
|
32
|
+
reason: options.reason,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
spinner.stop();
|
|
36
|
+
|
|
37
|
+
console.log(chalk.red('✗') + ' Pull request rejected and closed.');
|
|
38
|
+
console.log(` ${prUrl}`);
|
|
39
|
+
if (options.reason) {
|
|
40
|
+
console.log(` Reason: ${options.reason}`);
|
|
41
|
+
}
|
|
42
|
+
} catch (error) {
|
|
43
|
+
spinner.stop();
|
|
44
|
+
console.error('ERROR:', error.message);
|
|
45
|
+
process.exit(1);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { getApiKey, getApiUrl, requireApiKey } from '../lib/auth.js';
|
|
2
|
+
import { ApiClient } from '../lib/api-client.js';
|
|
3
|
+
import { parsePrUrl } from '../lib/pr-parser.js';
|
|
4
|
+
import { formatSessionsTable, formatSessionsJson } from '../lib/formatters.js';
|
|
5
|
+
|
|
6
|
+
export function statusCommand(program) {
|
|
7
|
+
program
|
|
8
|
+
.command('status <pr-url>')
|
|
9
|
+
.description('List all agent checks for a pull request')
|
|
10
|
+
.option('--org <slug>', 'Organization context')
|
|
11
|
+
.option('--token <key>', 'API key')
|
|
12
|
+
.option('--format <type>', 'Output format (table|json)', 'table')
|
|
13
|
+
.action(async (prUrl, options) => {
|
|
14
|
+
try {
|
|
15
|
+
// Get API key
|
|
16
|
+
const apiKey = getApiKey(options.token);
|
|
17
|
+
requireApiKey(apiKey);
|
|
18
|
+
|
|
19
|
+
// Parse PR URL
|
|
20
|
+
const pr = parsePrUrl(prUrl);
|
|
21
|
+
|
|
22
|
+
// Create API client
|
|
23
|
+
const client = new ApiClient(getApiUrl(), apiKey);
|
|
24
|
+
|
|
25
|
+
// Get agent sessions
|
|
26
|
+
const result = await client.getAgentSessions(pr.url, {
|
|
27
|
+
organizationId: options.org,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const sessions = result.sessions || [];
|
|
31
|
+
|
|
32
|
+
// Calculate summary
|
|
33
|
+
const terminalStates = ['completed', 'failed', 'cancelled', 'suspended'];
|
|
34
|
+
const summary = {
|
|
35
|
+
total: sessions.length,
|
|
36
|
+
completed: sessions.filter(s => s.status === 'completed').length,
|
|
37
|
+
running: sessions.filter(s => !terminalStates.includes(s.status)).length,
|
|
38
|
+
failed: sessions.filter(s => s.status === 'failed').length,
|
|
39
|
+
pending: sessions.filter(s => s.status === 'pending').length,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// Format output
|
|
43
|
+
if (options.format === 'json') {
|
|
44
|
+
console.log(formatSessionsJson(sessions, summary));
|
|
45
|
+
} else {
|
|
46
|
+
console.log(formatSessionsTable(sessions, summary));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Exit with error if no sessions found
|
|
50
|
+
if (sessions.length === 0) {
|
|
51
|
+
process.exit(2);
|
|
52
|
+
}
|
|
53
|
+
} catch (error) {
|
|
54
|
+
console.error('ERROR:', error.message);
|
|
55
|
+
process.exit(1);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { getApiKey, getApiUrl, requireApiKey } from '../lib/auth.js';
|
|
2
|
+
import { ApiClient } from '../lib/api-client.js';
|
|
3
|
+
import { parsePrUrl } from '../lib/pr-parser.js';
|
|
4
|
+
import { formatSessionsTable, formatSessionsJson, createSpinner } from '../lib/formatters.js';
|
|
5
|
+
|
|
6
|
+
export function waitCommand(program) {
|
|
7
|
+
program
|
|
8
|
+
.command('wait <pr-url>')
|
|
9
|
+
.description('Block until all agent checks complete or fail')
|
|
10
|
+
.option('--org <slug>', 'Organization context')
|
|
11
|
+
.option('--token <key>', 'API key')
|
|
12
|
+
.option('--timeout <seconds>', 'Max wait time', parseInt, 600)
|
|
13
|
+
.option('--fail-fast', 'Exit immediately on first failure', false)
|
|
14
|
+
.option('--format <type>', 'Output format (table|json)', 'table')
|
|
15
|
+
.action(async (prUrl, options) => {
|
|
16
|
+
let spinner;
|
|
17
|
+
try {
|
|
18
|
+
// Get API key
|
|
19
|
+
const apiKey = getApiKey(options.token);
|
|
20
|
+
requireApiKey(apiKey);
|
|
21
|
+
|
|
22
|
+
// Parse PR URL
|
|
23
|
+
const pr = parsePrUrl(prUrl);
|
|
24
|
+
|
|
25
|
+
// Create API client
|
|
26
|
+
const client = new ApiClient(getApiUrl(), apiKey);
|
|
27
|
+
|
|
28
|
+
// Show spinner for table format
|
|
29
|
+
if (options.format !== 'json') {
|
|
30
|
+
spinner = createSpinner('Waiting for agent checks to complete...');
|
|
31
|
+
spinner.start();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Wait for completion
|
|
35
|
+
const result = await client.waitForCompletion(pr.url, {
|
|
36
|
+
timeout: options.timeout,
|
|
37
|
+
failFast: options.failFast,
|
|
38
|
+
organizationId: options.org,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
if (spinner) {
|
|
42
|
+
spinner.stop();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const sessions = result.sessions || [];
|
|
46
|
+
const summary = result.summary;
|
|
47
|
+
|
|
48
|
+
// Format output
|
|
49
|
+
if (options.format === 'json') {
|
|
50
|
+
console.log(formatSessionsJson(sessions, summary));
|
|
51
|
+
} else {
|
|
52
|
+
console.log(formatSessionsTable(sessions, summary));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Exit codes
|
|
56
|
+
if (summary.failed > 0) {
|
|
57
|
+
process.exit(1); // Any failed
|
|
58
|
+
} else if (result.shouldContinuePolling) {
|
|
59
|
+
process.exit(124); // Timeout
|
|
60
|
+
} else {
|
|
61
|
+
process.exit(0); // Success
|
|
62
|
+
}
|
|
63
|
+
} catch (error) {
|
|
64
|
+
if (spinner) {
|
|
65
|
+
spinner.stop();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (error.message.includes('Timeout')) {
|
|
69
|
+
console.error('ERROR: Timeout reached. Agent checks are still running.');
|
|
70
|
+
process.exit(124);
|
|
71
|
+
} else {
|
|
72
|
+
console.error('ERROR:', error.message);
|
|
73
|
+
process.exit(1);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from 'commander';
|
|
3
|
+
import { statusCommand } from './commands/status.js';
|
|
4
|
+
import { waitCommand } from './commands/wait.js';
|
|
5
|
+
import { logsCommand } from './commands/logs.js';
|
|
6
|
+
import { diffCommand } from './commands/diff.js';
|
|
7
|
+
import { approveCommand } from './commands/approve.js';
|
|
8
|
+
import { rejectCommand } from './commands/reject.js';
|
|
9
|
+
|
|
10
|
+
const program = new Command();
|
|
11
|
+
|
|
12
|
+
program
|
|
13
|
+
.name('cai')
|
|
14
|
+
.description('CLI tool for interacting with Continue agent checks on pull requests')
|
|
15
|
+
.version('0.1.0');
|
|
16
|
+
|
|
17
|
+
// Register commands
|
|
18
|
+
statusCommand(program);
|
|
19
|
+
waitCommand(program);
|
|
20
|
+
logsCommand(program);
|
|
21
|
+
diffCommand(program);
|
|
22
|
+
approveCommand(program);
|
|
23
|
+
rejectCommand(program);
|
|
24
|
+
|
|
25
|
+
program.parse(process.argv);
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import fetch from 'node-fetch';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* API client wrapper for Continue API
|
|
5
|
+
*/
|
|
6
|
+
export class ApiClient {
|
|
7
|
+
constructor(apiUrl, apiKey) {
|
|
8
|
+
this.apiUrl = apiUrl;
|
|
9
|
+
this.apiKey = apiKey;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Make a GET request
|
|
14
|
+
*/
|
|
15
|
+
async get(path, queryParams = {}) {
|
|
16
|
+
const url = new URL(path, this.apiUrl);
|
|
17
|
+
Object.entries(queryParams).forEach(([key, value]) => {
|
|
18
|
+
if (value !== undefined && value !== null) {
|
|
19
|
+
url.searchParams.append(key, String(value));
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const response = await fetch(url.toString(), {
|
|
24
|
+
method: 'GET',
|
|
25
|
+
headers: {
|
|
26
|
+
'Authorization': `Bearer ${this.apiKey}`,
|
|
27
|
+
'Content-Type': 'application/json',
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
if (!response.ok) {
|
|
32
|
+
const error = await response.text();
|
|
33
|
+
throw new Error(`API Error (${response.status}): ${error}`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return response.json();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Make a POST request
|
|
41
|
+
*/
|
|
42
|
+
async post(path, body = {}) {
|
|
43
|
+
const url = new URL(path, this.apiUrl);
|
|
44
|
+
|
|
45
|
+
const response = await fetch(url.toString(), {
|
|
46
|
+
method: 'POST',
|
|
47
|
+
headers: {
|
|
48
|
+
'Authorization': `Bearer ${this.apiKey}`,
|
|
49
|
+
'Content-Type': 'application/json',
|
|
50
|
+
},
|
|
51
|
+
body: JSON.stringify(body),
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
if (!response.ok) {
|
|
55
|
+
const error = await response.text();
|
|
56
|
+
throw new Error(`API Error (${response.status}): ${error}`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return response.json();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Wait for agent completion (with polling)
|
|
64
|
+
*/
|
|
65
|
+
async waitForCompletion(pullRequestUrl, options = {}) {
|
|
66
|
+
const { timeout = 600, failFast = false, organizationId } = options;
|
|
67
|
+
const startTime = Date.now();
|
|
68
|
+
const maxTime = timeout * 1000;
|
|
69
|
+
let backoffDelay = 1000; // Start with 1s
|
|
70
|
+
|
|
71
|
+
while (Date.now() - startTime < maxTime) {
|
|
72
|
+
const result = await this.get('/agents/wait-for-completion', {
|
|
73
|
+
pullRequestUrl,
|
|
74
|
+
organizationId,
|
|
75
|
+
timeout: 30, // Server-side timeout
|
|
76
|
+
failFast: failFast ? 'true' : 'false',
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
if (!result.shouldContinuePolling) {
|
|
80
|
+
return result;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Wait with exponential backoff
|
|
84
|
+
await new Promise(resolve => setTimeout(resolve, Math.min(backoffDelay, 5000)));
|
|
85
|
+
backoffDelay = Math.min(backoffDelay * 1.5, 5000);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
throw new Error('Timeout reached waiting for agent completion');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Get agent sessions for a PR
|
|
93
|
+
*/
|
|
94
|
+
async getAgentSessions(pullRequestUrl, options = {}) {
|
|
95
|
+
const { organizationId, limit = 100, offset = 0 } = options;
|
|
96
|
+
return this.get('/agents', {
|
|
97
|
+
pullRequestUrl,
|
|
98
|
+
organizationId,
|
|
99
|
+
limit,
|
|
100
|
+
offset,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Get agent session summary
|
|
106
|
+
*/
|
|
107
|
+
async getSessionSummary(sessionId) {
|
|
108
|
+
return this.get(`/agents/${sessionId}/summary`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Get agent session state (logs)
|
|
113
|
+
*/
|
|
114
|
+
async getSessionState(sessionId) {
|
|
115
|
+
return this.get(`/agents/${sessionId}/state`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Get agent session diff
|
|
120
|
+
*/
|
|
121
|
+
async getSessionDiff(sessionId) {
|
|
122
|
+
return this.get(`/agents/${sessionId}/diff`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Approve a PR and optionally merge
|
|
127
|
+
*/
|
|
128
|
+
async approvePr(pullRequestUrl, options = {}) {
|
|
129
|
+
const { organizationSlug, autoMerge = false, mergeMethod = 'squash' } = options;
|
|
130
|
+
return this.post('/agents/approve-pr', {
|
|
131
|
+
pullRequestUrl,
|
|
132
|
+
organizationSlug,
|
|
133
|
+
autoMerge,
|
|
134
|
+
mergeMethod,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Reject a PR (close it)
|
|
140
|
+
*/
|
|
141
|
+
async rejectPr(pullRequestUrl, options = {}) {
|
|
142
|
+
const { organizationSlug, reason } = options;
|
|
143
|
+
return this.post('/agents/reject-pr', {
|
|
144
|
+
pullRequestUrl,
|
|
145
|
+
organizationSlug,
|
|
146
|
+
reason,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
}
|
package/src/lib/auth.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Get API key from multiple sources (priority order):
|
|
7
|
+
* 1. --token flag
|
|
8
|
+
* 2. CONTINUE_API_KEY env var
|
|
9
|
+
* 3. ~/.continue/cli-config.json
|
|
10
|
+
*/
|
|
11
|
+
export function getApiKey(tokenFlag) {
|
|
12
|
+
// 1. Check --token flag
|
|
13
|
+
if (tokenFlag) {
|
|
14
|
+
return tokenFlag;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// 2. Check environment variable
|
|
18
|
+
if (process.env.CONTINUE_API_KEY) {
|
|
19
|
+
return process.env.CONTINUE_API_KEY;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// 3. Check config file
|
|
23
|
+
const configPath = path.join(os.homedir(), '.continue', 'cli-config.json');
|
|
24
|
+
try {
|
|
25
|
+
if (fs.existsSync(configPath)) {
|
|
26
|
+
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
27
|
+
if (config.apiKey) {
|
|
28
|
+
return config.apiKey;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
} catch (error) {
|
|
32
|
+
// Ignore config file errors, will show error below
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Get API URL from config or use default
|
|
40
|
+
*/
|
|
41
|
+
export function getApiUrl() {
|
|
42
|
+
const configPath = path.join(os.homedir(), '.continue', 'cli-config.json');
|
|
43
|
+
try {
|
|
44
|
+
if (fs.existsSync(configPath)) {
|
|
45
|
+
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
46
|
+
if (config.apiUrl) {
|
|
47
|
+
return config.apiUrl;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
} catch (error) {
|
|
51
|
+
// Ignore config file errors, use default
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return process.env.CONTINUE_API_URL || 'https://api.continue.dev';
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Validate that an API key is provided and show helpful error if not
|
|
59
|
+
*/
|
|
60
|
+
export function requireApiKey(apiKey) {
|
|
61
|
+
if (!apiKey) {
|
|
62
|
+
// Use hub.continue.dev for the settings page (web UI), not the API URL
|
|
63
|
+
console.error(`ERROR: No API key provided. Get one at https://hub.continue.dev/settings/api-keys (or your self-hosted hub)`);
|
|
64
|
+
console.error('');
|
|
65
|
+
console.error('Set it with:');
|
|
66
|
+
console.error(' export CONTINUE_API_KEY="con-xxx"');
|
|
67
|
+
console.error('Or pass it with:');
|
|
68
|
+
console.error(' cai <command> --token "con-xxx"');
|
|
69
|
+
process.exit(1);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Format agent session status as a table
|
|
5
|
+
*/
|
|
6
|
+
export function formatSessionsTable(sessions, summary) {
|
|
7
|
+
if (sessions.length === 0) {
|
|
8
|
+
return 'No agent sessions found for this PR.';
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Header
|
|
12
|
+
let output = '\nAgent Checks\n\n';
|
|
13
|
+
|
|
14
|
+
// Table header
|
|
15
|
+
const headers = ['Session', 'Status', 'Updated'];
|
|
16
|
+
const columnWidths = [12, 20, 20];
|
|
17
|
+
|
|
18
|
+
output += headers.map((h, i) => h.padEnd(columnWidths[i])).join(' ') + '\n';
|
|
19
|
+
output += columnWidths.map(w => '-'.repeat(w)).join(' ') + '\n';
|
|
20
|
+
|
|
21
|
+
// Table rows
|
|
22
|
+
for (const session of sessions) {
|
|
23
|
+
const sessionId = session.shortId || session.id.substring(0, 8);
|
|
24
|
+
const status = formatStatus(session.status);
|
|
25
|
+
const updated = formatRelativeTime(session.updatedAt);
|
|
26
|
+
|
|
27
|
+
output += [
|
|
28
|
+
sessionId.padEnd(columnWidths[0]),
|
|
29
|
+
status.padEnd(columnWidths[1] + 10), // +10 for ANSI codes
|
|
30
|
+
updated.padEnd(columnWidths[2]),
|
|
31
|
+
].join(' ') + '\n';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Summary
|
|
35
|
+
output += '\n';
|
|
36
|
+
output += `Summary: ${summary.completed} completed, ${summary.running} running, ${summary.failed} failed`;
|
|
37
|
+
if (summary.pending > 0) {
|
|
38
|
+
output += `, ${summary.pending} pending`;
|
|
39
|
+
}
|
|
40
|
+
output += '\n';
|
|
41
|
+
|
|
42
|
+
return output;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Format status with color and symbol
|
|
47
|
+
*/
|
|
48
|
+
function formatStatus(status) {
|
|
49
|
+
switch (status) {
|
|
50
|
+
case 'completed':
|
|
51
|
+
return chalk.green('✓ Completed');
|
|
52
|
+
case 'failed':
|
|
53
|
+
return chalk.red('✗ Failed');
|
|
54
|
+
case 'running':
|
|
55
|
+
case 'in_progress':
|
|
56
|
+
return chalk.yellow('⏳ Running');
|
|
57
|
+
case 'pending':
|
|
58
|
+
return chalk.gray('⏸ Pending');
|
|
59
|
+
case 'suspended':
|
|
60
|
+
case 'cancelled':
|
|
61
|
+
return chalk.gray('⚠ Cancelled');
|
|
62
|
+
default:
|
|
63
|
+
return chalk.gray(status);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Format timestamp as relative time
|
|
69
|
+
*/
|
|
70
|
+
function formatRelativeTime(timestamp) {
|
|
71
|
+
const now = Date.now();
|
|
72
|
+
const then = new Date(timestamp).getTime();
|
|
73
|
+
const diff = Math.abs(now - then) / 1000; // seconds
|
|
74
|
+
|
|
75
|
+
if (diff < 60) {
|
|
76
|
+
return 'just now';
|
|
77
|
+
} else if (diff < 3600) {
|
|
78
|
+
const mins = Math.floor(diff / 60);
|
|
79
|
+
return `${mins} minute${mins > 1 ? 's' : ''} ago`;
|
|
80
|
+
} else if (diff < 86400) {
|
|
81
|
+
const hours = Math.floor(diff / 3600);
|
|
82
|
+
return `${hours} hour${hours > 1 ? 's' : ''} ago`;
|
|
83
|
+
} else {
|
|
84
|
+
const days = Math.floor(diff / 86400);
|
|
85
|
+
return `${days} day${days > 1 ? 's' : ''} ago`;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Format sessions as JSON
|
|
91
|
+
*/
|
|
92
|
+
export function formatSessionsJson(sessions, summary) {
|
|
93
|
+
return JSON.stringify({ sessions, summary }, null, 2);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Show spinner with message
|
|
98
|
+
*/
|
|
99
|
+
export function createSpinner(message) {
|
|
100
|
+
const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
101
|
+
let i = 0;
|
|
102
|
+
let interval;
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
start() {
|
|
106
|
+
interval = setInterval(() => {
|
|
107
|
+
process.stdout.write(`\r${chalk.cyan(frames[i])} ${message}`);
|
|
108
|
+
i = (i + 1) % frames.length;
|
|
109
|
+
}, 80);
|
|
110
|
+
},
|
|
111
|
+
stop() {
|
|
112
|
+
if (interval) {
|
|
113
|
+
clearInterval(interval);
|
|
114
|
+
process.stdout.write('\r');
|
|
115
|
+
}
|
|
116
|
+
},
|
|
117
|
+
succeed(text) {
|
|
118
|
+
this.stop();
|
|
119
|
+
console.log(chalk.green('✓') + ' ' + text);
|
|
120
|
+
},
|
|
121
|
+
fail(text) {
|
|
122
|
+
this.stop();
|
|
123
|
+
console.log(chalk.red('✗') + ' ' + text);
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse a GitHub PR URL into its components
|
|
3
|
+
*/
|
|
4
|
+
export function parsePrUrl(url) {
|
|
5
|
+
try {
|
|
6
|
+
// More strict regex: must be HTTPS GitHub URL with proper format
|
|
7
|
+
const match = url.match(/^https:\/\/github\.com\/([^\/]+)\/([^\/]+)\/pull\/(\d+)(?:[?#].*)?$/);
|
|
8
|
+
if (!match) {
|
|
9
|
+
throw new Error('Invalid GitHub PR URL format. Expected: https://github.com/owner/repo/pull/123');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const [, owner, repo, prNumber] = match;
|
|
13
|
+
return {
|
|
14
|
+
owner,
|
|
15
|
+
repo,
|
|
16
|
+
prNumber: parseInt(prNumber, 10),
|
|
17
|
+
url,
|
|
18
|
+
};
|
|
19
|
+
} catch (error) {
|
|
20
|
+
throw new Error(`Failed to parse PR URL: ${error.message}`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Validate a PR URL
|
|
26
|
+
*/
|
|
27
|
+
export function isValidPrUrl(url) {
|
|
28
|
+
try {
|
|
29
|
+
parsePrUrl(url);
|
|
30
|
+
return true;
|
|
31
|
+
} catch {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}
|