@decencia/ch-cli 1.34.5 → 1.35.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/agent/2t-decencia-channel-issue-coder.md +1 -1
- package/agent/2t-decencia-channel-pr-merger.md +1 -1
- package/agent/2t-decencia-channel-terraformer.md +1 -1
- package/dist/client.d.ts +4 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +7 -1
- package/dist/client.js.map +1 -1
- package/dist/commands/skills.d.ts +29 -0
- package/dist/commands/skills.d.ts.map +1 -1
- package/dist/commands/skills.js +707 -2
- package/dist/commands/skills.js.map +1 -1
- package/dist/skills-cache.d.ts +140 -0
- package/dist/skills-cache.d.ts.map +1 -0
- package/dist/skills-cache.js +388 -0
- package/dist/skills-cache.js.map +1 -0
- package/dist/skills-diff.d.ts +151 -0
- package/dist/skills-diff.d.ts.map +1 -0
- package/dist/skills-diff.js +476 -0
- package/dist/skills-diff.js.map +1 -0
- package/package.json +1 -1
- package/skill/2t-decencia-channel-change-manager/SKILL.md +1 -1
- package/skill/2t-decencia-channel-change-propagation-v2/SKILL.md +1 -1
- package/skill/2t-decencia-channel-cli-v2/SKILL.md +1 -1
- package/skill/2t-decencia-channel-db-schema-v2/SKILL.md +1 -1
- package/skill/2t-decencia-channel-github-issue/SKILL.md +1 -1
- package/skill/2t-decencia-channel-ia-v2/SKILL.md +1 -1
- package/skill/2t-decencia-channel-orchestrator/SKILL.md +5 -1
- package/skill/2t-decencia-channel-policy-v2/SKILL.md +1 -1
- package/skill/2t-decencia-channel-prd-v2/SKILL.md +1 -1
- package/skill/2t-decencia-channel-project-bootstrap/SKILL.md +1 -1
- package/skill/2t-decencia-channel-screen-v2/SKILL.md +1 -1
- package/skill/2t-decencia-channel-spec-v2/SKILL.md +1 -1
- package/skill/2t-decencia-channel-sprint-builder-v2/SKILL.md +1 -1
- package/skill/2t-decencia-channel-sprint-runner/SKILL.md +1 -1
- package/skill/2t-decencia-channel-sqa-v2/SKILL.md +1 -1
- package/skill/2t-decencia-channel-userflow-v2/SKILL.md +1 -1
- package/skill/2t-decencia-channel-work-status-v2/SKILL.md +1 -1
package/dist/commands/skills.js
CHANGED
|
@@ -50,12 +50,20 @@ exports.prepareSkillUpload = prepareSkillUpload;
|
|
|
50
50
|
exports.filterSkillsByGroup = filterSkillsByGroup;
|
|
51
51
|
exports.collectTag = collectTag;
|
|
52
52
|
exports.filterSkillsByTag = filterSkillsByTag;
|
|
53
|
+
exports.planConfirmation = planConfirmation;
|
|
54
|
+
exports.isAffirmative = isAffirmative;
|
|
53
55
|
exports.registerSkillsCommand = registerSkillsCommand;
|
|
56
|
+
exports.parseSyncScopes = parseSyncScopes;
|
|
57
|
+
const chalk_1 = __importDefault(require("chalk"));
|
|
54
58
|
const fs = __importStar(require("fs"));
|
|
55
59
|
const path = __importStar(require("path"));
|
|
60
|
+
const readline = __importStar(require("readline"));
|
|
56
61
|
const js_yaml_1 = require("js-yaml");
|
|
57
62
|
const form_data_1 = __importDefault(require("form-data"));
|
|
58
63
|
const client_1 = require("../client");
|
|
64
|
+
const config_1 = require("../config");
|
|
65
|
+
const skills_cache_1 = require("../skills-cache");
|
|
66
|
+
const skills_diff_1 = require("../skills-diff");
|
|
59
67
|
const output_1 = require("../output");
|
|
60
68
|
/** 스킬 폴더 규약상 고정된 매니페스트 파일명. frontmatter 의 출처. */
|
|
61
69
|
exports.SKILL_MANIFEST_FILENAME = 'SKILL.md';
|
|
@@ -267,6 +275,47 @@ function filterSkillsByTag(rows, tag) {
|
|
|
267
275
|
return rows;
|
|
268
276
|
return rows.filter((r) => (r.tags ?? []).includes(target));
|
|
269
277
|
}
|
|
278
|
+
/**
|
|
279
|
+
* 확인을 어떻게 받을지 정한다(순수 판정 — 실제 입출력은 호출측).
|
|
280
|
+
*
|
|
281
|
+
* `--yes` 가 없는데 stdin 이 대화형이 아니면(파이프·CI·`--json` 기계 모드) 물어볼
|
|
282
|
+
* 방법이 없다. 이때 조용히 삭제하면 위험하고, 조용히 성공한 척 끝내면 호출한
|
|
283
|
+
* 스크립트가 삭제된 줄 안다. 그래서 refuse — 실행하지 않고 exit≠0 으로 알린다.
|
|
284
|
+
*/
|
|
285
|
+
function planConfirmation(opts) {
|
|
286
|
+
if (opts.yes)
|
|
287
|
+
return 'proceed';
|
|
288
|
+
return opts.interactive ? 'prompt' : 'refuse';
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* 확인 프롬프트 응답을 해석한다 — `y`/`yes`(대소문자·공백 무시)만 승인.
|
|
292
|
+
* 빈 입력(그냥 엔터)을 포함한 그 외 모든 입력은 거부다(파괴적 작업의 안전 기본값).
|
|
293
|
+
*/
|
|
294
|
+
function isAffirmative(answer) {
|
|
295
|
+
const normalized = (answer ?? '').trim().toLowerCase();
|
|
296
|
+
return normalized === 'y' || normalized === 'yes';
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* 확인 질문을 stderr 로 묻고 한 줄을 읽는다.
|
|
300
|
+
* 질문을 stdout 이 아닌 stderr 로 보내는 이유는 stdout 출력 계약(원문/JSON 전용)을
|
|
301
|
+
* 어떤 모드에서도 깨지 않기 위해서다.
|
|
302
|
+
*/
|
|
303
|
+
async function askConfirmation(question) {
|
|
304
|
+
const rl = readline.createInterface({
|
|
305
|
+
input: process.stdin,
|
|
306
|
+
output: process.stderr,
|
|
307
|
+
});
|
|
308
|
+
try {
|
|
309
|
+
const answer = await new Promise((resolve) => {
|
|
310
|
+
rl.question(question, resolve);
|
|
311
|
+
});
|
|
312
|
+
return isAffirmative(answer);
|
|
313
|
+
}
|
|
314
|
+
finally {
|
|
315
|
+
rl.close();
|
|
316
|
+
process.stdin.pause(); // 남은 stdin 이 프로세스를 붙잡지 않도록.
|
|
317
|
+
}
|
|
318
|
+
}
|
|
270
319
|
/**
|
|
271
320
|
* 폴더구조를 보존한 multipart 폼을 만든다.
|
|
272
321
|
* `files` 를 상대경로 정렬 순서대로 append 하고, 같은 순서의 `paths`(JSON 배열)를
|
|
@@ -291,11 +340,136 @@ function buildUploadForm(uploads, group, tags) {
|
|
|
291
340
|
}
|
|
292
341
|
return form;
|
|
293
342
|
}
|
|
343
|
+
// ── 캐시 적재(use) 보조 ───────────────────────────────────────────────────
|
|
344
|
+
/**
|
|
345
|
+
* 캐시 적재는 본조회(get/list)와 달리 실패해도 캐시본으로 진행해야 하므로
|
|
346
|
+
* 에러 인터셉터가 없는 probe 클라이언트를 쓴다(연결 실패에 process.exit 하지
|
|
347
|
+
* 않는다). 다만 이쪽은 부가 조회가 아니라 본작업이라 타임아웃은 본조회와 맞춘다.
|
|
348
|
+
*/
|
|
349
|
+
const SKILL_FETCH_TIMEOUT_MS = 30000;
|
|
350
|
+
/**
|
|
351
|
+
* use 의 사람용 메시지는 항상 stderr 로 보낸다.
|
|
352
|
+
* stdout 은 SKILL.md 본문(기계 모드면 JSON 하나) 전용이라, 기본 모드에서 stdout
|
|
353
|
+
* 으로 나가는 printInfo/printWarning 을 쓰면 본문이 오염된다.
|
|
354
|
+
*/
|
|
355
|
+
function stderrNote(message, tone = 'info') {
|
|
356
|
+
console.error(tone === 'warn' ? chalk_1.default.yellow(message) : chalk_1.default.gray(message));
|
|
357
|
+
}
|
|
358
|
+
/** 실패 사유를 한 줄로 요약한다(경고문에 끼워 넣는 용도). */
|
|
359
|
+
function describeRequestFailure(error) {
|
|
360
|
+
const status = error?.response?.status;
|
|
361
|
+
if (status) {
|
|
362
|
+
const data = error?.response?.data;
|
|
363
|
+
const serverMessage = typeof data === 'object' && data ? data.message || data.error : '';
|
|
364
|
+
return serverMessage ? `${status} ${serverMessage}` : `${status}`;
|
|
365
|
+
}
|
|
366
|
+
if (error?.code)
|
|
367
|
+
return String(error.code);
|
|
368
|
+
return error?.message ? String(error.message) : '알 수 없는 오류';
|
|
369
|
+
}
|
|
370
|
+
/** 스코프의 목록(신선도 맵 원본)을 읽는다. 실패는 호출측이 처리한다. */
|
|
371
|
+
async function fetchSkillIndex(client, scope) {
|
|
372
|
+
const { data } = await client.get(skillBasePath(scope));
|
|
373
|
+
return Array.isArray(data) ? data : [];
|
|
374
|
+
}
|
|
375
|
+
/** 목록에서 이름으로 항목(문서 id + 신선도 토큰)을 뽑는다. */
|
|
376
|
+
function indexEntryOf(rows, name) {
|
|
377
|
+
const row = rows.find((r) => r?.name === name);
|
|
378
|
+
if (!row?.id)
|
|
379
|
+
return undefined;
|
|
380
|
+
return { id: String(row.id), token: (0, skills_cache_1.freshnessToken)(row) };
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* 스킬 하나를 통째로 받는다 — SKILL.md 본문(단건 응답) + 번들 파일(파일 API).
|
|
384
|
+
* 번들은 상대경로 그대로 받아 캐시가 폴더 구조를 보존하게 한다.
|
|
385
|
+
*/
|
|
386
|
+
async function downloadSkill(client, scope, entry) {
|
|
387
|
+
const { data } = await client.get(`${skillBasePath(scope)}/${entry.id}`);
|
|
388
|
+
const relPaths = (Array.isArray(data?.files) ? data.files : [])
|
|
389
|
+
.map((f) => String(f?.path ?? ''))
|
|
390
|
+
.filter((p) => p.length > 0);
|
|
391
|
+
const manifestPath = pickRootSkillMd(relPaths) ?? exports.SKILL_MANIFEST_FILENAME;
|
|
392
|
+
const files = [];
|
|
393
|
+
for (const relPath of relPaths) {
|
|
394
|
+
// 매니페스트는 단건 응답의 content 가 이미 원문이라 다시 받지 않는다.
|
|
395
|
+
if (relPath === manifestPath)
|
|
396
|
+
continue;
|
|
397
|
+
const res = await client.get(`${skillBasePath(scope)}/${entry.id}/file`, {
|
|
398
|
+
params: { path: relPath },
|
|
399
|
+
responseType: 'arraybuffer',
|
|
400
|
+
});
|
|
401
|
+
files.push({ path: relPath, data: Buffer.from(res.data) });
|
|
402
|
+
}
|
|
403
|
+
return {
|
|
404
|
+
id: entry.id,
|
|
405
|
+
// 실제로 받은 문서의 시각이 정본이다 — 목록을 읽은 뒤 서버가 바뀌었을 수 있다.
|
|
406
|
+
token: (0, skills_cache_1.freshnessToken)(data) ?? entry.token,
|
|
407
|
+
manifestPath,
|
|
408
|
+
content: typeof data?.content === 'string' ? data.content : '',
|
|
409
|
+
files,
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* use 의 출력 — 기본은 본문 원문, 기계 모드면 캐시 위치까지 담은 JSON 하나.
|
|
414
|
+
*
|
|
415
|
+
* 캐시 폴더는 어느 경로로 끝나든 stderr 로 알린다. SKILL.md 가 번들 파일
|
|
416
|
+
* (`references/...`)을 상대경로로 가리키므로, 그 폴더를 모르면 본문만 읽고
|
|
417
|
+
* 참조를 열 수 없다.
|
|
418
|
+
*/
|
|
419
|
+
function emitSkill(view, globalOpts) {
|
|
420
|
+
if (view.dir) {
|
|
421
|
+
const what = view.source === 'cache' ? '캐시본' : '내려받아 캐시했습니다';
|
|
422
|
+
stderrNote(`스킬 "${view.name}"(${view.scope}) ${what} — 파일 ${view.files.length}개, ${view.dir}`);
|
|
423
|
+
}
|
|
424
|
+
if ((0, output_1.isMachineMode)()) {
|
|
425
|
+
(0, output_1.formatOutput)(view, globalOpts);
|
|
426
|
+
}
|
|
427
|
+
else {
|
|
428
|
+
process.stdout.write(view.content);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
/**
|
|
432
|
+
* 사용 기록 ping 의 제한시간. 적재가 끝난 뒤에 나가는 뒷정리라 본조회(30초)보다
|
|
433
|
+
* 짧게 잡는다 — 서버가 굼뜨다고 다 끝난 명령이 오래 매달려 있으면 안 된다.
|
|
434
|
+
*/
|
|
435
|
+
const SKILL_PING_TIMEOUT_MS = 3000;
|
|
436
|
+
/**
|
|
437
|
+
* 사용 기록 ping(SKILL-015) — 적재 1회를 서버에 알린다.
|
|
438
|
+
*
|
|
439
|
+
* 적재를 블로킹하지 않는다: 본문을 이미 낸 뒤에 보내고, 실패는 삼킨다.
|
|
440
|
+
* 오프라인이면 조용히 넘어간다 — 기록 하나 놓친 걸로 사람을 경고로 붙잡지 않는다.
|
|
441
|
+
* 캐시가 최신이라 네트워크가 0회인 적재에서도 보낸다. 그래야 계측이 된다.
|
|
442
|
+
*
|
|
443
|
+
* 결과를 무시하면서도 기다리는 이유: 요청이 떠 있는 동안은 노드가 어차피 프로세스를
|
|
444
|
+
* 붙잡는다. 안 기다리는 척해 봐야 종료가 늦어지는 건 같으니 명시적으로 기다린다.
|
|
445
|
+
*
|
|
446
|
+
* 그래서 이 수치는 정확한 계수가 아니라 경향치다. 실패한 ping 은 그냥 누락된다.
|
|
447
|
+
*/
|
|
448
|
+
async function pingSkillUsage(client, scope, id) {
|
|
449
|
+
try {
|
|
450
|
+
// 본문은 빈 객체여야 한다. 클라이언트가 Content-Type: application/json 을
|
|
451
|
+
// 달고 나가는데 null 을 주면 axios 가 "null" 을 실어 보내고, 서버의 JSON
|
|
452
|
+
// 파서가 strict 모드라 그걸 400 으로 되던진다(기록이 통째로 유실된다).
|
|
453
|
+
await client.post(`${skillBasePath(scope)}/${id}/use`, {}, { timeout: SKILL_PING_TIMEOUT_MS });
|
|
454
|
+
}
|
|
455
|
+
catch {
|
|
456
|
+
// 기록 실패는 적재의 성패와 무관하다.
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
/** 목록 스냅샷 저장은 부가 효과다 — 실패해도 명령이 흔들리면 안 된다. */
|
|
460
|
+
function saveIndexSnapshotSafely(scope, projectId, rows) {
|
|
461
|
+
try {
|
|
462
|
+
(0, skills_cache_1.saveIndexSnapshot)(scope, projectId, rows);
|
|
463
|
+
}
|
|
464
|
+
catch {
|
|
465
|
+
// 캐시 쓰기 실패는 조회 결과에 영향을 주지 않는다.
|
|
466
|
+
}
|
|
467
|
+
}
|
|
294
468
|
// ── 커맨드 등록 ────────────────────────────────────────────────────────────
|
|
295
469
|
function registerSkillsCommand(program) {
|
|
296
470
|
const skills = program
|
|
297
471
|
.command('skills')
|
|
298
|
-
.description('스킬
|
|
472
|
+
.description('스킬 목록·조회·등록·수정·삭제 (E19) — ch-api /skills 프록시');
|
|
299
473
|
// ch skills list
|
|
300
474
|
skills
|
|
301
475
|
.command('list')
|
|
@@ -306,6 +480,7 @@ function registerSkillsCommand(program) {
|
|
|
306
480
|
.action(async (options) => {
|
|
307
481
|
const globalOpts = program.opts();
|
|
308
482
|
const scopes = scopesToList(options);
|
|
483
|
+
const projectId = (0, config_1.getProjectContext)(globalOpts.project)?.projectId ?? null;
|
|
309
484
|
const client = (0, client_1.createClient)({ projectId: globalOpts.project });
|
|
310
485
|
// 스코프별로 독립 조회해 합친다 — 한쪽(예: 프로젝트 컨텍스트 미해석)이
|
|
311
486
|
// 실패해도 다른 쪽 결과는 보존한다(인터셉터가 exitCode 로 실패를 알린다).
|
|
@@ -313,8 +488,13 @@ function registerSkillsCommand(program) {
|
|
|
313
488
|
for (const scope of scopes) {
|
|
314
489
|
try {
|
|
315
490
|
const { data } = await client.get(skillBasePath(scope));
|
|
316
|
-
if (Array.isArray(data))
|
|
491
|
+
if (Array.isArray(data)) {
|
|
317
492
|
merged.push(...data);
|
|
493
|
+
// 이 응답이 곧 신선도 맵이다(SKILL-011). 저장해 두면 뒤이은
|
|
494
|
+
// `ch skills use` 가 서버를 다시 묻지 않는다. 필터(--group/--tag)
|
|
495
|
+
// 이전의 전체 응답을 넣어야 맵이 온전하다.
|
|
496
|
+
saveIndexSnapshotSafely(scope, projectId, data);
|
|
497
|
+
}
|
|
318
498
|
}
|
|
319
499
|
catch (error) {
|
|
320
500
|
// Error already handled by client interceptor (exitCode/stderr).
|
|
@@ -329,9 +509,16 @@ function registerSkillsCommand(program) {
|
|
|
329
509
|
.description('스킬 단건 조회 — SKILL.md 본문 + 파일 매니페스트, --file 로 개별 파일만')
|
|
330
510
|
.option('--global', '글로벌(팀 공유) 스킬. 생략 시 프로젝트 스킬(대상 프로젝트는 --project <id>/.ch-project/config)')
|
|
331
511
|
.option('--file <path>', '지목한 개별 파일만 반환(스킬 폴더 기준 상대경로). 기본은 파일 원본을 stdout 으로, --json 이면 {scope,name,path,mime,content} JSON')
|
|
512
|
+
.option('--raw', 'SKILL.md 본문만 stdout 으로(메타 표·구분선 없음). --json 이면 {scope,name,content} JSON')
|
|
332
513
|
.action(async (name, options) => {
|
|
333
514
|
const globalOpts = program.opts();
|
|
334
515
|
const scope = resolveScope(options);
|
|
516
|
+
// --file 은 이미 원본을 그대로 내보내므로 --raw 와 겹친다. 어느 쪽이
|
|
517
|
+
// 이겼는지 침묵으로 넘기지 않고 명시적으로 거부한다.
|
|
518
|
+
if (options.raw && options.file) {
|
|
519
|
+
(0, output_1.failInput)('--raw 와 --file 은 함께 쓸 수 없습니다. SKILL.md 본문은 --raw, 개별 파일 원본은 --file 만으로 얻습니다.');
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
335
522
|
try {
|
|
336
523
|
const client = (0, client_1.createClient)({ projectId: globalOpts.project });
|
|
337
524
|
// 서버 엔드포인트는 문서 id 를 받으므로 name→id 를 목록에서 해석한다.
|
|
@@ -368,12 +555,137 @@ function registerSkillsCommand(program) {
|
|
|
368
555
|
}
|
|
369
556
|
// 단건 — SKILL.md 본문 + files 매니페스트.
|
|
370
557
|
const { data } = await client.get(`${skillBasePath(scope)}/${id}`);
|
|
558
|
+
if (options.raw) {
|
|
559
|
+
// 본문만 — 메타 표·`── 섹션 ──` 구분선 없이(에이전트가 그대로 소비).
|
|
560
|
+
// 기계 모드에서는 stdout 이 순수 JSON 하나여야 하므로 --file 과 같은
|
|
561
|
+
// 방식으로 봉투를 씌운다(1.x 출력 계약).
|
|
562
|
+
if ((0, output_1.isMachineMode)()) {
|
|
563
|
+
(0, output_1.formatOutput)({ scope, name, content: data?.content ?? '' }, globalOpts);
|
|
564
|
+
}
|
|
565
|
+
else {
|
|
566
|
+
process.stdout.write(data?.content ?? '');
|
|
567
|
+
}
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
371
570
|
(0, output_1.formatOutput)(data, globalOpts);
|
|
372
571
|
}
|
|
373
572
|
catch (error) {
|
|
374
573
|
// Error already handled by client interceptor
|
|
375
574
|
}
|
|
376
575
|
});
|
|
576
|
+
// ch skills use <name> [--global|--project] [--refresh]
|
|
577
|
+
skills
|
|
578
|
+
.command('use <name>')
|
|
579
|
+
.description('스킬을 로컬 캐시에 적재하고 SKILL.md 본문을 출력 — 캐시가 최신이면 네트워크 없이, 바뀌었으면 그 스킬만 다시 받는다')
|
|
580
|
+
.option('--global', '글로벌(팀 공유) 스킬. 생략 시 프로젝트 스킬(대상 프로젝트는 --project <id>/.ch-project/config)')
|
|
581
|
+
.option('--refresh', '목록 스냅샷을 무시하고 서버에서 신선도를 다시 확인(방금 서버에서 고쳤을 때)')
|
|
582
|
+
.action(async (name, options) => {
|
|
583
|
+
const globalOpts = program.opts();
|
|
584
|
+
const scope = resolveScope(options);
|
|
585
|
+
const projectId = (0, config_1.getProjectContext)(globalOpts.project)?.projectId ?? null;
|
|
586
|
+
// 프로젝트 스킬은 프로젝트마다 같은 이름이 있을 수 있어 캐시 키에
|
|
587
|
+
// projectId 가 필요하다. 미해석이면 어느 캐시인지부터 정할 수 없다.
|
|
588
|
+
if (scope === 'project' && !projectId) {
|
|
589
|
+
(0, output_1.failInput)('프로젝트가 선택되지 않았습니다. --project <projectId> 를 지정하거나 .ch-project / ~/.ch/config.json 에 projectId 를 설정하세요. 팀 공유 스킬이면 --global 을 붙이세요.');
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
const target = { scope, projectId, name };
|
|
593
|
+
const cached = (0, skills_cache_1.readCachedSkill)(target);
|
|
594
|
+
const viewOf = (content, source, stored) => ({
|
|
595
|
+
scope,
|
|
596
|
+
name,
|
|
597
|
+
content,
|
|
598
|
+
dir: stored?.dir ?? null,
|
|
599
|
+
files: stored?.files ?? [],
|
|
600
|
+
source,
|
|
601
|
+
});
|
|
602
|
+
/**
|
|
603
|
+
* 조회가 막혔을 때의 출구 — 캐시본으로 진행하고 경고만 남긴다(fail-open).
|
|
604
|
+
* 스킬 본문은 CLI 가 스스로 받아올 수 있는 것이지 사람이 손대야 하는 게
|
|
605
|
+
* 아니므로, 못 받았다고 명령을 실패로 끝내지 않는다. 캐시조차 없으면
|
|
606
|
+
* 줄 게 없으니 그때는 호출측이 실패로 끝낸다.
|
|
607
|
+
*/
|
|
608
|
+
const fallBackToCache = (reason) => {
|
|
609
|
+
const content = cached ? (0, skills_cache_1.readCachedManifest)(target) : null;
|
|
610
|
+
if (!cached || content === null)
|
|
611
|
+
return false;
|
|
612
|
+
stderrNote(`${reason} 캐시본으로 진행합니다 (캐시 시각 ${cached.cachedAt}).`, 'warn');
|
|
613
|
+
emitSkill(viewOf(content, 'cache', cached), globalOpts);
|
|
614
|
+
return true;
|
|
615
|
+
};
|
|
616
|
+
/** 서버도 캐시도 줄 게 없을 때의 종료 — 두 자리에서 같은 문장을 쓴다. */
|
|
617
|
+
const failNoSource = (reason) => {
|
|
618
|
+
(0, output_1.failInput)(`스킬 "${name}"(${scope})을(를) 받을 수 없고 로컬 캐시에도 없습니다: ${reason}`);
|
|
619
|
+
};
|
|
620
|
+
const client = (0, client_1.createProbeClient)({
|
|
621
|
+
projectId: globalOpts.project,
|
|
622
|
+
timeout: SKILL_FETCH_TIMEOUT_MS,
|
|
623
|
+
});
|
|
624
|
+
if (!client) {
|
|
625
|
+
if (fallBackToCache('API Key 가 없어 서버를 조회하지 못했습니다.')) {
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
(0, output_1.failInput)('API Key가 설정되지 않았습니다. "ch auth login" 명령어로 로그인하세요.');
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
// 1) 신선도 맵 — 세션 스냅샷이 살아 있으면 서버를 묻지 않는다(추가 조회 0회).
|
|
632
|
+
// 스냅샷에 이름이 없으면(그새 등록된 스킬일 수 있다) 서버를 한 번 읽는다.
|
|
633
|
+
let entry = options.refresh
|
|
634
|
+
? undefined
|
|
635
|
+
: (0, skills_cache_1.readIndexSnapshot)(scope, projectId)?.entries[name];
|
|
636
|
+
if (!entry) {
|
|
637
|
+
try {
|
|
638
|
+
const rows = await fetchSkillIndex(client, scope);
|
|
639
|
+
saveIndexSnapshotSafely(scope, projectId, rows);
|
|
640
|
+
entry = indexEntryOf(rows, name);
|
|
641
|
+
}
|
|
642
|
+
catch (error) {
|
|
643
|
+
const reason = describeRequestFailure(error);
|
|
644
|
+
if (fallBackToCache(`서버 목록을 읽지 못했습니다(${reason}).`))
|
|
645
|
+
return;
|
|
646
|
+
failNoSource(reason);
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
if (!entry) {
|
|
651
|
+
// 목록은 읽혔는데 그 이름이 없다 — 조회 실패가 아니라 없는 스킬이다.
|
|
652
|
+
// 낡은 캐시본을 최신인 척 내보내지 않고 실패로 알린다.
|
|
653
|
+
(0, output_1.failInput)(`스킬을 찾을 수 없습니다: "${name}" (${scope}). 목록은 ch skills list 로 확인하세요.` +
|
|
654
|
+
(cached ? ' 로컬 캐시에는 남아 있지만 서버 목록에는 없습니다.' : ''));
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
// 2) 캐시 적중 — 토큰이 같으면 받지 않고 그대로 낸다.
|
|
658
|
+
if ((0, skills_cache_1.isFresh)(cached, entry.token)) {
|
|
659
|
+
const content = (0, skills_cache_1.readCachedManifest)(target);
|
|
660
|
+
if (content !== null) {
|
|
661
|
+
emitSkill(viewOf(content, 'cache', cached), globalOpts);
|
|
662
|
+
await pingSkillUsage(client, scope, entry.id);
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
// 3) 캐시에 없거나 낡았으면 그 스킬만 받아서 캐시한다.
|
|
667
|
+
let payload;
|
|
668
|
+
try {
|
|
669
|
+
payload = await downloadSkill(client, scope, entry);
|
|
670
|
+
}
|
|
671
|
+
catch (error) {
|
|
672
|
+
const reason = describeRequestFailure(error);
|
|
673
|
+
if (fallBackToCache(`스킬을 내려받지 못했습니다(${reason}).`))
|
|
674
|
+
return;
|
|
675
|
+
failNoSource(reason);
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
let stored = null;
|
|
679
|
+
try {
|
|
680
|
+
stored = (0, skills_cache_1.writeCachedSkill)(target, payload);
|
|
681
|
+
}
|
|
682
|
+
catch (e) {
|
|
683
|
+
// 받아온 본문은 이미 손에 있다 — 저장만 실패했다고 출력까지 접지 않는다.
|
|
684
|
+
stderrNote(`캐시에 저장하지 못했습니다(${e?.message ?? e}). 이번에는 받은 본문만 출력합니다.`, 'warn');
|
|
685
|
+
}
|
|
686
|
+
emitSkill(viewOf(payload.content, 'download', stored), globalOpts);
|
|
687
|
+
await pingSkillUsage(client, scope, entry.id);
|
|
688
|
+
});
|
|
377
689
|
// ch skills register <dir> --global|--project
|
|
378
690
|
skills
|
|
379
691
|
.command('register <dir>')
|
|
@@ -459,5 +771,398 @@ function registerSkillsCommand(program) {
|
|
|
459
771
|
// Error already handled
|
|
460
772
|
}
|
|
461
773
|
});
|
|
774
|
+
// ch skills delete <name> --global|--project [--yes]
|
|
775
|
+
skills
|
|
776
|
+
.command('delete <name>')
|
|
777
|
+
.description('스킬 삭제 (파괴적) — 문서와 저장된 파일을 함께 제거')
|
|
778
|
+
.option('--global', '글로벌(팀 공유) 스킬. 생략 시 프로젝트 스킬(대상 프로젝트는 --project <id>/.ch-project/config)')
|
|
779
|
+
.option('--yes', '확인 없이 바로 삭제(스크립트·에이전트용)')
|
|
780
|
+
.action(async (name, options) => {
|
|
781
|
+
const globalOpts = program.opts();
|
|
782
|
+
const scope = resolveScope(options);
|
|
783
|
+
try {
|
|
784
|
+
const client = (0, client_1.createClient)({ projectId: globalOpts.project });
|
|
785
|
+
// 서버 DELETE 는 문서 id 를 받으므로 name→id 를 목록에서 해석한다.
|
|
786
|
+
// 없는 이름이면 여기서 끝난다 — 확인 프롬프트로 사람을 붙잡지 않는다.
|
|
787
|
+
const { data: list } = await client.get(skillBasePath(scope));
|
|
788
|
+
const id = findSkillIdByName(Array.isArray(list) ? list : [], name);
|
|
789
|
+
if (!id) {
|
|
790
|
+
(0, output_1.failInput)(`삭제할 스킬을 찾을 수 없습니다: "${name}" (${scope}). 목록은 ch skills list 로 확인하세요.`);
|
|
791
|
+
return;
|
|
792
|
+
}
|
|
793
|
+
if (globalOpts.dryRun) {
|
|
794
|
+
(0, output_1.printInfo)(`[DRY-RUN] 스킬 "${name}"(${scope}) 삭제 요청`);
|
|
795
|
+
return;
|
|
796
|
+
}
|
|
797
|
+
const plan = planConfirmation({
|
|
798
|
+
yes: options.yes,
|
|
799
|
+
// 기계 모드(--json)는 사람이 답할 자리가 아니므로 대화형에서 제외한다.
|
|
800
|
+
interactive: !!process.stdin.isTTY && !(0, output_1.isMachineMode)(),
|
|
801
|
+
});
|
|
802
|
+
if (plan === 'refuse') {
|
|
803
|
+
(0, output_1.failInput)(`삭제 확인을 받을 수 없습니다(비대화형). 스킬 "${name}"(${scope})을(를) 지우려면 --yes 를 붙이세요.`);
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
806
|
+
if (plan === 'prompt') {
|
|
807
|
+
const confirmed = await askConfirmation(`스킬 "${name}"(${scope})을(를) 삭제합니다. 파일까지 함께 지워지며 되돌릴 수 없습니다. 계속할까요? (y/N) `);
|
|
808
|
+
if (!confirmed) {
|
|
809
|
+
// 사람이 스스로 그만둔 것이므로 실패가 아니다(exit 0).
|
|
810
|
+
(0, output_1.printWarning)('삭제를 취소했습니다. 아무것도 지우지 않았습니다.');
|
|
811
|
+
return;
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
// 인가(글로벌=systemAdmin, 프로젝트=admin·developer)는 서버가 판정한다.
|
|
815
|
+
const { data } = await client.delete(`${skillBasePath(scope)}/${id}`);
|
|
816
|
+
// 서버에서 지운 스킬이 로컬 캐시에 유령으로 남으면 `use` 가 없는 스킬의
|
|
817
|
+
// 본문을 계속 내준다. 삭제가 성공한 자리에서 캐시도 함께 회수한다.
|
|
818
|
+
let cacheReclaimed = false;
|
|
819
|
+
try {
|
|
820
|
+
cacheReclaimed = (0, skills_cache_1.removeCachedSkill)({
|
|
821
|
+
scope,
|
|
822
|
+
projectId: (0, config_1.getProjectContext)(globalOpts.project)?.projectId ?? null,
|
|
823
|
+
name,
|
|
824
|
+
});
|
|
825
|
+
}
|
|
826
|
+
catch {
|
|
827
|
+
// 로컬 캐시 정리 실패가 서버 삭제 결과를 뒤집지는 않는다.
|
|
828
|
+
}
|
|
829
|
+
// 기계 모드에서는 서버 응답을 stdout 에 순수 JSON 으로 남겨 JSON.parse 가
|
|
830
|
+
// 되게 한다. 사람용(표) 모드에서는 성공 한 줄이면 충분하다.
|
|
831
|
+
if ((0, output_1.isMachineMode)())
|
|
832
|
+
(0, output_1.formatOutput)(data, globalOpts);
|
|
833
|
+
(0, output_1.printSuccess)(`스킬 "${name}"이(가) 삭제되었습니다 (${scope}).` +
|
|
834
|
+
(cacheReclaimed ? ' 로컬 캐시도 회수했습니다.' : ''));
|
|
835
|
+
}
|
|
836
|
+
catch (error) {
|
|
837
|
+
// Error already handled by client interceptor (403·404 등)
|
|
838
|
+
}
|
|
839
|
+
});
|
|
840
|
+
// ch skills sync --full [--scope global|project|all] [--prune]
|
|
841
|
+
skills
|
|
842
|
+
.command('sync')
|
|
843
|
+
.description('공유스킬 본문을 전량 로컬 캐시에 내려받아 로컬 검색을 가능하게 한다 — 목록 1회로 대조해 바뀐 것만 받는다')
|
|
844
|
+
.option('--full', '전량 동기화(현재 유일한 모드). 무거운 작업이라 명시적으로 켠다')
|
|
845
|
+
.option('--scope <scope>', '동기화할 스코프: global | project | all (기본 all — 글로벌+프로젝트)', 'all')
|
|
846
|
+
.option('--prune', '서버 목록에 없는 스킬의 캐시를 제거')
|
|
847
|
+
.action(async (options) => {
|
|
848
|
+
await runSkillsSync(options, program.opts());
|
|
849
|
+
});
|
|
850
|
+
// ch skills diff [--local <dir>] [--global]
|
|
851
|
+
skills
|
|
852
|
+
.command('diff')
|
|
853
|
+
.description('서버 공유스킬과 로컬 사본을 대조해 어느 쪽이 최신인지 판정 — 판정만 한다(삭제·업로드 없음)')
|
|
854
|
+
.option('--global', '글로벌(팀 공유) 스킬만 대조. 생략 시 글로벌+프로젝트를 함께 대조(로컬 폴더에는 스코프 표시가 없어 한쪽만 보면 오분류된다)')
|
|
855
|
+
.option('--local <dir>', '대조할 로컬 스킬 폴더(기본: ~/.claude/skills). 읽기만 한다.')
|
|
856
|
+
.action(async (options) => {
|
|
857
|
+
const globalOpts = program.opts();
|
|
858
|
+
const projectId = (0, config_1.getProjectContext)(globalOpts.project)?.projectId ?? null;
|
|
859
|
+
const localDir = path.resolve(options.local ?? (0, skills_diff_1.defaultLocalSkillsDir)());
|
|
860
|
+
// 로컬 폴더는 읽기 전용 대상이다. 사용자가 지목한 경로가 없으면 오타일
|
|
861
|
+
// 가능성이 크니 실패로 알리고, 기본 경로가 없으면 "사본이 없는 상태"로 본다.
|
|
862
|
+
const localExists = isDirectory(localDir);
|
|
863
|
+
if (!localExists) {
|
|
864
|
+
if (options.local) {
|
|
865
|
+
(0, output_1.failInput)(`로컬 스킬 폴더를 찾을 수 없습니다: ${localDir}`);
|
|
866
|
+
return;
|
|
867
|
+
}
|
|
868
|
+
(0, output_1.printWarning)(`로컬 스킬 폴더가 없습니다(${localDir}) — 로컬 사본이 없는 것으로 보고 대조합니다.`);
|
|
869
|
+
}
|
|
870
|
+
const { copies, skipped } = localExists
|
|
871
|
+
? (0, skills_diff_1.scanLocalSkills)(localDir, (md) => parseSkillFrontmatter(md).name)
|
|
872
|
+
: { copies: [], skipped: [] };
|
|
873
|
+
// 스킬 폴더 하나를 직접 가리킨 경우 — 하위에 스킬이 없어 "로컬 사본 없음"으로
|
|
874
|
+
// 보인다. 그 침묵이 곧 오답이므로(사본이 있는데 없다고 읽힌다) 짚어 준다.
|
|
875
|
+
if (copies.length === 0 &&
|
|
876
|
+
localExists &&
|
|
877
|
+
fs.existsSync(path.join(localDir, exports.SKILL_MANIFEST_FILENAME))) {
|
|
878
|
+
(0, output_1.printWarning)('지정한 폴더 자체가 스킬 폴더입니다(SKILL.md 가 있습니다) — 스킬들을 담은 상위 폴더를 지정하세요.');
|
|
879
|
+
}
|
|
880
|
+
const client = (0, client_1.createClient)({ projectId: globalOpts.project });
|
|
881
|
+
const serverEntries = [];
|
|
882
|
+
const scanned = [];
|
|
883
|
+
for (const scope of scopesToList(options)) {
|
|
884
|
+
if (scope === 'project' && !projectId) {
|
|
885
|
+
(0, output_1.printWarning)('프로젝트가 정해지지 않아 프로젝트 스킬은 건너뜁니다(--project <id> / .ch-project). 팀 공유 스킬만 대조합니다.');
|
|
886
|
+
continue;
|
|
887
|
+
}
|
|
888
|
+
try {
|
|
889
|
+
const rows = await fetchSkillIndex(client, scope);
|
|
890
|
+
saveIndexSnapshotSafely(scope, projectId, rows);
|
|
891
|
+
serverEntries.push(...rows.map((row) => toServerEntry(scope, row)));
|
|
892
|
+
scanned.push(scope);
|
|
893
|
+
}
|
|
894
|
+
catch {
|
|
895
|
+
// 반쪽 목록으로 판정하면 서버에 있는 스킬이 "로컬에만 있음"으로 뒤집혀
|
|
896
|
+
// 사람이 지우면 안 될 것을 지우게 된다. 그래서 대조를 접는다.
|
|
897
|
+
(0, output_1.failInput)(`서버 스킬 목록(${scope})을 읽지 못해 대조를 중단합니다 — 반쪽 목록으로 판정하면 로컬 사본을 잘못 정리하게 됩니다.`);
|
|
898
|
+
return;
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
// 본문은 겹치는 스킬만 받는다(로컬 사본이 없는 스킬은 본문이 필요 없다).
|
|
902
|
+
const localKeys = new Set(copies.flatMap(skills_diff_1.localMatchKeys));
|
|
903
|
+
const manifests = new Map();
|
|
904
|
+
for (const entry of serverEntries) {
|
|
905
|
+
if (!localKeys.has((0, skills_diff_1.normalizeSkillKey)(entry.name)))
|
|
906
|
+
continue;
|
|
907
|
+
manifests.set((0, skills_diff_1.manifestKey)(entry), await loadServerManifest(client, entry, projectId));
|
|
908
|
+
}
|
|
909
|
+
const result = (0, skills_diff_1.buildDiffResult)({
|
|
910
|
+
localDir,
|
|
911
|
+
scopes: scanned,
|
|
912
|
+
serverEntries,
|
|
913
|
+
localCopies: copies,
|
|
914
|
+
skipped,
|
|
915
|
+
manifests,
|
|
916
|
+
});
|
|
917
|
+
if ((0, output_1.isMachineMode)())
|
|
918
|
+
(0, output_1.formatOutput)(result, globalOpts);
|
|
919
|
+
else
|
|
920
|
+
console.log((0, skills_diff_1.renderDiffReport)(result));
|
|
921
|
+
});
|
|
922
|
+
}
|
|
923
|
+
// ── sync --full (SKILL-016) ───────────────────────────────────────────────
|
|
924
|
+
//
|
|
925
|
+
// 목록은 이름·설명 인덱스만 준다. 본문은 GCS 에 있어 서버 전문검색이 없으므로
|
|
926
|
+
// "어떤 스킬이 무엇을 참조하나" 같은 질문에 스킬 수만큼의 조회가 들었다. 전량을
|
|
927
|
+
// 캐시해 두면 같은 질문이 로컬 파일 검색 한 번으로 끝난다.
|
|
928
|
+
//
|
|
929
|
+
// `use` 가 스킬 1건에 하는 일(목록으로 신선도 확인 → 낡았으면 그것만 재다운로드)
|
|
930
|
+
// 을 전량으로 넓힌 것뿐이다 — 저장소·신선도 규칙은 `use` 와 같은 것을 쓴다.
|
|
931
|
+
/**
|
|
932
|
+
* `--scope` 값을 훑을 스코프 목록으로 바꾼다. 기본(값 없음)은 글로벌+프로젝트 —
|
|
933
|
+
* `list` 의 기본과 같은 합집합이다(로컬 검색은 둘 다 있어야 온전하다).
|
|
934
|
+
* 알 수 없는 값이면 null 을 돌려 호출측이 입력 오류로 끝내게 한다.
|
|
935
|
+
*/
|
|
936
|
+
function parseSyncScopes(value) {
|
|
937
|
+
const normalized = (value ?? 'all').trim().toLowerCase();
|
|
938
|
+
if (normalized === '' || normalized === 'all')
|
|
939
|
+
return ['global', 'project'];
|
|
940
|
+
if (normalized === 'global')
|
|
941
|
+
return ['global'];
|
|
942
|
+
if (normalized === 'project')
|
|
943
|
+
return ['project'];
|
|
944
|
+
return null;
|
|
945
|
+
}
|
|
946
|
+
/**
|
|
947
|
+
* 한 스코프를 동기화한다 — 목록 1회 → 캐시 대조 → 바뀐 것만 다운로드.
|
|
948
|
+
*
|
|
949
|
+
* 개별 스킬의 실패로 전체를 중단하지 않는다. 52개 중 1개가 막혔다고 나머지를
|
|
950
|
+
* 포기하면 다음 실행이 또 전량을 받아야 한다. 실패는 세어 두었다가 종료코드로 알린다.
|
|
951
|
+
*/
|
|
952
|
+
async function syncSkillScope(client, scope, projectId, opts) {
|
|
953
|
+
const result = {
|
|
954
|
+
scope,
|
|
955
|
+
projectId,
|
|
956
|
+
total: 0,
|
|
957
|
+
downloaded: 0,
|
|
958
|
+
upToDate: 0,
|
|
959
|
+
pruned: 0,
|
|
960
|
+
failed: 0,
|
|
961
|
+
};
|
|
962
|
+
let rows;
|
|
963
|
+
try {
|
|
964
|
+
rows = await fetchSkillIndex(client, scope);
|
|
965
|
+
}
|
|
966
|
+
catch (error) {
|
|
967
|
+
// 목록을 못 읽으면 이 스코프는 대조할 근거가 없다. 특히 --prune 은 목록을
|
|
968
|
+
// 근거로 지우므로, 반쪽 정보로 멀쩡한 캐시를 지우지 않도록 통째로 건너뛴다.
|
|
969
|
+
result.error = describeRequestFailure(error);
|
|
970
|
+
(0, output_1.printWarning)(`${scope} 목록을 읽지 못해 건너뜁니다(${result.error}). 캐시는 그대로 둡니다.`);
|
|
971
|
+
return result;
|
|
972
|
+
}
|
|
973
|
+
// 이 목록이 곧 신선도 맵이다(SKILL-011). 저장해 두면 뒤이은 `ch skills use` 가
|
|
974
|
+
// 서버를 다시 묻지 않는다. dry-run 은 로컬 상태도 남기지 않는다 — 시뮬레이션이
|
|
975
|
+
// 뒤이은 명령의 신선도 판정을 바꾸면 그건 이미 시뮬레이션이 아니다.
|
|
976
|
+
if (!opts.dryRun)
|
|
977
|
+
saveIndexSnapshotSafely(scope, projectId, rows);
|
|
978
|
+
result.total = rows.length;
|
|
979
|
+
const serverNames = new Set();
|
|
980
|
+
let seen = 0;
|
|
981
|
+
for (const row of rows) {
|
|
982
|
+
seen += 1;
|
|
983
|
+
const name = typeof row?.name === 'string' ? row.name.trim() : '';
|
|
984
|
+
const id = row?.id ? String(row.id) : '';
|
|
985
|
+
if (!name || !id) {
|
|
986
|
+
// 이름이나 id 가 없는 항목은 받을 수도, 캐시 키를 만들 수도 없다.
|
|
987
|
+
result.failed += 1;
|
|
988
|
+
(0, output_1.printWarning)(`이름·id 가 없는 목록 항목을 건너뜁니다 (${scope}).`);
|
|
989
|
+
continue;
|
|
990
|
+
}
|
|
991
|
+
serverNames.add(name);
|
|
992
|
+
const target = { scope, projectId, name };
|
|
993
|
+
const token = (0, skills_cache_1.freshnessToken)(row);
|
|
994
|
+
// 토큰이 같으면 받지 않는다. 어느 한쪽이라도 토큰을 모르면 신선하다고 우기지
|
|
995
|
+
// 않고 다시 받는다 — `use` 와 같은 규칙이다.
|
|
996
|
+
if ((0, skills_cache_1.isFresh)((0, skills_cache_1.readCachedSkill)(target), token)) {
|
|
997
|
+
result.upToDate += 1;
|
|
998
|
+
continue;
|
|
999
|
+
}
|
|
1000
|
+
if (opts.dryRun) {
|
|
1001
|
+
result.downloaded += 1;
|
|
1002
|
+
(0, output_1.printInfo)(`[DRY-RUN] (${seen}/${rows.length}) ${scope}/${name} 내려받기`);
|
|
1003
|
+
continue;
|
|
1004
|
+
}
|
|
1005
|
+
try {
|
|
1006
|
+
const payload = await downloadSkill(client, scope, { id, token });
|
|
1007
|
+
(0, skills_cache_1.writeCachedSkill)(target, payload);
|
|
1008
|
+
result.downloaded += 1;
|
|
1009
|
+
(0, output_1.printInfo)(`(${seen}/${rows.length}) ${scope}/${name} — 파일 ${(payload.files?.length ?? 0) + 1}개 캐시`);
|
|
1010
|
+
}
|
|
1011
|
+
catch (error) {
|
|
1012
|
+
result.failed += 1;
|
|
1013
|
+
(0, output_1.printWarning)(`${scope}/${name} 을(를) 받아 캐시하지 못했습니다(${describeRequestFailure(error)}). 나머지는 계속합니다.`);
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
if (opts.prune) {
|
|
1017
|
+
for (const cached of (0, skills_cache_1.listCachedSkills)()) {
|
|
1018
|
+
if (cached.scope !== scope)
|
|
1019
|
+
continue;
|
|
1020
|
+
// 프로젝트 캐시는 프로젝트마다 따로 산다 — 지금 동기화한 프로젝트의 것만 본다.
|
|
1021
|
+
if (scope === 'project' && cached.projectId !== projectId)
|
|
1022
|
+
continue;
|
|
1023
|
+
if (serverNames.has(cached.name))
|
|
1024
|
+
continue;
|
|
1025
|
+
if (opts.dryRun) {
|
|
1026
|
+
result.pruned += 1;
|
|
1027
|
+
(0, output_1.printInfo)(`[DRY-RUN] ${scope}/${cached.name} 캐시 제거`);
|
|
1028
|
+
continue;
|
|
1029
|
+
}
|
|
1030
|
+
try {
|
|
1031
|
+
if ((0, skills_cache_1.removeCachedSkill)({
|
|
1032
|
+
scope,
|
|
1033
|
+
projectId: cached.projectId,
|
|
1034
|
+
name: cached.name,
|
|
1035
|
+
})) {
|
|
1036
|
+
result.pruned += 1;
|
|
1037
|
+
(0, output_1.printInfo)(`${scope}/${cached.name} — 서버에 없어 캐시에서 제거`);
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
catch (e) {
|
|
1041
|
+
result.failed += 1;
|
|
1042
|
+
(0, output_1.printWarning)(`${scope}/${cached.name} 캐시를 지우지 못했습니다(${e?.message ?? e}).`);
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
return result;
|
|
1047
|
+
}
|
|
1048
|
+
/**
|
|
1049
|
+
* `ch skills sync --full` 본체.
|
|
1050
|
+
*
|
|
1051
|
+
* 커맨드 등록부는 얇게 두고 실제 일은 여기서 한다(같은 파일을 여러 작업이 동시에
|
|
1052
|
+
* 건드리므로 등록부는 최소로).
|
|
1053
|
+
*/
|
|
1054
|
+
async function runSkillsSync(options, globalOpts) {
|
|
1055
|
+
// 전량 동기화는 스킬 수만큼 네트워크를 쓰는 무거운 작업이라 옵트인이다.
|
|
1056
|
+
// 맨몸 `ch skills sync` 를 조용히 전량 실행으로 해석하지 않는다.
|
|
1057
|
+
if (!options.full) {
|
|
1058
|
+
(0, output_1.failInput)('ch skills sync 는 전량 동기화만 지원합니다 — "ch skills sync --full" 로 실행하세요. 스킬 1건만 필요하면 ch skills use <name> 를 쓰세요.');
|
|
1059
|
+
return;
|
|
1060
|
+
}
|
|
1061
|
+
const requested = parseSyncScopes(options.scope);
|
|
1062
|
+
if (!requested) {
|
|
1063
|
+
(0, output_1.failInput)(`--scope 값이 올바르지 않습니다: "${options.scope}". global | project | all 중 하나여야 합니다.`);
|
|
1064
|
+
return;
|
|
1065
|
+
}
|
|
1066
|
+
const projectId = (0, config_1.getProjectContext)(globalOpts.project)?.projectId ?? null;
|
|
1067
|
+
// 프로젝트 스킬은 프로젝트마다 같은 이름이 있을 수 있어 캐시 키에 projectId 가
|
|
1068
|
+
// 필요하다. 미해석이면 어느 캐시인지부터 정할 수 없어 그 스코프는 돌 수 없다.
|
|
1069
|
+
const scopes = requested.filter((scope) => scope !== 'project' || projectId);
|
|
1070
|
+
if (scopes.length === 0) {
|
|
1071
|
+
(0, output_1.failInput)('프로젝트가 선택되지 않았습니다. --project <projectId> 를 지정하거나 .ch-project / ~/.ch/config.json 에 projectId 를 설정하세요. 팀 공유 스킬만 받으려면 --scope global 을 쓰세요.');
|
|
1072
|
+
return;
|
|
1073
|
+
}
|
|
1074
|
+
if (scopes.length < requested.length) {
|
|
1075
|
+
// 기본(all)으로 왔는데 프로젝트가 없는 경우다 — 글로벌은 받을 수 있으므로
|
|
1076
|
+
// 실패로 끝내지 않고 받을 수 있는 것만 받는다.
|
|
1077
|
+
(0, output_1.printWarning)('프로젝트가 선택되지 않아 글로벌 스킬만 동기화합니다. 프로젝트 스킬도 받으려면 --project <projectId> 를 지정하세요.');
|
|
1078
|
+
}
|
|
1079
|
+
const client = (0, client_1.createProbeClient)({
|
|
1080
|
+
projectId: globalOpts.project,
|
|
1081
|
+
timeout: SKILL_FETCH_TIMEOUT_MS,
|
|
1082
|
+
});
|
|
1083
|
+
if (!client) {
|
|
1084
|
+
(0, output_1.failInput)('API Key가 설정되지 않았습니다. "ch auth login" 명령어로 로그인하세요.');
|
|
1085
|
+
return;
|
|
1086
|
+
}
|
|
1087
|
+
const dryRun = !!globalOpts.dryRun;
|
|
1088
|
+
const results = [];
|
|
1089
|
+
for (const scope of scopes) {
|
|
1090
|
+
// 스코프는 차례로 돈다. 스킬도 차례로 받는다 — 서버를 동시에 두드리지 않고,
|
|
1091
|
+
// 무거운 건 어차피 최초 1회뿐이다.
|
|
1092
|
+
results.push(await syncSkillScope(client, scope, scope === 'global' ? null : projectId, { prune: !!options.prune, dryRun }));
|
|
1093
|
+
}
|
|
1094
|
+
const sum = (pick) => results.reduce((acc, r) => acc + pick(r), 0);
|
|
1095
|
+
const failedScopes = results.filter((r) => r.error);
|
|
1096
|
+
const summary = {
|
|
1097
|
+
scanned: sum((r) => r.total),
|
|
1098
|
+
downloaded: sum((r) => r.downloaded),
|
|
1099
|
+
upToDate: sum((r) => r.upToDate),
|
|
1100
|
+
pruned: sum((r) => r.pruned),
|
|
1101
|
+
failed: sum((r) => r.failed),
|
|
1102
|
+
dryRun,
|
|
1103
|
+
// 캐시 폴더를 알려 준다 — 이 명령의 목적이 이 폴더에서의 로컬 검색이다.
|
|
1104
|
+
cacheDir: (0, skills_cache_1.cacheRoot)(),
|
|
1105
|
+
scopes: results,
|
|
1106
|
+
};
|
|
1107
|
+
(0, output_1.formatOutput)(summary, globalOpts);
|
|
1108
|
+
if (!(0, output_1.isMachineMode)()) {
|
|
1109
|
+
(0, output_1.printInfo)(`로컬 검색은 이 폴더에서: ${(0, skills_cache_1.cacheRoot)()}`);
|
|
1110
|
+
}
|
|
1111
|
+
if (summary.failed > 0 || failedScopes.length > 0) {
|
|
1112
|
+
// 반쪽 동기화를 성공으로 보고하지 않는다 — 부른 쪽이 종료코드만으로 알 수 있게.
|
|
1113
|
+
(0, output_1.failInput)(`동기화를 마쳤지만 실패가 있습니다 — 스킬 ${summary.failed}건${failedScopes.length
|
|
1114
|
+
? `, 스코프 ${failedScopes.map((r) => r.scope).join('·')}`
|
|
1115
|
+
: ''}. 다시 실행하면 실패한 것만 받습니다.`);
|
|
1116
|
+
return;
|
|
1117
|
+
}
|
|
1118
|
+
(0, output_1.printSuccess)(`스킬 캐시 동기화 완료 — 조회 ${summary.scanned}건 중 ${dryRun ? '받을 것' : '내려받음'} ${summary.downloaded}건, 최신 ${summary.upToDate}건` +
|
|
1119
|
+
(options.prune ? `, 정리 ${summary.pruned}건` : ''));
|
|
1120
|
+
}
|
|
1121
|
+
// ── diff 보조 (SKILL-012) ─────────────────────────────────────────────────
|
|
1122
|
+
/** 경로가 폴더인지. 접근할 수 없으면 폴더가 아닌 것으로 본다. */
|
|
1123
|
+
function isDirectory(target) {
|
|
1124
|
+
try {
|
|
1125
|
+
return fs.statSync(target).isDirectory();
|
|
1126
|
+
}
|
|
1127
|
+
catch {
|
|
1128
|
+
return false;
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
/** 목록 응답 한 줄을 대조용 항목으로 좁힌다(신선도 토큰 = updatedAt ?? createdAt). */
|
|
1132
|
+
function toServerEntry(scope, row) {
|
|
1133
|
+
return {
|
|
1134
|
+
scope,
|
|
1135
|
+
id: String(row?.id ?? ''),
|
|
1136
|
+
name: String(row?.name ?? ''),
|
|
1137
|
+
token: (0, skills_cache_1.freshnessToken)(row),
|
|
1138
|
+
fileCount: typeof row?.fileCount === 'number' ? row.fileCount : null,
|
|
1139
|
+
};
|
|
1140
|
+
}
|
|
1141
|
+
/**
|
|
1142
|
+
* 대조할 서버 본문을 얻는다 — 캐시가 신선하면 캐시본(네트워크 0), 아니면 단건 조회.
|
|
1143
|
+
*
|
|
1144
|
+
* 캐시는 **읽기만** 한다. 여기서 받은 본문을 캐시에 적재하면 번들 파일 없이 레코드가
|
|
1145
|
+
* 생겨 뒤이은 `use` 가 반쪽 폴더를 최신인 양 내주게 된다(캐시 계약은 폴더 통째 교체다).
|
|
1146
|
+
* 본문을 못 받으면 null — 그 항목만 "판정 보류"가 되고 대조 전체는 계속된다.
|
|
1147
|
+
*/
|
|
1148
|
+
async function loadServerManifest(client, entry, projectId) {
|
|
1149
|
+
const target = {
|
|
1150
|
+
scope: entry.scope,
|
|
1151
|
+
projectId,
|
|
1152
|
+
name: entry.name,
|
|
1153
|
+
};
|
|
1154
|
+
if ((0, skills_cache_1.isFresh)((0, skills_cache_1.readCachedSkill)(target), entry.token)) {
|
|
1155
|
+
const cachedManifest = (0, skills_cache_1.readCachedManifest)(target);
|
|
1156
|
+
if (cachedManifest !== null)
|
|
1157
|
+
return cachedManifest;
|
|
1158
|
+
}
|
|
1159
|
+
try {
|
|
1160
|
+
const { data } = await client.get(`${skillBasePath(entry.scope)}/${entry.id}`);
|
|
1161
|
+
return typeof data?.content === 'string' ? data.content : null;
|
|
1162
|
+
}
|
|
1163
|
+
catch {
|
|
1164
|
+
// 인터셉터가 이미 사유를 알리고 exitCode 를 세웠다.
|
|
1165
|
+
return null;
|
|
1166
|
+
}
|
|
462
1167
|
}
|
|
463
1168
|
//# sourceMappingURL=skills.js.map
|