@synkro-sh/cli 1.6.8 → 1.6.10
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/dist/bootstrap.js +1217 -188
- package/dist/bootstrap.js.map +1 -1
- package/package.json +1 -1
package/dist/bootstrap.js
CHANGED
|
@@ -1264,45 +1264,28 @@ export async function runInstallScan(command: string, jwt: string): Promise<Inst
|
|
|
1264
1264
|
scanned: false, action: 'allow', blockContext: '', summary: '',
|
|
1265
1265
|
scannedLabel: '', findings: [], violatedIds: [],
|
|
1266
1266
|
};
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
const
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
if (token.startsWith('-')) {
|
|
1279
|
-
if (/^--(python|target|prefix|root|constraint|requirement|index-url|extra-index-url|find-links|build|src|cache-dir|filter|workspace)$/.test(token)) skipNext = true;
|
|
1280
|
-
continue;
|
|
1281
|
-
}
|
|
1282
|
-
const ecosystem = isPip ? 'PyPI' : 'npm';
|
|
1283
|
-
if (isPip) {
|
|
1284
|
-
const pipMatch = token.match(/^([a-zA-Z0-9_.-]+)(?:[=~!<>]=?(.+))?$/);
|
|
1285
|
-
if (pipMatch) {
|
|
1286
|
-
packages.push({ name: pipMatch[1], version: pipMatch[2]?.replace(/^=/, '') || '*', ecosystem });
|
|
1287
|
-
continue;
|
|
1288
|
-
}
|
|
1289
|
-
}
|
|
1290
|
-
const atIdx = token.lastIndexOf('@');
|
|
1291
|
-
if (atIdx > 0) packages.push({ name: token.slice(0, atIdx), version: token.slice(atIdx + 1), ecosystem });
|
|
1292
|
-
else packages.push({ name: token, version: '*', ecosystem });
|
|
1293
|
-
}
|
|
1294
|
-
if (packages.length === 0) return empty;
|
|
1295
|
-
const scannedLabel = packages.map(p => p.name + '@' + p.version).join(', ');
|
|
1267
|
+
// Stage 0 \u2014 recall-tuned pre-filter. Cheap, dependency-free, runs on every
|
|
1268
|
+
// Bash command. A false positive only costs a wasted server round-trip; a
|
|
1269
|
+
// false negative would let a vulnerable install through \u2014 so keep it loose.
|
|
1270
|
+
const lc = command.toLowerCase();
|
|
1271
|
+
const HINTS = [
|
|
1272
|
+
'npm', 'pnpm', 'yarn', 'bun', 'pip', 'cargo', 'gem', 'composer',
|
|
1273
|
+
'apt', 'apk', 'brew', 'dnf', 'yum', 'pacman', 'install', 'require',
|
|
1274
|
+
'eval', '$(', '| sh', '| bash', 'curl', 'wget', 'make ',
|
|
1275
|
+
];
|
|
1276
|
+
if (!HINTS.some(h => lc.includes(h))) return empty;
|
|
1277
|
+
|
|
1296
1278
|
try {
|
|
1297
1279
|
const scanResp = await fetch(GATEWAY_URL + '/api/v1/pkg-scan', {
|
|
1298
1280
|
method: 'POST',
|
|
1299
1281
|
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + jwt },
|
|
1300
|
-
body: JSON.stringify({
|
|
1301
|
-
signal: AbortSignal.timeout(
|
|
1282
|
+
body: JSON.stringify({ command }),
|
|
1283
|
+
signal: AbortSignal.timeout(10000),
|
|
1302
1284
|
}).then(r => r.json()) as any;
|
|
1303
1285
|
const action = scanResp?.action || 'allow';
|
|
1304
1286
|
const pkgResults = Array.isArray(scanResp?.packages) ? scanResp.packages : [];
|
|
1305
1287
|
const summary = scanResp?.summary || '';
|
|
1288
|
+
const scannedLabel = pkgResults.map((p: any) => p.name + '@' + p.version).join(', ');
|
|
1306
1289
|
if (action === 'block') {
|
|
1307
1290
|
const blockSignals = pkgResults
|
|
1308
1291
|
.flatMap((p: any) => (p.signals || []).filter((s: any) => s.severity === 'critical' || s.severity === 'high'))
|
|
@@ -1319,7 +1302,7 @@ export async function runInstallScan(command: string, jwt: string): Promise<Inst
|
|
|
1319
1302
|
}
|
|
1320
1303
|
}
|
|
1321
1304
|
}
|
|
1322
|
-
const details = blockSignals.map((s: any) => s.detail).join('\\n');
|
|
1305
|
+
const details = blockSignals.map((s: any) => s.detail).join('\\n') || summary;
|
|
1323
1306
|
return {
|
|
1324
1307
|
scanned: true, action: 'block',
|
|
1325
1308
|
blockContext: details + '\\nDo NOT install packages with security risks. Use a patched version or a different package.',
|
|
@@ -1332,7 +1315,14 @@ export async function runInstallScan(command: string, jwt: string): Promise<Inst
|
|
|
1332
1315
|
blockContext: '', summary, scannedLabel, findings: [], violatedIds: [],
|
|
1333
1316
|
};
|
|
1334
1317
|
} catch {
|
|
1335
|
-
|
|
1318
|
+
// Fail closed \u2014 could not verify the install (timeout / server
|
|
1319
|
+
// unreachable). Blocking an unverified install beats letting a
|
|
1320
|
+
// vulnerable package through.
|
|
1321
|
+
return {
|
|
1322
|
+
scanned: true, action: 'block',
|
|
1323
|
+
blockContext: 'Synkro could not verify this install is safe \u2014 the package scanner timed out or was unreachable. This is a verification failure, not a confirmed vulnerability. Retry once the Synkro server is reachable.',
|
|
1324
|
+
summary: 'install scan unavailable', scannedLabel: '', findings: [], violatedIds: ['scan_unavailable'],
|
|
1325
|
+
};
|
|
1336
1326
|
}
|
|
1337
1327
|
}
|
|
1338
1328
|
|
|
@@ -3091,6 +3081,7 @@ import {
|
|
|
3091
3081
|
extractTranscript, readLastPrompt, appendSessionAction, readSessionLog, compressSessionLog, log,
|
|
3092
3082
|
outputJson, outputEmpty, setupCursorHookSignals, isShellTool, hookSessionId, GATEWAY_URL,
|
|
3093
3083
|
logGraderUnavailable, filterRules, normalizeMode, appendLocalTelemetry, isSafeInRepoRead,
|
|
3084
|
+
runInstallScan,
|
|
3094
3085
|
type HookConfig, type Rule,
|
|
3095
3086
|
} from './_synkro-common.ts';
|
|
3096
3087
|
|
|
@@ -3161,7 +3152,7 @@ async function main() {
|
|
|
3161
3152
|
tool_name: toolName,
|
|
3162
3153
|
command: command.slice(0, 200),
|
|
3163
3154
|
session_id: sessionId,
|
|
3164
|
-
repo: cwd,
|
|
3155
|
+
repo: gitRepo || cwd,
|
|
3165
3156
|
cc_model: transcript.ccModel,
|
|
3166
3157
|
reasoning: 'Safe in-repo read — auto-allowed without an LLM grade.',
|
|
3167
3158
|
});
|
|
@@ -3176,108 +3167,44 @@ async function main() {
|
|
|
3176
3167
|
}
|
|
3177
3168
|
|
|
3178
3169
|
// ─── Install protection: server-side pkg-scan (CVE + typosquat + tarball + reputation) ───
|
|
3170
|
+
// Detection + extraction happen server-side (runInstallScan); the hook
|
|
3171
|
+
// relays the verdict. A block is handed to the grader as an authoritative
|
|
3172
|
+
// concern so the normal ask + consent-carryover flow lets the user
|
|
3173
|
+
// override it on the next turn.
|
|
3179
3174
|
let installScanMsg = '';
|
|
3175
|
+
let scanConcern = '';
|
|
3176
|
+
let scanBlockContext = '';
|
|
3180
3177
|
if (toolName === 'Bash') {
|
|
3181
|
-
const
|
|
3182
|
-
|
|
3183
|
-
|
|
3184
|
-
|
|
3185
|
-
|
|
3186
|
-
|
|
3187
|
-
|
|
3188
|
-
|
|
3189
|
-
|
|
3190
|
-
|
|
3191
|
-
|
|
3192
|
-
|
|
3193
|
-
|
|
3194
|
-
|
|
3195
|
-
continue;
|
|
3196
|
-
}
|
|
3197
|
-
const ecosystem = isPip ? 'PyPI' : 'npm';
|
|
3198
|
-
if (isPip) {
|
|
3199
|
-
const pipMatch = token.match(/^([a-zA-Z0-9_.-]+)(?:[=~!<>]=?(.+))?$/);
|
|
3200
|
-
if (pipMatch) {
|
|
3201
|
-
packages.push({ name: pipMatch[1], version: pipMatch[2]?.replace(/^=/, '') || '*', ecosystem });
|
|
3202
|
-
continue;
|
|
3203
|
-
}
|
|
3204
|
-
}
|
|
3205
|
-
const atIdx = token.lastIndexOf('@');
|
|
3206
|
-
if (atIdx > 0) {
|
|
3207
|
-
packages.push({ name: token.slice(0, atIdx), version: token.slice(atIdx + 1), ecosystem });
|
|
3208
|
-
} else {
|
|
3209
|
-
packages.push({ name: token, version: '*', ecosystem });
|
|
3210
|
-
}
|
|
3211
|
-
}
|
|
3212
|
-
|
|
3213
|
-
if (packages.length > 0) {
|
|
3214
|
-
try {
|
|
3215
|
-
const scanResp = await fetch(GATEWAY_URL + '/api/v1/pkg-scan', {
|
|
3216
|
-
method: 'POST',
|
|
3217
|
-
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + jwt },
|
|
3218
|
-
body: JSON.stringify({ packages, command }),
|
|
3219
|
-
signal: AbortSignal.timeout(15000),
|
|
3220
|
-
}).then(r => r.json()) as any;
|
|
3221
|
-
|
|
3222
|
-
const action = scanResp?.action || 'allow';
|
|
3223
|
-
const pkgResults = Array.isArray(scanResp?.packages) ? scanResp.packages : [];
|
|
3224
|
-
const summary = scanResp?.summary || '';
|
|
3225
|
-
|
|
3226
|
-
if (action === 'block') {
|
|
3227
|
-
const blockSignals = pkgResults
|
|
3228
|
-
.flatMap((p: any) => (p.signals || []).filter((s: any) => s.severity === 'critical' || s.severity === 'high'))
|
|
3229
|
-
.slice(0, 5);
|
|
3230
|
-
const scanMsg = '[synkro:installScan] ' + cmdShort + ' → blocked';
|
|
3231
|
-
const details = blockSignals.map((s: any) => s.detail).join('\n');
|
|
3232
|
-
const ctx = details + '\nDo NOT install packages with security risks. Use a patched version or a different package.';
|
|
3233
|
-
|
|
3234
|
-
const config = await loadConfig(jwt);
|
|
3235
|
-
for (const p of pkgResults) {
|
|
3236
|
-
for (const s of (p.signals || [])) {
|
|
3237
|
-
if (s.severity === 'critical' || s.severity === 'high') {
|
|
3238
|
-
const advisoryMatch = (s.detail || '').match(/\\b(GHSA-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}|CVE-\\d{4}-\\d+)\\b/i);
|
|
3239
|
-
const advisoryId = advisoryMatch ? advisoryMatch[1] : s.type;
|
|
3240
|
-
dispatchFinding(jwt, {
|
|
3241
|
-
session_id: sessionId,
|
|
3242
|
-
file_path: command,
|
|
3243
|
-
finding_type: 'cve' as const,
|
|
3244
|
-
finding_id: advisoryId + ':' + p.name,
|
|
3245
|
-
severity: s.severity,
|
|
3246
|
-
status: 'open',
|
|
3247
|
-
detail: s.detail,
|
|
3248
|
-
package_name: p.name,
|
|
3249
|
-
package_version: p.version,
|
|
3250
|
-
}, config.captureDepth);
|
|
3251
|
-
}
|
|
3252
|
-
}
|
|
3253
|
-
}
|
|
3254
|
-
|
|
3255
|
-
const violatedIds = blockSignals.map((s: any) => s.type + ':' + s.detail.slice(0, 40));
|
|
3256
|
-
dispatchCapture(jwt, 'pkg', 'block', 'critical', 'security',
|
|
3257
|
-
'Bash', gitRepo, sessionId, config.captureDepth, {
|
|
3258
|
-
command,
|
|
3259
|
-
reasoning: details.slice(0, 200),
|
|
3260
|
-
violatedRules: violatedIds,
|
|
3261
|
-
ccModel: transcript.ccModel,
|
|
3262
|
-
});
|
|
3263
|
-
|
|
3264
|
-
outputJson({
|
|
3265
|
-
systemMessage: scanMsg,
|
|
3266
|
-
hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: ctx, additionalContext: ctx },
|
|
3267
|
-
});
|
|
3268
|
-
return;
|
|
3269
|
-
}
|
|
3270
|
-
|
|
3271
|
-
if (action === 'warn') {
|
|
3272
|
-
installScanMsg = '[synkro:installScan] ' + summary;
|
|
3273
|
-
} else {
|
|
3274
|
-
const scannedPkgs = packages.map(p => p.name + '@' + p.version).join(', ');
|
|
3275
|
-
installScanMsg = '[synkro:installScan] ' + scannedPkgs + ' → clean';
|
|
3276
|
-
}
|
|
3277
|
-
} catch (e) {
|
|
3278
|
-
log('bashGuard pkg-scan failed: ' + String(e));
|
|
3279
|
-
}
|
|
3178
|
+
const scan = await runInstallScan(command, jwt);
|
|
3179
|
+
if (scan.action === 'block') {
|
|
3180
|
+
for (const f of scan.findings) {
|
|
3181
|
+
dispatchFinding(jwt, {
|
|
3182
|
+
session_id: sessionId,
|
|
3183
|
+
file_path: command,
|
|
3184
|
+
finding_type: 'cve' as const,
|
|
3185
|
+
finding_id: f.advisoryId + ':' + f.name,
|
|
3186
|
+
severity: f.severity,
|
|
3187
|
+
status: 'open',
|
|
3188
|
+
detail: f.detail,
|
|
3189
|
+
package_name: f.name,
|
|
3190
|
+
package_version: f.version,
|
|
3191
|
+
}, config.captureDepth);
|
|
3280
3192
|
}
|
|
3193
|
+
dispatchCapture(jwt, 'pkg', 'block', 'critical', 'security',
|
|
3194
|
+
'Bash', gitRepo, sessionId, config.captureDepth, {
|
|
3195
|
+
command,
|
|
3196
|
+
reasoning: scan.blockContext.slice(0, 200),
|
|
3197
|
+
violatedRules: scan.violatedIds,
|
|
3198
|
+
ccModel: transcript.ccModel,
|
|
3199
|
+
});
|
|
3200
|
+
scanBlockContext = scan.blockContext;
|
|
3201
|
+
scanConcern = 'PACKAGE SCANNER FLAG (authoritative — do NOT re-evaluate whether the vulnerability is real): '
|
|
3202
|
+
+ scan.blockContext
|
|
3203
|
+
+ ' For this concern you MUST return ok=false with rule_id "SYNKRO_PKGSCAN", rule_mode "ask", and the reason above — UNLESS the user has explicitly consented in this conversation to installing this despite the warning, in which case return ok=true.';
|
|
3204
|
+
} else if (scan.scanned && scan.action === 'warn') {
|
|
3205
|
+
installScanMsg = '[synkro:installScan] ' + scan.summary;
|
|
3206
|
+
} else if (scan.scanned) {
|
|
3207
|
+
installScanMsg = '[synkro:installScan] ' + (scan.scannedLabel || cmdShort) + ' → clean';
|
|
3281
3208
|
}
|
|
3282
3209
|
}
|
|
3283
3210
|
|
|
@@ -3298,6 +3225,7 @@ async function main() {
|
|
|
3298
3225
|
'User intent (last human message): ' + (transcript.userIntent || 'none stated'),
|
|
3299
3226
|
'Last user prompt: ' + (lastPrompt || 'none'),
|
|
3300
3227
|
'Org rules: ' + JSON.stringify(relevantRules),
|
|
3228
|
+
scanConcern,
|
|
3301
3229
|
'IMPORTANT: If a rule is violated, ALWAYS return ok=false with the rule_id and reason, regardless of the rule mode. Do NOT pass a command just because the rule mode is "fix". The enforcement layer handles ask vs fix — your job is only to detect violations.',
|
|
3302
3230
|
'The rules shown were pre-selected as the ones relevant to this command — every rule here IS relevant, do not label any "not relevant". When passing (ok=true), give a terse, specific reason each rule passes. Format: "R003: no secrets in grep args. R005: in-repo path only." Cover every rule shown.',
|
|
3303
3231
|
'Rules with preconditions (e.g. "run X before Y") are CONSUMED after the protected action completes. Use the session history timestamps to determine ordering: a precondition satisfied before the last occurrence of the protected action does NOT satisfy the next occurrence. Each new protected action needs its precondition re-satisfied.',
|
|
@@ -3308,6 +3236,16 @@ async function main() {
|
|
|
3308
3236
|
gradeResp = await localGrade('bash', graderPrompt);
|
|
3309
3237
|
} catch (err) {
|
|
3310
3238
|
logGraderUnavailable('bashGuard', toolInput.command?.slice(0, 200) || '', (err as Error).message || String(err));
|
|
3239
|
+
if (scanConcern) {
|
|
3240
|
+
// Grader unavailable to run the consent check — fail closed on a
|
|
3241
|
+
// scanner-flagged install (ask-mode so the user can still consent).
|
|
3242
|
+
const ctx = scanBlockContext + ' Synkro flagged this install but the grader is unavailable to check consent — ask the user for explicit consent, then retry.';
|
|
3243
|
+
outputJson({
|
|
3244
|
+
systemMessage: tagStr + ' bashGuard → install blocked (scan flagged; grader unavailable)',
|
|
3245
|
+
hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: ctx, additionalContext: ctx },
|
|
3246
|
+
});
|
|
3247
|
+
return;
|
|
3248
|
+
}
|
|
3311
3249
|
outputJson({ systemMessage: tagStr + ' bashGuard → local grader unavailable, skipped' });
|
|
3312
3250
|
return;
|
|
3313
3251
|
}
|
|
@@ -3371,6 +3309,18 @@ async function main() {
|
|
|
3371
3309
|
session_summary: transcript.sessionSummary || null,
|
|
3372
3310
|
};
|
|
3373
3311
|
|
|
3312
|
+
if (scanConcern) {
|
|
3313
|
+
// Cloud grading does not carry the package-scanner concern through the
|
|
3314
|
+
// consent flow — block here with ask-mode messaging so the user can
|
|
3315
|
+
// consent and retry.
|
|
3316
|
+
const ctx = scanBlockContext + ' Ask the user for explicit consent before retrying.';
|
|
3317
|
+
outputJson({
|
|
3318
|
+
systemMessage: tagStr + ' bashGuard → install blocked: ' + cmdShort,
|
|
3319
|
+
hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: ctx, additionalContext: ctx },
|
|
3320
|
+
});
|
|
3321
|
+
return;
|
|
3322
|
+
}
|
|
3323
|
+
|
|
3374
3324
|
const resp = await postWithRetry(GATEWAY_URL + '/api/v1/hook/judge', body, jwt, 8000);
|
|
3375
3325
|
|
|
3376
3326
|
if (!resp) {
|
|
@@ -4034,23 +3984,13 @@ async function main() {
|
|
|
4034
3984
|
}).catch(() => {});
|
|
4035
3985
|
}
|
|
4036
3986
|
|
|
4037
|
-
|
|
4038
|
-
|
|
3987
|
+
// Transcript consent gates only CLOUD transmission. Local persistence \u2014
|
|
3988
|
+
// this machine's own PGLite \u2014 is the same category as the local telemetry
|
|
3989
|
+
// already captured for every command, so it always runs.
|
|
3990
|
+
const cloudConsent = process.env.SYNKRO_TRANSCRIPT_CONSENT !== 'no';
|
|
4039
3991
|
const gitRepo = detectRepo(cwd);
|
|
4040
|
-
if (!gitRepo) { outputEmpty(); return; }
|
|
4041
|
-
|
|
4042
|
-
let captureDepth = 'local_only';
|
|
4043
|
-
try {
|
|
4044
|
-
const r = await fetch(GATEWAY_URL + '/api/v1/hook/config', {
|
|
4045
|
-
headers: { Authorization: 'Bearer ' + jwt },
|
|
4046
|
-
signal: AbortSignal.timeout(3000),
|
|
4047
|
-
});
|
|
4048
|
-
const data = await r.json() as any;
|
|
4049
|
-
captureDepth = data.capture_depth || 'local_only';
|
|
4050
|
-
} catch {}
|
|
4051
|
-
|
|
4052
|
-
if (captureDepth === 'local_only') { outputEmpty(); return; }
|
|
4053
3992
|
|
|
3993
|
+
// Offset-tracked extraction of new user/assistant turns from the transcript.
|
|
4054
3994
|
const offsetDir = join(homedir(), '.synkro', '.transcript-offsets');
|
|
4055
3995
|
mkdirSync(offsetDir, { recursive: true });
|
|
4056
3996
|
const offsetFile = join(offsetDir, sessionId);
|
|
@@ -4085,7 +4025,7 @@ async function main() {
|
|
|
4085
4025
|
}).join(' ').slice(0, 8000);
|
|
4086
4026
|
}
|
|
4087
4027
|
|
|
4088
|
-
const msg: any = { message_index: i, type: entry.type, content: text };
|
|
4028
|
+
const msg: any = { message_index: i, type: entry.type, content: text, ts: entry.timestamp || null };
|
|
4089
4029
|
if (entry.type === 'assistant') {
|
|
4090
4030
|
const toolCalls = (Array.isArray(content) ? content : [])
|
|
4091
4031
|
.filter((c: any) => c?.type === 'tool_use')
|
|
@@ -4103,16 +4043,39 @@ async function main() {
|
|
|
4103
4043
|
|
|
4104
4044
|
if (messages.length === 0) { outputEmpty(); return; }
|
|
4105
4045
|
|
|
4106
|
-
|
|
4107
|
-
|
|
4108
|
-
|
|
4109
|
-
}
|
|
4110
|
-
|
|
4111
|
-
|
|
4112
|
-
|
|
4113
|
-
|
|
4114
|
-
|
|
4115
|
-
|
|
4046
|
+
// Local persist \u2014 always. The conversation timeline lives in PGLite.
|
|
4047
|
+
const mcpPort = process.env.SYNKRO_MCP_PORT || '18931';
|
|
4048
|
+
let mcpToken = '';
|
|
4049
|
+
try { mcpToken = readFileSync(join(homedir(), '.synkro', '.mcp-jwt'), 'utf-8').trim(); } catch {}
|
|
4050
|
+
if (mcpToken) {
|
|
4051
|
+
fetch('http://127.0.0.1:' + mcpPort + '/api/conversation-sync', {
|
|
4052
|
+
method: 'POST',
|
|
4053
|
+
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + mcpToken },
|
|
4054
|
+
body: JSON.stringify({ session_id: sessionId, repo: gitRepo || '', messages }),
|
|
4055
|
+
signal: AbortSignal.timeout(5000),
|
|
4056
|
+
}).catch(() => {});
|
|
4057
|
+
}
|
|
4058
|
+
|
|
4059
|
+
// Cloud sync \u2014 only when consented and the org isn't local-only.
|
|
4060
|
+
if (cloudConsent && gitRepo) {
|
|
4061
|
+
let captureDepth = 'local_only';
|
|
4062
|
+
try {
|
|
4063
|
+
const r = await fetch(GATEWAY_URL + '/api/v1/hook/config', {
|
|
4064
|
+
headers: { Authorization: 'Bearer ' + jwt },
|
|
4065
|
+
signal: AbortSignal.timeout(3000),
|
|
4066
|
+
});
|
|
4067
|
+
const data = await r.json() as any;
|
|
4068
|
+
captureDepth = data.capture_depth || 'local_only';
|
|
4069
|
+
} catch {}
|
|
4070
|
+
if (captureDepth !== 'local_only') {
|
|
4071
|
+
fetch(GATEWAY_URL + '/api/v1/cli/sync-transcripts', {
|
|
4072
|
+
method: 'POST',
|
|
4073
|
+
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + jwt },
|
|
4074
|
+
body: JSON.stringify({ repo: gitRepo, sessions: [{ cc_session_id: sessionId, messages }] }),
|
|
4075
|
+
signal: AbortSignal.timeout(10000),
|
|
4076
|
+
}).catch(() => {});
|
|
4077
|
+
}
|
|
4078
|
+
}
|
|
4116
4079
|
|
|
4117
4080
|
outputEmpty();
|
|
4118
4081
|
} catch {
|
|
@@ -4295,7 +4258,7 @@ async function main() {
|
|
|
4295
4258
|
appendLocalTelemetry({
|
|
4296
4259
|
capture_type: 'local_verdict', verdict: 'pass', hook_type: 'bash',
|
|
4297
4260
|
category: 'safe_read', tool_name: toolName, command: command.slice(0, 200),
|
|
4298
|
-
session_id: sessionId, repo: cwd,
|
|
4261
|
+
session_id: sessionId, repo: repo || cwd,
|
|
4299
4262
|
cc_model: model,
|
|
4300
4263
|
reasoning: 'Safe in-repo read \u2014 auto-allowed without an LLM grade.',
|
|
4301
4264
|
});
|
|
@@ -4316,6 +4279,10 @@ async function main() {
|
|
|
4316
4279
|
const tagStr = tag(rt, config);
|
|
4317
4280
|
|
|
4318
4281
|
// Install protection \u2014 scan packages before any npm/pip/cargo/etc. install.
|
|
4282
|
+
// A block is handed to the grader as an authoritative concern so the
|
|
4283
|
+
// normal ask + consent-carryover flow can let the user override it.
|
|
4284
|
+
let scanConcern = '';
|
|
4285
|
+
let scanBlockContext = '';
|
|
4319
4286
|
if (SHELL_TOOL_NAMES.has(toolName)) {
|
|
4320
4287
|
const scan = await runInstallScan(command, jwt);
|
|
4321
4288
|
if (scan.action === 'block') {
|
|
@@ -4332,11 +4299,10 @@ async function main() {
|
|
|
4332
4299
|
command, reasoning: scan.blockContext.slice(0, 200),
|
|
4333
4300
|
violatedRules: scan.violatedIds, ccModel: model,
|
|
4334
4301
|
});
|
|
4335
|
-
|
|
4336
|
-
|
|
4337
|
-
|
|
4338
|
-
|
|
4339
|
-
});
|
|
4302
|
+
scanBlockContext = scan.blockContext;
|
|
4303
|
+
scanConcern = 'PACKAGE SCANNER FLAG (authoritative \u2014 do NOT re-evaluate whether the vulnerability is real): '
|
|
4304
|
+
+ scan.blockContext
|
|
4305
|
+
+ ' For this concern you MUST return ok=false with rule_id "SYNKRO_PKGSCAN", rule_mode "ask", and the reason above \u2014 UNLESS the user has explicitly consented in this conversation to installing this despite the warning, in which case return ok=true.';
|
|
4340
4306
|
} else if (scan.scanned && scan.action === 'warn') {
|
|
4341
4307
|
log('bashGuard installScan warn: ' + scan.summary);
|
|
4342
4308
|
}
|
|
@@ -4354,6 +4320,7 @@ async function main() {
|
|
|
4354
4320
|
'User intent (last human message): ' + (transcript.userIntent || lastPrompt || 'none stated'),
|
|
4355
4321
|
'Last user prompt: ' + (lastPrompt || 'none'),
|
|
4356
4322
|
'Org rules: ' + JSON.stringify(relevantRules),
|
|
4323
|
+
scanConcern,
|
|
4357
4324
|
'IMPORTANT: If a rule is violated, ALWAYS return ok=false with the rule_id and reason, regardless of the rule mode. Do NOT pass a command just because the rule mode is "fix". The enforcement layer handles ask vs fix \u2014 your job is only to detect violations.',
|
|
4358
4325
|
'The rules shown were pre-selected as the ones relevant to this command \u2014 every rule here IS relevant, do not label any "not relevant". When passing (ok=true), give a terse, specific reason each rule passes. Format: "R003: no secrets in grep args. R005: in-repo path only." Cover every rule shown.',
|
|
4359
4326
|
'Rules with preconditions (e.g. "run X before Y") are CONSUMED after the protected action completes. Use the session history timestamps to determine ordering: a precondition satisfied before the last occurrence of the protected action does NOT satisfy the next occurrence. Each new protected action needs its precondition re-satisfied.',
|
|
@@ -4364,6 +4331,15 @@ async function main() {
|
|
|
4364
4331
|
gradeResp = await localGrade('bash', graderPrompt, CURSOR_GRADE_TIMEOUT_MS, 'cursor');
|
|
4365
4332
|
} catch (e) {
|
|
4366
4333
|
logGraderUnavailable('bashGuard', command.slice(0, 200), (e as Error).message || String(e));
|
|
4334
|
+
if (scanConcern) {
|
|
4335
|
+
// Grader unavailable to run the consent check \u2014 fail closed on a
|
|
4336
|
+
// scanner-flagged install (ask-mode so the user can still consent).
|
|
4337
|
+
finishWith({
|
|
4338
|
+
permission: 'deny',
|
|
4339
|
+
user_message: tagStr + ' installScan \u2192 blocked (grader unavailable): ' + cmdShort,
|
|
4340
|
+
agent_message: 'Synkro flagged this install: ' + scanBlockContext + ' The grader is unavailable to check consent \u2014 ask the user for explicit consent, then retry.',
|
|
4341
|
+
});
|
|
4342
|
+
}
|
|
4367
4343
|
log('bashGuard ' + cmdShort + ' \u2192 pass (grade unavailable): ' + String(e));
|
|
4368
4344
|
finishWith({ permission: 'allow' });
|
|
4369
4345
|
}
|
|
@@ -4402,6 +4378,17 @@ async function main() {
|
|
|
4402
4378
|
finishWith({ permission: 'allow' });
|
|
4403
4379
|
}
|
|
4404
4380
|
|
|
4381
|
+
if (scanConcern) {
|
|
4382
|
+
// Cloud grading does not carry the package-scanner concern through the
|
|
4383
|
+
// consent flow \u2014 block here with ask-mode messaging so the user can
|
|
4384
|
+
// consent and retry.
|
|
4385
|
+
finishWith({
|
|
4386
|
+
permission: 'deny',
|
|
4387
|
+
user_message: tagStr + ' installScan \u2192 blocked: ' + cmdShort,
|
|
4388
|
+
agent_message: 'Synkro flagged this install: ' + scanBlockContext + ' Ask the user for explicit consent before retrying.',
|
|
4389
|
+
});
|
|
4390
|
+
}
|
|
4391
|
+
|
|
4405
4392
|
const body: Record<string, any> = {
|
|
4406
4393
|
hook_event: 'PreToolUse',
|
|
4407
4394
|
tool_name: toolName || 'Bash',
|
|
@@ -5698,6 +5685,7 @@ __export(macKeychain_exports, {
|
|
|
5698
5685
|
readKeychainCreds: () => readKeychainCreds,
|
|
5699
5686
|
refreshCreds: () => refreshCreds,
|
|
5700
5687
|
uninstallRefreshAgent: () => uninstallRefreshAgent,
|
|
5688
|
+
validateCursorApiKey: () => validateCursorApiKey,
|
|
5701
5689
|
writeCursorApiKey: () => writeCursorApiKey,
|
|
5702
5690
|
writeRefreshAgent: () => writeRefreshAgent
|
|
5703
5691
|
});
|
|
@@ -5742,6 +5730,27 @@ function writeCursorApiKey(key) {
|
|
|
5742
5730
|
writeFileSync6(CURSOR_API_KEY_FILE, trimmed, "utf-8");
|
|
5743
5731
|
chmodSync(CURSOR_API_KEY_FILE, 384);
|
|
5744
5732
|
}
|
|
5733
|
+
async function validateCursorApiKey() {
|
|
5734
|
+
let key;
|
|
5735
|
+
try {
|
|
5736
|
+
key = readFileSync6(CURSOR_API_KEY_FILE, "utf-8").trim();
|
|
5737
|
+
} catch {
|
|
5738
|
+
return null;
|
|
5739
|
+
}
|
|
5740
|
+
if (!key) return null;
|
|
5741
|
+
try {
|
|
5742
|
+
const auth = Buffer.from(`${key}:`).toString("base64");
|
|
5743
|
+
const r = await fetch("https://api.cursor.com/v1/me", {
|
|
5744
|
+
headers: { Authorization: `Basic ${auth}` },
|
|
5745
|
+
signal: AbortSignal.timeout(8e3)
|
|
5746
|
+
});
|
|
5747
|
+
if (r.status === 401 || r.status === 403) return false;
|
|
5748
|
+
if (r.ok) return true;
|
|
5749
|
+
return null;
|
|
5750
|
+
} catch {
|
|
5751
|
+
return null;
|
|
5752
|
+
}
|
|
5753
|
+
}
|
|
5745
5754
|
function credsAreStale() {
|
|
5746
5755
|
if (!existsSync7(CLAUDE_CREDS_FILE)) return true;
|
|
5747
5756
|
try {
|
|
@@ -5756,7 +5765,7 @@ function writeRefreshAgent(synkroBinPath) {
|
|
|
5756
5765
|
throw new KeychainExportError("writeRefreshAgent is darwin-only");
|
|
5757
5766
|
}
|
|
5758
5767
|
mkdirSync6(join6(homedir6(), "Library", "LaunchAgents"), { recursive: true });
|
|
5759
|
-
const shellCmd = `export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH" && "${synkroBinPath}" local-cc refresh-creds`;
|
|
5768
|
+
const shellCmd = `export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH" && { "${synkroBinPath}" local-cc refresh-creds || synkro local-cc refresh-creds; }`;
|
|
5760
5769
|
const plist = `<?xml version="1.0" encoding="UTF-8"?>
|
|
5761
5770
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
5762
5771
|
<plist version="1.0">
|
|
@@ -5946,6 +5955,11 @@ function claudeCredsHostDir() {
|
|
|
5946
5955
|
if (needsKeychainBridge()) return CLAUDE_CREDS_DIR;
|
|
5947
5956
|
return join7(homedir7(), ".claude");
|
|
5948
5957
|
}
|
|
5958
|
+
function resolveSynkroBin() {
|
|
5959
|
+
const which2 = spawnSync2("which", ["synkro"], { encoding: "utf-8", timeout: 5e3 });
|
|
5960
|
+
const resolved = (which2.stdout || "").split("\n")[0].trim();
|
|
5961
|
+
return resolved || "synkro";
|
|
5962
|
+
}
|
|
5949
5963
|
async function dockerInstall(opts = {}) {
|
|
5950
5964
|
assertDockerAvailable();
|
|
5951
5965
|
const image = imageTag();
|
|
@@ -5979,7 +5993,7 @@ async function dockerInstall(opts = {}) {
|
|
|
5979
5993
|
console.warn(" Generate a key at cursor.com \u2192 Settings \u2192 API Keys, then:");
|
|
5980
5994
|
console.warn(` echo 'YOUR_KEY' > ~/.synkro/cursor-creds/api-key && chmod 600 ~/.synkro/cursor-creds/api-key`);
|
|
5981
5995
|
}
|
|
5982
|
-
const plist = writeRefreshAgent(
|
|
5996
|
+
const plist = writeRefreshAgent(resolveSynkroBin());
|
|
5983
5997
|
try {
|
|
5984
5998
|
loadRefreshAgent();
|
|
5985
5999
|
} catch (err) {
|
|
@@ -6048,6 +6062,12 @@ async function dockerInstall(opts = {}) {
|
|
|
6048
6062
|
...process.env.SYNKRO_CURSOR_MODEL ? ["-e", `SYNKRO_CURSOR_MODEL=${process.env.SYNKRO_CURSOR_MODEL}`] : [],
|
|
6049
6063
|
// Connected repo — the server seeds the local app + ruleset named after it.
|
|
6050
6064
|
...opts.connectedRepo ? ["-e", `SYNKRO_CONNECTED_REPO=${opts.connectedRepo}`] : [],
|
|
6065
|
+
// Real account identity (from config.env via process.env) — telemetry is
|
|
6066
|
+
// stamped with these instead of a `local-user` placeholder, so the data is
|
|
6067
|
+
// correctly attributed from the first graded command.
|
|
6068
|
+
...process.env.SYNKRO_ORG_ID ? ["-e", `SYNKRO_ORG_ID=${process.env.SYNKRO_ORG_ID}`] : [],
|
|
6069
|
+
...process.env.SYNKRO_USER_ID ? ["-e", `SYNKRO_USER_ID=${process.env.SYNKRO_USER_ID}`] : [],
|
|
6070
|
+
...process.env.SYNKRO_EMAIL ? ["-e", `SYNKRO_EMAIL=${process.env.SYNKRO_EMAIL}`] : [],
|
|
6051
6071
|
image
|
|
6052
6072
|
];
|
|
6053
6073
|
const run = spawnSync2("docker", args2, { encoding: "utf-8", stdio: "inherit", timeout: 6e4 });
|
|
@@ -6270,6 +6290,7 @@ var init_dockerInstall = __esm({
|
|
|
6270
6290
|
// cli/commands/install.ts
|
|
6271
6291
|
var install_exports = {};
|
|
6272
6292
|
__export(install_exports, {
|
|
6293
|
+
detectGitRepo: () => detectGitRepo2,
|
|
6273
6294
|
installCommand: () => installCommand,
|
|
6274
6295
|
parseArgs: () => parseArgs
|
|
6275
6296
|
});
|
|
@@ -6324,8 +6345,16 @@ async function promptAgentSelection(detected) {
|
|
|
6324
6345
|
return ask2();
|
|
6325
6346
|
}
|
|
6326
6347
|
async function promptCursorApiKey(opts) {
|
|
6327
|
-
const { cursorApiKeyConfigured: cursorApiKeyConfigured2, writeCursorApiKey: writeCursorApiKey2 } = await Promise.resolve().then(() => (init_macKeychain(), macKeychain_exports));
|
|
6328
|
-
if (cursorApiKeyConfigured2())
|
|
6348
|
+
const { cursorApiKeyConfigured: cursorApiKeyConfigured2, writeCursorApiKey: writeCursorApiKey2, validateCursorApiKey: validateCursorApiKey2 } = await Promise.resolve().then(() => (init_macKeychain(), macKeychain_exports));
|
|
6349
|
+
if (cursorApiKeyConfigured2()) {
|
|
6350
|
+
const valid = await validateCursorApiKey2();
|
|
6351
|
+
if (valid !== false) {
|
|
6352
|
+
console.log(" \u2713 Cursor API key validated.");
|
|
6353
|
+
return;
|
|
6354
|
+
}
|
|
6355
|
+
console.warn(" \u26A0 The saved Cursor API key was rejected by Cursor (revoked or expired).");
|
|
6356
|
+
console.warn(" Paste a fresh key below, or press Enter to skip (Cursor workers stay idle).");
|
|
6357
|
+
}
|
|
6329
6358
|
const provided = (opts.cursorApiKey || process.env.SYNKRO_CURSOR_API_KEY || "").trim();
|
|
6330
6359
|
if (provided) {
|
|
6331
6360
|
writeCursorApiKey2(provided);
|
|
@@ -6450,7 +6479,7 @@ function writeConfigEnv(opts) {
|
|
|
6450
6479
|
`SYNKRO_CREDENTIALS_PATH=${shellQuoteSingle(credsPath)}`,
|
|
6451
6480
|
`SYNKRO_TIER=${shellQuoteSingle(safeTier)}`,
|
|
6452
6481
|
`SYNKRO_INFERENCE=${shellQuoteSingle(safeInference)}`,
|
|
6453
|
-
`SYNKRO_VERSION=${shellQuoteSingle("1.6.
|
|
6482
|
+
`SYNKRO_VERSION=${shellQuoteSingle("1.6.10")}`
|
|
6454
6483
|
];
|
|
6455
6484
|
if (safeSynkroBin) lines.push(`SYNKRO_CLI_BIN=${shellQuoteSingle(safeSynkroBin)}`);
|
|
6456
6485
|
if (safeUserId) lines.push(`SYNKRO_USER_ID=${shellQuoteSingle(safeUserId)}`);
|
|
@@ -6577,6 +6606,11 @@ function assertGatewayAllowed(gatewayUrl) {
|
|
|
6577
6606
|
}
|
|
6578
6607
|
}
|
|
6579
6608
|
async function installCommand(opts = {}) {
|
|
6609
|
+
if (!detectGitRepo2()) {
|
|
6610
|
+
console.error("Synkro must be installed inside a git repository.");
|
|
6611
|
+
console.error(" `cd` into your project \u2014 a git repo with an `origin` remote \u2014 and re-run `synkro install`.");
|
|
6612
|
+
process.exit(1);
|
|
6613
|
+
}
|
|
6580
6614
|
const gatewayUrl = opts.gatewayUrl || sanitizeGatewayCandidate(process.env.SYNKRO_GATEWAY_URL) || "https://api.synkro.sh";
|
|
6581
6615
|
try {
|
|
6582
6616
|
assertGatewayAllowed(gatewayUrl);
|
|
@@ -6916,15 +6950,19 @@ async function installCommand(opts = {}) {
|
|
|
6916
6950
|
console.log("\u2713 Synkro installed.");
|
|
6917
6951
|
}
|
|
6918
6952
|
function detectGitRepo2() {
|
|
6919
|
-
|
|
6920
|
-
|
|
6921
|
-
|
|
6922
|
-
|
|
6923
|
-
|
|
6924
|
-
|
|
6925
|
-
}
|
|
6926
|
-
|
|
6953
|
+
const run = (cmd2) => {
|
|
6954
|
+
try {
|
|
6955
|
+
return execSync5(cmd2, { encoding: "utf-8", timeout: 5e3, stdio: ["pipe", "pipe", "pipe"] }).trim();
|
|
6956
|
+
} catch {
|
|
6957
|
+
return "";
|
|
6958
|
+
}
|
|
6959
|
+
};
|
|
6960
|
+
const remoteUrl = run("git remote get-url origin");
|
|
6961
|
+
if (remoteUrl) {
|
|
6962
|
+
return remoteUrl.replace(/^git@[^:]+:/, "").replace(/^https?:\/\/[^/]+\//, "").replace(/\.git$/, "");
|
|
6927
6963
|
}
|
|
6964
|
+
const root = run("git rev-parse --show-toplevel");
|
|
6965
|
+
return root ? root.split("/").pop() || null : null;
|
|
6928
6966
|
}
|
|
6929
6967
|
function getClaudeProjectsFolder() {
|
|
6930
6968
|
const cwd = process.cwd();
|
|
@@ -7181,6 +7219,37 @@ import { existsSync as existsSync10, mkdirSync as mkdirSync9, writeFileSync as w
|
|
|
7181
7219
|
import { join as join9 } from "path";
|
|
7182
7220
|
import { homedir as homedir9 } from "os";
|
|
7183
7221
|
import { spawnSync as spawnSync3 } from "child_process";
|
|
7222
|
+
function writePluginFiles() {
|
|
7223
|
+
for (const c of CHANNELS) {
|
|
7224
|
+
mkdirSync9(c.sessionDir, { recursive: true });
|
|
7225
|
+
mkdirSync9(c.pluginSettingsDir, { recursive: true });
|
|
7226
|
+
writeFileSync8(c.pluginPkgPath, PLUGIN_PACKAGE_JSON, "utf-8");
|
|
7227
|
+
writeFileSync8(
|
|
7228
|
+
c.pluginSettingsPath,
|
|
7229
|
+
JSON.stringify({
|
|
7230
|
+
fastMode: true,
|
|
7231
|
+
enabledMcpjsonServers: ["synkro-local"]
|
|
7232
|
+
}, null, 2) + "\n",
|
|
7233
|
+
"utf-8"
|
|
7234
|
+
);
|
|
7235
|
+
writeFileSync8(c.runScriptPath, c.runScriptSource, "utf-8");
|
|
7236
|
+
chmodSync3(c.runScriptPath, 493);
|
|
7237
|
+
}
|
|
7238
|
+
}
|
|
7239
|
+
function runBunInstall() {
|
|
7240
|
+
for (const c of CHANNELS) {
|
|
7241
|
+
const r = spawnSync3("bun", ["install", "--silent"], {
|
|
7242
|
+
cwd: c.sessionDir,
|
|
7243
|
+
encoding: "utf-8",
|
|
7244
|
+
timeout: 12e4
|
|
7245
|
+
});
|
|
7246
|
+
if (r.status !== 0) {
|
|
7247
|
+
throw new LocalCCInstallError(
|
|
7248
|
+
`bun install failed in ${c.sessionDir}: ${r.stderr || r.stdout || "unknown"}`
|
|
7249
|
+
);
|
|
7250
|
+
}
|
|
7251
|
+
}
|
|
7252
|
+
}
|
|
7184
7253
|
function safelyMutateClaudeJson(mutator) {
|
|
7185
7254
|
if (!existsSync10(CLAUDE_JSON_PATH)) {
|
|
7186
7255
|
return;
|
|
@@ -7240,6 +7309,73 @@ function safelyMutateClaudeJson(mutator) {
|
|
|
7240
7309
|
);
|
|
7241
7310
|
}
|
|
7242
7311
|
}
|
|
7312
|
+
function writeProjectMcpJson() {
|
|
7313
|
+
for (const c of CHANNELS) {
|
|
7314
|
+
const mcp = {
|
|
7315
|
+
mcpServers: {
|
|
7316
|
+
[MCP_SERVER_NAME]: {
|
|
7317
|
+
command: "bun",
|
|
7318
|
+
args: [c.pluginPath]
|
|
7319
|
+
}
|
|
7320
|
+
}
|
|
7321
|
+
};
|
|
7322
|
+
writeFileSync8(c.projectMcpPath, JSON.stringify(mcp, null, 2) + "\n", "utf-8");
|
|
7323
|
+
}
|
|
7324
|
+
}
|
|
7325
|
+
function patchClaudeJson() {
|
|
7326
|
+
safelyMutateClaudeJson((parsed) => {
|
|
7327
|
+
let dirty = false;
|
|
7328
|
+
if (parsed.mcpServers && typeof parsed.mcpServers === "object" && parsed.mcpServers[MCP_SERVER_NAME]) {
|
|
7329
|
+
delete parsed.mcpServers[MCP_SERVER_NAME];
|
|
7330
|
+
dirty = true;
|
|
7331
|
+
}
|
|
7332
|
+
if (!parsed.projects || typeof parsed.projects !== "object") {
|
|
7333
|
+
parsed.projects = {};
|
|
7334
|
+
}
|
|
7335
|
+
const projects = parsed.projects;
|
|
7336
|
+
for (const dir of CHANNELS.map((c) => c.sessionDir)) {
|
|
7337
|
+
const existing = projects[dir] && typeof projects[dir] === "object" ? projects[dir] : {};
|
|
7338
|
+
const wantEnabled = Array.from(/* @__PURE__ */ new Set([
|
|
7339
|
+
...existing.enabledMcpjsonServers ?? [],
|
|
7340
|
+
MCP_SERVER_NAME
|
|
7341
|
+
]));
|
|
7342
|
+
const next = {
|
|
7343
|
+
...existing,
|
|
7344
|
+
hasTrustDialogAccepted: true,
|
|
7345
|
+
hasCompletedProjectOnboarding: true,
|
|
7346
|
+
enabledMcpjsonServers: wantEnabled
|
|
7347
|
+
};
|
|
7348
|
+
if (existing.hasTrustDialogAccepted !== true || existing.hasCompletedProjectOnboarding !== true || JSON.stringify(existing.enabledMcpjsonServers ?? []) !== JSON.stringify(wantEnabled)) {
|
|
7349
|
+
projects[dir] = next;
|
|
7350
|
+
dirty = true;
|
|
7351
|
+
}
|
|
7352
|
+
}
|
|
7353
|
+
return dirty;
|
|
7354
|
+
});
|
|
7355
|
+
}
|
|
7356
|
+
function installLocalCC() {
|
|
7357
|
+
let bunCheck = spawnSync3("bun", ["--version"], { encoding: "utf-8" });
|
|
7358
|
+
if (bunCheck.status !== 0) {
|
|
7359
|
+
if (process.platform === "darwin") {
|
|
7360
|
+
console.log(" Installing bun via brew...");
|
|
7361
|
+
const brewR = spawnSync3("brew", ["install", "oven-sh/bun/bun"], { encoding: "utf-8", stdio: "inherit", timeout: 12e4 });
|
|
7362
|
+
if (brewR.status !== 0) {
|
|
7363
|
+
throw new LocalCCInstallError("bun auto-install failed. Install manually: curl -fsSL https://bun.sh/install | bash");
|
|
7364
|
+
}
|
|
7365
|
+
bunCheck = spawnSync3("bun", ["--version"], { encoding: "utf-8" });
|
|
7366
|
+
if (bunCheck.status !== 0) {
|
|
7367
|
+
throw new LocalCCInstallError("bun installed but not found on PATH. Restart your terminal and re-run install.");
|
|
7368
|
+
}
|
|
7369
|
+
} else {
|
|
7370
|
+
throw new LocalCCInstallError("bun is required. Install it: curl -fsSL https://bun.sh/install | bash");
|
|
7371
|
+
}
|
|
7372
|
+
}
|
|
7373
|
+
writePluginFiles();
|
|
7374
|
+
runBunInstall();
|
|
7375
|
+
writeProjectMcpJson();
|
|
7376
|
+
patchClaudeJson();
|
|
7377
|
+
return { sessionDir: SESSION_DIR, pluginPath: PLUGIN_PATH };
|
|
7378
|
+
}
|
|
7243
7379
|
function uninstallLocalCC() {
|
|
7244
7380
|
safelyMutateClaudeJson((parsed) => {
|
|
7245
7381
|
let dirty = false;
|
|
@@ -7725,6 +7861,86 @@ function appendTurn(args2) {
|
|
|
7725
7861
|
} catch {
|
|
7726
7862
|
}
|
|
7727
7863
|
}
|
|
7864
|
+
function readRecentTurns(n = 20) {
|
|
7865
|
+
if (!existsSync12(TURN_LOG_PATH)) return [];
|
|
7866
|
+
try {
|
|
7867
|
+
const size = statSync2(TURN_LOG_PATH).size;
|
|
7868
|
+
if (size === 0) return [];
|
|
7869
|
+
const text = readFileSync9(TURN_LOG_PATH, "utf-8");
|
|
7870
|
+
const lines = text.split("\n").filter(Boolean);
|
|
7871
|
+
const lastN = lines.slice(-n).reverse();
|
|
7872
|
+
return lastN.map((line) => {
|
|
7873
|
+
try {
|
|
7874
|
+
return JSON.parse(line);
|
|
7875
|
+
} catch {
|
|
7876
|
+
return null;
|
|
7877
|
+
}
|
|
7878
|
+
}).filter((x) => x !== null);
|
|
7879
|
+
} catch {
|
|
7880
|
+
return [];
|
|
7881
|
+
}
|
|
7882
|
+
}
|
|
7883
|
+
function followTurns(onEntry) {
|
|
7884
|
+
try {
|
|
7885
|
+
mkdirSync10(dirname5(TURN_LOG_PATH), { recursive: true });
|
|
7886
|
+
if (!existsSync12(TURN_LOG_PATH)) {
|
|
7887
|
+
appendFileSync(TURN_LOG_PATH, "", "utf-8");
|
|
7888
|
+
}
|
|
7889
|
+
} catch {
|
|
7890
|
+
}
|
|
7891
|
+
let lastSize = (() => {
|
|
7892
|
+
try {
|
|
7893
|
+
return statSync2(TURN_LOG_PATH).size;
|
|
7894
|
+
} catch {
|
|
7895
|
+
return 0;
|
|
7896
|
+
}
|
|
7897
|
+
})();
|
|
7898
|
+
let pendingPartial = "";
|
|
7899
|
+
const drainNewBytes = (from, to) => {
|
|
7900
|
+
if (to <= from) return;
|
|
7901
|
+
let fd = null;
|
|
7902
|
+
try {
|
|
7903
|
+
fd = openSync2(TURN_LOG_PATH, "r");
|
|
7904
|
+
const len = to - from;
|
|
7905
|
+
const buf = Buffer.alloc(len);
|
|
7906
|
+
readSync(fd, buf, 0, len, from);
|
|
7907
|
+
const text = pendingPartial + buf.toString("utf-8");
|
|
7908
|
+
const lastNewline = text.lastIndexOf("\n");
|
|
7909
|
+
if (lastNewline === -1) {
|
|
7910
|
+
pendingPartial = text;
|
|
7911
|
+
return;
|
|
7912
|
+
}
|
|
7913
|
+
const complete = text.slice(0, lastNewline);
|
|
7914
|
+
pendingPartial = text.slice(lastNewline + 1);
|
|
7915
|
+
for (const line of complete.split("\n")) {
|
|
7916
|
+
if (!line) continue;
|
|
7917
|
+
try {
|
|
7918
|
+
onEntry(JSON.parse(line));
|
|
7919
|
+
} catch {
|
|
7920
|
+
}
|
|
7921
|
+
}
|
|
7922
|
+
} catch {
|
|
7923
|
+
} finally {
|
|
7924
|
+
if (fd !== null) {
|
|
7925
|
+
try {
|
|
7926
|
+
closeSync2(fd);
|
|
7927
|
+
} catch {
|
|
7928
|
+
}
|
|
7929
|
+
}
|
|
7930
|
+
}
|
|
7931
|
+
};
|
|
7932
|
+
watchFile(TURN_LOG_PATH, { interval: 250 }, (curr, prev) => {
|
|
7933
|
+
if (curr.size < lastSize) {
|
|
7934
|
+
lastSize = 0;
|
|
7935
|
+
pendingPartial = "";
|
|
7936
|
+
}
|
|
7937
|
+
if (curr.size > lastSize) {
|
|
7938
|
+
drainNewBytes(lastSize, curr.size);
|
|
7939
|
+
lastSize = curr.size;
|
|
7940
|
+
}
|
|
7941
|
+
});
|
|
7942
|
+
return () => unwatchFile(TURN_LOG_PATH);
|
|
7943
|
+
}
|
|
7728
7944
|
var TURN_LOG_PATH, PREVIEW_MAX;
|
|
7729
7945
|
var init_turnLog = __esm({
|
|
7730
7946
|
"cli/local-cc/turnLog.ts"() {
|
|
@@ -7794,6 +8010,21 @@ async function submitToChannel(role, payload, opts = {}) {
|
|
|
7794
8010
|
throw err;
|
|
7795
8011
|
}
|
|
7796
8012
|
}
|
|
8013
|
+
function isChannelAvailable(port = CHANNEL_PORT, timeoutMs = 500) {
|
|
8014
|
+
return new Promise((resolve3) => {
|
|
8015
|
+
const sock = connect(port, CHANNEL_HOST);
|
|
8016
|
+
const done = (ok) => {
|
|
8017
|
+
try {
|
|
8018
|
+
sock.destroy();
|
|
8019
|
+
} catch {
|
|
8020
|
+
}
|
|
8021
|
+
resolve3(ok);
|
|
8022
|
+
};
|
|
8023
|
+
sock.once("connect", () => done(true));
|
|
8024
|
+
sock.once("error", () => done(false));
|
|
8025
|
+
sock.setTimeout(timeoutMs, () => done(false));
|
|
8026
|
+
});
|
|
8027
|
+
}
|
|
7797
8028
|
var CHANNEL_HOST, CHANNEL_PORT, DEFAULT_TIMEOUT_MS, LocalCCError;
|
|
7798
8029
|
var init_client = __esm({
|
|
7799
8030
|
"cli/local-cc/client.ts"() {
|
|
@@ -7862,6 +8093,795 @@ var init_grade = __esm({
|
|
|
7862
8093
|
}
|
|
7863
8094
|
});
|
|
7864
8095
|
|
|
8096
|
+
// cli/local-cc/pueue.ts
|
|
8097
|
+
import { execFileSync, spawnSync as spawnSync5, spawn } from "child_process";
|
|
8098
|
+
import { homedir as homedir12 } from "os";
|
|
8099
|
+
import { join as join12 } from "path";
|
|
8100
|
+
import { connect as connect2 } from "net";
|
|
8101
|
+
function pueueAvailable() {
|
|
8102
|
+
const r = spawnSync5("pueue", ["--version"], { encoding: "utf-8" });
|
|
8103
|
+
if (r.status !== 0) {
|
|
8104
|
+
throw new PueueError("pueue CLI not found on PATH. Install pueue (https://github.com/Nukesor/pueue) and start `pueued`.");
|
|
8105
|
+
}
|
|
8106
|
+
}
|
|
8107
|
+
function statusJson() {
|
|
8108
|
+
pueueAvailable();
|
|
8109
|
+
const r = spawnSync5("pueue", ["status", "--json"], { encoding: "utf-8" });
|
|
8110
|
+
if (r.status !== 0) {
|
|
8111
|
+
throw new PueueError(`pueue status failed: ${r.stderr || r.stdout || "unknown error"} \u2014 is pueued running?`);
|
|
8112
|
+
}
|
|
8113
|
+
try {
|
|
8114
|
+
return JSON.parse(r.stdout);
|
|
8115
|
+
} catch (err) {
|
|
8116
|
+
throw new PueueError(`pueue status returned non-JSON output: ${r.stdout.slice(0, 200)}`, err);
|
|
8117
|
+
}
|
|
8118
|
+
}
|
|
8119
|
+
function statusName(s) {
|
|
8120
|
+
if (typeof s === "string") return s;
|
|
8121
|
+
if (s && typeof s === "object") {
|
|
8122
|
+
if ("Running" in s) return "Running";
|
|
8123
|
+
if ("Done" in s) {
|
|
8124
|
+
const result = s.Done?.result;
|
|
8125
|
+
if (typeof result === "string") return `Done (${result})`;
|
|
8126
|
+
if (result && typeof result === "object") return `Done (${Object.keys(result)[0] ?? "unknown"})`;
|
|
8127
|
+
return "Done";
|
|
8128
|
+
}
|
|
8129
|
+
return Object.keys(s)[0] ?? "unknown";
|
|
8130
|
+
}
|
|
8131
|
+
return "unknown";
|
|
8132
|
+
}
|
|
8133
|
+
function findTask(channel = CHANNEL_PRIMARY) {
|
|
8134
|
+
const data = statusJson();
|
|
8135
|
+
for (const [id, t] of Object.entries(data.tasks)) {
|
|
8136
|
+
if (t.label === channel.taskLabel) {
|
|
8137
|
+
return {
|
|
8138
|
+
id: Number(id),
|
|
8139
|
+
label: t.label,
|
|
8140
|
+
status: statusName(t.status),
|
|
8141
|
+
command: t.command,
|
|
8142
|
+
cwd: t.path
|
|
8143
|
+
};
|
|
8144
|
+
}
|
|
8145
|
+
}
|
|
8146
|
+
return null;
|
|
8147
|
+
}
|
|
8148
|
+
function startTask(opts = {}) {
|
|
8149
|
+
const ch = opts.channel ?? CHANNEL_PRIMARY;
|
|
8150
|
+
const cwd = opts.cwd ?? ch.sessionDir;
|
|
8151
|
+
let existing = findTask(ch);
|
|
8152
|
+
while (existing) {
|
|
8153
|
+
if (existing.status === "Running" || existing.status === "Queued") {
|
|
8154
|
+
spawnSync5("tmux", ["kill-session", "-t", `=${ch.tmuxSession}`], { encoding: "utf-8" });
|
|
8155
|
+
spawnSync5("pueue", ["kill", String(existing.id)], { encoding: "utf-8" });
|
|
8156
|
+
for (let i = 0; i < 10; i++) {
|
|
8157
|
+
const check = findTask(ch);
|
|
8158
|
+
if (!check || check.id !== existing.id || check.status !== "Running" && check.status !== "Queued") break;
|
|
8159
|
+
spawnSync5("sleep", ["0.5"], { encoding: "utf-8" });
|
|
8160
|
+
}
|
|
8161
|
+
}
|
|
8162
|
+
spawnSync5("pueue", ["remove", String(existing.id)], { encoding: "utf-8" });
|
|
8163
|
+
existing = findTask(ch);
|
|
8164
|
+
}
|
|
8165
|
+
const runScript = join12(cwd, "run-claude.sh");
|
|
8166
|
+
const args2 = [
|
|
8167
|
+
"add",
|
|
8168
|
+
"--label",
|
|
8169
|
+
ch.taskLabel,
|
|
8170
|
+
"--working-directory",
|
|
8171
|
+
cwd,
|
|
8172
|
+
"--",
|
|
8173
|
+
"bash",
|
|
8174
|
+
runScript
|
|
8175
|
+
];
|
|
8176
|
+
const r = spawnSync5("pueue", args2, { encoding: "utf-8" });
|
|
8177
|
+
if (r.status !== 0) {
|
|
8178
|
+
throw new PueueError(`pueue add failed: ${r.stderr || r.stdout}`);
|
|
8179
|
+
}
|
|
8180
|
+
const created = findTask(ch);
|
|
8181
|
+
if (!created) {
|
|
8182
|
+
throw new PueueError(`pueue add succeeded but no task with label ${ch.taskLabel} found`);
|
|
8183
|
+
}
|
|
8184
|
+
return created;
|
|
8185
|
+
}
|
|
8186
|
+
function stopTask(channel = CHANNEL_PRIMARY) {
|
|
8187
|
+
spawnSync5("tmux", ["kill-session", "-t", `=${channel.tmuxSession}`], { encoding: "utf-8" });
|
|
8188
|
+
let t = findTask(channel);
|
|
8189
|
+
while (t) {
|
|
8190
|
+
if (t.status === "Running" || t.status === "Queued") {
|
|
8191
|
+
spawnSync5("pueue", ["kill", String(t.id)], { encoding: "utf-8" });
|
|
8192
|
+
for (let i = 0; i < 10; i++) {
|
|
8193
|
+
const check = findTask(channel);
|
|
8194
|
+
if (!check || check.id !== t.id || check.status !== "Running" && check.status !== "Queued") break;
|
|
8195
|
+
spawnSync5("sleep", ["0.5"], { encoding: "utf-8" });
|
|
8196
|
+
}
|
|
8197
|
+
}
|
|
8198
|
+
spawnSync5("pueue", ["remove", String(t.id)], { encoding: "utf-8" });
|
|
8199
|
+
t = findTask(channel);
|
|
8200
|
+
}
|
|
8201
|
+
}
|
|
8202
|
+
function tailLogs(lines = 80, channel = CHANNEL_PRIMARY) {
|
|
8203
|
+
const t = findTask(channel);
|
|
8204
|
+
if (!t) return `(no ${channel.taskLabel} task)`;
|
|
8205
|
+
const r = spawnSync5("pueue", ["log", "--lines", String(lines), String(t.id)], { encoding: "utf-8" });
|
|
8206
|
+
return r.stdout || r.stderr || "(no output)";
|
|
8207
|
+
}
|
|
8208
|
+
function ensureRunning(opts = {}) {
|
|
8209
|
+
const ch = opts.channel ?? CHANNEL_PRIMARY;
|
|
8210
|
+
const t = findTask(ch);
|
|
8211
|
+
if (t && t.status === "Running") return t;
|
|
8212
|
+
return startTask(opts);
|
|
8213
|
+
}
|
|
8214
|
+
function probePort(host, port, timeoutMs = 500) {
|
|
8215
|
+
return new Promise((resolve3) => {
|
|
8216
|
+
const sock = connect2(port, host);
|
|
8217
|
+
const done = (ok) => {
|
|
8218
|
+
try {
|
|
8219
|
+
sock.destroy();
|
|
8220
|
+
} catch {
|
|
8221
|
+
}
|
|
8222
|
+
resolve3(ok);
|
|
8223
|
+
};
|
|
8224
|
+
sock.once("connect", () => done(true));
|
|
8225
|
+
sock.once("error", () => done(false));
|
|
8226
|
+
sock.setTimeout(timeoutMs, () => done(false));
|
|
8227
|
+
});
|
|
8228
|
+
}
|
|
8229
|
+
function tmuxDismissPrompts(tmuxSession = TMUX_SESSION) {
|
|
8230
|
+
spawnSync5("tmux", ["send-keys", "-t", tmuxSession, "1"], { encoding: "utf-8" });
|
|
8231
|
+
spawnSync5("tmux", ["send-keys", "-t", tmuxSession, "Enter"], { encoding: "utf-8" });
|
|
8232
|
+
}
|
|
8233
|
+
async function waitForChannelReady(port, timeoutMs = 6e4, host = "127.0.0.1", tmuxSession = TMUX_SESSION) {
|
|
8234
|
+
const deadline = Date.now() + timeoutMs;
|
|
8235
|
+
while (Date.now() < deadline) {
|
|
8236
|
+
if (await probePort(host, port)) return true;
|
|
8237
|
+
tmuxDismissPrompts(tmuxSession);
|
|
8238
|
+
await new Promise((r) => setTimeout(r, 1e3));
|
|
8239
|
+
}
|
|
8240
|
+
return probePort(host, port);
|
|
8241
|
+
}
|
|
8242
|
+
function brewInstall(pkg) {
|
|
8243
|
+
const brew = spawnSync5("brew", ["--version"], { encoding: "utf-8" });
|
|
8244
|
+
if (brew.status !== 0) return false;
|
|
8245
|
+
console.log(` Installing ${pkg} via brew...`);
|
|
8246
|
+
const r = spawnSync5("brew", ["install", pkg], { encoding: "utf-8", stdio: "inherit", timeout: 12e4 });
|
|
8247
|
+
return r.status === 0;
|
|
8248
|
+
}
|
|
8249
|
+
function assertPueueInstalled() {
|
|
8250
|
+
let r = spawnSync5("pueue", ["--version"], { encoding: "utf-8" });
|
|
8251
|
+
if (r.status !== 0) {
|
|
8252
|
+
if (process.platform === "darwin" && brewInstall("pueue")) {
|
|
8253
|
+
r = spawnSync5("pueue", ["--version"], { encoding: "utf-8" });
|
|
8254
|
+
if (r.status !== 0) throw new PueueError("pueue install succeeded but binary not found on PATH.");
|
|
8255
|
+
} else {
|
|
8256
|
+
throw new PueueError("pueue not found. Install it: brew install pueue (macOS) or https://github.com/Nukesor/pueue");
|
|
8257
|
+
}
|
|
8258
|
+
}
|
|
8259
|
+
const status = spawnSync5("pueue", ["status", "--json"], { encoding: "utf-8", timeout: 5e3 });
|
|
8260
|
+
if (status.status !== 0) {
|
|
8261
|
+
console.log(" Starting pueued daemon...");
|
|
8262
|
+
const child = spawn("pueued", ["-d"], { stdio: "ignore", detached: true });
|
|
8263
|
+
child.unref();
|
|
8264
|
+
spawnSync5("sleep", ["1"]);
|
|
8265
|
+
const retry = spawnSync5("pueue", ["status", "--json"], { encoding: "utf-8", timeout: 5e3 });
|
|
8266
|
+
if (retry.status !== 0) {
|
|
8267
|
+
throw new PueueError("pueue daemon not reachable after starting pueued. Check `pueued` manually.");
|
|
8268
|
+
}
|
|
8269
|
+
}
|
|
8270
|
+
spawnSync5("pueue", ["parallel", "2"], { encoding: "utf-8" });
|
|
8271
|
+
}
|
|
8272
|
+
function assertClaudeInstalled() {
|
|
8273
|
+
const r = spawnSync5("claude", ["--version"], { encoding: "utf-8" });
|
|
8274
|
+
if (r.status !== 0) {
|
|
8275
|
+
throw new PueueError("claude CLI not found on PATH. Install Claude Code first: https://docs.claude.com/claude-code");
|
|
8276
|
+
}
|
|
8277
|
+
}
|
|
8278
|
+
function assertTmuxInstalled() {
|
|
8279
|
+
let r = spawnSync5("tmux", ["-V"], { encoding: "utf-8" });
|
|
8280
|
+
if (r.status !== 0) {
|
|
8281
|
+
if (process.platform === "darwin" && brewInstall("tmux")) {
|
|
8282
|
+
r = spawnSync5("tmux", ["-V"], { encoding: "utf-8" });
|
|
8283
|
+
if (r.status !== 0) throw new PueueError("tmux install succeeded but binary not found on PATH.");
|
|
8284
|
+
} else {
|
|
8285
|
+
throw new PueueError("tmux not found. Install it: brew install tmux (macOS) or apt install tmux (Linux)");
|
|
8286
|
+
}
|
|
8287
|
+
}
|
|
8288
|
+
}
|
|
8289
|
+
var TASK_LABEL, TMUX_SESSION, SESSION_DIR2, TASK_LABEL_2, TMUX_SESSION_2, SESSION_DIR_22, SESSION_DIR_32, SESSION_DIR_42, PueueError, CHANNEL_PRIMARY, CHANNEL_SECONDARY;
|
|
8290
|
+
var init_pueue = __esm({
|
|
8291
|
+
"cli/local-cc/pueue.ts"() {
|
|
8292
|
+
"use strict";
|
|
8293
|
+
TASK_LABEL = "synkro-local-cc";
|
|
8294
|
+
TMUX_SESSION = "synkro-local-cc";
|
|
8295
|
+
SESSION_DIR2 = join12(homedir12(), ".synkro", "cc_sessions");
|
|
8296
|
+
TASK_LABEL_2 = "synkro-local-cc-2";
|
|
8297
|
+
TMUX_SESSION_2 = "synkro-local-cc-2";
|
|
8298
|
+
SESSION_DIR_22 = join12(homedir12(), ".synkro", "cc_sessions_2");
|
|
8299
|
+
SESSION_DIR_32 = join12(homedir12(), ".synkro", "cc_sessions_3");
|
|
8300
|
+
SESSION_DIR_42 = join12(homedir12(), ".synkro", "cc_sessions_4");
|
|
8301
|
+
PueueError = class extends Error {
|
|
8302
|
+
constructor(message, cause) {
|
|
8303
|
+
super(message);
|
|
8304
|
+
this.cause = cause;
|
|
8305
|
+
this.name = "PueueError";
|
|
8306
|
+
}
|
|
8307
|
+
cause;
|
|
8308
|
+
};
|
|
8309
|
+
CHANNEL_PRIMARY = { taskLabel: TASK_LABEL, tmuxSession: TMUX_SESSION, sessionDir: SESSION_DIR2 };
|
|
8310
|
+
CHANNEL_SECONDARY = { taskLabel: TASK_LABEL_2, tmuxSession: TMUX_SESSION_2, sessionDir: SESSION_DIR_22 };
|
|
8311
|
+
}
|
|
8312
|
+
});
|
|
8313
|
+
|
|
8314
|
+
// cli/local-cc/settings.ts
|
|
8315
|
+
import { existsSync as existsSync13, readFileSync as readFileSync10 } from "fs";
|
|
8316
|
+
import { homedir as homedir13 } from "os";
|
|
8317
|
+
import { join as join13 } from "path";
|
|
8318
|
+
function isLocalCCEnabled() {
|
|
8319
|
+
if (!existsSync13(CONFIG_PATH3)) return false;
|
|
8320
|
+
try {
|
|
8321
|
+
const content = readFileSync10(CONFIG_PATH3, "utf-8");
|
|
8322
|
+
const match = content.match(/^SYNKRO_LOCAL_INFERENCE='([^']*)'/m);
|
|
8323
|
+
return match?.[1] === "yes";
|
|
8324
|
+
} catch {
|
|
8325
|
+
return false;
|
|
8326
|
+
}
|
|
8327
|
+
}
|
|
8328
|
+
var CONFIG_PATH3;
|
|
8329
|
+
var init_settings = __esm({
|
|
8330
|
+
"cli/local-cc/settings.ts"() {
|
|
8331
|
+
"use strict";
|
|
8332
|
+
CONFIG_PATH3 = join13(homedir13(), ".synkro", "config.env");
|
|
8333
|
+
}
|
|
8334
|
+
});
|
|
8335
|
+
|
|
8336
|
+
// cli/commands/localCc.ts
|
|
8337
|
+
var localCc_exports = {};
|
|
8338
|
+
__export(localCc_exports, {
|
|
8339
|
+
localCcCommand: () => localCcCommand
|
|
8340
|
+
});
|
|
8341
|
+
import { spawnSync as spawnSync6 } from "child_process";
|
|
8342
|
+
import { homedir as homedir14 } from "os";
|
|
8343
|
+
import { join as join14 } from "path";
|
|
8344
|
+
import { readFileSync as fsReadFileSync, existsSync as fsExistsSync } from "fs";
|
|
8345
|
+
import { existsSync as existsSync14, readFileSync as readFileSync11, writeFileSync as writeFileSync9 } from "fs";
|
|
8346
|
+
function deploymentMode() {
|
|
8347
|
+
const env = (process.env.SYNKRO_DEPLOYMENT_MODE || "").toLowerCase();
|
|
8348
|
+
if (env === "docker") return "docker";
|
|
8349
|
+
if (env === "bare-host") return "bare-host";
|
|
8350
|
+
try {
|
|
8351
|
+
if (fsExistsSync(SYNKRO_CONFIG_PATH)) {
|
|
8352
|
+
const m = fsReadFileSync(SYNKRO_CONFIG_PATH, "utf-8").match(/^SYNKRO_DEPLOYMENT_MODE='([^']*)'/m);
|
|
8353
|
+
if (m && m[1] === "docker") return "docker";
|
|
8354
|
+
}
|
|
8355
|
+
} catch {
|
|
8356
|
+
}
|
|
8357
|
+
return "bare-host";
|
|
8358
|
+
}
|
|
8359
|
+
function inDockerMode() {
|
|
8360
|
+
return deploymentMode() === "docker";
|
|
8361
|
+
}
|
|
8362
|
+
function printHelp() {
|
|
8363
|
+
console.log(`synkro local-cc \u2014 manage the local Claude Code inference session
|
|
8364
|
+
|
|
8365
|
+
OVERVIEW
|
|
8366
|
+
Routes Synkro's grading and intent-classification work through a long-running
|
|
8367
|
+
Claude Code session on this machine instead of remote Inngest+Gemini.
|
|
8368
|
+
|
|
8369
|
+
When enabled, three call sites switch over:
|
|
8370
|
+
\u2022 security grading on edit/bash hooks
|
|
8371
|
+
\u2022 intent classification (agent input \u2192 structured intent)
|
|
8372
|
+
\u2022 remediate intent classification
|
|
8373
|
+
|
|
8374
|
+
The session is hosted in a detached tmux session managed by a pueue task.
|
|
8375
|
+
The CLI talks to it via Claude Code's channels API: a Bun MCP plugin that
|
|
8376
|
+
pushes events into the session and receives Claude's responses through a
|
|
8377
|
+
\`reply\` MCP tool.
|
|
8378
|
+
|
|
8379
|
+
USAGE
|
|
8380
|
+
synkro local-cc <subcommand> [args]
|
|
8381
|
+
|
|
8382
|
+
SUBCOMMANDS
|
|
8383
|
+
enable Install plugin, start pueue task, flip toggle to local-cc
|
|
8384
|
+
disable Flip toggle back to inngest (pueue task left running)
|
|
8385
|
+
status Show provider toggle, pueue task state, channel reachability
|
|
8386
|
+
start Idempotently bring up the pueue task + wait for the channel
|
|
8387
|
+
stop Kill the tmux session and remove the pueue task
|
|
8388
|
+
restart stop, then start
|
|
8389
|
+
install Regenerate ~/.synkro/cc_sessions/ files (plugin, runner, settings)
|
|
8390
|
+
logs [N] [--raw] [--live]
|
|
8391
|
+
Show the last N (default 20) channel turns: when, role,
|
|
8392
|
+
duration, severity, request preview.
|
|
8393
|
+
--raw / -r include full request/response payloads
|
|
8394
|
+
--live / -f tail the log; print new turns as they arrive
|
|
8395
|
+
(Ctrl-C to exit)
|
|
8396
|
+
--tmux escape hatch \u2014 print the raw pueue/tmux
|
|
8397
|
+
pane log instead
|
|
8398
|
+
attach [--readonly] Attach to the tmux session hosting claude (Ctrl-B D to detach;
|
|
8399
|
+
--readonly / -r to attach view-only)
|
|
8400
|
+
test Send a smoke-test classification through the channel
|
|
8401
|
+
help Show this message
|
|
8402
|
+
|
|
8403
|
+
CONFIGURATION
|
|
8404
|
+
Provider toggle:
|
|
8405
|
+
Stored server-side in your inference settings (gradingProvider).
|
|
8406
|
+
Toggle via: synkro local-cc enable / disable
|
|
8407
|
+
Or via dashboard inference settings (set grading provider to claude-code).
|
|
8408
|
+
|
|
8409
|
+
Claude Code session settings (scoped to ~/.synkro/cc_sessions only):
|
|
8410
|
+
${PLUGIN_SETTINGS_PATH}
|
|
8411
|
+
Currently sets {"fastMode": true}. Edit to add other CC settings for this
|
|
8412
|
+
session without affecting your other CC projects.
|
|
8413
|
+
|
|
8414
|
+
MCP server registration (for the channel plugin):
|
|
8415
|
+
${CLAUDE_JSON_PATH}
|
|
8416
|
+
A 'synkro-local' entry under mcpServers, pointing at:
|
|
8417
|
+
bun ${PLUGIN_PATH}
|
|
8418
|
+
Workspace trust is also pre-accepted under projects[\`${SESSION_DIR}\`].
|
|
8419
|
+
|
|
8420
|
+
Channel runtime files:
|
|
8421
|
+
${RUN_SCRIPT_PATH}
|
|
8422
|
+
bash wrapper that pueue invokes; owns the tmux session lifecycle.
|
|
8423
|
+
${PLUGIN_PATH}
|
|
8424
|
+
Bun MCP channel plugin (auto-generated, do not edit).
|
|
8425
|
+
127.0.0.1:${CHANNEL_PORT}
|
|
8426
|
+
Loopback TCP endpoint the CLI POSTs to in order to submit a request.
|
|
8427
|
+
|
|
8428
|
+
ENVIRONMENT VARIABLES
|
|
8429
|
+
SYNKRO_CHANNEL_PORT Override the TCP port used by both the plugin and
|
|
8430
|
+
the CLI client (loopback only). Default: 8929
|
|
8431
|
+
SYNKRO_CHANNEL_TIMEOUT_MS Per-request timeout inside the channel plugin
|
|
8432
|
+
(default: 120000)
|
|
8433
|
+
|
|
8434
|
+
REQUIRED TOOLS
|
|
8435
|
+
claude Claude Code CLI, authenticated to your subscription
|
|
8436
|
+
pueue pueue + pueued (https://github.com/Nukesor/pueue) running
|
|
8437
|
+
tmux For detached pty around claude
|
|
8438
|
+
bun Runtime for the MCP channel plugin
|
|
8439
|
+
|
|
8440
|
+
TROUBLESHOOTING
|
|
8441
|
+
\u2022 Channel unreachable after \`enable\`?
|
|
8442
|
+
synkro local-cc logs # check pueue task output
|
|
8443
|
+
tmux attach -t ${TMUX_SESSION_NAME} # see what claude is showing
|
|
8444
|
+
\u2022 Stale state after a crash:
|
|
8445
|
+
synkro local-cc stop && synkro local-cc start
|
|
8446
|
+
\u2022 Want to inspect or interact with the live session:
|
|
8447
|
+
synkro local-cc attach
|
|
8448
|
+
`);
|
|
8449
|
+
}
|
|
8450
|
+
function readGatewayUrl() {
|
|
8451
|
+
if (existsSync14(CONFIG_PATH4)) {
|
|
8452
|
+
const m = readFileSync11(CONFIG_PATH4, "utf-8").match(/^SYNKRO_GATEWAY_URL='([^']*)'/m);
|
|
8453
|
+
if (m) return m[1];
|
|
8454
|
+
}
|
|
8455
|
+
return "https://api.synkro.sh";
|
|
8456
|
+
}
|
|
8457
|
+
function updateLocalInferenceFlag(enabled) {
|
|
8458
|
+
if (!existsSync14(CONFIG_PATH4)) return;
|
|
8459
|
+
let content = readFileSync11(CONFIG_PATH4, "utf-8");
|
|
8460
|
+
const flag = enabled ? "yes" : "no";
|
|
8461
|
+
if (content.includes("SYNKRO_LOCAL_INFERENCE=")) {
|
|
8462
|
+
content = content.replace(/^SYNKRO_LOCAL_INFERENCE='[^']*'/m, `SYNKRO_LOCAL_INFERENCE='${flag}'`);
|
|
8463
|
+
} else {
|
|
8464
|
+
content = content.trimEnd() + `
|
|
8465
|
+
SYNKRO_LOCAL_INFERENCE='${flag}'
|
|
8466
|
+
`;
|
|
8467
|
+
}
|
|
8468
|
+
writeFileSync9(CONFIG_PATH4, content, "utf-8");
|
|
8469
|
+
}
|
|
8470
|
+
async function setServerGradingProvider(provider) {
|
|
8471
|
+
await ensureValidToken();
|
|
8472
|
+
const jwt2 = getAccessToken();
|
|
8473
|
+
if (!jwt2) throw new Error("Not authenticated. Run `synkro install` first.");
|
|
8474
|
+
const gatewayUrl = readGatewayUrl();
|
|
8475
|
+
const body = provider ? { roles: { grading: { provider, model: "default" } } } : { roles: { grading: { provider: null, model: null } } };
|
|
8476
|
+
const resp = await fetch(`${gatewayUrl}/api/settings/inference?scope=user`, {
|
|
8477
|
+
method: "PUT",
|
|
8478
|
+
headers: { "Authorization": `Bearer ${jwt2}`, "Content-Type": "application/json" },
|
|
8479
|
+
body: JSON.stringify(body)
|
|
8480
|
+
});
|
|
8481
|
+
if (!resp.ok) {
|
|
8482
|
+
const text = await resp.text().catch(() => "");
|
|
8483
|
+
throw new Error(`Failed to update inference settings: ${resp.status} ${text.slice(0, 200)}`);
|
|
8484
|
+
}
|
|
8485
|
+
}
|
|
8486
|
+
async function cmdStatus() {
|
|
8487
|
+
console.log(`Local inference: ${isLocalCCEnabled() ? "enabled" : "disabled"}`);
|
|
8488
|
+
console.log(`Deployment mode: ${deploymentMode()}`);
|
|
8489
|
+
if (inDockerMode()) {
|
|
8490
|
+
const status = dockerStatus();
|
|
8491
|
+
if (!status.running) {
|
|
8492
|
+
console.log("synkro-server container: not running");
|
|
8493
|
+
console.log("Run `synkro install` (with SYNKRO_DEPLOYMENT_MODE=docker) to provision.");
|
|
8494
|
+
} else {
|
|
8495
|
+
console.log(`synkro-server container: running (${status.image})`);
|
|
8496
|
+
try {
|
|
8497
|
+
const r = await fetch(`${status.healthz}healthz`, { signal: AbortSignal.timeout(3e3) });
|
|
8498
|
+
console.log(`Health probe: ${r.ok ? "ok" : `HTTP ${r.status}`}`);
|
|
8499
|
+
} catch (err) {
|
|
8500
|
+
console.log(`Health probe: ${err.message}`);
|
|
8501
|
+
}
|
|
8502
|
+
}
|
|
8503
|
+
if (needsKeychainBridge()) {
|
|
8504
|
+
console.log(`Keychain creds: ${credsAreStale() ? "STALE \u2014 run `synkro local-cc refresh-creds`" : "fresh"}`);
|
|
8505
|
+
}
|
|
8506
|
+
return;
|
|
8507
|
+
}
|
|
8508
|
+
try {
|
|
8509
|
+
assertPueueInstalled();
|
|
8510
|
+
} catch (err) {
|
|
8511
|
+
console.log(`Pueue: NOT AVAILABLE (${err.message})`);
|
|
8512
|
+
return;
|
|
8513
|
+
}
|
|
8514
|
+
const t = findTask(CHANNEL_PRIMARY);
|
|
8515
|
+
if (!t) {
|
|
8516
|
+
console.log("Channel 1 (judge) pueue task: not present");
|
|
8517
|
+
} else {
|
|
8518
|
+
console.log(`Channel 1 (judge) pueue task: id=${t.id} status=${t.status}`);
|
|
8519
|
+
}
|
|
8520
|
+
const ch1Up = await isChannelAvailable();
|
|
8521
|
+
console.log(`Channel 1 ${CHANNEL_HOST}:${CHANNEL_PORT}: ${ch1Up ? "reachable" : "unreachable"}`);
|
|
8522
|
+
const tmux1 = spawnSync6("tmux", ["has-session", "-t", `=${TMUX_SESSION_NAME}`], { encoding: "utf-8" });
|
|
8523
|
+
console.log(`tmux '${TMUX_SESSION_NAME}': ${tmux1.status === 0 ? "live" : "absent"}`);
|
|
8524
|
+
const t2 = findTask(CHANNEL_SECONDARY);
|
|
8525
|
+
if (!t2) {
|
|
8526
|
+
console.log("Channel 2 (CWE) pueue task: not present");
|
|
8527
|
+
} else {
|
|
8528
|
+
console.log(`Channel 2 (CWE) pueue task: id=${t2.id} status=${t2.status}`);
|
|
8529
|
+
}
|
|
8530
|
+
const ch2Up = await isChannelAvailable(CHANNEL_2_PORT);
|
|
8531
|
+
console.log(`Channel 2 ${CHANNEL_HOST}:${CHANNEL_2_PORT}: ${ch2Up ? "reachable" : "unreachable"}`);
|
|
8532
|
+
const tmux2 = spawnSync6("tmux", ["has-session", "-t", `=${TMUX_SESSION_NAME_2}`], { encoding: "utf-8" });
|
|
8533
|
+
console.log(`tmux '${TMUX_SESSION_NAME_2}': ${tmux2.status === 0 ? "live" : "absent"}`);
|
|
8534
|
+
}
|
|
8535
|
+
async function cmdEnable() {
|
|
8536
|
+
assertClaudeInstalled();
|
|
8537
|
+
assertPueueInstalled();
|
|
8538
|
+
assertTmuxInstalled();
|
|
8539
|
+
console.log("Installing local-CC channel plugin...");
|
|
8540
|
+
const r = installLocalCC();
|
|
8541
|
+
console.log(` plugin: ${r.pluginPath}`);
|
|
8542
|
+
console.log(` cwd: ${r.sessionDir}`);
|
|
8543
|
+
console.log("Starting channel 1 (judge)...");
|
|
8544
|
+
const t1 = ensureRunning({ channel: CHANNEL_PRIMARY });
|
|
8545
|
+
console.log(` task: id=${t1.id} status=${t1.status}`);
|
|
8546
|
+
console.log("Starting channel 2 (CWE)...");
|
|
8547
|
+
const t2 = ensureRunning({ channel: CHANNEL_SECONDARY });
|
|
8548
|
+
console.log(` task: id=${t2.id} status=${t2.status}`);
|
|
8549
|
+
console.log("Waiting for channels (auto-confirming any CC prompts)...");
|
|
8550
|
+
const [ready1, ready2] = await Promise.all([
|
|
8551
|
+
waitForChannelReady(CHANNEL_PORT, 6e4, CHANNEL_HOST, CHANNEL_PRIMARY.tmuxSession),
|
|
8552
|
+
waitForChannelReady(CHANNEL_2_PORT, 6e4, CHANNEL_HOST, CHANNEL_SECONDARY.tmuxSession)
|
|
8553
|
+
]);
|
|
8554
|
+
if (ready1) console.log(` channel 1 ready at ${CHANNEL_HOST}:${CHANNEL_PORT}`);
|
|
8555
|
+
else console.warn(` \u26A0 channel 1 did not come up within 60s \u2014 check \`synkro local-cc logs\``);
|
|
8556
|
+
if (ready2) console.log(` channel 2 ready at ${CHANNEL_HOST}:${CHANNEL_2_PORT}`);
|
|
8557
|
+
else console.warn(` \u26A0 channel 2 (CWE) did not come up within 60s`);
|
|
8558
|
+
console.log("Updating inference settings...");
|
|
8559
|
+
await setServerGradingProvider("claude-code");
|
|
8560
|
+
updateLocalInferenceFlag(true);
|
|
8561
|
+
console.log("Grading provider set to claude-code (local inference enabled).");
|
|
8562
|
+
}
|
|
8563
|
+
async function cmdDisable() {
|
|
8564
|
+
console.log("Updating inference settings...");
|
|
8565
|
+
await setServerGradingProvider(null);
|
|
8566
|
+
updateLocalInferenceFlag(false);
|
|
8567
|
+
console.log("Grading provider cleared (remote inference restored). Pueue task left running \u2014 use `synkro local-cc stop` to terminate.");
|
|
8568
|
+
}
|
|
8569
|
+
async function warmChannels(ready1, ready2) {
|
|
8570
|
+
const warmups = [];
|
|
8571
|
+
if (ready1) {
|
|
8572
|
+
warmups.push(
|
|
8573
|
+
submitToChannel("grade-bash", "Proposed command: echo hello\nUser intent: warmup\nRecent user messages: []\nRecent actions: []\nOrg rules: []\n", { timeoutMs: 3e4 }).then(() => console.log(" channel 1 warm.")).catch(() => console.log(" channel 1 warmup skipped (non-fatal)."))
|
|
8574
|
+
);
|
|
8575
|
+
}
|
|
8576
|
+
if (ready2) {
|
|
8577
|
+
warmups.push(
|
|
8578
|
+
submitToChannel("grade-cwe", 'File: /tmp/warmup.ts\nContent (first 4000 chars):\nconsole.log("hello");\n\nCWE rules to check against:\n[]\n', { timeoutMs: 3e4, port: CHANNEL_2_PORT }).then(() => console.log(" channel 2 warm.")).catch(() => console.log(" channel 2 warmup skipped (non-fatal)."))
|
|
8579
|
+
);
|
|
8580
|
+
}
|
|
8581
|
+
if (warmups.length) {
|
|
8582
|
+
console.log("Warming up inference...");
|
|
8583
|
+
await Promise.all(warmups);
|
|
8584
|
+
}
|
|
8585
|
+
}
|
|
8586
|
+
async function cmdStart(rest = []) {
|
|
8587
|
+
if (inDockerMode()) {
|
|
8588
|
+
if (rest.length > 0) {
|
|
8589
|
+
const { claudeWorkers, cursorWorkers } = resolveWorkerConfig(rest);
|
|
8590
|
+
console.log(`Starting synkro-server container (${claudeWorkers} claude + ${cursorWorkers} cursor)...`);
|
|
8591
|
+
await dockerUpdate({ claudeWorkers, cursorWorkers });
|
|
8592
|
+
const ready3 = await waitForContainerReady(6e4);
|
|
8593
|
+
console.log(ready3 ? "\u2713 container ready" : "\u26A0 container did not pass /healthz within 60s");
|
|
8594
|
+
return;
|
|
8595
|
+
}
|
|
8596
|
+
const status = dockerStatus();
|
|
8597
|
+
if (!status.running) {
|
|
8598
|
+
console.warn("synkro-server container is not running. Run `synkro install` to provision it.");
|
|
8599
|
+
process.exitCode = 1;
|
|
8600
|
+
return;
|
|
8601
|
+
}
|
|
8602
|
+
console.log("synkro-server container already running \u2014 waiting for /healthz...");
|
|
8603
|
+
const ready = await waitForContainerReady(6e4);
|
|
8604
|
+
console.log(ready ? `\u2713 container ready (${status.healthz})` : "\u26A0 container did not pass /healthz within 60s");
|
|
8605
|
+
return;
|
|
8606
|
+
}
|
|
8607
|
+
assertClaudeInstalled();
|
|
8608
|
+
assertPueueInstalled();
|
|
8609
|
+
assertTmuxInstalled();
|
|
8610
|
+
const t1 = ensureRunning({ channel: CHANNEL_PRIMARY });
|
|
8611
|
+
console.log(`Channel 1 (judge): id=${t1.id} status=${t1.status}`);
|
|
8612
|
+
const t2 = ensureRunning({ channel: CHANNEL_SECONDARY });
|
|
8613
|
+
console.log(`Channel 2 (CWE): id=${t2.id} status=${t2.status}`);
|
|
8614
|
+
const [ready1, ready2] = await Promise.all([
|
|
8615
|
+
waitForChannelReady(CHANNEL_PORT, 6e4, CHANNEL_HOST, CHANNEL_PRIMARY.tmuxSession),
|
|
8616
|
+
waitForChannelReady(CHANNEL_2_PORT, 6e4, CHANNEL_HOST, CHANNEL_SECONDARY.tmuxSession)
|
|
8617
|
+
]);
|
|
8618
|
+
console.log(ready1 ? `channel 1 ready (${CHANNEL_PORT}).` : "\u26A0 channel 1 did not come up within 60s.");
|
|
8619
|
+
console.log(ready2 ? `channel 2 ready (${CHANNEL_2_PORT}).` : "\u26A0 channel 2 (CWE) did not come up within 60s.");
|
|
8620
|
+
await warmChannels(ready1, ready2);
|
|
8621
|
+
}
|
|
8622
|
+
function cmdStop() {
|
|
8623
|
+
if (inDockerMode()) {
|
|
8624
|
+
dockerStop();
|
|
8625
|
+
console.log("synkro-server container stopped and removed.");
|
|
8626
|
+
return;
|
|
8627
|
+
}
|
|
8628
|
+
stopTask(CHANNEL_PRIMARY);
|
|
8629
|
+
stopTask(CHANNEL_SECONDARY);
|
|
8630
|
+
console.log("Both channels stopped.");
|
|
8631
|
+
}
|
|
8632
|
+
async function cmdRestart(rest = []) {
|
|
8633
|
+
if (inDockerMode()) {
|
|
8634
|
+
const { claudeWorkers, cursorWorkers } = resolveWorkerConfig(rest);
|
|
8635
|
+
console.log(`Restarting synkro-server container (${claudeWorkers} claude + ${cursorWorkers} cursor, pulling latest image)...`);
|
|
8636
|
+
await dockerUpdate({ claudeWorkers, cursorWorkers });
|
|
8637
|
+
const ready = await waitForContainerReady(6e4);
|
|
8638
|
+
console.log(ready ? "\u2713 container ready" : "\u26A0 container did not pass /healthz within 60s");
|
|
8639
|
+
return;
|
|
8640
|
+
}
|
|
8641
|
+
stopTask(CHANNEL_PRIMARY);
|
|
8642
|
+
stopTask(CHANNEL_SECONDARY);
|
|
8643
|
+
const t1 = startTask({ channel: CHANNEL_PRIMARY });
|
|
8644
|
+
const t2 = startTask({ channel: CHANNEL_SECONDARY });
|
|
8645
|
+
console.log(`Channel 1 restarted: id=${t1.id} status=${t1.status}`);
|
|
8646
|
+
console.log(`Channel 2 restarted: id=${t2.id} status=${t2.status}`);
|
|
8647
|
+
const [ready1, ready2] = await Promise.all([
|
|
8648
|
+
waitForChannelReady(CHANNEL_PORT, 6e4, CHANNEL_HOST, CHANNEL_PRIMARY.tmuxSession),
|
|
8649
|
+
waitForChannelReady(CHANNEL_2_PORT, 6e4, CHANNEL_HOST, CHANNEL_SECONDARY.tmuxSession)
|
|
8650
|
+
]);
|
|
8651
|
+
console.log(ready1 ? `channel 1 ready (${CHANNEL_PORT}).` : "\u26A0 channel 1 did not come up within 60s.");
|
|
8652
|
+
console.log(ready2 ? `channel 2 ready (${CHANNEL_2_PORT}).` : "\u26A0 channel 2 (CWE) did not come up within 60s.");
|
|
8653
|
+
await warmChannels(ready1, ready2);
|
|
8654
|
+
}
|
|
8655
|
+
function relativeTime(iso) {
|
|
8656
|
+
const ts = new Date(iso).getTime();
|
|
8657
|
+
if (!Number.isFinite(ts)) return iso;
|
|
8658
|
+
const sec = Math.max(0, Math.round((Date.now() - ts) / 1e3));
|
|
8659
|
+
if (sec < 60) return `${sec}s ago`;
|
|
8660
|
+
if (sec < 3600) return `${Math.round(sec / 60)}m ago`;
|
|
8661
|
+
if (sec < 86400) return `${Math.round(sec / 3600)}h ago`;
|
|
8662
|
+
return `${Math.round(sec / 86400)}d ago`;
|
|
8663
|
+
}
|
|
8664
|
+
function colorize(s, code) {
|
|
8665
|
+
if (!process.stdout.isTTY) return s;
|
|
8666
|
+
return `\x1B[${code}m${s}\x1B[0m`;
|
|
8667
|
+
}
|
|
8668
|
+
function statusGlyph(t) {
|
|
8669
|
+
if (t.status === "ok") return colorize("\u2713", 32);
|
|
8670
|
+
if (t.status === "timeout") return colorize("\u23F1", 33);
|
|
8671
|
+
return colorize("\u2717", 31);
|
|
8672
|
+
}
|
|
8673
|
+
function severityCell(t) {
|
|
8674
|
+
if (t.severity) {
|
|
8675
|
+
const sev = t.severity;
|
|
8676
|
+
if (sev === "block" || sev === "violations" || sev === "unclear") return colorize(sev, 33);
|
|
8677
|
+
return colorize(sev, 36);
|
|
8678
|
+
}
|
|
8679
|
+
if (t.error) return colorize(t.error.slice(0, 40), 31);
|
|
8680
|
+
return "\u2014";
|
|
8681
|
+
}
|
|
8682
|
+
function firstLine(s) {
|
|
8683
|
+
return s.split("\n").find((l) => l.trim().length > 0)?.trim() ?? "";
|
|
8684
|
+
}
|
|
8685
|
+
function formatTurn(t, raw) {
|
|
8686
|
+
const when = relativeTime(t.ts).padEnd(8);
|
|
8687
|
+
const dur = (t.duration_ms < 1e3 ? `${t.duration_ms}ms` : `${(t.duration_ms / 1e3).toFixed(1)}s`).padStart(7);
|
|
8688
|
+
const role = t.role.padEnd(24);
|
|
8689
|
+
const sev = severityCell(t).padEnd(20);
|
|
8690
|
+
const preview = (() => {
|
|
8691
|
+
const req = firstLine(t.request_preview).slice(0, 60);
|
|
8692
|
+
return colorize(req, 90);
|
|
8693
|
+
})();
|
|
8694
|
+
const head = `${statusGlyph(t)} ${when} ${dur} ${role} ${sev} ${preview}`;
|
|
8695
|
+
if (!raw) return head;
|
|
8696
|
+
const blocks = [head];
|
|
8697
|
+
blocks.push(colorize(" request:", 90));
|
|
8698
|
+
blocks.push(" " + t.request_preview.replace(/\n/g, "\n "));
|
|
8699
|
+
if (t.response_preview) {
|
|
8700
|
+
blocks.push(colorize(" response:", 90));
|
|
8701
|
+
blocks.push(" " + t.response_preview.replace(/\n/g, "\n "));
|
|
8702
|
+
}
|
|
8703
|
+
if (t.error) {
|
|
8704
|
+
blocks.push(colorize(" error:", 31) + " " + t.error);
|
|
8705
|
+
}
|
|
8706
|
+
return blocks.join("\n");
|
|
8707
|
+
}
|
|
8708
|
+
function cmdLogs(rest) {
|
|
8709
|
+
let n = 20;
|
|
8710
|
+
let raw = false;
|
|
8711
|
+
let live = false;
|
|
8712
|
+
if (inDockerMode()) {
|
|
8713
|
+
const followFlag = rest.includes("--live") || rest.includes("-f") ? ["--follow"] : [];
|
|
8714
|
+
const tailArg = (() => {
|
|
8715
|
+
for (const arg of rest) {
|
|
8716
|
+
const parsed = parseInt(arg, 10);
|
|
8717
|
+
if (parsed > 0) return String(parsed);
|
|
8718
|
+
}
|
|
8719
|
+
return "200";
|
|
8720
|
+
})();
|
|
8721
|
+
spawnSync6("docker", ["logs", "--tail", tailArg, ...followFlag, "synkro-server"], { stdio: "inherit" });
|
|
8722
|
+
return;
|
|
8723
|
+
}
|
|
8724
|
+
for (const arg of rest) {
|
|
8725
|
+
if (arg === "--raw" || arg === "-r") raw = true;
|
|
8726
|
+
else if (arg === "--live" || arg === "-f") live = true;
|
|
8727
|
+
else if (arg === "--tmux") {
|
|
8728
|
+
console.log(tailLogs(80));
|
|
8729
|
+
return;
|
|
8730
|
+
} else {
|
|
8731
|
+
const parsed = parseInt(arg, 10);
|
|
8732
|
+
if (parsed > 0) n = parsed;
|
|
8733
|
+
}
|
|
8734
|
+
}
|
|
8735
|
+
const header = " " + colorize("status when dur role severity request", 90);
|
|
8736
|
+
const turns = readRecentTurns(n);
|
|
8737
|
+
if (turns.length === 0) {
|
|
8738
|
+
if (!live) {
|
|
8739
|
+
console.log(`No turns logged yet at ${TURN_LOG_PATH}.`);
|
|
8740
|
+
console.log("Run a few requests through the channel (synkro local-cc test) and try again.");
|
|
8741
|
+
return;
|
|
8742
|
+
}
|
|
8743
|
+
console.log(`No turns logged yet at ${TURN_LOG_PATH} \u2014 waiting for new entries\u2026 (Ctrl-C to exit)`);
|
|
8744
|
+
} else {
|
|
8745
|
+
console.log(`Last ${turns.length} channel turn(s) (newest first):`);
|
|
8746
|
+
console.log(header);
|
|
8747
|
+
for (const t of turns) console.log(" " + formatTurn(t, raw));
|
|
8748
|
+
}
|
|
8749
|
+
if (!live) {
|
|
8750
|
+
if (!raw) console.log(" " + colorize("(use --raw / -r to see full payloads, --live / -f to follow)", 90));
|
|
8751
|
+
return;
|
|
8752
|
+
}
|
|
8753
|
+
return new Promise((resolve3) => {
|
|
8754
|
+
console.log(" " + colorize("\u2014 following new turns (Ctrl-C to exit) \u2014", 90));
|
|
8755
|
+
const stop = followTurns((t) => {
|
|
8756
|
+
console.log(" " + formatTurn(t, raw));
|
|
8757
|
+
});
|
|
8758
|
+
const onSigint = () => {
|
|
8759
|
+
stop();
|
|
8760
|
+
process.removeListener("SIGINT", onSigint);
|
|
8761
|
+
resolve3();
|
|
8762
|
+
};
|
|
8763
|
+
process.on("SIGINT", onSigint);
|
|
8764
|
+
});
|
|
8765
|
+
}
|
|
8766
|
+
function cmdAttach(rest) {
|
|
8767
|
+
assertTmuxInstalled();
|
|
8768
|
+
const readonly = rest.some((a) => a === "--readonly" || a === "-r");
|
|
8769
|
+
const has = spawnSync6("tmux", ["has-session", "-t", `=${TMUX_SESSION_NAME}`], { encoding: "utf-8" });
|
|
8770
|
+
if (has.status !== 0) {
|
|
8771
|
+
console.error(`No tmux session '${TMUX_SESSION_NAME}' running. Start it with: synkro local-cc start`);
|
|
8772
|
+
process.exit(1);
|
|
8773
|
+
}
|
|
8774
|
+
if (!process.stdout.isTTY) {
|
|
8775
|
+
console.error("attach requires a TTY. Run this command directly in your terminal, not piped.");
|
|
8776
|
+
process.exit(1);
|
|
8777
|
+
}
|
|
8778
|
+
console.log(`Attaching to tmux session '${TMUX_SESSION_NAME}'${readonly ? " (read-only)" : ""}.`);
|
|
8779
|
+
console.log("Detach with Ctrl-B then D. (Do not press Ctrl-C \u2014 that would interrupt claude.)");
|
|
8780
|
+
console.log();
|
|
8781
|
+
const args2 = readonly ? ["attach-session", "-r", "-t", TMUX_SESSION_NAME] : ["attach-session", "-t", TMUX_SESSION_NAME];
|
|
8782
|
+
const r = spawnSync6("tmux", args2, { stdio: "inherit" });
|
|
8783
|
+
process.exit(r.status ?? 0);
|
|
8784
|
+
}
|
|
8785
|
+
async function cmdTest() {
|
|
8786
|
+
console.log("Sending smoke-test grading request through the channel...");
|
|
8787
|
+
const result = await submitToChannel(
|
|
8788
|
+
"grade-bash",
|
|
8789
|
+
'Command: echo "hello world"\nIntent: testing channel connectivity'
|
|
8790
|
+
);
|
|
8791
|
+
console.log("Raw reply:");
|
|
8792
|
+
console.log(result);
|
|
8793
|
+
}
|
|
8794
|
+
function cmdInstall() {
|
|
8795
|
+
const r = installLocalCC();
|
|
8796
|
+
console.log(`Reinstalled plugin at ${r.pluginPath}`);
|
|
8797
|
+
}
|
|
8798
|
+
function cmdRefreshCreds() {
|
|
8799
|
+
if (!needsKeychainBridge()) {
|
|
8800
|
+
console.log("No-op on this platform \u2014 credentials are already file-based.");
|
|
8801
|
+
return;
|
|
8802
|
+
}
|
|
8803
|
+
const refreshed = refreshCreds();
|
|
8804
|
+
if (refreshed) {
|
|
8805
|
+
console.log(`Exported claude credentials to ${CLAUDE_CREDS_FILE}`);
|
|
8806
|
+
} else {
|
|
8807
|
+
console.warn("No Claude Code credentials found in the keychain.");
|
|
8808
|
+
console.warn("Run `claude login` first, then re-run this command.");
|
|
8809
|
+
process.exitCode = 1;
|
|
8810
|
+
}
|
|
8811
|
+
if (credsAreStale()) {
|
|
8812
|
+
console.warn("\u26A0 Exported credentials are older than the refresh interval.");
|
|
8813
|
+
}
|
|
8814
|
+
}
|
|
8815
|
+
async function localCcCommand(args2) {
|
|
8816
|
+
const sub = args2[0] ?? "";
|
|
8817
|
+
try {
|
|
8818
|
+
switch (sub) {
|
|
8819
|
+
case "enable":
|
|
8820
|
+
await cmdEnable();
|
|
8821
|
+
break;
|
|
8822
|
+
case "disable":
|
|
8823
|
+
cmdDisable();
|
|
8824
|
+
break;
|
|
8825
|
+
case "status":
|
|
8826
|
+
await cmdStatus();
|
|
8827
|
+
break;
|
|
8828
|
+
case "start":
|
|
8829
|
+
await cmdStart(args2.slice(1));
|
|
8830
|
+
break;
|
|
8831
|
+
case "stop":
|
|
8832
|
+
cmdStop();
|
|
8833
|
+
break;
|
|
8834
|
+
case "restart":
|
|
8835
|
+
await cmdRestart(args2.slice(1));
|
|
8836
|
+
break;
|
|
8837
|
+
case "install":
|
|
8838
|
+
cmdInstall();
|
|
8839
|
+
break;
|
|
8840
|
+
case "logs":
|
|
8841
|
+
await cmdLogs(args2.slice(1));
|
|
8842
|
+
break;
|
|
8843
|
+
case "attach":
|
|
8844
|
+
cmdAttach(args2.slice(1));
|
|
8845
|
+
break;
|
|
8846
|
+
case "test":
|
|
8847
|
+
await cmdTest();
|
|
8848
|
+
break;
|
|
8849
|
+
case "refresh-creds":
|
|
8850
|
+
cmdRefreshCreds();
|
|
8851
|
+
break;
|
|
8852
|
+
case "":
|
|
8853
|
+
case "help":
|
|
8854
|
+
case "--help":
|
|
8855
|
+
case "-h":
|
|
8856
|
+
printHelp();
|
|
8857
|
+
break;
|
|
8858
|
+
default:
|
|
8859
|
+
console.error(`Unknown subcommand: ${sub}`);
|
|
8860
|
+
printHelp();
|
|
8861
|
+
process.exit(1);
|
|
8862
|
+
}
|
|
8863
|
+
} catch (err) {
|
|
8864
|
+
console.error(err.message);
|
|
8865
|
+
process.exit(1);
|
|
8866
|
+
}
|
|
8867
|
+
}
|
|
8868
|
+
var SYNKRO_CONFIG_PATH, CONFIG_PATH4;
|
|
8869
|
+
var init_localCc = __esm({
|
|
8870
|
+
"cli/commands/localCc.ts"() {
|
|
8871
|
+
"use strict";
|
|
8872
|
+
init_install2();
|
|
8873
|
+
init_turnLog();
|
|
8874
|
+
init_pueue();
|
|
8875
|
+
init_settings();
|
|
8876
|
+
init_macKeychain();
|
|
8877
|
+
init_dockerInstall();
|
|
8878
|
+
init_client();
|
|
8879
|
+
init_stub();
|
|
8880
|
+
SYNKRO_CONFIG_PATH = join14(homedir14(), ".synkro", "config.env");
|
|
8881
|
+
CONFIG_PATH4 = join14(homedir14(), ".synkro", "config.env");
|
|
8882
|
+
}
|
|
8883
|
+
});
|
|
8884
|
+
|
|
7865
8885
|
// cli/commands/lifecycle.ts
|
|
7866
8886
|
var lifecycle_exports = {};
|
|
7867
8887
|
__export(lifecycle_exports, {
|
|
@@ -7870,6 +8890,9 @@ __export(lifecycle_exports, {
|
|
|
7870
8890
|
stopCommand: () => stopCommand,
|
|
7871
8891
|
updateCommand: () => updateCommand
|
|
7872
8892
|
});
|
|
8893
|
+
function resolveConnectedRepo() {
|
|
8894
|
+
return detectGitRepo2() ?? readContainerConfig()?.connectedRepo ?? void 0;
|
|
8895
|
+
}
|
|
7873
8896
|
async function stopCommand() {
|
|
7874
8897
|
assertDockerAvailable();
|
|
7875
8898
|
console.log("Synkro: stopping server\n");
|
|
@@ -7886,7 +8909,7 @@ async function startCommand(rest = []) {
|
|
|
7886
8909
|
if (cfg.explicit) {
|
|
7887
8910
|
console.log(`Synkro: starting server (${cfg.claudeWorkers} claude + ${cfg.cursorWorkers} cursor)
|
|
7888
8911
|
`);
|
|
7889
|
-
await dockerUpdate({ claudeWorkers: cfg.claudeWorkers, cursorWorkers: cfg.cursorWorkers });
|
|
8912
|
+
await dockerUpdate({ claudeWorkers: cfg.claudeWorkers, cursorWorkers: cfg.cursorWorkers, connectedRepo: resolveConnectedRepo() });
|
|
7890
8913
|
const ready = await waitForContainerReady(6e4);
|
|
7891
8914
|
if (!ready) {
|
|
7892
8915
|
console.error("\n\u26A0 container did not pass /healthz within 60s");
|
|
@@ -7916,7 +8939,7 @@ async function updateCommand() {
|
|
|
7916
8939
|
console.log("Synkro: updating to the latest container image");
|
|
7917
8940
|
console.log(` preserving pool: ${claudeWorkers} claude + ${cursorWorkers} cursor worker(s)
|
|
7918
8941
|
`);
|
|
7919
|
-
await dockerUpdate({ claudeWorkers, cursorWorkers, connectedRepo:
|
|
8942
|
+
await dockerUpdate({ claudeWorkers, cursorWorkers, connectedRepo: resolveConnectedRepo() });
|
|
7920
8943
|
const ready = await waitForContainerReady(9e4);
|
|
7921
8944
|
if (!ready) {
|
|
7922
8945
|
console.error("\n\u26A0 container did not pass its health check within 90s \u2014 check: docker logs synkro-server");
|
|
@@ -7930,7 +8953,7 @@ async function restartCommand(rest = []) {
|
|
|
7930
8953
|
if (cfg.explicit) {
|
|
7931
8954
|
console.log(`Synkro: restarting server (${cfg.claudeWorkers} claude + ${cfg.cursorWorkers} cursor)
|
|
7932
8955
|
`);
|
|
7933
|
-
await dockerUpdate({ claudeWorkers: cfg.claudeWorkers, cursorWorkers: cfg.cursorWorkers });
|
|
8956
|
+
await dockerUpdate({ claudeWorkers: cfg.claudeWorkers, cursorWorkers: cfg.cursorWorkers, connectedRepo: resolveConnectedRepo() });
|
|
7934
8957
|
const ready = await waitForContainerReady(6e4);
|
|
7935
8958
|
if (!ready) {
|
|
7936
8959
|
console.error("\n\u26A0 container did not pass /healthz within 60s");
|
|
@@ -7953,19 +8976,20 @@ var init_lifecycle = __esm({
|
|
|
7953
8976
|
"cli/commands/lifecycle.ts"() {
|
|
7954
8977
|
"use strict";
|
|
7955
8978
|
init_dockerInstall();
|
|
8979
|
+
init_install();
|
|
7956
8980
|
}
|
|
7957
8981
|
});
|
|
7958
8982
|
|
|
7959
8983
|
// cli/bootstrap.js
|
|
7960
|
-
import { readFileSync as
|
|
8984
|
+
import { readFileSync as readFileSync12, existsSync as existsSync15 } from "fs";
|
|
7961
8985
|
import { resolve as resolve2 } from "path";
|
|
7962
8986
|
var envCandidates = [
|
|
7963
8987
|
resolve2(process.cwd(), ".env"),
|
|
7964
8988
|
resolve2(process.env.HOME ?? "", ".synkro", "config.env")
|
|
7965
8989
|
];
|
|
7966
8990
|
for (const envPath of envCandidates) {
|
|
7967
|
-
if (!
|
|
7968
|
-
const envContent =
|
|
8991
|
+
if (!existsSync15(envPath)) continue;
|
|
8992
|
+
const envContent = readFileSync12(envPath, "utf-8");
|
|
7969
8993
|
for (const line of envContent.split("\n")) {
|
|
7970
8994
|
const trimmed = line.trim();
|
|
7971
8995
|
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
@@ -7980,9 +9004,9 @@ var args = process.argv.slice(2);
|
|
|
7980
9004
|
var cmd = args[0] || "";
|
|
7981
9005
|
var subArgs = args.slice(1);
|
|
7982
9006
|
function printVersion() {
|
|
7983
|
-
console.log("1.6.
|
|
9007
|
+
console.log("1.6.10");
|
|
7984
9008
|
}
|
|
7985
|
-
function
|
|
9009
|
+
function printHelp2() {
|
|
7986
9010
|
console.log(`Synkro CLI \u2014 runtime safety for AI coding agents
|
|
7987
9011
|
|
|
7988
9012
|
Usage:
|
|
@@ -8025,6 +9049,11 @@ async function main() {
|
|
|
8025
9049
|
await gradeCommand2(subArgs);
|
|
8026
9050
|
break;
|
|
8027
9051
|
}
|
|
9052
|
+
case "local-cc": {
|
|
9053
|
+
const { localCcCommand: localCcCommand2 } = await Promise.resolve().then(() => (init_localCc(), localCc_exports));
|
|
9054
|
+
await localCcCommand2(subArgs);
|
|
9055
|
+
break;
|
|
9056
|
+
}
|
|
8028
9057
|
case "version":
|
|
8029
9058
|
case "--version":
|
|
8030
9059
|
case "-v": {
|
|
@@ -8035,7 +9064,7 @@ async function main() {
|
|
|
8035
9064
|
case "--help":
|
|
8036
9065
|
case "-h":
|
|
8037
9066
|
case "": {
|
|
8038
|
-
|
|
9067
|
+
printHelp2();
|
|
8039
9068
|
break;
|
|
8040
9069
|
}
|
|
8041
9070
|
case "stop": {
|
|
@@ -8060,7 +9089,7 @@ async function main() {
|
|
|
8060
9089
|
}
|
|
8061
9090
|
default: {
|
|
8062
9091
|
console.error(`Unknown command: ${cmd}`);
|
|
8063
|
-
|
|
9092
|
+
printHelp2();
|
|
8064
9093
|
process.exit(1);
|
|
8065
9094
|
}
|
|
8066
9095
|
}
|