@capstone-nomos/cli 0.1.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 +29 -0
- package/dist/bridge/claude-args.js +45 -0
- package/dist/bridge/credentials.js +38 -0
- package/dist/bridge/mcp-server.js +130 -0
- package/dist/bridge/nomos-client.js +161 -0
- package/dist/bridge/push.js +93 -0
- package/dist/executor/cli.js +350 -0
- package/dist/executor/connect.js +74 -0
- package/dist/executor/device-login.js +85 -0
- package/dist/executor/paths.js +27 -0
- package/dist/executor/preflight.js +22 -0
- package/dist/executor/prompt.js +30 -0
- package/dist/executor/repo-checkout.js +74 -0
- package/dist/executor/runner.js +59 -0
- package/dist/executor/task-source.js +10 -0
- package/dist/executor/verify.js +106 -0
- package/dist/executor/workspace.js +92 -0
- package/package.json +28 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { existsSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { buildClaudeArgs } from '../bridge/claude-args.js';
|
|
5
|
+
export const DEFAULT_TIMEOUT_MS = 30 * 60 * 1000;
|
|
6
|
+
// ⚠️ Windows에서 spawn('claude', args, { shell: true })를 쓰면 안 된다.
|
|
7
|
+
// shell:true는 인자를 이스케이프 없이 이어붙여서 따옴표·괄호가 든 프롬프트가 셸에 먹힌다
|
|
8
|
+
// (스파이크에서 도구 호출이 한 번도 안 일어났다). .cmd 셰이퍼는 shell 없이 못 돌리므로
|
|
9
|
+
// node로 CLI 진입점을 직접 띄운다.
|
|
10
|
+
export function resolveClaudeCommand(args) {
|
|
11
|
+
if (process.platform === 'win32' && process.env.APPDATA) {
|
|
12
|
+
const cli = path.join(process.env.APPDATA, 'npm', 'node_modules', '@anthropic-ai', 'claude-code', 'cli-wrapper.cjs');
|
|
13
|
+
if (existsSync(cli))
|
|
14
|
+
return { command: process.execPath, commandArgs: [cli, ...args] };
|
|
15
|
+
}
|
|
16
|
+
return { command: 'claude', commandArgs: args };
|
|
17
|
+
}
|
|
18
|
+
export async function runClaude(input) {
|
|
19
|
+
const args = buildClaudeArgs({
|
|
20
|
+
prompt: input.prompt,
|
|
21
|
+
mcpConfigPath: input.mcpConfigPath,
|
|
22
|
+
cwd: input.workspaceDir,
|
|
23
|
+
...(input.model === undefined ? {} : { model: input.model }),
|
|
24
|
+
});
|
|
25
|
+
const { command, commandArgs } = resolveClaudeCommand(args);
|
|
26
|
+
const logPath = path.join(input.workspaceDir, '.nomos-run.log');
|
|
27
|
+
const startedAt = Date.now();
|
|
28
|
+
return new Promise((resolve) => {
|
|
29
|
+
// stdin을 열어두면 claude가 파이프 입력을 몇 초간 기다린다. 쓸 일이 없으니 닫는다.
|
|
30
|
+
const child = spawn(command, commandArgs, {
|
|
31
|
+
cwd: input.workspaceDir,
|
|
32
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
33
|
+
});
|
|
34
|
+
const chunks = [];
|
|
35
|
+
const collect = (c) => chunks.push(c.toString());
|
|
36
|
+
child.stdout.on('data', collect);
|
|
37
|
+
child.stderr.on('data', collect);
|
|
38
|
+
let timedOut = false;
|
|
39
|
+
const timer = setTimeout(() => {
|
|
40
|
+
timedOut = true;
|
|
41
|
+
child.kill();
|
|
42
|
+
}, input.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
43
|
+
child.on('close', (code) => {
|
|
44
|
+
clearTimeout(timer);
|
|
45
|
+
writeFileSync(logPath, chunks.join(''));
|
|
46
|
+
resolve({
|
|
47
|
+
outcome: timedOut ? 'timeout' : code === 0 ? 'completed' : 'failed',
|
|
48
|
+
exitCode: code,
|
|
49
|
+
durationMs: Date.now() - startedAt,
|
|
50
|
+
logPath,
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
child.on('error', (err) => {
|
|
54
|
+
clearTimeout(timer);
|
|
55
|
+
writeFileSync(logPath, `${chunks.join('')}\n[executor] spawn 실패: ${err.message}\n`);
|
|
56
|
+
resolve({ outcome: 'failed', exitCode: null, durationMs: Date.now() - startedAt, logPath });
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// 서버가 에이전트 토큰을 보고 자기 역할로 필터해 준다 — 여기서 역할을 다시 거르지 않는다.
|
|
2
|
+
export function pollingTaskSource(client, projectId) {
|
|
3
|
+
return {
|
|
4
|
+
kind: 'polling',
|
|
5
|
+
async nextTasks(limit) {
|
|
6
|
+
const rows = await client.listTasks(projectId, { state: 'READY', limit });
|
|
7
|
+
return rows;
|
|
8
|
+
},
|
|
9
|
+
};
|
|
10
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// V2(PM 시험지)와 V4(린트) — 작업공간이 있어야 돌 수 있는 두 단계.
|
|
2
|
+
// 제출 시점에는 결과가 물리적으로 존재할 수 없으므로 서버가 동기로 돌릴 수 없고,
|
|
3
|
+
// Executor가 돌린 뒤 POST /api/artifacts/:id/verifications로 보고한다.
|
|
4
|
+
//
|
|
5
|
+
// 여기서 판정하는 것은 "명령이 0으로 끝났는가"뿐이다. 출력을 읽고 해석하지 않는다 —
|
|
6
|
+
// 해석이 들어가는 순간 브릿지마다 결과가 달라지고, 그건 결정적 검증이 아니다.
|
|
7
|
+
import { execFile } from 'node:child_process';
|
|
8
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { promisify } from 'node:util';
|
|
11
|
+
const run = promisify(execFile);
|
|
12
|
+
// 린트와 테스트는 오래 걸릴 수 있지만 무한정은 아니다. 초과하면 SKIPPED로 남긴다 —
|
|
13
|
+
// FAIL로 적으면 코드가 아니라 기계가 느려서 재시도 횟수를 잃는다.
|
|
14
|
+
const STEP_TIMEOUT_MS = 5 * 60 * 1000;
|
|
15
|
+
// Windows에서 npm은 .cmd라 shell 없이는 못 띄운다. 인자를 문자열로 합치지 않고
|
|
16
|
+
// npm-cli.js를 직접 node로 부르는 방식이 안전하지만, 여기서는 인자가 고정이라
|
|
17
|
+
// shell:true로도 주입 여지가 없다 — 사용자 입력이 섞이지 않는다.
|
|
18
|
+
async function exec(cmd, args, cwd) {
|
|
19
|
+
try {
|
|
20
|
+
const { stdout, stderr } = await run(cmd, args, {
|
|
21
|
+
cwd,
|
|
22
|
+
timeout: STEP_TIMEOUT_MS,
|
|
23
|
+
shell: process.platform === 'win32',
|
|
24
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
25
|
+
});
|
|
26
|
+
return { code: 0, stdout, stderr };
|
|
27
|
+
}
|
|
28
|
+
catch (err) {
|
|
29
|
+
const e = err;
|
|
30
|
+
return {
|
|
31
|
+
code: typeof e.code === 'number' ? e.code : e.killed ? 124 : 1,
|
|
32
|
+
stdout: e.stdout ?? '',
|
|
33
|
+
stderr: e.stderr ?? String(err),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
// 출력 전체를 서버에 올리지 않는다. events·verifications는 지워지지 않으므로
|
|
38
|
+
// 로그를 통째로 넣으면 비밀값이 섞여 들어갈 수 있다.
|
|
39
|
+
function tail(text, lines = 20) {
|
|
40
|
+
return text.trim().split('\n').slice(-lines).join('\n');
|
|
41
|
+
}
|
|
42
|
+
function packageScripts(workspaceDir) {
|
|
43
|
+
const file = path.join(workspaceDir, 'package.json');
|
|
44
|
+
if (!existsSync(file))
|
|
45
|
+
return {};
|
|
46
|
+
try {
|
|
47
|
+
const parsed = JSON.parse(readFileSync(file, 'utf-8'));
|
|
48
|
+
return parsed.scripts ?? {};
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return {};
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
// ── V4 — 린트 ─────────────────────────────────────────────────────────────
|
|
55
|
+
export async function runLint(workspaceDir) {
|
|
56
|
+
const started = Date.now();
|
|
57
|
+
const done = (result, detail) => ({ stage: 'V4', result, detail, durationMs: Date.now() - started });
|
|
58
|
+
const scripts = packageScripts(workspaceDir);
|
|
59
|
+
if (!('lint' in scripts)) {
|
|
60
|
+
return done('SKIPPED', { reason: 'package.json에 lint 스크립트가 없다' });
|
|
61
|
+
}
|
|
62
|
+
const out = await exec('npm', ['run', '--silent', 'lint'], workspaceDir);
|
|
63
|
+
if (out.code === 124)
|
|
64
|
+
return done('SKIPPED', { reason: '린트가 제한 시간을 넘겼다' });
|
|
65
|
+
return out.code === 0
|
|
66
|
+
? done('PASS', { command: 'npm run lint' })
|
|
67
|
+
: done('FAIL', { command: 'npm run lint', exitCode: out.code, output: tail(out.stderr || out.stdout) });
|
|
68
|
+
}
|
|
69
|
+
const SPEC_TEST_DIR = '.nomos-spec-tests';
|
|
70
|
+
export async function runSpecTests(workspaceDir, specTests) {
|
|
71
|
+
const started = Date.now();
|
|
72
|
+
const done = (result, detail) => ({ stage: 'V2', result, detail, durationMs: Date.now() - started });
|
|
73
|
+
if (specTests.length === 0) {
|
|
74
|
+
return done('SKIPPED', { reason: '잠긴 spec_tests가 없다' });
|
|
75
|
+
}
|
|
76
|
+
// vitest가 작업공간에 설치돼 있을 때만 돌린다. npx에 맡기면 없을 때 레지스트리에서
|
|
77
|
+
// 받아오느라 몇 분이 사라지고, 네트워크가 막힌 노트북에서는 그대로 멈춘다.
|
|
78
|
+
if (!existsSync(path.join(workspaceDir, 'node_modules', 'vitest'))) {
|
|
79
|
+
return done('SKIPPED', { reason: '작업공간에 vitest가 설치돼 있지 않다' });
|
|
80
|
+
}
|
|
81
|
+
const dir = path.join(workspaceDir, SPEC_TEST_DIR);
|
|
82
|
+
// 시험지는 작업 결과가 아니다. 커밋에 섞이면 V3에서 신고 누락으로 잡힌다.
|
|
83
|
+
rmSync(dir, { recursive: true, force: true });
|
|
84
|
+
mkdirSync(dir, { recursive: true });
|
|
85
|
+
try {
|
|
86
|
+
const files = specTests.map((t) => {
|
|
87
|
+
const file = path.join(dir, `${t.id}.test.ts`);
|
|
88
|
+
writeFileSync(file, t.testCode, 'utf-8');
|
|
89
|
+
return path.posix.join(SPEC_TEST_DIR, `${t.id}.test.ts`);
|
|
90
|
+
});
|
|
91
|
+
const out = await exec('npx', ['vitest', 'run', ...files], workspaceDir);
|
|
92
|
+
if (out.code === 124)
|
|
93
|
+
return done('SKIPPED', { reason: '시험지가 제한 시간을 넘겼다' });
|
|
94
|
+
return out.code === 0
|
|
95
|
+
? done('PASS', { specTestIds: specTests.map((t) => t.id) })
|
|
96
|
+
: done('FAIL', {
|
|
97
|
+
specTestIds: specTests.map((t) => t.id),
|
|
98
|
+
exitCode: out.code,
|
|
99
|
+
output: tail(out.stdout || out.stderr),
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
finally {
|
|
103
|
+
// 남겨두면 다음 실행에서 모델이 시험지를 읽고 거기에 맞춰 고칠 수 있다.
|
|
104
|
+
rmSync(dir, { recursive: true, force: true });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
// 작업공간은 레포 자체와 분리한다. git worktree로 분기하므로 사용자의 기존 작업 트리를 건드리지 않는다.
|
|
6
|
+
// 태스크가 끝나도 남긴다 — 실패 원인은 로그가 아니라 남은 파일에서 드러나는 경우가 많다.
|
|
7
|
+
export function workspacesRoot() {
|
|
8
|
+
return path.join(os.homedir(), '.nomos', 'workspaces');
|
|
9
|
+
}
|
|
10
|
+
export function workspaceDir(projectId, taskId) {
|
|
11
|
+
return path.join(workspacesRoot(), projectId, taskId);
|
|
12
|
+
}
|
|
13
|
+
// 레포 fullName → 이미 받아 둔 로컬 경로(선택). 없으면 CLI가 ~/.nomos/repos/에 받는다(repo-checkout.ts).
|
|
14
|
+
export function repoMapPath() {
|
|
15
|
+
return path.join(os.homedir(), '.nomos', 'repos.json');
|
|
16
|
+
}
|
|
17
|
+
// 없으면 빈 맵. 시드는 "적힌 것만" 쓰면 되므로 실패시키지 않는다.
|
|
18
|
+
export function readRepoMap() {
|
|
19
|
+
const file = repoMapPath();
|
|
20
|
+
if (!existsSync(file))
|
|
21
|
+
return {};
|
|
22
|
+
return JSON.parse(readFileSync(file, 'utf-8'));
|
|
23
|
+
}
|
|
24
|
+
function git(repoPath, args) {
|
|
25
|
+
return execFileSync('git', args, { cwd: repoPath, encoding: 'utf-8' }).trim();
|
|
26
|
+
}
|
|
27
|
+
function branchExists(repoPath, branch) {
|
|
28
|
+
try {
|
|
29
|
+
// stdio를 삼킨다 — 없는 브랜치면 git이 stderr에 fatal을 찍어 로그를 더럽힌다.
|
|
30
|
+
execFileSync('git', ['rev-parse', '--verify', `refs/heads/${branch}`], {
|
|
31
|
+
cwd: repoPath,
|
|
32
|
+
stdio: 'ignore',
|
|
33
|
+
});
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
// .claude/settings.json이 **로컬 방어선**이다. 서버의 제출 시점 검증과 두 겹을 이룬다.
|
|
41
|
+
// 생성에 쓴 policy_hash를 같이 남겨 둔다 — 나중에 agents.settings_hash 대조에 쓴다.
|
|
42
|
+
export function prepareWorkspace(input) {
|
|
43
|
+
const dir = workspaceDir(input.projectId, input.taskId);
|
|
44
|
+
const branch = input.branchName ?? `task/${input.taskId}`;
|
|
45
|
+
const existed = branchExists(input.repoPath, branch);
|
|
46
|
+
if (!existsSync(dir)) {
|
|
47
|
+
mkdirSync(path.dirname(dir), { recursive: true });
|
|
48
|
+
const args = existed
|
|
49
|
+
? ['worktree', 'add', dir, branch]
|
|
50
|
+
: ['worktree', 'add', '-b', branch, dir, input.baseBranch];
|
|
51
|
+
git(input.repoPath, args);
|
|
52
|
+
}
|
|
53
|
+
const claudeDir = path.join(dir, '.claude');
|
|
54
|
+
mkdirSync(claudeDir, { recursive: true });
|
|
55
|
+
const settingsPath = path.join(claudeDir, 'settings.json');
|
|
56
|
+
writeFileSync(settingsPath, `${JSON.stringify(input.settings, null, 2)}\n`);
|
|
57
|
+
writeFileSync(path.join(claudeDir, '.nomos-policy.json'), `${JSON.stringify({ policyHash: input.policyHash, taskId: input.taskId, generatedAt: new Date().toISOString() }, null, 2)}\n`);
|
|
58
|
+
return { dir, branch, createdBranch: !existed, settingsPath };
|
|
59
|
+
}
|
|
60
|
+
// executor clean — worktree를 정리한다. 남겨두는 게 기본이라 정리는 명시적 명령으로만 한다.
|
|
61
|
+
export function cleanWorkspaces() {
|
|
62
|
+
const pruned = [];
|
|
63
|
+
// repos.json에 적힌 레포 + CLI가 받아 둔 레포(~/.nomos/repos/<조직>/<레포>).
|
|
64
|
+
const repos = { ...readRepoMap() };
|
|
65
|
+
const managed = path.join(os.homedir(), '.nomos', 'repos');
|
|
66
|
+
if (existsSync(managed)) {
|
|
67
|
+
for (const owner of readdirSync(managed)) {
|
|
68
|
+
const ownerDir = path.join(managed, owner);
|
|
69
|
+
if (!statSync(ownerDir).isDirectory())
|
|
70
|
+
continue;
|
|
71
|
+
for (const name of readdirSync(ownerDir))
|
|
72
|
+
repos[`${owner}/${name}`] ??= path.join(ownerDir, name);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
for (const [fullName, repoPath] of Object.entries(repos)) {
|
|
76
|
+
try {
|
|
77
|
+
git(repoPath, ['worktree', 'prune']);
|
|
78
|
+
pruned.push(fullName);
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
// 레포가 사라졌거나 git이 없어도 디렉터리 삭제는 진행한다.
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const root = workspacesRoot();
|
|
85
|
+
if (!existsSync(root))
|
|
86
|
+
return { pruned, removed: null };
|
|
87
|
+
rmSync(root, { recursive: true, force: true });
|
|
88
|
+
return { pruned, removed: root };
|
|
89
|
+
}
|
|
90
|
+
export function headSha(dir) {
|
|
91
|
+
return execFileSync('git', ['rev-parse', 'HEAD'], { cwd: dir, encoding: 'utf-8' }).trim();
|
|
92
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@capstone-nomos/cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "NOMOS 에이전트 CLI — 노트북의 Claude Code를 NOMOS 서버에 연결하고 배정된 태스크를 실행한다",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"nomos": "dist/executor/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist"
|
|
11
|
+
],
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=22"
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
17
|
+
"zod": "^3.24.1"
|
|
18
|
+
},
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "git+https://github.com/Capstone-NOMOS/backend.git",
|
|
22
|
+
"directory": "packages/cli"
|
|
23
|
+
},
|
|
24
|
+
"license": "UNLICENSED",
|
|
25
|
+
"publishConfig": {
|
|
26
|
+
"access": "public"
|
|
27
|
+
}
|
|
28
|
+
}
|