@rch4com/agent-setup 1.0.0 → 1.3.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/LICENSE +21 -21
- package/README.md +151 -3
- package/install.mjs +102 -33
- package/lib/args.mjs +104 -94
- package/lib/bootstrap/adapter.mjs +96 -95
- package/lib/bootstrap/apply.mjs +380 -269
- package/lib/bootstrap/flow.mjs +73 -54
- package/lib/bootstrap/record.mjs +154 -0
- package/lib/bootstrap/templates.mjs +148 -148
- package/lib/bootstrap/text.mjs +19 -0
- package/lib/catalog.mjs +213 -145
- package/lib/claude-plugins.mjs +64 -64
- package/lib/clis.mjs +102 -102
- package/lib/context.mjs +66 -65
- package/lib/deps.mjs +5 -7
- package/lib/design-md/catalog.json +454 -454
- package/lib/design-md/catalog.mjs +266 -268
- package/lib/design-md/flow.mjs +181 -166
- package/lib/design-md/open.mjs +51 -50
- package/lib/design-md/providers/awesome-design-md.mjs +117 -114
- package/lib/design-md/providers/index.mjs +9 -9
- package/lib/design-md/scan.mjs +14 -10
- package/lib/engine.mjs +48 -44
- package/lib/gitignore.mjs +18 -18
- package/lib/i18n/catalog/en.mjs +369 -0
- package/lib/i18n/catalog/ko.mjs +359 -0
- package/lib/i18n/detect.mjs +39 -0
- package/lib/i18n/index.mjs +93 -0
- package/lib/items/mcp.codebase-memory.mjs +6 -6
- package/lib/items/mcp.graphify.mjs +8 -0
- package/lib/items/mcp.headroom.mjs +8 -0
- 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 +7 -7
- package/lib/items/plugin.ecc.mjs +11 -0
- package/lib/items/plugin.impeccable.mjs +14 -0
- package/lib/items/plugin.mattpocock-skills.mjs +2 -2
- package/lib/items/plugin.ponytail.mjs +104 -0
- package/lib/items/plugin.superpowers.mjs +7 -7
- package/lib/items/plugin.understand-anything.mjs +10 -0
- package/lib/items/skill.caveman.mjs +7 -0
- package/lib/items/skill.gsd.mjs +25 -24
- package/lib/items/skill.gstack.mjs +40 -39
- package/lib/items/skill.karpathy.mjs +7 -0
- package/lib/items/skill.taste.mjs +9 -0
- package/lib/jsonfile.mjs +40 -40
- package/lib/status.mjs +93 -0
- package/lib/tomlfile.mjs +60 -60
- package/lib/tui/render.mjs +41 -63
- package/lib/tui/rows.mjs +278 -206
- package/lib/tui/run.mjs +64 -28
- package/lib/update.mjs +85 -0
- package/lib/width.mjs +57 -0
- package/package.json +36 -36
- package/lib/items/.omc/state/sessions/a859aff7-59d9-4ff6-afc6-8cafe4471dc4/pre-tool-advisory-throttle.json +0 -14
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { CLI_IDS } from '../clis.mjs'
|
|
2
|
+
import { isPluginEnabled, enablePlugin, disablePlugin } from '../claude-plugins.mjs'
|
|
3
|
+
import { repoPath, repoPathStrict } from '../context.mjs'
|
|
4
|
+
import { readJson, setKey, removeKey } from '../jsonfile.mjs'
|
|
5
|
+
import { msg, LocalizedError } from '../i18n/index.mjs'
|
|
6
|
+
|
|
7
|
+
// 상류 안내는 `--scope user`지만 이 저장소의 항목은 전부 프로젝트 스코프다.
|
|
8
|
+
// 같은 규칙 묶음이 두 경로로 들어간다 — Claude Code는 플러그인 CLI(실패하면
|
|
9
|
+
// .claude/settings.json 기록), OpenCode는 프로젝트 설정의 plugin 배열.
|
|
10
|
+
// 두 경로 모두 저장소 안에만 쓴다.
|
|
11
|
+
const INSTALL_ID = 'ponytail@ponytail'
|
|
12
|
+
const MARKETPLACE = { name: 'ponytail', repo: 'DietrichGebert/ponytail' }
|
|
13
|
+
const OPENCODE_FILE = 'opencode.jsonc'
|
|
14
|
+
const OPENCODE_ENTRY = '@dietrichgebert/ponytail'
|
|
15
|
+
const SUPPORTS = ['claude', 'opencode']
|
|
16
|
+
|
|
17
|
+
// plugin 키가 배열이 아니면 손대지 않는다 — 통째로 덮어쓰면 사용자가 적어 둔
|
|
18
|
+
// 설정이 조용히 사라진다. 없는 것(undefined)과 다른 타입을 구분해서 돌려준다.
|
|
19
|
+
function opencodePlugins(root) {
|
|
20
|
+
const value = readJson(repoPath(root, OPENCODE_FILE))?.plugin
|
|
21
|
+
if (value === undefined) return []
|
|
22
|
+
if (!Array.isArray(value)) return null
|
|
23
|
+
return value
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function opencodeListOrThrow(root) {
|
|
27
|
+
const list = opencodePlugins(root)
|
|
28
|
+
if (list === null) throw new LocalizedError('error.ponytailOpencodePlugin', { file: OPENCODE_FILE })
|
|
29
|
+
return list
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function hasOpencode(root) {
|
|
33
|
+
return (opencodePlugins(root) ?? []).includes(OPENCODE_ENTRY)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export default {
|
|
37
|
+
id: 'plugin.ponytail', category: 'plugin', label: 'Ponytail', scope: 'project',
|
|
38
|
+
group: '__token',
|
|
39
|
+
supports: SUPPORTS,
|
|
40
|
+
unsupported: Object.fromEntries(
|
|
41
|
+
CLI_IDS.filter((c) => !SUPPORTS.includes(c)).map((c) => [
|
|
42
|
+
c,
|
|
43
|
+
// codex·copilot·gemini는 상류에 설치 경로가 있지만 전부 사용자 스코프다.
|
|
44
|
+
// 나머지는 플러그인 기구 자체가 없어 AGENTS.md에 규칙을 옮겨 적어야 한다.
|
|
45
|
+
['codex', 'copilot', 'gemini'].includes(c)
|
|
46
|
+
? msg('item.unsupported.ponytailUser')
|
|
47
|
+
: msg('item.unsupported.ponytailRules'),
|
|
48
|
+
]),
|
|
49
|
+
),
|
|
50
|
+
note: 'item.plugin.ponytail.note',
|
|
51
|
+
|
|
52
|
+
async detect({ root }) {
|
|
53
|
+
const present = []
|
|
54
|
+
if (isPluginEnabled(root, [INSTALL_ID])) present.push('claude')
|
|
55
|
+
if (hasOpencode(root)) present.push('opencode')
|
|
56
|
+
if (present.length === 0) return { status: 'absent' }
|
|
57
|
+
if (present.length === SUPPORTS.length) return { status: 'installed' }
|
|
58
|
+
return {
|
|
59
|
+
status: 'partial',
|
|
60
|
+
detail: msg('item.plugin.partial', {
|
|
61
|
+
present: present.join(', '),
|
|
62
|
+
missing: SUPPORTS.filter((c) => !present.includes(c)).join(', '),
|
|
63
|
+
}),
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
|
|
67
|
+
async install({ root, dryRun, exec, log = () => {}, t }) {
|
|
68
|
+
let deferred = false
|
|
69
|
+
if (!isPluginEnabled(root, [INSTALL_ID])) {
|
|
70
|
+
exec('claude', ['plugin', 'marketplace', 'add', MARKETPLACE.repo], { cwd: root })
|
|
71
|
+
const r = exec('claude', ['plugin', 'install', INSTALL_ID, '--scope', 'project'], { cwd: root })
|
|
72
|
+
if (!r.ok) {
|
|
73
|
+
if (!dryRun) enablePlugin(root, INSTALL_ID, MARKETPLACE)
|
|
74
|
+
deferred = true
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (!hasOpencode(root)) {
|
|
78
|
+
const list = opencodeListOrThrow(root)
|
|
79
|
+
if (dryRun) log(t('log.plugin.add', { cli: 'OpenCode', name: OPENCODE_ENTRY }))
|
|
80
|
+
// 파일이 없거나 plugin 키가 없으면 배열째 만들고, 있으면 끝에 덧붙인다.
|
|
81
|
+
else if (list.length === 0) setKey(repoPathStrict(root, OPENCODE_FILE), ['plugin'], [OPENCODE_ENTRY])
|
|
82
|
+
else setKey(repoPathStrict(root, OPENCODE_FILE), ['plugin', list.length], OPENCODE_ENTRY)
|
|
83
|
+
}
|
|
84
|
+
if (deferred) return { fallback: true, message: msg('item.plugin.deferred') }
|
|
85
|
+
},
|
|
86
|
+
|
|
87
|
+
async uninstall({ root, dryRun, exec, log = () => {}, t }) {
|
|
88
|
+
if (isPluginEnabled(root, [INSTALL_ID])) {
|
|
89
|
+
const r = exec('claude', ['plugin', 'uninstall', INSTALL_ID], { cwd: root })
|
|
90
|
+
if (!r.ok && !dryRun) disablePlugin(root, [INSTALL_ID])
|
|
91
|
+
}
|
|
92
|
+
if (!hasOpencode(root)) return
|
|
93
|
+
if (dryRun) {
|
|
94
|
+
log(t('log.plugin.remove', { cli: 'OpenCode', name: OPENCODE_ENTRY }))
|
|
95
|
+
return
|
|
96
|
+
}
|
|
97
|
+
const kept = opencodeListOrThrow(root).filter((p) => p !== OPENCODE_ENTRY)
|
|
98
|
+
const file = repoPathStrict(root, OPENCODE_FILE)
|
|
99
|
+
// 우리가 넣은 항목만 빼고 비면 키째 지운다 — 빈 배열을 남기면 우리가
|
|
100
|
+
// 만든 흔적이 설정에 계속 남는다.
|
|
101
|
+
if (kept.length === 0) removeKey(file, ['plugin'])
|
|
102
|
+
else setKey(file, ['plugin'], kept)
|
|
103
|
+
},
|
|
104
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { definePlugin } from '../catalog.mjs'
|
|
2
|
-
export default definePlugin({
|
|
3
|
-
id: 'plugin.superpowers', label: 'superpowers',
|
|
4
|
-
installId: 'superpowers@claude-plugins-official',
|
|
5
|
-
detectIds: ['superpowers@claude-plugins-official', 'superpowers@superpowers-marketplace'],
|
|
6
|
-
note: '
|
|
7
|
-
})
|
|
1
|
+
import { definePlugin } from '../catalog.mjs'
|
|
2
|
+
export default definePlugin({
|
|
3
|
+
id: 'plugin.superpowers', label: 'superpowers', group: '__flow',
|
|
4
|
+
installId: 'superpowers@claude-plugins-official',
|
|
5
|
+
detectIds: ['superpowers@claude-plugins-official', 'superpowers@superpowers-marketplace'],
|
|
6
|
+
note: 'item.plugin.superpowers.note',
|
|
7
|
+
})
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { definePlugin } from '../catalog.mjs'
|
|
2
|
+
// 다른 CLI용 install.sh도 있지만 그 경로는 ~/.understand-anything에 클론하고
|
|
3
|
+
// 홈에 심볼릭 링크를 건다 — 사용자 스코프라 이 저장소의 항목이 될 수 없다.
|
|
4
|
+
export default definePlugin({
|
|
5
|
+
id: 'plugin.understand-anything', label: 'Understand Anything', group: '__context',
|
|
6
|
+
installId: 'understand-anything@understand-anything',
|
|
7
|
+
detectIds: ['understand-anything@understand-anything'],
|
|
8
|
+
marketplace: { name: 'understand-anything', repo: 'Egonex-AI/Understand-Anything' },
|
|
9
|
+
note: 'item.plugin.understand-anything.note',
|
|
10
|
+
})
|
package/lib/items/skill.gsd.mjs
CHANGED
|
@@ -1,24 +1,25 @@
|
|
|
1
|
-
import { existsSync, readdirSync } from 'node:fs'
|
|
2
|
-
import { join } from 'node:path'
|
|
3
|
-
import { defineSkill } from '../catalog.mjs'
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
}
|
|
1
|
+
import { existsSync, readdirSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { defineSkill } from '../catalog.mjs'
|
|
4
|
+
import { LocalizedError } from '../i18n/index.mjs'
|
|
5
|
+
|
|
6
|
+
function hasGsdFiles(dir) {
|
|
7
|
+
return existsSync(dir) && readdirSync(dir).some((f) => f.startsWith('gsd-'))
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export default defineSkill({
|
|
11
|
+
id: 'skill.gsd', label: 'GSD (Get Shit Done)', group: '__flow', scope: 'project',
|
|
12
|
+
note: 'item.skill.gsd.note',
|
|
13
|
+
async detect({ root }) {
|
|
14
|
+
const found = hasGsdFiles(join(root, '.claude', 'commands')) || hasGsdFiles(join(root, '.claude', 'skills'))
|
|
15
|
+
return { status: found ? 'installed' : 'absent' }
|
|
16
|
+
},
|
|
17
|
+
async install({ root, exec }) {
|
|
18
|
+
const r = exec('npx', ['-y', '@opengsd/gsd-core@latest', '--claude', '--local'], { cwd: root })
|
|
19
|
+
if (!r.ok) throw new LocalizedError('error.gsdInstall', { output: r.output })
|
|
20
|
+
},
|
|
21
|
+
async uninstall({ root, exec }) {
|
|
22
|
+
const r = exec('npx', ['-y', '@opengsd/gsd-core@latest', '--uninstall'], { cwd: root })
|
|
23
|
+
if (!r.ok) throw new LocalizedError('error.gsdUninstall', { output: r.output })
|
|
24
|
+
},
|
|
25
|
+
})
|
|
@@ -1,39 +1,40 @@
|
|
|
1
|
-
import { existsSync, rmSync } from 'node:fs'
|
|
2
|
-
import { join } from 'node:path'
|
|
3
|
-
import { defineSkill } from '../catalog.mjs'
|
|
4
|
-
import { repoPath, repoPathStrict } from '../context.mjs'
|
|
5
|
-
import { ensureGitignoreEntries } from '../gitignore.mjs'
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
}
|
|
1
|
+
import { existsSync, rmSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { defineSkill } from '../catalog.mjs'
|
|
4
|
+
import { repoPath, repoPathStrict } from '../context.mjs'
|
|
5
|
+
import { ensureGitignoreEntries } from '../gitignore.mjs'
|
|
6
|
+
import { LocalizedError } from '../i18n/index.mjs'
|
|
7
|
+
|
|
8
|
+
const REL_DIR = '.claude/skills/gstack'
|
|
9
|
+
|
|
10
|
+
export default defineSkill({
|
|
11
|
+
id: 'skill.gstack', label: 'gstack', group: '__flow', scope: 'project',
|
|
12
|
+
note: 'item.skill.gstack.note',
|
|
13
|
+
async detect({ root }) {
|
|
14
|
+
return { status: existsSync(repoPath(root, REL_DIR)) ? 'installed' : 'absent' }
|
|
15
|
+
},
|
|
16
|
+
async install({ root, dryRun, exec }) {
|
|
17
|
+
// clone·삭제가 일어나는 경로다. 어휘적 검사만으로는 .claude/skills가
|
|
18
|
+
// 저장소 밖을 가리키는 링크일 때를 막지 못한다(부트스트랩이 만드는
|
|
19
|
+
// .agents/skills Junction은 저장소 안이라 그대로 통과한다).
|
|
20
|
+
const dir = repoPathStrict(root, REL_DIR)
|
|
21
|
+
if (!existsSync(dir)) {
|
|
22
|
+
const clone = exec('git', ['clone', '--single-branch', '--depth', '1', 'https://github.com/garrytan/gstack.git', dir])
|
|
23
|
+
if (!clone.ok) throw new LocalizedError('error.gstackClone', { output: clone.output })
|
|
24
|
+
const setup = exec('bash', ['./setup'], { cwd: dir })
|
|
25
|
+
if (!setup.ok) {
|
|
26
|
+
// setup 실패 잔존물이 detect를 installed로 오판시키지 않도록 정리한다.
|
|
27
|
+
rmSync(dir, { recursive: true, force: true })
|
|
28
|
+
throw new LocalizedError('error.gstackSetup', { output: setup.output })
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
// 부트스트랩 저장소에서는 .claude/skills가 .agents/skills Junction이므로 두 경로 모두 무시 처리
|
|
32
|
+
if (!dryRun) ensureGitignoreEntries(root, ['.claude/skills/gstack', '.agents/skills/gstack'])
|
|
33
|
+
},
|
|
34
|
+
async uninstall({ root, dryRun, exec }) {
|
|
35
|
+
const dir = repoPathStrict(root, REL_DIR)
|
|
36
|
+
if (!existsSync(dir)) return
|
|
37
|
+
exec('bash', [join(dir, 'bin', 'gstack-uninstall'), '--force'], { cwd: dir }) // 실패해도 디렉터리 삭제로 폴백
|
|
38
|
+
if (!dryRun && existsSync(dir)) rmSync(dir, { recursive: true, force: true })
|
|
39
|
+
},
|
|
40
|
+
})
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { defineRegistrySkill } from '../catalog.mjs'
|
|
2
|
+
export default defineRegistrySkill({
|
|
3
|
+
id: 'skill.karpathy', label: 'Karpathy guidelines', group: '__flow',
|
|
4
|
+
source: 'https://github.com/multica-ai/andrej-karpathy-skills',
|
|
5
|
+
skill: 'karpathy-guidelines',
|
|
6
|
+
note: 'item.skill.karpathy.note',
|
|
7
|
+
})
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { defineRegistrySkill } from '../catalog.mjs'
|
|
2
|
+
// 저장소의 폴더 이름은 taste-skill이지만 SKILL.md의 name은 design-taste-frontend다.
|
|
3
|
+
// 레지스트리는 name으로 고르므로 여기에도 name을 적는다.
|
|
4
|
+
export default defineRegistrySkill({
|
|
5
|
+
id: 'skill.taste', label: 'Taste Skill', group: '__style',
|
|
6
|
+
source: 'https://github.com/Leonxlnx/taste-skill',
|
|
7
|
+
skill: 'design-taste-frontend',
|
|
8
|
+
note: 'item.skill.taste.note',
|
|
9
|
+
})
|
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
ADDED
|
@@ -0,0 +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
|
+
}
|
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
|
+
}
|