@rch4com/agent-setup 1.28.0 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -20
- package/install.mjs +25 -8
- package/lib/activation.mjs +31 -0
- package/lib/args.mjs +5 -2
- package/lib/bootstrap/manifest.mjs +21 -47
- package/lib/bootstrap/templates.mjs +7 -60
- package/lib/catalog.mjs +22 -241
- package/lib/clis.mjs +11 -118
- package/lib/commands.mjs +47 -0
- package/lib/engine.mjs +16 -11
- package/lib/i18n/catalog/en.mjs +58 -49
- package/lib/i18n/catalog/ko.mjs +58 -49
- package/lib/items/mcp.codebase-memory.mjs +6 -4
- package/lib/items/mcp.graft.mjs +6 -4
- package/lib/items/mcp.graphify.mjs +6 -4
- package/lib/items/mcp.headroom.mjs +6 -4
- package/lib/items/mcp.notion.mjs +7 -4
- package/lib/items/mcp.supabase.mjs +7 -4
- package/lib/items/mcp.vercel.mjs +7 -4
- package/lib/items/plugin.ai-memory.mjs +29 -0
- package/lib/items/plugin.cli-anything.mjs +18 -15
- package/lib/items/plugin.ecc.mjs +22 -20
- package/lib/items/plugin.hyperframes.mjs +17 -13
- package/lib/items/plugin.impeccable.mjs +19 -22
- package/lib/items/plugin.mattpocock-skills.mjs +23 -17
- package/lib/items/plugin.ponytail.mjs +24 -106
- package/lib/items/plugin.superpowers.mjs +20 -24
- package/lib/items/plugin.understand-anything.mjs +21 -19
- package/lib/items/skill.agent-browser.mjs +6 -6
- package/lib/items/skill.agent-handoff-manager.mjs +14 -0
- package/lib/items/skill.archify.mjs +5 -5
- package/lib/items/skill.caveman.mjs +5 -5
- package/lib/items/skill.diagram-design.mjs +5 -5
- package/lib/items/skill.eli5.mjs +6 -6
- package/lib/items/skill.find-skills.mjs +5 -5
- package/lib/items/skill.gsd.mjs +28 -89
- package/lib/items/skill.gstack.mjs +58 -65
- package/lib/items/skill.hallmark.mjs +5 -5
- package/lib/items/skill.html-plan.mjs +15 -0
- package/lib/items/skill.i-have-adhd.mjs +6 -6
- package/lib/items/skill.im-not-ai.mjs +5 -6
- package/lib/items/skill.impeccable.mjs +6 -6
- package/lib/items/skill.karpathy.mjs +5 -5
- package/lib/items/skill.mattpocock-skills.mjs +5 -6
- package/lib/items/skill.mcp-builder.mjs +5 -5
- package/lib/items/skill.officecli.mjs +5 -5
- package/lib/items/skill.prompt-master.mjs +5 -5
- package/lib/items/skill.remotion.mjs +5 -5
- package/lib/items/skill.strix.mjs +5 -6
- package/lib/items/skill.superpowers.mjs +6 -7
- package/lib/items/skill.taste.mjs +5 -5
- package/lib/items/skill.unslop.mjs +12 -0
- package/lib/probe.mjs +136 -0
- package/lib/recipe-helpers.mjs +281 -0
- package/lib/recipe.mjs +181 -0
- package/lib/skills-registry.mjs +93 -0
- package/lib/status.mjs +7 -3
- package/lib/tui/detail.mjs +5 -25
- package/lib/tui/rows.mjs +25 -14
- package/lib/tui/run.mjs +35 -8
- package/package.json +4 -3
- package/lib/claude-plugins.mjs +0 -64
- package/lib/global-plugin.mjs +0 -239
- package/lib/items/global.ponytail.mjs +0 -56
- package/lib/items/global.superpowers.mjs +0 -58
- package/lib/tomlfile.mjs +0 -67
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// 공유 스킬 디렉터리(.agents/skills)와 레지스트리 잠금 파일을 읽고 정리하는
|
|
2
|
+
// 보조 함수. 감지(probe)와 레지스트리 스킬 레시피가 함께 쓴다.
|
|
3
|
+
import { existsSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { repoPath, repoPathStrict } from './context.mjs'
|
|
6
|
+
import { escapeRegExp, isSafeSegment } from './untrusted.mjs'
|
|
7
|
+
|
|
8
|
+
// 이 저장소가 공유 스킬 자리로 쓰는 디렉터리. vercel-labs/skills 레지스트리의
|
|
9
|
+
// `--agent universal` 프로젝트 경로와 같은 값이다 — 그래서 레지스트리 설치가
|
|
10
|
+
// 우리 배선 위에 그대로 얹힌다.
|
|
11
|
+
export const SHARED_SKILLS = '.agents/skills'
|
|
12
|
+
|
|
13
|
+
function skillDirs(root) {
|
|
14
|
+
const dir = repoPath(root, SHARED_SKILLS)
|
|
15
|
+
if (!existsSync(dir)) return []
|
|
16
|
+
return readdirSync(dir).filter((name) => {
|
|
17
|
+
try {
|
|
18
|
+
return statSync(join(dir, name)).isDirectory()
|
|
19
|
+
} catch {
|
|
20
|
+
return false
|
|
21
|
+
}
|
|
22
|
+
})
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// 설치된 디렉터리 이름이 스킬 이름과 다를 수 있다(레지스트리는 SKILL.md의
|
|
26
|
+
// frontmatter name으로 고르는데, 저장소의 폴더 이름은 다른 경우가 있다 —
|
|
27
|
+
// taste-skill/이 design-taste-frontend를 담는 식이다). 이름이 같은 디렉터리를
|
|
28
|
+
// 먼저 보고, 없으면 frontmatter를 읽어 찾는다. 디렉터리 이름만 보면 설치해
|
|
29
|
+
// 놓고도 계속 '미설치'로 읽힌다.
|
|
30
|
+
export function findSkillDir(root, skill) {
|
|
31
|
+
const base = repoPath(root, SHARED_SKILLS)
|
|
32
|
+
if (existsSync(join(base, skill, 'SKILL.md'))) return skill
|
|
33
|
+
for (const name of skillDirs(root)) {
|
|
34
|
+
const file = join(base, name, 'SKILL.md')
|
|
35
|
+
if (!existsSync(file)) continue
|
|
36
|
+
const head = readFileSync(file, 'utf8').slice(0, 2000)
|
|
37
|
+
if (new RegExp(`^name:\\s*['"]?${escapeRegExp(skill)}['"]?\\s*$`, 'm').test(head)) return name
|
|
38
|
+
}
|
|
39
|
+
return null
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// 잠금 파일의 이름은 상류 SKILL.md frontmatter에서 온다 — 신뢰 경계 밖이다.
|
|
43
|
+
// `npx skills remove <이름>`의 인자이자 `.agents/skills/<이름>` 경로가 되므로,
|
|
44
|
+
// 플래그로 읽히는 선행 대시와 경로 구분자·상위 이동은 어느 쪽으로도 쓸 수 없다.
|
|
45
|
+
export function isSafeSkillName(name) {
|
|
46
|
+
return isSafeSegment(name) && !name.startsWith('-')
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// 레지스트리가 저장소 루트에 남기는 잠금 파일. 스킬 이름마다 어느 출처에서
|
|
50
|
+
// 왔는지를 적어 두므로, 한 상류가 여러 스킬을 깔았을 때 "우리가 넣은 것"만
|
|
51
|
+
// 골라 지울 수 있는 유일한 근거다.
|
|
52
|
+
export const SKILLS_LOCK = 'skills-lock.json'
|
|
53
|
+
|
|
54
|
+
// 잠금 파일의 source는 `owner/repo`로 정규화돼 있고 항목은 URL을 넘긴다.
|
|
55
|
+
export function sourceKey(source) {
|
|
56
|
+
return String(source).replace(/^https?:\/\/github\.com\//, '').replace(/\.git$/, '').replace(/\/+$/, '')
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function readLock(root) {
|
|
60
|
+
const file = repoPath(root, SKILLS_LOCK)
|
|
61
|
+
if (!existsSync(file)) return null
|
|
62
|
+
try {
|
|
63
|
+
return JSON.parse(readFileSync(file, 'utf8'))
|
|
64
|
+
} catch {
|
|
65
|
+
// 잠금 파일이 깨졌다고 제거를 막지는 않는다 — 디렉터리 스캔 폴백이 있다.
|
|
66
|
+
return null
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function lockedSkills(root, source) {
|
|
71
|
+
const want = sourceKey(source)
|
|
72
|
+
return Object.entries(readLock(root)?.skills ?? {})
|
|
73
|
+
.filter(([, v]) => sourceKey(v?.source ?? '') === want)
|
|
74
|
+
.map(([name]) => name)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// 레지스트리의 remove는 디렉터리도, 잠금 파일의 항목도 남긴다(실측). 디렉터리는
|
|
78
|
+
// 아래에서 우리가 지우는데 잠금 항목을 그대로 두면 파일이 "설치돼 있다"고
|
|
79
|
+
// 거짓말한다 — 커밋해서 팀과 나누는 파일이라, 동료가 experimental_install로
|
|
80
|
+
// 복원하면 지운 스킬이 되살아난다.
|
|
81
|
+
export function pruneLock(root, names) {
|
|
82
|
+
const parsed = readLock(root)
|
|
83
|
+
const skills = parsed?.skills
|
|
84
|
+
if (!skills) return
|
|
85
|
+
const gone = names.filter((n) => Object.hasOwn(skills, n))
|
|
86
|
+
if (gone.length === 0) return
|
|
87
|
+
for (const n of gone) delete skills[n]
|
|
88
|
+
const file = repoPathStrict(root, SKILLS_LOCK)
|
|
89
|
+
// 남은 스킬이 없으면 파일째 지운다. 이 파일은 레지스트리가 만든 산출물이라
|
|
90
|
+
// 빈 껍데기를 남기면 우리가 만든 흔적이 저장소에 계속 남는다.
|
|
91
|
+
if (Object.keys(skills).length === 0) rmSync(file, { force: true })
|
|
92
|
+
else writeFileSync(file, `${JSON.stringify(parsed, null, 2)}\n`)
|
|
93
|
+
}
|
package/lib/status.mjs
CHANGED
|
@@ -7,6 +7,8 @@ import { MANIFEST } from './bootstrap/manifest.mjs'
|
|
|
7
7
|
import { updateBlocks, updateFiles } from './bootstrap/apply.mjs'
|
|
8
8
|
import { readRecord, toolVersion } from './bootstrap/record.mjs'
|
|
9
9
|
import { createT } from './i18n/index.mjs'
|
|
10
|
+
import { makeExec } from './catalog.mjs'
|
|
11
|
+
import { createProbe } from './probe.mjs'
|
|
10
12
|
|
|
11
13
|
// 미배선 사유는 상류를 실측한 날의 사실이다. 그 날짜(item.verified)가 이보다
|
|
12
14
|
// 오래되면 status가 알린다 — 사유 문장이 화면에 낡은 채 남는 것을 막는 유일한
|
|
@@ -18,7 +20,7 @@ function daysSince(iso, now) {
|
|
|
18
20
|
return Number.isFinite(ms) ? Math.floor(ms / 86_400_000) : 0
|
|
19
21
|
}
|
|
20
22
|
|
|
21
|
-
export async function collectStatus(root, { manifest = MANIFEST, items = [], latest, now = Date.now() } = {}) {
|
|
23
|
+
export async function collectStatus(root, { manifest = MANIFEST, items = [], latest, now = Date.now(), probe = null } = {}) {
|
|
22
24
|
const record = readRecord(root)
|
|
23
25
|
|
|
24
26
|
// dry-run으로 갱신 판정만 얻는다 — 판정 로직을 두 벌 두면 status와 update가
|
|
@@ -33,10 +35,12 @@ export async function collectStatus(root, { manifest = MANIFEST, items = [], lat
|
|
|
33
35
|
|
|
34
36
|
const count = (action) => verdicts.filter((v) => v.action === action).length
|
|
35
37
|
|
|
38
|
+
// 화면·--list와 같은 감지 경로 — 레시피 항목은 CLI 목록 캐시(probe)를 읽는다.
|
|
39
|
+
const shared = probe ?? createProbe({ root, scope: 'project', exec: makeExec(false) })
|
|
36
40
|
const states = []
|
|
37
41
|
for (const item of items) {
|
|
38
42
|
try {
|
|
39
|
-
const r = await item.detect({ root })
|
|
43
|
+
const r = await item.detect({ root, probe: shared })
|
|
40
44
|
states.push({ id: item.id, status: r.status })
|
|
41
45
|
} catch {
|
|
42
46
|
// 감지 실패로 status 전체가 죽으면 진단 도구가 아니다.
|
|
@@ -123,7 +127,7 @@ export async function fetchLatestVersion(fetchImpl = fetch) {
|
|
|
123
127
|
|
|
124
128
|
export async function runStatus(root, { json = false, log = console.log, fetchImpl = fetch, t = createT('en') } = {}) {
|
|
125
129
|
const { loadItems } = await import('./catalog.mjs')
|
|
126
|
-
const report = await collectStatus(root, { items: await loadItems(), latest: await fetchLatestVersion(fetchImpl) })
|
|
130
|
+
const report = await collectStatus(root, { items: await loadItems({ scope: 'project' }), latest: await fetchLatestVersion(fetchImpl) })
|
|
127
131
|
log(json ? JSON.stringify(report, null, 2) : formatStatus(report, t))
|
|
128
132
|
return report
|
|
129
133
|
}
|
package/lib/tui/detail.mjs
CHANGED
|
@@ -6,7 +6,6 @@
|
|
|
6
6
|
// 렌더 층의 의존성이 된다. 그래서 사유 그룹핑이 rows가 아니라 여기 있고,
|
|
7
7
|
// rows.mjs가 반대로 여기서 가져다 쓴다.
|
|
8
8
|
import { toText, createT } from '../i18n/index.mjs'
|
|
9
|
-
import { CLIS } from '../clis.mjs'
|
|
10
9
|
import { categoryLabel } from '../design-md/flow.mjs'
|
|
11
10
|
import { cut, labelWidth, pad, width, wrap } from '../width.mjs'
|
|
12
11
|
|
|
@@ -68,36 +67,17 @@ function wrapNames(names, limit) {
|
|
|
68
67
|
function wiredLines(item, w, lead, t) {
|
|
69
68
|
const supports = item.supports ?? []
|
|
70
69
|
if (supports.length === 0) return []
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
// 놓으면 80×24에서 `배선 ✔ claude` 뒤가 `…외 9줄`로 잘려 정작 "어디에
|
|
76
|
-
// 되나"를 펼쳐야만 읽을 수 있었다. 이름을 ·로 이어 접는다(미배선과 같은 꼴).
|
|
77
|
-
if (!showFile) {
|
|
78
|
-
return wrapNames(supports, Math.max(1, w - lead - 2)).map((line, i) =>
|
|
79
|
-
cut(`${i === 0 ? pad(t('detail.wired'), lead) : ' '.repeat(lead)}${i === 0 ? '✔ ' : ' '}${line}`, w))
|
|
80
|
-
}
|
|
81
|
-
// MCP는 "CLI 경로" 쌍을 한 줄에 들어가는 만큼 채운다. 한 줄에 하나씩 놓으면
|
|
82
|
-
// 열 개가 열 줄이라 80×24 패널에서 절반이 `…외 N줄`로 접혔다. 폭이 좁으면
|
|
83
|
-
// 한 줄에 하나로 자연히 돌아간다.
|
|
84
|
-
const pairs = supports.map((cli) => (CLIS[cli]?.file ? `${cli} ${CLIS[cli].file}` : cli))
|
|
85
|
-
const colW = Math.max(...pairs.map((p) => width(p))) + 3
|
|
86
|
-
const room = Math.max(1, w - lead - 2)
|
|
87
|
-
const perLine = Math.max(1, Math.floor(room / colW))
|
|
88
|
-
const rows = []
|
|
89
|
-
for (let i = 0; i < pairs.length; i += perLine) {
|
|
90
|
-
const chunk = pairs.slice(i, i + perLine)
|
|
91
|
-
rows.push(chunk.map((p, j) => (j < chunk.length - 1 ? pad(p, colW) : p)).join(''))
|
|
92
|
-
}
|
|
93
|
-
return rows.map((line, i) => cut(`${i === 0 ? pad(t('detail.wired'), lead) : ' '.repeat(lead)}${i === 0 ? '✔ ' : ' '}${line}`, w))
|
|
70
|
+
// 설치는 각 CLI의 명령이 맡아 이 설치기가 쓰는 설정 파일이 없다 — 이름만
|
|
71
|
+
// ·로 이어 접는다(미배선과 같은 꼴).
|
|
72
|
+
return wrapNames(supports, Math.max(1, w - lead - 2)).map((line, i) =>
|
|
73
|
+
cut(`${i === 0 ? pad(t('detail.wired'), lead) : ' '.repeat(lead)}${i === 0 ? '✔ ' : ' '}${line}`, w))
|
|
94
74
|
}
|
|
95
75
|
|
|
96
76
|
function unwiredLines(item, w, lead, t) {
|
|
97
77
|
const out = []
|
|
98
78
|
unsupportedGroups(item, t).forEach((group, i) => {
|
|
99
79
|
const head = i === 0 ? pad(t('detail.unwired'), lead) : ' '.repeat(lead)
|
|
100
|
-
// 미배선 CLI 이름도 같은 규칙으로 접는다 —
|
|
80
|
+
// 미배선 CLI 이름도 같은 규칙으로 접는다 — 여럿을 한 줄에 두면 좁은 화면에서 뒤가 잘린다.
|
|
101
81
|
wrapNames(group.clis, Math.max(1, w - lead - 2)).forEach((line, j) => {
|
|
102
82
|
out.push(cut(`${j === 0 ? head : ' '.repeat(lead)}${j === 0 ? '✖ ' : ' '}${line}`, w))
|
|
103
83
|
})
|
package/lib/tui/rows.mjs
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
import { existsSync } from 'node:fs'
|
|
4
4
|
import { join } from 'node:path'
|
|
5
5
|
import { scan } from '../engine.mjs'
|
|
6
|
-
import { loadItems } from '../catalog.mjs'
|
|
6
|
+
import { loadItems, makeExec } from '../catalog.mjs'
|
|
7
|
+
import { createProbe } from '../probe.mjs'
|
|
7
8
|
import { CLI_IDS } from '../clis.mjs'
|
|
8
9
|
import { runBootstrap } from '../bootstrap/flow.mjs'
|
|
9
10
|
import { MANIFEST } from '../bootstrap/manifest.mjs'
|
|
@@ -30,7 +31,7 @@ export const SECTION_ORDER = [ACTION_SECTION, 'plugin', 'mcp', 'skill', 'config'
|
|
|
30
31
|
// __commit은 CONFIG 탭에만 나오지만 규칙은 같다 — 헤더가 "무엇을 고르는
|
|
31
32
|
// 자리인가"를 말한다. 커밋 템플릿은 대상 파일이 하나뿐이라 헤더에 그 파일
|
|
32
33
|
// 이름과 "하나만"이라는 규칙까지 실어, 라디오 표시와 함께 읽히게 한다.
|
|
33
|
-
export const GROUP_ORDER = ['
|
|
34
|
+
export const GROUP_ORDER = ['__token', '__context', '__style', '__flow', '__commit', '__service']
|
|
34
35
|
|
|
35
36
|
function groupRank(group) {
|
|
36
37
|
const i = GROUP_ORDER.indexOf(group)
|
|
@@ -106,7 +107,7 @@ export function designHint(state, multiProvider = false, t) {
|
|
|
106
107
|
return parts.filter(Boolean).join(' · ')
|
|
107
108
|
}
|
|
108
109
|
|
|
109
|
-
// 이름을 나열할 수 있는 최대 CLI 수. 넷까지는 이름(codex·
|
|
110
|
+
// 이름을 나열할 수 있는 최대 CLI 수. 넷까지는 이름(claude·codex·opencode·antigravity)이
|
|
110
111
|
// 80칸 힌트 자리에 들고, 그보다 많으면 "몇 개나 되나"가 더 유용하다.
|
|
111
112
|
const NAME_LIMIT = 4
|
|
112
113
|
|
|
@@ -152,7 +153,7 @@ export function designShortHint(state, multiProvider = false, t = createT('en'))
|
|
|
152
153
|
|
|
153
154
|
// group = 탭 **안쪽**의 소분류 헤더. design.md만 76개라 카테고리로 갈라야 읽힌다.
|
|
154
155
|
// 나머지 탭은 항목이 적어 그룹 없이 평평하게 둔다.
|
|
155
|
-
function itemRow({ id, section, label, hint, fullHint = hint, statusDetail = null, status, previewTarget = null, item, extra = '', group = null, t = createT('en') }) {
|
|
156
|
+
function itemRow({ id, section, label, hint, fullHint = hint, statusDetail = null, status, previewTarget = null, item, result = null, extra = '', group = null, t = createT('en') }) {
|
|
156
157
|
return {
|
|
157
158
|
kind: 'item',
|
|
158
159
|
id,
|
|
@@ -171,6 +172,7 @@ function itemRow({ id, section, label, hint, fullHint = hint, statusDetail = nul
|
|
|
171
172
|
status,
|
|
172
173
|
previewTarget,
|
|
173
174
|
item,
|
|
175
|
+
result,
|
|
174
176
|
searchText: `${label} ${fullHint} ${sectionTerms(t, section)} ${extra}`.toLowerCase(),
|
|
175
177
|
}
|
|
176
178
|
}
|
|
@@ -202,7 +204,7 @@ function bootstrapHint(root, t) {
|
|
|
202
204
|
return t('action.bootstrap.hint', { present, total: MANIFEST.files.length })
|
|
203
205
|
}
|
|
204
206
|
|
|
205
|
-
export function buildActions(root, { designItems = [], t = createT('en') } = {}) {
|
|
207
|
+
export function buildActions(root, { designItems = [], scope = 'project', t = createT('en') } = {}) {
|
|
206
208
|
return [
|
|
207
209
|
// 맨 위 상주 행. 실행은 run.mjs가 특수 처리한다 — 화면을 벗어나지 않고
|
|
208
210
|
// 그 자리에서 t를 갈아끼워야 하므로 다른 액션과 흐름이 다르다(run은 null).
|
|
@@ -212,6 +214,14 @@ export function buildActions(root, { designItems = [], t = createT('en') } = {})
|
|
|
212
214
|
hint: t('action.language.hint', { current: t(`locale.${t.locale}`) }),
|
|
213
215
|
t,
|
|
214
216
|
}),
|
|
217
|
+
// 언어 행과 같은 방식 — run.mjs가 그 자리에서 범위를 바꾸고 다시 스캔한다.
|
|
218
|
+
// 목록 포커스의 글자 키는 검색칸으로 올라가므로 단축키 대신 행으로 둔다.
|
|
219
|
+
actionRow({
|
|
220
|
+
id: 'action.scope',
|
|
221
|
+
label: t('action.scope.label'),
|
|
222
|
+
hint: t('action.scope.hint', { current: t(`scope.${scope}`) }),
|
|
223
|
+
t,
|
|
224
|
+
}),
|
|
215
225
|
actionRow({
|
|
216
226
|
id: 'action.bootstrap',
|
|
217
227
|
label: t('action.bootstrap.label'),
|
|
@@ -270,18 +280,16 @@ export function buildRows({ actions = [], agentStates = [], designStates = [], m
|
|
|
270
280
|
itemRow({
|
|
271
281
|
id: s.item.id,
|
|
272
282
|
section: s.item.category,
|
|
273
|
-
//
|
|
274
|
-
|
|
275
|
-
// 표시 계층의 오버라이드다: 항목의 group 필드는 성격의 진실로 남는다.
|
|
276
|
-
group: s.item.category === 'plugin'
|
|
277
|
-
? (s.item.scope === 'user' ? '__scope-user' : '__scope-project')
|
|
278
|
-
: s.item.group ?? null,
|
|
283
|
+
// 범위는 실행당 하나(작업 탭의 범위 행)라 plugin 탭도 성격으로 가른다.
|
|
284
|
+
group: s.item.group ?? null,
|
|
279
285
|
label: scopedLabel(s.item, t),
|
|
280
286
|
hint: agentShortHint(s.item, s, t),
|
|
281
287
|
fullHint: agentHint(s.item, s, t),
|
|
282
288
|
statusDetail: toText(t, s.detail) ?? null,
|
|
283
289
|
status: s.status,
|
|
284
290
|
item: s.item,
|
|
291
|
+
// 감지 결과 — 변경 계획이 어느 CLI에 이미 있는지(present)를 여기서 읽는다.
|
|
292
|
+
result: s.result ?? null,
|
|
285
293
|
extra: s.item.id,
|
|
286
294
|
t,
|
|
287
295
|
}),
|
|
@@ -324,7 +332,10 @@ export function buildRows({ actions = [], agentStates = [], designStates = [], m
|
|
|
324
332
|
export async function collectRows(root, opts = {}) {
|
|
325
333
|
const { fetchImpl = netFetch, designDirs = [], env = process.env, catalogFile = CATALOG_PATH, log = () => {}, t = createT('en') } = opts
|
|
326
334
|
|
|
327
|
-
const
|
|
335
|
+
const scope = opts.scope ?? 'project'
|
|
336
|
+
const agentItems = await loadItems({ scope })
|
|
337
|
+
// 스캔마다 새 감지 캐시 — 적용 뒤 재스캔이 적용 전 목록을 읽으면 안 된다.
|
|
338
|
+
const probe = createProbe({ root, scope, exec: makeExec(false) })
|
|
328
339
|
const catalog = loadCatalog(catalogFile)
|
|
329
340
|
const sources = opts.sources ?? discoverSources({
|
|
330
341
|
...(opts.bundleDir ? { bundleDir: opts.bundleDir } : {}),
|
|
@@ -334,12 +345,12 @@ export async function collectRows(root, opts = {}) {
|
|
|
334
345
|
})
|
|
335
346
|
const designItems = buildItems(catalog, { fetchImpl, sources })
|
|
336
347
|
|
|
337
|
-
const [agentStates, designStates] = await Promise.all([scan(root, agentItems), scan(root, designItems)])
|
|
348
|
+
const [agentStates, designStates] = await Promise.all([scan(root, agentItems, { probe }), scan(root, designItems)])
|
|
338
349
|
const multiProvider = new Set(designItems.map((i) => i.providerId)).size > 1
|
|
339
350
|
|
|
340
351
|
return {
|
|
341
352
|
rows: buildRows({
|
|
342
|
-
actions: buildActions(root, { designItems, t }),
|
|
353
|
+
actions: buildActions(root, { designItems, scope, t }),
|
|
343
354
|
agentStates,
|
|
344
355
|
designStates,
|
|
345
356
|
multiProvider,
|
package/lib/tui/run.mjs
CHANGED
|
@@ -15,6 +15,8 @@ import {
|
|
|
15
15
|
import { render, renderReview, renderHelp, bodyHeight, footerHeight } from './render.mjs'
|
|
16
16
|
import { createProgress, applyEvent, progressLines } from './progress.mjs'
|
|
17
17
|
import { scopedLabel } from '../labels.mjs'
|
|
18
|
+
import { planCommands, formatCommands } from '../commands.mjs'
|
|
19
|
+
import { activationLines } from '../activation.mjs'
|
|
18
20
|
|
|
19
21
|
const ESC = String.fromCharCode(27)
|
|
20
22
|
const HIDE_CURSOR = `${ESC}[?25l`
|
|
@@ -53,7 +55,7 @@ function keyReader(stdin) {
|
|
|
53
55
|
}
|
|
54
56
|
|
|
55
57
|
function itemStates(rows) {
|
|
56
|
-
return rows.filter((r) => r.kind === 'item').map((r) => ({ item: r.item, status: r.status }))
|
|
58
|
+
return rows.filter((r) => r.kind === 'item').map((r) => ({ item: r.item, status: r.status, result: r.result }))
|
|
57
59
|
}
|
|
58
60
|
|
|
59
61
|
// row.section은 이제 소문자 id다(rows.mjs Task 9) — t 없이 그대로 찍으면
|
|
@@ -92,8 +94,10 @@ export async function runTui(root, opts = {}) {
|
|
|
92
94
|
const opener = opts.opener ?? makeOpener(dryRun, log)
|
|
93
95
|
// let으로 둔다 — 언어 전환 행(cycleLanguage)이 실행 중에 갈아끼운다.
|
|
94
96
|
let { t = createT('en'), localeForced = false } = opts
|
|
97
|
+
// 범위 행(action.scope)이 실행 중에 갈아끼운다.
|
|
98
|
+
let scope = opts.scope ?? 'project'
|
|
95
99
|
|
|
96
|
-
let collected = await collectRows(root, { fetchImpl, designDirs, env, catalogFile, log: () => {}, t })
|
|
100
|
+
let collected = await collectRows(root, { fetchImpl, designDirs, env, catalogFile, scope, log: () => {}, t })
|
|
97
101
|
|
|
98
102
|
// raw 모드를 켤 수 없으면(CI·파이프) 목록만 내고 끝낸다.
|
|
99
103
|
if (!stdin.isTTY || typeof stdin.setRawMode !== 'function') {
|
|
@@ -106,7 +110,7 @@ export async function runTui(root, opts = {}) {
|
|
|
106
110
|
// 타이핑하면 검색칸으로 올라간다.
|
|
107
111
|
let state = createState(collected.rows, { selectedIds: installedIds(collected.rows), focus: 'list' })
|
|
108
112
|
let status = ''
|
|
109
|
-
// 순환 대상: 전체(null) + CLI
|
|
113
|
+
// 순환 대상: 전체(null) + CLI 넷. state.mjs는 아무것도 import 하지 않으므로
|
|
110
114
|
// 목록을 여기서 만들어 넘긴다.
|
|
111
115
|
const CLI_OPTIONS = [null, ...CLI_IDS]
|
|
112
116
|
let detailExpanded = false
|
|
@@ -165,7 +169,7 @@ export async function runTui(root, opts = {}) {
|
|
|
165
169
|
// 실제 설치 상태로 되돌려야 한다 — 둘을 한 함수로 묶으면 언어를 바꿀 때
|
|
166
170
|
// 사용자가 고르던 항목이 조용히 날아간다.
|
|
167
171
|
const rebuild = async (keepSelection) => {
|
|
168
|
-
collected = await collectRows(root, { fetchImpl, designDirs, env, catalogFile, t, log: () => {} })
|
|
172
|
+
collected = await collectRows(root, { fetchImpl, designDirs, env, catalogFile, scope, t, log: () => {} })
|
|
169
173
|
const ids = keepSelection ? [...state.selected] : installedIds(collected.rows)
|
|
170
174
|
state = replaceRows(state, collected.rows, ids)
|
|
171
175
|
}
|
|
@@ -227,6 +231,7 @@ export async function runTui(root, opts = {}) {
|
|
|
227
231
|
if (key.ctrl && (key.name === 'c' || key.name === 'q')) return 'quit'
|
|
228
232
|
if (key.name === 'escape') return 'cancel'
|
|
229
233
|
if (key.name === 'return' || key.name === 'enter') return 'apply'
|
|
234
|
+
if (key.str === 'p') return 'print'
|
|
230
235
|
}
|
|
231
236
|
} finally {
|
|
232
237
|
redraw = paint
|
|
@@ -256,7 +261,6 @@ export async function runTui(root, opts = {}) {
|
|
|
256
261
|
|
|
257
262
|
try {
|
|
258
263
|
const results = await apply(root, changes, {
|
|
259
|
-
dryRun,
|
|
260
264
|
log: () => {}, // 로그는 진행 화면이 대신한다
|
|
261
265
|
t,
|
|
262
266
|
shouldStop: checkAbort,
|
|
@@ -385,6 +389,14 @@ export async function runTui(root, opts = {}) {
|
|
|
385
389
|
const row = currentRow(state)
|
|
386
390
|
// 언어 행은 화면을 벗어나지 않는다 — 그 자리에서 t를 갈아끼우고 다시 그린다.
|
|
387
391
|
if (row?.id === 'action.language') { status = await cycleLanguage(); continue }
|
|
392
|
+
if (row?.id === 'action.scope') {
|
|
393
|
+
scope = scope === 'project' ? 'user' : 'project'
|
|
394
|
+
// 다른 범위의 설치 상태로 선택을 다시 맞춘다 — 저장소에서 고르던 체크가
|
|
395
|
+
// 전역 목록에 남으면 엉뚱한 항목이 설치된다.
|
|
396
|
+
await rebuild(false)
|
|
397
|
+
status = t('tui.scope.switched', { current: t(`scope.${scope}`) })
|
|
398
|
+
continue
|
|
399
|
+
}
|
|
388
400
|
if (row?.kind === 'action') {
|
|
389
401
|
await suspend(async () => {
|
|
390
402
|
// 작업 하나의 예외가 화면 전체를 끌고 내려가면 안 된다 — 깨진
|
|
@@ -409,13 +421,24 @@ export async function runTui(root, opts = {}) {
|
|
|
409
421
|
if (verdict === 'quit') break
|
|
410
422
|
if (verdict === 'cancel') { status = t('tui.submitCancelled'); continue }
|
|
411
423
|
|
|
424
|
+
// --print로 띄웠거나 검토 화면에서 p를 눌렀다 — 실행 대신 같은 steps()의
|
|
425
|
+
// 명령을 화면 밖에 편다. 복사해 직접 돌릴 수 있어야 한다.
|
|
426
|
+
if (verdict === 'print' || dryRun) {
|
|
427
|
+
await suspend(async () => {
|
|
428
|
+
log('')
|
|
429
|
+
for (const line of formatCommands(planCommands(changes), { t, scope })) log(line)
|
|
430
|
+
for (const line of activationLines(changes, t)) log(line)
|
|
431
|
+
await pause()
|
|
432
|
+
})
|
|
433
|
+
continue
|
|
434
|
+
}
|
|
435
|
+
|
|
412
436
|
const results = await runApply(changes)
|
|
413
437
|
|
|
414
438
|
// 실패가 있으면 자세한 사연을 화면 밖에서 보여 준다 — 실패 메시지는
|
|
415
439
|
// 길고, 화면 안에서 잘리면 진단이 불가능해진다. 성공해도 message가
|
|
416
|
-
// 붙은
|
|
417
|
-
//
|
|
418
|
-
// 그 사연이 조용히 사라진다. 건너뜀은 실패가 아니다 — 진행 화면에
|
|
440
|
+
// 붙은 결과는 똑같이 사연이 있다 — ok만 보고 걸러내면 그 사연이
|
|
441
|
+
// 조용히 사라진다. 건너뜀은 실패가 아니다 — 진행 화면에
|
|
419
442
|
// 이미 나타났고, 다시 여기 나열하면 사용자가 스스로 취소한 일을
|
|
420
443
|
// 실패처럼 읽게 된다.
|
|
421
444
|
const notable = results.filter((r) => !r.skipped && (!r.ok || r.message))
|
|
@@ -427,6 +450,10 @@ export async function runTui(root, opts = {}) {
|
|
|
427
450
|
log(` ${r.ok ? '✔' : '✖'} ${t(`change.${r.action}`)} ${scopedLabel(r.item, t)}${message ? ` — ${message}` : ''}`)
|
|
428
451
|
}
|
|
429
452
|
}
|
|
453
|
+
// 성공한 설치만 안내한다 — 실패한 항목에 "새 세션을 시작하라"고 하면 거짓이다.
|
|
454
|
+
const done = new Set(results.filter((r) => r.ok).map((r) => r.item.id))
|
|
455
|
+
const notes = activationLines(changes.filter((c) => done.has(c.item.id)), t)
|
|
456
|
+
if (notes.length > 0) log(`\n${notes.join('\n')}`)
|
|
430
457
|
log(`\n${t('apply.seeGitDiff')}`)
|
|
431
458
|
await pause()
|
|
432
459
|
})
|
package/package.json
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rch4com/agent-setup",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.2.0",
|
|
4
4
|
"description": "Repository-scoped bootstrap and optional-item installer for using multiple coding-agent CLIs in one repository | 여러 코딩 에이전트 CLI를 한 저장소에서 함께 쓰기 위한 저장소 범위 부트스트랩과 선택 항목 설치기",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"agents-md",
|
|
7
7
|
"claude-code",
|
|
8
8
|
"codex",
|
|
9
|
-
"
|
|
10
|
-
"
|
|
9
|
+
"opencode",
|
|
10
|
+
"antigravity",
|
|
11
|
+
"github-copilot",
|
|
11
12
|
"mcp",
|
|
12
13
|
"agent-skills"
|
|
13
14
|
],
|
package/lib/claude-plugins.mjs
DELETED
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
import { repoPath, repoPathStrict } from './context.mjs'
|
|
2
|
-
import { readJson, setKey, removeKey } from './jsonfile.mjs'
|
|
3
|
-
|
|
4
|
-
const SETTINGS = '.claude/settings.json'
|
|
5
|
-
|
|
6
|
-
function readSettings(root) {
|
|
7
|
-
return readJson(repoPath(root, SETTINGS)) ?? {}
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
// 쓰기 경로만 링크 이탈까지 검사한다 — 저장소 안 `.claude`가 홈을 가리키는
|
|
11
|
-
// Junction이면 어휘적 검사는 통과하고 글로벌 settings.json이 수정된다.
|
|
12
|
-
// clis.mjs의 add·remove, bootstrap의 apply.mjs와 같은 규칙.
|
|
13
|
-
function settingsFile(root) {
|
|
14
|
-
return repoPathStrict(root, SETTINGS)
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
function enabledList(settings) {
|
|
18
|
-
const ep = settings.enabledPlugins
|
|
19
|
-
if (Array.isArray(ep)) return ep
|
|
20
|
-
if (ep && typeof ep === 'object') return Object.keys(ep).filter((k) => ep[k])
|
|
21
|
-
return []
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export function isPluginEnabled(root, ids) {
|
|
25
|
-
const list = enabledList(readSettings(root))
|
|
26
|
-
return ids.some((id) => list.includes(id))
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export function enablePlugin(root, id, marketplace) {
|
|
30
|
-
const file = settingsFile(root)
|
|
31
|
-
const settings = readSettings(root)
|
|
32
|
-
if (Array.isArray(settings.enabledPlugins)) {
|
|
33
|
-
if (!settings.enabledPlugins.includes(id)) {
|
|
34
|
-
setKey(file, ['enabledPlugins', settings.enabledPlugins.length], id)
|
|
35
|
-
}
|
|
36
|
-
} else {
|
|
37
|
-
setKey(file, ['enabledPlugins', id], true)
|
|
38
|
-
}
|
|
39
|
-
if (marketplace) {
|
|
40
|
-
setKey(file, ['extraKnownMarketplaces', marketplace.name], {
|
|
41
|
-
source: { source: 'github', repo: marketplace.repo },
|
|
42
|
-
})
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
export function disablePlugin(root, ids) {
|
|
47
|
-
const file = settingsFile(root)
|
|
48
|
-
const settings = readSettings(root)
|
|
49
|
-
if (Array.isArray(settings.enabledPlugins)) {
|
|
50
|
-
const kept = settings.enabledPlugins.filter((e) => !ids.includes(e))
|
|
51
|
-
setKey(file, ['enabledPlugins'], kept)
|
|
52
|
-
} else if (settings.enabledPlugins) {
|
|
53
|
-
for (const id of ids) removeKey(file, ['enabledPlugins', id])
|
|
54
|
-
}
|
|
55
|
-
// 제거한 플러그인이 쓰던 마켓플레이스만 정리 대상으로 삼는다 (무관한 사용자 항목 보존).
|
|
56
|
-
const candidates = new Set(ids.map((id) => id.slice(id.indexOf('@') + 1)))
|
|
57
|
-
const after = readSettings(root)
|
|
58
|
-
const remaining = enabledList(after).map((e) => e.slice(e.indexOf('@') + 1))
|
|
59
|
-
for (const mkt of candidates) {
|
|
60
|
-
if (!remaining.includes(mkt) && after.extraKnownMarketplaces?.[mkt] !== undefined) {
|
|
61
|
-
removeKey(file, ['extraKnownMarketplaces', mkt])
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
}
|