@rch4com/agent-setup 1.10.0 → 1.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.md +199 -199
- package/install.mjs +163 -163
- package/lib/args.mjs +276 -276
- package/lib/bootstrap/adapter.mjs +96 -96
- package/lib/bootstrap/apply.mjs +380 -380
- package/lib/bootstrap/flow.mjs +140 -140
- package/lib/bootstrap/record.mjs +154 -154
- package/lib/bootstrap/templates.mjs +148 -148
- package/lib/bootstrap/text.mjs +19 -19
- package/lib/catalog.mjs +234 -234
- package/lib/claude-plugins.mjs +64 -64
- package/lib/clis.mjs +111 -111
- package/lib/context.mjs +66 -66
- package/lib/deps.mjs +17 -17
- package/lib/design-md/catalog.json +454 -454
- package/lib/design-md/catalog.mjs +266 -266
- package/lib/design-md/flow.mjs +192 -192
- package/lib/design-md/open.mjs +51 -51
- package/lib/design-md/providers/awesome-design-md.mjs +124 -124
- package/lib/design-md/providers/index.mjs +9 -9
- package/lib/design-md/scan.mjs +295 -295
- package/lib/engine.mjs +115 -115
- package/lib/gitignore.mjs +18 -18
- package/lib/i18n/catalog/en.mjs +431 -426
- package/lib/i18n/catalog/ko.mjs +416 -411
- package/lib/i18n/detect.mjs +39 -39
- package/lib/i18n/index.mjs +93 -93
- package/lib/items/mcp.codebase-memory.mjs +6 -6
- package/lib/items/mcp.notion.mjs +6 -6
- package/lib/items/mcp.supabase.mjs +6 -6
- package/lib/items/mcp.vercel.mjs +6 -6
- package/lib/items/plugin.bkit.mjs +5 -3
- package/lib/items/plugin.ecc.mjs +17 -2
- package/lib/items/plugin.mattpocock-skills.mjs +18 -8
- package/lib/items/plugin.ponytail.mjs +106 -104
- package/lib/items/plugin.superpowers.mjs +7 -4
- package/lib/items/plugin.understand-anything.mjs +13 -2
- package/lib/items/skill.diagram-design.mjs +14 -0
- package/lib/items/skill.gsd.mjs +46 -44
- package/lib/items/skill.gstack.mjs +54 -54
- package/lib/jsonfile.mjs +40 -40
- package/lib/status.mjs +93 -93
- package/lib/tomlfile.mjs +60 -60
- package/lib/tui/detail.mjs +127 -127
- package/lib/tui/progress.mjs +160 -160
- package/lib/tui/render.mjs +268 -268
- package/lib/tui/rows.mjs +317 -317
- package/lib/tui/run.mjs +396 -396
- package/lib/tui/state.mjs +249 -249
- package/lib/update.mjs +85 -85
- package/lib/width.mjs +115 -115
- package/package.json +36 -36
|
@@ -1,10 +1,21 @@
|
|
|
1
1
|
import { definePlugin } from '../catalog.mjs'
|
|
2
|
-
|
|
3
|
-
|
|
2
|
+
import { CLI_IDS } from '../clis.mjs'
|
|
3
|
+
import { msg } from '../i18n/index.mjs'
|
|
4
|
+
// 상류는 Claude 전용이 아니다 — README의 "Multi-Platform Installation"이
|
|
5
|
+
// codex·opencode·gemini·vscode·kimi·kiro를 install.sh -s <platform>으로 안내한다.
|
|
6
|
+
// 그 경로가 ~/.understand-anything/repo에 클론하고 홈에 심볼릭 링크를 거는
|
|
7
|
+
// 사용자 스코프라 이 저장소의 항목이 될 수 없다.
|
|
8
|
+
// Copilot CLI만 갈래가 다르다 —
|
|
9
|
+
// `copilot plugin install Egonex-AI/Understand-Anything:understand-anything-plugin`
|
|
10
|
+
// 이라는 플러그인 직접 설치인데, `copilot plugin install`에 스코프 플래그가
|
|
11
|
+
// 없어 결국 사용자 스코프다(2026-08-15 재측정).
|
|
4
12
|
export default definePlugin({
|
|
5
13
|
id: 'plugin.understand-anything', label: 'Understand Anything', group: '__context',
|
|
6
14
|
installId: 'understand-anything@understand-anything',
|
|
7
15
|
detectIds: ['understand-anything@understand-anything'],
|
|
8
16
|
marketplace: { name: 'understand-anything', repo: 'Egonex-AI/Understand-Anything' },
|
|
9
17
|
note: 'item.plugin.understand-anything.note',
|
|
18
|
+
unsupported: Object.fromEntries(
|
|
19
|
+
CLI_IDS.filter((c) => c !== 'claude').map((c) => [c, msg('item.unsupported.uaUser')]),
|
|
20
|
+
),
|
|
10
21
|
})
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { defineRegistrySkill } from '../catalog.mjs'
|
|
2
|
+
// 상류는 .claude-plugin·.codex-plugin 매니페스트를 함께 두지만 알맹이는
|
|
3
|
+
// skills/diagram-design/SKILL.md 배치라, 기존 레지스트리 경로가 그대로 읽는다 —
|
|
4
|
+
// 배선은 한 줄도 새로 만들지 않았다. SKILL.md의 name이 디렉터리 이름과 같아
|
|
5
|
+
// taste가 밟던 frontmatter 폴백도 필요 없다.
|
|
6
|
+
// 플러그인 경로만 주는 것은 commands/ 3개(/export-diagram·/import-drawio·
|
|
7
|
+
// /import-mermaid)인데, 본문을 skills/.../references/에 위임하는 얇은 래퍼라
|
|
8
|
+
// 규칙의 알맹이는 스킬 쪽에 전부 온다.
|
|
9
|
+
export default defineRegistrySkill({
|
|
10
|
+
id: 'skill.diagram-design', label: 'Diagram Design', group: '__style',
|
|
11
|
+
source: 'https://github.com/cathrynlavery/diagram-design',
|
|
12
|
+
skill: 'diagram-design',
|
|
13
|
+
note: 'item.skill.diagram-design.note',
|
|
14
|
+
})
|
package/lib/items/skill.gsd.mjs
CHANGED
|
@@ -1,44 +1,46 @@
|
|
|
1
|
-
import { existsSync, readdirSync } from 'node:fs'
|
|
2
|
-
import { join } from 'node:path'
|
|
3
|
-
import { defineSkill } from '../catalog.mjs'
|
|
4
|
-
import { CLI_IDS } from '../clis.mjs'
|
|
5
|
-
import { LocalizedError, msg } from '../i18n/index.mjs'
|
|
6
|
-
|
|
7
|
-
function hasGsdFiles(dir) {
|
|
8
|
-
return existsSync(dir) && readdirSync(dir).some((f) => f.startsWith('gsd-'))
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
// 상류(v1.
|
|
12
|
-
// 이 항목은 --claude만 배선한다. Gemini CLI는 상류가 명시 제거했다
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
//
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
})
|
|
1
|
+
import { existsSync, readdirSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { defineSkill } from '../catalog.mjs'
|
|
4
|
+
import { CLI_IDS } from '../clis.mjs'
|
|
5
|
+
import { LocalizedError, msg } from '../i18n/index.mjs'
|
|
6
|
+
|
|
7
|
+
function hasGsdFiles(dir) {
|
|
8
|
+
return existsSync(dir) && readdirSync(dir).some((f) => f.startsWith('gsd-'))
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// 상류(v1.10.0)는 런타임 플래그 19개(--codex·--antigravity 등)를 지원하지만
|
|
12
|
+
// 이 항목은 --claude만 배선한다. Gemini CLI는 상류가 명시 제거했다 —
|
|
13
|
+
// `--gemini --local`이 2026-06-18 종료 안내를 찍고 파일을 하나도 만들지 않는
|
|
14
|
+
// 것을 실측했다(2026-08-15).
|
|
15
|
+
// --kilo가 이 저장소의 Kilo Code와 같은 제품인지는 오래 미확인이었는데,
|
|
16
|
+
// `--kilo --local`이 만든 .kilo/skills/ 71개를 설치된 Kilo Code
|
|
17
|
+
// (@kilocode/cli 7.3.45)가 전부 로드하는 것을 실측해 해소했다(2026-08-15).
|
|
18
|
+
const FLAGS = ['codex', 'opencode', 'copilot', 'kimi', 'kilo']
|
|
19
|
+
|
|
20
|
+
export default defineSkill({
|
|
21
|
+
id: 'skill.gsd', label: 'GSD (Get Shit Done)', group: '__flow', scope: 'project',
|
|
22
|
+
note: 'item.skill.gsd.note',
|
|
23
|
+
unsupported: Object.fromEntries(
|
|
24
|
+
CLI_IDS.filter((c) => c !== 'claude').map((c) => [
|
|
25
|
+
c,
|
|
26
|
+
FLAGS.includes(c) ? msg('item.unsupported.gsdFlag')
|
|
27
|
+
: c === 'gemini' ? msg('item.unsupported.gsdGemini')
|
|
28
|
+
: msg('item.unsupported.upstreamNone'),
|
|
29
|
+
]),
|
|
30
|
+
),
|
|
31
|
+
async detect({ root }) {
|
|
32
|
+
const found = hasGsdFiles(join(root, '.claude', 'commands')) || hasGsdFiles(join(root, '.claude', 'skills'))
|
|
33
|
+
return { status: found ? 'installed' : 'absent' }
|
|
34
|
+
},
|
|
35
|
+
async install({ root, exec }) {
|
|
36
|
+
const r = await exec('npx', ['-y', '@opengsd/gsd-core@latest', '--claude', '--local'], { cwd: root })
|
|
37
|
+
if (!r.ok) throw new LocalizedError('error.gsdInstall', { output: r.output })
|
|
38
|
+
},
|
|
39
|
+
async uninstall({ root, exec }) {
|
|
40
|
+
// 런타임 플래그가 없으면 상류가 기본값 claude를 지운다 — 지금은 --claude만
|
|
41
|
+
// 설치하므로 맞지만, 다른 런타임을 설치하게 되면 여기에도 같은 플래그를
|
|
42
|
+
// 넘겨야 엉뚱한 설치본이 지워지지 않는다.
|
|
43
|
+
const r = await exec('npx', ['-y', '@opengsd/gsd-core@latest', '--uninstall'], { cwd: root })
|
|
44
|
+
if (!r.ok) throw new LocalizedError('error.gsdUninstall', { output: r.output })
|
|
45
|
+
},
|
|
46
|
+
})
|
|
@@ -1,54 +1,54 @@
|
|
|
1
|
-
import { existsSync, rmSync } from 'node:fs'
|
|
2
|
-
import { join } from 'node:path'
|
|
3
|
-
import { defineSkill } from '../catalog.mjs'
|
|
4
|
-
import { CLI_IDS } from '../clis.mjs'
|
|
5
|
-
import { repoPath, repoPathStrict } from '../context.mjs'
|
|
6
|
-
import { ensureGitignoreEntries } from '../gitignore.mjs'
|
|
7
|
-
import { LocalizedError, msg } from '../i18n/index.mjs'
|
|
8
|
-
|
|
9
|
-
const REL_DIR = '.claude/skills/gstack'
|
|
10
|
-
|
|
11
|
-
// 상류 setup은 --host codex·kiro·opencode(와 factory)도 지원하지만 이 항목은
|
|
12
|
-
// 인자 없이 실행하며 setup의 기본값이 claude 단독이다. README 표에 있는
|
|
13
|
-
// --host cursor·slate는 setup의 case 문이 거부해 exit 1로 죽는다 —
|
|
14
|
-
// 확장한다면 안전한 값은 claude·codex·kiro·factory·opencode·auto뿐이다
|
|
15
|
-
// (2026-08-02 검증).
|
|
16
|
-
const HOSTS = ['codex', 'kiro', 'opencode']
|
|
17
|
-
|
|
18
|
-
export default defineSkill({
|
|
19
|
-
id: 'skill.gstack', label: 'gstack', group: '__flow', scope: 'project',
|
|
20
|
-
note: 'item.skill.gstack.note',
|
|
21
|
-
unsupported: Object.fromEntries(
|
|
22
|
-
CLI_IDS.filter((c) => c !== 'claude').map((c) => [
|
|
23
|
-
c,
|
|
24
|
-
HOSTS.includes(c) ? msg('item.unsupported.gstackHost') : msg('item.unsupported.upstreamNone'),
|
|
25
|
-
]),
|
|
26
|
-
),
|
|
27
|
-
async detect({ root }) {
|
|
28
|
-
return { status: existsSync(repoPath(root, REL_DIR)) ? 'installed' : 'absent' }
|
|
29
|
-
},
|
|
30
|
-
async install({ root, dryRun, exec }) {
|
|
31
|
-
// clone·삭제가 일어나는 경로다. 어휘적 검사만으로는 .claude/skills가
|
|
32
|
-
// 저장소 밖을 가리키는 링크일 때를 막지 못한다(부트스트랩이 만드는
|
|
33
|
-
// .agents/skills Junction은 저장소 안이라 그대로 통과한다).
|
|
34
|
-
const dir = repoPathStrict(root, REL_DIR)
|
|
35
|
-
if (!existsSync(dir)) {
|
|
36
|
-
const clone = await exec('git', ['clone', '--single-branch', '--depth', '1', 'https://github.com/garrytan/gstack.git', dir])
|
|
37
|
-
if (!clone.ok) throw new LocalizedError('error.gstackClone', { output: clone.output })
|
|
38
|
-
const setup = await exec('bash', ['./setup'], { cwd: dir })
|
|
39
|
-
if (!setup.ok) {
|
|
40
|
-
// setup 실패 잔존물이 detect를 installed로 오판시키지 않도록 정리한다.
|
|
41
|
-
rmSync(dir, { recursive: true, force: true })
|
|
42
|
-
throw new LocalizedError('error.gstackSetup', { output: setup.output })
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
// 부트스트랩 저장소에서는 .claude/skills가 .agents/skills Junction이므로 두 경로 모두 무시 처리
|
|
46
|
-
if (!dryRun) ensureGitignoreEntries(root, ['.claude/skills/gstack', '.agents/skills/gstack'])
|
|
47
|
-
},
|
|
48
|
-
async uninstall({ root, dryRun, exec }) {
|
|
49
|
-
const dir = repoPathStrict(root, REL_DIR)
|
|
50
|
-
if (!existsSync(dir)) return
|
|
51
|
-
await exec('bash', [join(dir, 'bin', 'gstack-uninstall'), '--force'], { cwd: dir }) // 실패해도 디렉터리 삭제로 폴백
|
|
52
|
-
if (!dryRun && existsSync(dir)) rmSync(dir, { recursive: true, force: true })
|
|
53
|
-
},
|
|
54
|
-
})
|
|
1
|
+
import { existsSync, rmSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { defineSkill } from '../catalog.mjs'
|
|
4
|
+
import { CLI_IDS } from '../clis.mjs'
|
|
5
|
+
import { repoPath, repoPathStrict } from '../context.mjs'
|
|
6
|
+
import { ensureGitignoreEntries } from '../gitignore.mjs'
|
|
7
|
+
import { LocalizedError, msg } from '../i18n/index.mjs'
|
|
8
|
+
|
|
9
|
+
const REL_DIR = '.claude/skills/gstack'
|
|
10
|
+
|
|
11
|
+
// 상류 setup은 --host codex·kiro·opencode(와 factory)도 지원하지만 이 항목은
|
|
12
|
+
// 인자 없이 실행하며 setup의 기본값이 claude 단독이다. README 표에 있는
|
|
13
|
+
// --host cursor·slate는 setup의 case 문이 거부해 exit 1로 죽는다 —
|
|
14
|
+
// 확장한다면 안전한 값은 claude·codex·kiro·factory·opencode·auto뿐이다
|
|
15
|
+
// (2026-08-02 검증).
|
|
16
|
+
const HOSTS = ['codex', 'kiro', 'opencode']
|
|
17
|
+
|
|
18
|
+
export default defineSkill({
|
|
19
|
+
id: 'skill.gstack', label: 'gstack', group: '__flow', scope: 'project',
|
|
20
|
+
note: 'item.skill.gstack.note',
|
|
21
|
+
unsupported: Object.fromEntries(
|
|
22
|
+
CLI_IDS.filter((c) => c !== 'claude').map((c) => [
|
|
23
|
+
c,
|
|
24
|
+
HOSTS.includes(c) ? msg('item.unsupported.gstackHost') : msg('item.unsupported.upstreamNone'),
|
|
25
|
+
]),
|
|
26
|
+
),
|
|
27
|
+
async detect({ root }) {
|
|
28
|
+
return { status: existsSync(repoPath(root, REL_DIR)) ? 'installed' : 'absent' }
|
|
29
|
+
},
|
|
30
|
+
async install({ root, dryRun, exec }) {
|
|
31
|
+
// clone·삭제가 일어나는 경로다. 어휘적 검사만으로는 .claude/skills가
|
|
32
|
+
// 저장소 밖을 가리키는 링크일 때를 막지 못한다(부트스트랩이 만드는
|
|
33
|
+
// .agents/skills Junction은 저장소 안이라 그대로 통과한다).
|
|
34
|
+
const dir = repoPathStrict(root, REL_DIR)
|
|
35
|
+
if (!existsSync(dir)) {
|
|
36
|
+
const clone = await exec('git', ['clone', '--single-branch', '--depth', '1', 'https://github.com/garrytan/gstack.git', dir])
|
|
37
|
+
if (!clone.ok) throw new LocalizedError('error.gstackClone', { output: clone.output })
|
|
38
|
+
const setup = await exec('bash', ['./setup'], { cwd: dir })
|
|
39
|
+
if (!setup.ok) {
|
|
40
|
+
// setup 실패 잔존물이 detect를 installed로 오판시키지 않도록 정리한다.
|
|
41
|
+
rmSync(dir, { recursive: true, force: true })
|
|
42
|
+
throw new LocalizedError('error.gstackSetup', { output: setup.output })
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
// 부트스트랩 저장소에서는 .claude/skills가 .agents/skills Junction이므로 두 경로 모두 무시 처리
|
|
46
|
+
if (!dryRun) ensureGitignoreEntries(root, ['.claude/skills/gstack', '.agents/skills/gstack'])
|
|
47
|
+
},
|
|
48
|
+
async uninstall({ root, dryRun, exec }) {
|
|
49
|
+
const dir = repoPathStrict(root, REL_DIR)
|
|
50
|
+
if (!existsSync(dir)) return
|
|
51
|
+
await exec('bash', [join(dir, 'bin', 'gstack-uninstall'), '--force'], { cwd: dir }) // 실패해도 디렉터리 삭제로 폴백
|
|
52
|
+
if (!dryRun && existsSync(dir)) rmSync(dir, { recursive: true, force: true })
|
|
53
|
+
},
|
|
54
|
+
})
|
package/lib/jsonfile.mjs
CHANGED
|
@@ -1,40 +1,40 @@
|
|
|
1
|
-
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'
|
|
2
|
-
import { dirname } from 'node:path'
|
|
3
|
-
import { parse, modify, applyEdits } from 'jsonc-parser'
|
|
4
|
-
|
|
5
|
-
const FORMAT = { formattingOptions: { insertSpaces: true, tabSize: 2 } }
|
|
6
|
-
|
|
7
|
-
function readText(file) {
|
|
8
|
-
return existsSync(file) ? readFileSync(file, 'utf8') : ''
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
export function readJson(file) {
|
|
12
|
-
const text = readText(file)
|
|
13
|
-
if (!text.trim()) return undefined
|
|
14
|
-
return parse(text)
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export function setKey(file, path, value) {
|
|
18
|
-
let text = readText(file)
|
|
19
|
-
if (!text.trim()) text = '{}\n'
|
|
20
|
-
const edits = modify(text, path, value, FORMAT)
|
|
21
|
-
mkdirSync(dirname(file), { recursive: true })
|
|
22
|
-
writeFileSync(file, applyEdits(text, edits))
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export function removeKey(file, path) {
|
|
26
|
-
const text = readText(file)
|
|
27
|
-
if (!text.trim()) return
|
|
28
|
-
if (getIn(parse(text), path) === undefined) return
|
|
29
|
-
const edits = modify(text, path, undefined, FORMAT)
|
|
30
|
-
writeFileSync(file, applyEdits(text, edits))
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export function getIn(data, path) {
|
|
34
|
-
let cur = data
|
|
35
|
-
for (const key of path) {
|
|
36
|
-
if (cur == null || typeof cur !== 'object') return undefined
|
|
37
|
-
cur = cur[key]
|
|
38
|
-
}
|
|
39
|
-
return cur
|
|
40
|
-
}
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'
|
|
2
|
+
import { dirname } from 'node:path'
|
|
3
|
+
import { parse, modify, applyEdits } from 'jsonc-parser'
|
|
4
|
+
|
|
5
|
+
const FORMAT = { formattingOptions: { insertSpaces: true, tabSize: 2 } }
|
|
6
|
+
|
|
7
|
+
function readText(file) {
|
|
8
|
+
return existsSync(file) ? readFileSync(file, 'utf8') : ''
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function readJson(file) {
|
|
12
|
+
const text = readText(file)
|
|
13
|
+
if (!text.trim()) return undefined
|
|
14
|
+
return parse(text)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function setKey(file, path, value) {
|
|
18
|
+
let text = readText(file)
|
|
19
|
+
if (!text.trim()) text = '{}\n'
|
|
20
|
+
const edits = modify(text, path, value, FORMAT)
|
|
21
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
22
|
+
writeFileSync(file, applyEdits(text, edits))
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function removeKey(file, path) {
|
|
26
|
+
const text = readText(file)
|
|
27
|
+
if (!text.trim()) return
|
|
28
|
+
if (getIn(parse(text), path) === undefined) return
|
|
29
|
+
const edits = modify(text, path, undefined, FORMAT)
|
|
30
|
+
writeFileSync(file, applyEdits(text, edits))
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function getIn(data, path) {
|
|
34
|
+
let cur = data
|
|
35
|
+
for (const key of path) {
|
|
36
|
+
if (cur == null || typeof cur !== 'object') return undefined
|
|
37
|
+
cur = cur[key]
|
|
38
|
+
}
|
|
39
|
+
return cur
|
|
40
|
+
}
|
package/lib/status.mjs
CHANGED
|
@@ -1,93 +1,93 @@
|
|
|
1
|
-
// status — 의도(설치 기록) / 실제(스캔) / 가용(최신 패키지)을 나란히 보여준다.
|
|
2
|
-
//
|
|
3
|
-
// 실제 상태의 근거는 여전히 스캔이다. 기록은 의도일 뿐이므로 둘이 어긋나면
|
|
4
|
-
// 어느 쪽에만 있는지 갈라 보여준다 — 수동 설치·제거를 그대로 잡는다는
|
|
5
|
-
// 기존 강점을 기록이 가리지 않게 한다.
|
|
6
|
-
import { MANIFEST } from './bootstrap/manifest.mjs'
|
|
7
|
-
import { updateBlocks, updateFiles } from './bootstrap/apply.mjs'
|
|
8
|
-
import { readRecord, toolVersion } from './bootstrap/record.mjs'
|
|
9
|
-
import { createT } from './i18n/index.mjs'
|
|
10
|
-
|
|
11
|
-
export async function collectStatus(root, { manifest = MANIFEST, items = [], latest } = {}) {
|
|
12
|
-
const record = readRecord(root)
|
|
13
|
-
|
|
14
|
-
// dry-run으로 갱신 판정만 얻는다 — 판정 로직을 두 벌 두면 status와 update가
|
|
15
|
-
// 다른 답을 내는 순간이 온다.
|
|
16
|
-
const silent = () => {}
|
|
17
|
-
const verdicts = record
|
|
18
|
-
? [
|
|
19
|
-
...updateFiles(root, manifest.files, { managed: record.managed, dryRun: true, log: silent }),
|
|
20
|
-
...updateBlocks(root, manifest.blocks, { managed: record.managed, dryRun: true, log: silent }),
|
|
21
|
-
]
|
|
22
|
-
: []
|
|
23
|
-
|
|
24
|
-
const count = (action) => verdicts.filter((v) => v.action === action).length
|
|
25
|
-
|
|
26
|
-
const states = []
|
|
27
|
-
for (const item of items) {
|
|
28
|
-
try {
|
|
29
|
-
const r = await item.detect({ root })
|
|
30
|
-
states.push({ id: item.id, status: r.status })
|
|
31
|
-
} catch {
|
|
32
|
-
// 감지 실패로 status 전체가 죽으면 진단 도구가 아니다.
|
|
33
|
-
states.push({ id: item.id, status: 'absent' })
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
const installed = states.filter((s) => s.status !== 'absent').map((s) => s.id)
|
|
37
|
-
const intended = new Set(record?.items ?? [])
|
|
38
|
-
|
|
39
|
-
return {
|
|
40
|
-
hasRecord: Boolean(record),
|
|
41
|
-
tool: {
|
|
42
|
-
pinned: record?.pinnedVersion ?? null,
|
|
43
|
-
running: toolVersion(),
|
|
44
|
-
latest: latest ?? null,
|
|
45
|
-
},
|
|
46
|
-
files: {
|
|
47
|
-
total: verdicts.length,
|
|
48
|
-
current: count('skip'),
|
|
49
|
-
pending: count('update') + count('create'),
|
|
50
|
-
drift: count('drift'),
|
|
51
|
-
},
|
|
52
|
-
items: {
|
|
53
|
-
installed,
|
|
54
|
-
recordOnly: [...intended].filter((id) => !installed.includes(id)),
|
|
55
|
-
repoOnly: installed.filter((id) => !intended.has(id)),
|
|
56
|
-
},
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
export function formatStatus(report, t = createT('en')) {
|
|
61
|
-
const lines = []
|
|
62
|
-
const { tool, files, items } = report
|
|
63
|
-
|
|
64
|
-
if (!report.hasRecord) {
|
|
65
|
-
lines.push(t('status.noRecord'))
|
|
66
|
-
lines.push(t('status.noRecord.hint1'))
|
|
67
|
-
lines.push(t('status.noRecord.hint2'))
|
|
68
|
-
return lines.join('\n')
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
const version = tool.latest && tool.latest !== tool.running
|
|
72
|
-
? t('status.version.latest', { pinned: tool.pinned, running: tool.running, latest: tool.latest })
|
|
73
|
-
: t('status.version.pinned', { pinned: tool.pinned, running: tool.running })
|
|
74
|
-
lines.push(t('status.row.tool', { version }))
|
|
75
|
-
if (tool.pinned !== tool.running) lines.push(t('status.hint.update'))
|
|
76
|
-
|
|
77
|
-
lines.push(t('status.row.files', files))
|
|
78
|
-
if (files.pending > 0) lines.push(t('status.hint.pending'))
|
|
79
|
-
if (files.drift > 0) lines.push(t('status.hint.drift'))
|
|
80
|
-
|
|
81
|
-
lines.push(t('status.row.items', { list: items.installed.join(', ') || t('status.none') }))
|
|
82
|
-
if (items.recordOnly.length) lines.push(t('status.row.recordOnly', { list: items.recordOnly.join(', ') }))
|
|
83
|
-
if (items.repoOnly.length) lines.push(t('status.row.repoOnly', { list: items.repoOnly.join(', ') }))
|
|
84
|
-
|
|
85
|
-
return lines.join('\n')
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
export async function runStatus(root, { json = false, log = console.log, t = createT('en') } = {}) {
|
|
89
|
-
const { loadItems } = await import('./catalog.mjs')
|
|
90
|
-
const report = await collectStatus(root, { items: await loadItems() })
|
|
91
|
-
log(json ? JSON.stringify(report, null, 2) : formatStatus(report, t))
|
|
92
|
-
return report
|
|
93
|
-
}
|
|
1
|
+
// status — 의도(설치 기록) / 실제(스캔) / 가용(최신 패키지)을 나란히 보여준다.
|
|
2
|
+
//
|
|
3
|
+
// 실제 상태의 근거는 여전히 스캔이다. 기록은 의도일 뿐이므로 둘이 어긋나면
|
|
4
|
+
// 어느 쪽에만 있는지 갈라 보여준다 — 수동 설치·제거를 그대로 잡는다는
|
|
5
|
+
// 기존 강점을 기록이 가리지 않게 한다.
|
|
6
|
+
import { MANIFEST } from './bootstrap/manifest.mjs'
|
|
7
|
+
import { updateBlocks, updateFiles } from './bootstrap/apply.mjs'
|
|
8
|
+
import { readRecord, toolVersion } from './bootstrap/record.mjs'
|
|
9
|
+
import { createT } from './i18n/index.mjs'
|
|
10
|
+
|
|
11
|
+
export async function collectStatus(root, { manifest = MANIFEST, items = [], latest } = {}) {
|
|
12
|
+
const record = readRecord(root)
|
|
13
|
+
|
|
14
|
+
// dry-run으로 갱신 판정만 얻는다 — 판정 로직을 두 벌 두면 status와 update가
|
|
15
|
+
// 다른 답을 내는 순간이 온다.
|
|
16
|
+
const silent = () => {}
|
|
17
|
+
const verdicts = record
|
|
18
|
+
? [
|
|
19
|
+
...updateFiles(root, manifest.files, { managed: record.managed, dryRun: true, log: silent }),
|
|
20
|
+
...updateBlocks(root, manifest.blocks, { managed: record.managed, dryRun: true, log: silent }),
|
|
21
|
+
]
|
|
22
|
+
: []
|
|
23
|
+
|
|
24
|
+
const count = (action) => verdicts.filter((v) => v.action === action).length
|
|
25
|
+
|
|
26
|
+
const states = []
|
|
27
|
+
for (const item of items) {
|
|
28
|
+
try {
|
|
29
|
+
const r = await item.detect({ root })
|
|
30
|
+
states.push({ id: item.id, status: r.status })
|
|
31
|
+
} catch {
|
|
32
|
+
// 감지 실패로 status 전체가 죽으면 진단 도구가 아니다.
|
|
33
|
+
states.push({ id: item.id, status: 'absent' })
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
const installed = states.filter((s) => s.status !== 'absent').map((s) => s.id)
|
|
37
|
+
const intended = new Set(record?.items ?? [])
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
hasRecord: Boolean(record),
|
|
41
|
+
tool: {
|
|
42
|
+
pinned: record?.pinnedVersion ?? null,
|
|
43
|
+
running: toolVersion(),
|
|
44
|
+
latest: latest ?? null,
|
|
45
|
+
},
|
|
46
|
+
files: {
|
|
47
|
+
total: verdicts.length,
|
|
48
|
+
current: count('skip'),
|
|
49
|
+
pending: count('update') + count('create'),
|
|
50
|
+
drift: count('drift'),
|
|
51
|
+
},
|
|
52
|
+
items: {
|
|
53
|
+
installed,
|
|
54
|
+
recordOnly: [...intended].filter((id) => !installed.includes(id)),
|
|
55
|
+
repoOnly: installed.filter((id) => !intended.has(id)),
|
|
56
|
+
},
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function formatStatus(report, t = createT('en')) {
|
|
61
|
+
const lines = []
|
|
62
|
+
const { tool, files, items } = report
|
|
63
|
+
|
|
64
|
+
if (!report.hasRecord) {
|
|
65
|
+
lines.push(t('status.noRecord'))
|
|
66
|
+
lines.push(t('status.noRecord.hint1'))
|
|
67
|
+
lines.push(t('status.noRecord.hint2'))
|
|
68
|
+
return lines.join('\n')
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const version = tool.latest && tool.latest !== tool.running
|
|
72
|
+
? t('status.version.latest', { pinned: tool.pinned, running: tool.running, latest: tool.latest })
|
|
73
|
+
: t('status.version.pinned', { pinned: tool.pinned, running: tool.running })
|
|
74
|
+
lines.push(t('status.row.tool', { version }))
|
|
75
|
+
if (tool.pinned !== tool.running) lines.push(t('status.hint.update'))
|
|
76
|
+
|
|
77
|
+
lines.push(t('status.row.files', files))
|
|
78
|
+
if (files.pending > 0) lines.push(t('status.hint.pending'))
|
|
79
|
+
if (files.drift > 0) lines.push(t('status.hint.drift'))
|
|
80
|
+
|
|
81
|
+
lines.push(t('status.row.items', { list: items.installed.join(', ') || t('status.none') }))
|
|
82
|
+
if (items.recordOnly.length) lines.push(t('status.row.recordOnly', { list: items.recordOnly.join(', ') }))
|
|
83
|
+
if (items.repoOnly.length) lines.push(t('status.row.repoOnly', { list: items.repoOnly.join(', ') }))
|
|
84
|
+
|
|
85
|
+
return lines.join('\n')
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function runStatus(root, { json = false, log = console.log, t = createT('en') } = {}) {
|
|
89
|
+
const { loadItems } = await import('./catalog.mjs')
|
|
90
|
+
const report = await collectStatus(root, { items: await loadItems() })
|
|
91
|
+
log(json ? JSON.stringify(report, null, 2) : formatStatus(report, t))
|
|
92
|
+
return report
|
|
93
|
+
}
|
package/lib/tomlfile.mjs
CHANGED
|
@@ -1,60 +1,60 @@
|
|
|
1
|
-
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'
|
|
2
|
-
import { dirname } from 'node:path'
|
|
3
|
-
import { parse } from 'smol-toml'
|
|
4
|
-
|
|
5
|
-
function readText(file) {
|
|
6
|
-
return existsSync(file) ? readFileSync(file, 'utf8') : ''
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
function escapeRegExp(text) {
|
|
10
|
-
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export function hasSection(file, name) {
|
|
14
|
-
const text = readText(file)
|
|
15
|
-
if (!text.trim()) return false
|
|
16
|
-
try {
|
|
17
|
-
const data = parse(text)
|
|
18
|
-
return data.mcp_servers != null && Object.hasOwn(data.mcp_servers, name)
|
|
19
|
-
} catch {
|
|
20
|
-
return false
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export function appendSection(file, name, lines) {
|
|
25
|
-
let text = readText(file)
|
|
26
|
-
if (text.length > 0 && !text.endsWith('\n')) text += '\n'
|
|
27
|
-
const separator = text.length > 0 ? '\n' : ''
|
|
28
|
-
text += `${separator}[mcp_servers.${name}]\n${lines.join('\n')}\n`
|
|
29
|
-
mkdirSync(dirname(file), { recursive: true })
|
|
30
|
-
writeFileSync(file, text)
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export function removeSection(file, name) {
|
|
34
|
-
const text = readText(file)
|
|
35
|
-
if (!text) return
|
|
36
|
-
const lines = text.split('\n')
|
|
37
|
-
const headerRe = new RegExp(`^\\s*\\[mcp_servers\\.${escapeRegExp(name)}(\\.|\\])`)
|
|
38
|
-
const anyHeaderRe = /^\s*\[/
|
|
39
|
-
const out = []
|
|
40
|
-
let skipping = false
|
|
41
|
-
let removedAt = -1
|
|
42
|
-
for (const line of lines) {
|
|
43
|
-
if (skipping && anyHeaderRe.test(line) && !headerRe.test(line)) skipping = false
|
|
44
|
-
if (headerRe.test(line)) {
|
|
45
|
-
skipping = true
|
|
46
|
-
if (removedAt === -1) removedAt = out.length
|
|
47
|
-
}
|
|
48
|
-
if (!skipping) out.push(line)
|
|
49
|
-
}
|
|
50
|
-
// 제거 지점의 경계에서만 빈 줄 하나를 정리해, 다른 위치의 빈 줄들은 그대로 보존한다.
|
|
51
|
-
if (
|
|
52
|
-
removedAt > 0 &&
|
|
53
|
-
removedAt < out.length &&
|
|
54
|
-
out[removedAt - 1].trim() === '' &&
|
|
55
|
-
(out[removedAt] ?? '').trim() === ''
|
|
56
|
-
) {
|
|
57
|
-
out.splice(removedAt - 1, 1)
|
|
58
|
-
}
|
|
59
|
-
writeFileSync(file, out.join('\n'))
|
|
60
|
-
}
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'
|
|
2
|
+
import { dirname } from 'node:path'
|
|
3
|
+
import { parse } from 'smol-toml'
|
|
4
|
+
|
|
5
|
+
function readText(file) {
|
|
6
|
+
return existsSync(file) ? readFileSync(file, 'utf8') : ''
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function escapeRegExp(text) {
|
|
10
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function hasSection(file, name) {
|
|
14
|
+
const text = readText(file)
|
|
15
|
+
if (!text.trim()) return false
|
|
16
|
+
try {
|
|
17
|
+
const data = parse(text)
|
|
18
|
+
return data.mcp_servers != null && Object.hasOwn(data.mcp_servers, name)
|
|
19
|
+
} catch {
|
|
20
|
+
return false
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function appendSection(file, name, lines) {
|
|
25
|
+
let text = readText(file)
|
|
26
|
+
if (text.length > 0 && !text.endsWith('\n')) text += '\n'
|
|
27
|
+
const separator = text.length > 0 ? '\n' : ''
|
|
28
|
+
text += `${separator}[mcp_servers.${name}]\n${lines.join('\n')}\n`
|
|
29
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
30
|
+
writeFileSync(file, text)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function removeSection(file, name) {
|
|
34
|
+
const text = readText(file)
|
|
35
|
+
if (!text) return
|
|
36
|
+
const lines = text.split('\n')
|
|
37
|
+
const headerRe = new RegExp(`^\\s*\\[mcp_servers\\.${escapeRegExp(name)}(\\.|\\])`)
|
|
38
|
+
const anyHeaderRe = /^\s*\[/
|
|
39
|
+
const out = []
|
|
40
|
+
let skipping = false
|
|
41
|
+
let removedAt = -1
|
|
42
|
+
for (const line of lines) {
|
|
43
|
+
if (skipping && anyHeaderRe.test(line) && !headerRe.test(line)) skipping = false
|
|
44
|
+
if (headerRe.test(line)) {
|
|
45
|
+
skipping = true
|
|
46
|
+
if (removedAt === -1) removedAt = out.length
|
|
47
|
+
}
|
|
48
|
+
if (!skipping) out.push(line)
|
|
49
|
+
}
|
|
50
|
+
// 제거 지점의 경계에서만 빈 줄 하나를 정리해, 다른 위치의 빈 줄들은 그대로 보존한다.
|
|
51
|
+
if (
|
|
52
|
+
removedAt > 0 &&
|
|
53
|
+
removedAt < out.length &&
|
|
54
|
+
out[removedAt - 1].trim() === '' &&
|
|
55
|
+
(out[removedAt] ?? '').trim() === ''
|
|
56
|
+
) {
|
|
57
|
+
out.splice(removedAt - 1, 1)
|
|
58
|
+
}
|
|
59
|
+
writeFileSync(file, out.join('\n'))
|
|
60
|
+
}
|