@refleet-it/runner 0.1.186
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/LICENSE +21 -0
- package/README.md +92 -0
- package/dist/backend-client.js +76 -0
- package/dist/backends/claude.js +142 -0
- package/dist/backends/kiro.js +224 -0
- package/dist/backends/types.js +2 -0
- package/dist/cli.js +165 -0
- package/dist/config.js +57 -0
- package/dist/detect.js +71 -0
- package/dist/fleet/api.js +117 -0
- package/dist/fleet/execute.js +33 -0
- package/dist/fleet/git.js +34 -0
- package/dist/fleet/gitlab.js +66 -0
- package/dist/fleet/job.js +108 -0
- package/dist/fleet/log.js +4 -0
- package/dist/fleet/loop.js +128 -0
- package/dist/fleet/workspace.js +124 -0
- package/dist/prompt.js +110 -0
- package/dist/run.js +77 -0
- package/package.json +51 -0
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { homedir, hostname } from 'node:os';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { detectAgents } from '../detect.js';
|
|
4
|
+
import { FleetApi } from './api.js';
|
|
5
|
+
import { executeJob } from './execute.js';
|
|
6
|
+
import { createGitRunner } from './git.js';
|
|
7
|
+
import { claimAndRunJob } from './job.js';
|
|
8
|
+
import { createLogger } from './log.js';
|
|
9
|
+
function positiveInt(value, fallback) {
|
|
10
|
+
const parsed = Number.parseInt(value ?? '', 10);
|
|
11
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
12
|
+
}
|
|
13
|
+
function commaList(value) {
|
|
14
|
+
return (value ?? '')
|
|
15
|
+
.split(',')
|
|
16
|
+
.map(item => item.trim())
|
|
17
|
+
.filter(item => '' !== item);
|
|
18
|
+
}
|
|
19
|
+
function shortHostname() {
|
|
20
|
+
const name = hostname().split('.')[0];
|
|
21
|
+
return name && '' !== name ? name : 'local-runner';
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* The Docker image pins WORKSPACE_CACHE_DIR to /workspace/cache (its mounted volume);
|
|
25
|
+
* an npm install has no such mount, so it defaults to the XDG cache dir — the same
|
|
26
|
+
* ~/.cache convention the login config already follows with ~/.config.
|
|
27
|
+
*/
|
|
28
|
+
function defaultCacheDir(env) {
|
|
29
|
+
const xdgCacheHome = env.XDG_CACHE_HOME;
|
|
30
|
+
const base = xdgCacheHome && '' !== xdgCacheHome ? xdgCacheHome : join(homedir(), '.cache');
|
|
31
|
+
return join(base, 'refleet', 'workspace');
|
|
32
|
+
}
|
|
33
|
+
export function fleetOptionsFromEnv(env, credentials) {
|
|
34
|
+
return {
|
|
35
|
+
apiUrl: credentials.apiUrl,
|
|
36
|
+
apiKey: credentials.apiKey,
|
|
37
|
+
runnerName: env.RUNNER_NAME && '' !== env.RUNNER_NAME ? env.RUNNER_NAME : shortHostname(),
|
|
38
|
+
heartbeatIntervalSeconds: positiveInt(env.HEARTBEAT_INTERVAL_SECONDS, 30),
|
|
39
|
+
pollIntervalSeconds: positiveInt(env.POLL_INTERVAL_SECONDS, 10),
|
|
40
|
+
cacheDir: env.WORKSPACE_CACHE_DIR && '' !== env.WORKSPACE_CACHE_DIR ? env.WORKSPACE_CACHE_DIR : defaultCacheDir(env),
|
|
41
|
+
cacheMaxSizeMb: positiveInt(env.WORKSPACE_CACHE_MAX_SIZE_MB, 5120),
|
|
42
|
+
availableModels: {
|
|
43
|
+
claude: commaList(env.CLAUDE_AVAILABLE_MODELS),
|
|
44
|
+
kiro: commaList(env.KIRO_AVAILABLE_MODELS),
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* One identity per detected engine, each registering as its own Runner
|
|
50
|
+
* (`<name>-<engine>`) scoped to just that engine — see
|
|
51
|
+
* DoctrineRunnerJobRepository::CLAIM_NEXT_SQL, which filters claims by engine. A host
|
|
52
|
+
* with both claude and kiro shows up as two independent Runners instead of one that
|
|
53
|
+
* silently mixes both.
|
|
54
|
+
*/
|
|
55
|
+
export function identityFor(options, engine) {
|
|
56
|
+
return {
|
|
57
|
+
name: `${options.runnerName}-${engine}`,
|
|
58
|
+
supportedModes: ['ai'],
|
|
59
|
+
supportedEngines: [engine],
|
|
60
|
+
supportedModels: options.availableModels[engine] ?? [],
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
export function sleep(ms, signal) {
|
|
64
|
+
return new Promise(resolve => {
|
|
65
|
+
if (signal.aborted) {
|
|
66
|
+
resolve();
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
const timer = setTimeout(done, ms);
|
|
70
|
+
function done() {
|
|
71
|
+
clearTimeout(timer);
|
|
72
|
+
signal.removeEventListener('abort', done);
|
|
73
|
+
resolve();
|
|
74
|
+
}
|
|
75
|
+
signal.addEventListener('abort', done, { once: true });
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
/** Heartbeats and claims until `signal` aborts; a job in progress is finished first. */
|
|
79
|
+
export async function pollLoop(deps, timing, signal, sleepFn = sleep, now = Date.now) {
|
|
80
|
+
const heartbeatMs = timing.heartbeatIntervalSeconds * 1000;
|
|
81
|
+
let lastHeartbeat = null;
|
|
82
|
+
deps.log(`polling for jobs every ${String(timing.pollIntervalSeconds)}s`);
|
|
83
|
+
while (!signal.aborted) {
|
|
84
|
+
if (null === lastHeartbeat || now() - lastHeartbeat >= heartbeatMs) {
|
|
85
|
+
await deps.api.heartbeat(deps.identity);
|
|
86
|
+
lastHeartbeat = now();
|
|
87
|
+
}
|
|
88
|
+
await claimAndRunJob(deps);
|
|
89
|
+
await sleepFn(timing.pollIntervalSeconds * 1000, signal);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
export class FleetStartupError extends Error {
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Detects the installed agent CLIs, registers one Runner per engine and polls until
|
|
96
|
+
* `signal` aborts. Rejects as soon as any engine's loop dies of an unexpected error
|
|
97
|
+
* (Docker's `restart: unless-stopped` or a systemd unit brings the whole process
|
|
98
|
+
* back), rather than silently continuing with fewer runners than were detected.
|
|
99
|
+
*/
|
|
100
|
+
export async function runFleet(options, signal, deps = {}) {
|
|
101
|
+
const env = deps.env ?? process.env;
|
|
102
|
+
const startupLog = createLogger(options.runnerName, deps.logWrite);
|
|
103
|
+
const detected = (deps.detect ?? detectAgents)();
|
|
104
|
+
if (0 === detected.length) {
|
|
105
|
+
throw new FleetStartupError('no supported agent CLI (claude/kiro-cli) found on PATH');
|
|
106
|
+
}
|
|
107
|
+
startupLog(`detected agent CLIs: ${detected.map(agent => agent.engine).join(' ')}`);
|
|
108
|
+
if (detected.some(agent => 'claude' === agent.engine) && !env.ANTHROPIC_API_KEY) {
|
|
109
|
+
startupLog('WARNING: claude was detected but ANTHROPIC_API_KEY is not set — claude-engine jobs will fail until it is.');
|
|
110
|
+
}
|
|
111
|
+
const git = deps.git ?? createGitRunner();
|
|
112
|
+
const loops = detected.map(agent => {
|
|
113
|
+
const identity = identityFor(options, agent.engine);
|
|
114
|
+
const log = createLogger(identity.name, deps.logWrite);
|
|
115
|
+
const workspace = { cacheDir: options.cacheDir, maxSizeMb: options.cacheMaxSizeMb, git, log };
|
|
116
|
+
const jobDeps = {
|
|
117
|
+
api: new FleetApi(options.apiUrl, options.apiKey, log, deps.fetchFn),
|
|
118
|
+
identity,
|
|
119
|
+
workspace,
|
|
120
|
+
execute: deps.execute ?? executeJob,
|
|
121
|
+
log,
|
|
122
|
+
fetchFn: deps.fetchFn,
|
|
123
|
+
};
|
|
124
|
+
log(`registering as ${identity.name} against ${options.apiUrl}`);
|
|
125
|
+
return pollLoop(jobDeps, options, signal);
|
|
126
|
+
});
|
|
127
|
+
await Promise.all(loops);
|
|
128
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, utimesSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { gitAuthConfig } from './git.js';
|
|
4
|
+
import { createMergeRequest } from './gitlab.js';
|
|
5
|
+
const LAST_USED_MARKER = '.slipway-last-used';
|
|
6
|
+
function directorySizeBytes(dir) {
|
|
7
|
+
let total = 0;
|
|
8
|
+
let entries;
|
|
9
|
+
try {
|
|
10
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return 0;
|
|
14
|
+
}
|
|
15
|
+
for (const entry of entries) {
|
|
16
|
+
const path = join(dir, entry.name);
|
|
17
|
+
if (entry.isDirectory()) {
|
|
18
|
+
total += directorySizeBytes(path);
|
|
19
|
+
}
|
|
20
|
+
else if (entry.isFile()) {
|
|
21
|
+
try {
|
|
22
|
+
total += statSync(path).size;
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
// deleted between readdir and stat — a size estimate is all the cap needs
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return total;
|
|
30
|
+
}
|
|
31
|
+
export function cacheSizeMb(cacheDir) {
|
|
32
|
+
return Math.ceil(directorySizeBytes(cacheDir) / (1024 * 1024));
|
|
33
|
+
}
|
|
34
|
+
function touchMarker(repoDir) {
|
|
35
|
+
const marker = join(repoDir, LAST_USED_MARKER);
|
|
36
|
+
if (existsSync(marker)) {
|
|
37
|
+
const now = new Date();
|
|
38
|
+
utimesSync(marker, now, now);
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
writeFileSync(marker, '');
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function markerMtime(repoDir) {
|
|
45
|
+
const marker = join(repoDir, LAST_USED_MARKER);
|
|
46
|
+
if (!existsSync(marker)) {
|
|
47
|
+
touchMarker(repoDir);
|
|
48
|
+
}
|
|
49
|
+
return statSync(marker).mtimeMs;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Per-project checkout cache: a long-lived runner reuses one working copy per project
|
|
53
|
+
* (fetch + hard reset instead of a full clone on every job), bounded by total size
|
|
54
|
+
* rather than project count — least-recently-used checkouts go first once the cap is
|
|
55
|
+
* hit. Keyed by GitLab externalId alone since one runner talks to one GitLab instance.
|
|
56
|
+
*/
|
|
57
|
+
export function evictLruUntilUnderCap(workspace) {
|
|
58
|
+
if (!existsSync(workspace.cacheDir)) {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
while (cacheSizeMb(workspace.cacheDir) >= workspace.maxSizeMb) {
|
|
62
|
+
const checkouts = readdirSync(workspace.cacheDir, { withFileTypes: true })
|
|
63
|
+
.filter(entry => entry.isDirectory())
|
|
64
|
+
.map(entry => join(workspace.cacheDir, entry.name));
|
|
65
|
+
if (0 === checkouts.length) {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const oldest = checkouts.reduce((a, b) => (markerMtime(b) < markerMtime(a) ? b : a));
|
|
69
|
+
workspace.log(`workspace cache over ${String(workspace.maxSizeMb)}MB, evicting ${oldest}`);
|
|
70
|
+
rmSync(oldest, { recursive: true, force: true });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
function excludeMarkerFromGit(repoDir) {
|
|
74
|
+
const excludeFile = join(repoDir, '.git', 'info', 'exclude');
|
|
75
|
+
const existing = existsSync(excludeFile) ? readFileSync(excludeFile, 'utf8') : '';
|
|
76
|
+
if (existing.split('\n').includes(LAST_USED_MARKER)) {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
mkdirSync(join(repoDir, '.git', 'info'), { recursive: true });
|
|
80
|
+
appendFileSync(excludeFile, `${LAST_USED_MARKER}\n`);
|
|
81
|
+
}
|
|
82
|
+
/** Returns the ready-to-use working directory; throws (GitError) when any git step fails. */
|
|
83
|
+
export async function syncRepo(workspace, credentials, project) {
|
|
84
|
+
const repoDir = join(workspace.cacheDir, project.externalId);
|
|
85
|
+
const auth = gitAuthConfig(credentials.accessToken);
|
|
86
|
+
if (existsSync(join(repoDir, '.git'))) {
|
|
87
|
+
workspace.log(`reusing cached checkout for ${project.path}`);
|
|
88
|
+
await workspace.git([...auth, 'fetch', '--prune', 'origin'], repoDir);
|
|
89
|
+
await workspace.git(['checkout', '-f', project.defaultBranch], repoDir);
|
|
90
|
+
await workspace.git(['reset', '--hard', `origin/${project.defaultBranch}`], repoDir);
|
|
91
|
+
await workspace.git(['clean', '-fdx'], repoDir);
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
mkdirSync(workspace.cacheDir, { recursive: true });
|
|
95
|
+
evictLruUntilUnderCap(workspace);
|
|
96
|
+
workspace.log(`cloning ${project.path} (first use on this runner)`);
|
|
97
|
+
const cloneUrl = `${credentials.baseUrl.replace(/\/+$/, '')}/${project.path}.git`;
|
|
98
|
+
await workspace.git([...auth, 'clone', '--origin', 'origin', cloneUrl, repoDir]);
|
|
99
|
+
}
|
|
100
|
+
touchMarker(repoDir);
|
|
101
|
+
excludeMarkerFromGit(repoDir);
|
|
102
|
+
return repoDir;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Commits whatever the job changed, force-pushes it to a branch deterministic per shift
|
|
106
|
+
* target (so a retried job overwrites its own previous attempt instead of piling up
|
|
107
|
+
* branches) and opens a merge request. Returns null when there was nothing to commit —
|
|
108
|
+
* a change job that legitimately decided no change was needed. Throws when commit,
|
|
109
|
+
* push or MR creation fails, which the caller treats as a job failure rather than
|
|
110
|
+
* leaving the target MERGE_REQUEST_OPEN with no real merge request behind it.
|
|
111
|
+
*/
|
|
112
|
+
export async function publishChange(workspace, credentials, repoDir, project, shiftTargetId, title, description, fetchFn = fetch) {
|
|
113
|
+
const branchName = `slipway/change-${shiftTargetId}`;
|
|
114
|
+
await workspace.git(['add', '-A'], repoDir);
|
|
115
|
+
const staged = await workspace.git(['diff', '--cached', '--name-only'], repoDir);
|
|
116
|
+
if ('' === staged.stdout.trim()) {
|
|
117
|
+
workspace.log('job produced no file changes, skipping branch/MR creation');
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
await workspace.git(['-c', 'user.name=Slipway', '-c', 'user.email=slipway@localhost', 'commit', '-m', title], repoDir);
|
|
121
|
+
await workspace.git([...gitAuthConfig(credentials.accessToken), 'push', '--force', 'origin', `HEAD:refs/heads/${branchName}`], repoDir);
|
|
122
|
+
const mergeRequestUrl = await createMergeRequest(credentials, project.externalId, { sourceBranch: branchName, targetBranch: project.defaultBranch, title, description }, fetchFn);
|
|
123
|
+
return { branchName, mergeRequestUrl };
|
|
124
|
+
}
|
package/dist/prompt.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
const CTRL_C = String.fromCharCode(3);
|
|
2
|
+
const BACKSPACE = String.fromCharCode(8);
|
|
3
|
+
const DELETE = String.fromCharCode(127);
|
|
4
|
+
function stripTrailingCr(line) {
|
|
5
|
+
return line.endsWith('\r') ? line.slice(0, -1) : line;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Deliberately not `node:readline/promises`: its `question()` attaches a one-shot
|
|
9
|
+
* 'line' listener per call, but the interface starts consuming stdin as soon as
|
|
10
|
+
* it's constructed — when a whole multi-line answer set is already sitting in a
|
|
11
|
+
* pipe (piped/test input, not a human typing live), the first chunk gets parsed and
|
|
12
|
+
* emitted as 'line' events before a listener exists for the 2nd/3rd question, and
|
|
13
|
+
* those lines are silently lost (confirmed by hand: a second `rl.question()` call
|
|
14
|
+
* hung forever after piping 3 lines in one go). This hand-rolled buffer only ever
|
|
15
|
+
* attaches a listener while a read is actually pending, so nothing can be dropped.
|
|
16
|
+
*/
|
|
17
|
+
export function createPrompter(input = process.stdin, output = process.stdout) {
|
|
18
|
+
let buffer = '';
|
|
19
|
+
function takeBufferedLine() {
|
|
20
|
+
const newlineIndex = buffer.indexOf('\n');
|
|
21
|
+
if (-1 === newlineIndex) {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
const line = buffer.slice(0, newlineIndex);
|
|
25
|
+
buffer = buffer.slice(newlineIndex + 1);
|
|
26
|
+
return stripTrailingCr(line);
|
|
27
|
+
}
|
|
28
|
+
function readLine() {
|
|
29
|
+
const buffered = takeBufferedLine();
|
|
30
|
+
if (null !== buffered) {
|
|
31
|
+
return Promise.resolve(buffered);
|
|
32
|
+
}
|
|
33
|
+
return new Promise(resolve => {
|
|
34
|
+
const cleanup = () => {
|
|
35
|
+
input.removeListener('data', onData);
|
|
36
|
+
input.removeListener('end', onEnd);
|
|
37
|
+
};
|
|
38
|
+
const onData = (chunk) => {
|
|
39
|
+
buffer += chunk.toString('utf8');
|
|
40
|
+
const line = takeBufferedLine();
|
|
41
|
+
if (null !== line) {
|
|
42
|
+
cleanup();
|
|
43
|
+
resolve(line);
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
const onEnd = () => {
|
|
47
|
+
cleanup();
|
|
48
|
+
const rest = buffer;
|
|
49
|
+
buffer = '';
|
|
50
|
+
resolve(stripTrailingCr(rest));
|
|
51
|
+
};
|
|
52
|
+
input.on('data', onData);
|
|
53
|
+
input.on('end', onEnd);
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
async function question(text) {
|
|
57
|
+
output.write(text);
|
|
58
|
+
return (await readLine()).trim();
|
|
59
|
+
}
|
|
60
|
+
async function questionHidden(text) {
|
|
61
|
+
if (!input.isTTY) {
|
|
62
|
+
return question(text);
|
|
63
|
+
}
|
|
64
|
+
return readMasked(text, input, output);
|
|
65
|
+
}
|
|
66
|
+
function close() {
|
|
67
|
+
input.pause();
|
|
68
|
+
}
|
|
69
|
+
return { question, questionHidden, close };
|
|
70
|
+
}
|
|
71
|
+
function readMasked(text, input, output) {
|
|
72
|
+
output.write(text);
|
|
73
|
+
return new Promise(resolve => {
|
|
74
|
+
let value = '';
|
|
75
|
+
const cleanup = () => {
|
|
76
|
+
input.setRawMode(false);
|
|
77
|
+
input.pause();
|
|
78
|
+
input.removeListener('data', onData);
|
|
79
|
+
};
|
|
80
|
+
const onData = (chunk) => {
|
|
81
|
+
for (const char of chunk.toString('utf8')) {
|
|
82
|
+
if (CTRL_C === char) {
|
|
83
|
+
cleanup();
|
|
84
|
+
output.write('\n');
|
|
85
|
+
process.exit(130);
|
|
86
|
+
}
|
|
87
|
+
else if ('\r' === char || '\n' === char) {
|
|
88
|
+
cleanup();
|
|
89
|
+
output.write('\n');
|
|
90
|
+
resolve(value);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
else if (DELETE === char || BACKSPACE === char) {
|
|
94
|
+
if (value.length > 0) {
|
|
95
|
+
value = value.slice(0, -1);
|
|
96
|
+
output.write(`${BACKSPACE} ${BACKSPACE}`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
value += char;
|
|
101
|
+
output.write('*');
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
input.setRawMode(true);
|
|
106
|
+
input.resume();
|
|
107
|
+
input.setEncoding('utf8');
|
|
108
|
+
input.on('data', onData);
|
|
109
|
+
});
|
|
110
|
+
}
|
package/dist/run.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { resolveBackend } from './fleet/execute.js';
|
|
2
|
+
/**
|
|
3
|
+
* `node run.js --engine <claude|kiro> --workdir <dir> --kind <qualification|change>
|
|
4
|
+
* [--model <id>]`, prompt on stdin, final agent text on stdout, non-zero exit on
|
|
5
|
+
* failure. The fleet loop calls the backends in-process (see fleet/execute.ts); this
|
|
6
|
+
* standalone form is kept for running a single prompt by hand against a checkout.
|
|
7
|
+
*/
|
|
8
|
+
export function parseArgs(argv) {
|
|
9
|
+
let engine;
|
|
10
|
+
let workdir;
|
|
11
|
+
let kind;
|
|
12
|
+
let model;
|
|
13
|
+
for (let i = 0; i < argv.length; i++) {
|
|
14
|
+
const arg = argv[i];
|
|
15
|
+
switch (arg) {
|
|
16
|
+
case '--engine':
|
|
17
|
+
engine = argv[++i];
|
|
18
|
+
break;
|
|
19
|
+
case '--workdir':
|
|
20
|
+
workdir = argv[++i];
|
|
21
|
+
break;
|
|
22
|
+
case '--kind':
|
|
23
|
+
kind = argv[++i];
|
|
24
|
+
break;
|
|
25
|
+
case '--model':
|
|
26
|
+
model = argv[++i];
|
|
27
|
+
break;
|
|
28
|
+
default:
|
|
29
|
+
throw new Error(`unknown argument: ${String(arg)}`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
if ('claude' !== engine && 'kiro' !== engine) {
|
|
33
|
+
throw new Error(`--engine must be "claude" or "kiro", got: ${String(engine)}`);
|
|
34
|
+
}
|
|
35
|
+
if (!workdir) {
|
|
36
|
+
throw new Error('--workdir is required');
|
|
37
|
+
}
|
|
38
|
+
if ('qualification' !== kind && 'change' !== kind) {
|
|
39
|
+
throw new Error(`--kind must be "qualification" or "change", got: ${String(kind)}`);
|
|
40
|
+
}
|
|
41
|
+
return { engine, workdir, kind, model };
|
|
42
|
+
}
|
|
43
|
+
function readStdin() {
|
|
44
|
+
return new Promise((resolve, reject) => {
|
|
45
|
+
let data = '';
|
|
46
|
+
process.stdin.setEncoding('utf8');
|
|
47
|
+
process.stdin.on('data', (chunk) => (data += chunk));
|
|
48
|
+
process.stdin.on('end', () => resolve(data));
|
|
49
|
+
process.stdin.on('error', reject);
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
async function main() {
|
|
53
|
+
const args = parseArgs(process.argv.slice(2));
|
|
54
|
+
const prompt = (await readStdin()).trim();
|
|
55
|
+
if ('' === prompt) {
|
|
56
|
+
process.stderr.write('run: no prompt provided on stdin\n');
|
|
57
|
+
process.exitCode = 1;
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const backend = resolveBackend(args.engine);
|
|
61
|
+
const opts = { cwd: args.workdir, kind: args.kind, model: args.model };
|
|
62
|
+
try {
|
|
63
|
+
const result = await backend.execute(prompt, opts);
|
|
64
|
+
process.stdout.write(result.output);
|
|
65
|
+
}
|
|
66
|
+
catch (err) {
|
|
67
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
68
|
+
process.stderr.write(`run: ${args.engine} execution failed: ${message}\n`);
|
|
69
|
+
process.exitCode = 1;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
function isMainModule() {
|
|
73
|
+
return undefined !== process.argv[1] && import.meta.url === `file://${process.argv[1]}`;
|
|
74
|
+
}
|
|
75
|
+
if (isMainModule()) {
|
|
76
|
+
void main();
|
|
77
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@refleet-it/runner",
|
|
3
|
+
"version": "0.1.186",
|
|
4
|
+
"description": "Refleet fleet runner: claims code-modernisation jobs from the Refleet API and runs them through Claude Code or Kiro on your own machine.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"refleet",
|
|
7
|
+
"runner",
|
|
8
|
+
"claude-code",
|
|
9
|
+
"kiro",
|
|
10
|
+
"gitlab",
|
|
11
|
+
"code-modernisation"
|
|
12
|
+
],
|
|
13
|
+
"homepage": "https://refleet.it",
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://gitlab.com/slipway1/slipway/-/issues"
|
|
16
|
+
},
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://gitlab.com/slipway1/slipway.git",
|
|
20
|
+
"directory": "runner/agent"
|
|
21
|
+
},
|
|
22
|
+
"license": "MIT",
|
|
23
|
+
"type": "module",
|
|
24
|
+
"bin": {
|
|
25
|
+
"refleet-runner": "dist/cli.js"
|
|
26
|
+
},
|
|
27
|
+
"engines": {
|
|
28
|
+
"node": ">=22"
|
|
29
|
+
},
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "public"
|
|
32
|
+
},
|
|
33
|
+
"files": [
|
|
34
|
+
"dist/**/*.js",
|
|
35
|
+
"!dist/**/*.test.js",
|
|
36
|
+
"!dist/**/test-support.js"
|
|
37
|
+
],
|
|
38
|
+
"scripts": {
|
|
39
|
+
"build": "tsc -p tsconfig.json && chmod +x dist/cli.js",
|
|
40
|
+
"prepack": "npm run build",
|
|
41
|
+
"test": "npm run build && cd dist && node --test",
|
|
42
|
+
"login": "node dist/cli.js login",
|
|
43
|
+
"logout": "node dist/cli.js logout",
|
|
44
|
+
"whoami": "node dist/cli.js whoami",
|
|
45
|
+
"start": "node dist/cli.js run"
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@types/node": "^22.10.0",
|
|
49
|
+
"typescript": "^5.7.2"
|
|
50
|
+
}
|
|
51
|
+
}
|