@rch4com/agent-setup 1.27.0 → 2.0.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 +17 -47
- package/lib/bootstrap/templates.mjs +7 -60
- package/lib/catalog.mjs +14 -240
- package/lib/clis.mjs +7 -118
- package/lib/commands.mjs +47 -0
- package/lib/engine.mjs +16 -11
- package/lib/i18n/catalog/en.mjs +50 -47
- package/lib/i18n/catalog/ko.mjs +50 -47
- 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 +16 -15
- package/lib/items/plugin.ecc.mjs +16 -20
- package/lib/items/plugin.hyperframes.mjs +15 -13
- package/lib/items/plugin.impeccable.mjs +17 -22
- package/lib/items/plugin.mattpocock-skills.mjs +19 -17
- package/lib/items/plugin.ponytail.mjs +21 -106
- package/lib/items/plugin.superpowers.mjs +17 -24
- package/lib/items/plugin.understand-anything.mjs +16 -18
- package/lib/items/skill.agent-browser.mjs +6 -6
- 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 +24 -89
- package/lib/items/skill.gstack.mjs +54 -65
- package/lib/items/skill.hallmark.mjs +5 -5
- package/lib/items/skill.i-have-adhd.mjs +6 -6
- package/lib/items/skill.im-not-ai.mjs +15 -0
- 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/probe.mjs +111 -0
- package/lib/recipe-helpers.mjs +205 -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 +3 -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
package/lib/probe.mjs
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// 감지 질의. 항목 detect는 여기 캐시만 조회한다 — CLI 목록 명령은 스캔
|
|
2
|
+
// 한 번에 질의마다 한 번만 돈다. 쓰기는 하지 않는다: 읽는 파일도 CLI가
|
|
3
|
+
// 소유한 설정이라, 고치는 일은 그 CLI의 명령이 맡는다.
|
|
4
|
+
import { existsSync, readdirSync } from 'node:fs'
|
|
5
|
+
import { homedir } from 'node:os'
|
|
6
|
+
import { join, resolve } from 'node:path'
|
|
7
|
+
import { CLIS } from './clis.mjs'
|
|
8
|
+
import { readJson } from './jsonfile.mjs'
|
|
9
|
+
import { findSkillDir } from './skills-registry.mjs'
|
|
10
|
+
|
|
11
|
+
// 목록 명령 하나에 주는 시간. 설치 명령(10분)과 달리 목록은 금방 끝나야
|
|
12
|
+
// 정상이다 — 멈춘 CLI 하나가 화면 전체의 스캔을 붙잡지 않게 한다.
|
|
13
|
+
const PROBE_TIMEOUT_MS = 60 * 1000
|
|
14
|
+
|
|
15
|
+
// 구분자는 실행 중인 OS가 아니라 platform 인자를 따른다 — Windows의 PATH를
|
|
16
|
+
// 콜론으로 쪼개면 드라이브 문자(C:)에서 끊긴다.
|
|
17
|
+
export function onPath(bin, { env = process.env, platform = process.platform, exists = existsSync } = {}) {
|
|
18
|
+
const win = platform === 'win32'
|
|
19
|
+
const dirs = String(env.PATH ?? env.Path ?? '').split(win ? ';' : ':').filter(Boolean)
|
|
20
|
+
// Windows는 확장자 없는 파일을 실행하지 않는다 — PATHEXT 후보만 본다.
|
|
21
|
+
const exts = win ? String(env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean) : ['']
|
|
22
|
+
return dirs.some((dir) => exts.some((ext) => exists(join(dir, `${bin}${ext}`))))
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// `agy mcp list`는 JSON을 주지 않는다. 머리줄(NAME …) 뒤 각 줄의 첫 열이 이름이다.
|
|
26
|
+
export function parseAgyMcpList(text) {
|
|
27
|
+
const lines = String(text).split(/\r?\n/).map((l) => l.trim()).filter(Boolean)
|
|
28
|
+
const head = lines.findIndex((l) => /^NAME\s/.test(l))
|
|
29
|
+
return head === -1 ? [] : lines.slice(head + 1).map((l) => l.split(/\s+/)[0])
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// OpenCode 설정 파일. 둘 다 없으면 OpenCode가 새로 만들 자리를 돌려준다.
|
|
33
|
+
export function opencodeConfigFile({ root, scope, home = homedir(), env = process.env }) {
|
|
34
|
+
const dir = scope === 'project' ? root : join(env.XDG_CONFIG_HOME || join(home, '.config'), 'opencode')
|
|
35
|
+
for (const name of ['opencode.jsonc', 'opencode.json']) {
|
|
36
|
+
if (existsSync(join(dir, name))) return join(dir, name)
|
|
37
|
+
}
|
|
38
|
+
return join(dir, scope === 'project' ? 'opencode.jsonc' : 'opencode.json')
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function readOpencodeConfig(opts) {
|
|
42
|
+
const file = opencodeConfigFile(opts)
|
|
43
|
+
return { file, data: readJson(file) ?? null }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function samePath(a, b, platform) {
|
|
47
|
+
if (!a) return false
|
|
48
|
+
const norm = (p) => (platform === 'win32' ? resolve(p).toLowerCase() : resolve(p))
|
|
49
|
+
return norm(a) === norm(b)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function createProbe({ root, scope, exec, env = process.env, home = homedir(), platform = process.platform }) {
|
|
53
|
+
const memo = new Map()
|
|
54
|
+
// 실패도 캐시한다 — 같은 스캔에서 항목마다 죽은 CLI를 다시 부르지 않는다.
|
|
55
|
+
const once = (key, fn) => {
|
|
56
|
+
if (!memo.has(key)) memo.set(key, Promise.resolve().then(fn))
|
|
57
|
+
return memo.get(key)
|
|
58
|
+
}
|
|
59
|
+
const run = (cli, args) => once(`${cli} ${args.join(' ')}`, async () => {
|
|
60
|
+
const r = await exec(CLIS[cli]?.bin ?? cli, args, { cwd: root, timeout: PROBE_TIMEOUT_MS })
|
|
61
|
+
if (!r.ok) throw new Error(String(r.output ?? '').trim() || `${cli} ${args.join(' ')} failed`)
|
|
62
|
+
return r.output
|
|
63
|
+
})
|
|
64
|
+
const json = async (cli, args) => JSON.parse(await run(cli, args))
|
|
65
|
+
const opencode = () => readOpencodeConfig({ root, scope, home, env }).data
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
root, scope, home,
|
|
69
|
+
has: (cli) => onPath(CLIS[cli].bin, { env, platform }),
|
|
70
|
+
async claudePlugin(ids) {
|
|
71
|
+
const list = await json('claude', ['plugin', 'list', '--json'])
|
|
72
|
+
return list.some((p) => ids.includes(p.id) && p.scope === scope
|
|
73
|
+
&& (scope !== 'project' || samePath(p.projectPath, root, platform)))
|
|
74
|
+
},
|
|
75
|
+
async codexPlugin(id) {
|
|
76
|
+
const data = await json('codex', ['plugin', 'list', '--json'])
|
|
77
|
+
return (data.installed ?? []).some((p) => p.pluginId === id && p.installed !== false)
|
|
78
|
+
},
|
|
79
|
+
async agyPlugin(name) {
|
|
80
|
+
return (await json('antigravity', ['plugin', 'list', '--json'])).some((p) => p.name === name)
|
|
81
|
+
},
|
|
82
|
+
async opencodePlugin(module) {
|
|
83
|
+
const list = opencode()?.plugin
|
|
84
|
+
return Array.isArray(list) && list.includes(module)
|
|
85
|
+
},
|
|
86
|
+
async mcp(cli, name) {
|
|
87
|
+
if (cli === 'claude') {
|
|
88
|
+
// `claude mcp list`는 서버마다 연결을 검사해 느리고 네트워크를 탄다.
|
|
89
|
+
const file = scope === 'project' ? join(root, '.mcp.json') : join(home, '.claude.json')
|
|
90
|
+
return readJson(file)?.mcpServers?.[name] !== undefined
|
|
91
|
+
}
|
|
92
|
+
if (cli === 'codex') return (await json('codex', ['mcp', 'list', '--json'])).some((s) => s.name === name)
|
|
93
|
+
if (cli === 'antigravity') return parseAgyMcpList(await run('antigravity', ['mcp', 'list'])).includes(name)
|
|
94
|
+
if (cli === 'opencode') return opencode()?.mcp?.[name] !== undefined
|
|
95
|
+
return false
|
|
96
|
+
},
|
|
97
|
+
async skill(name) {
|
|
98
|
+
if (scope === 'project') return findSkillDir(root, name) !== null
|
|
99
|
+
const list = JSON.parse(await run('npx', ['-y', 'skills@latest', 'list', '-g', '--json']))
|
|
100
|
+
return list.some((s) => s.name === name)
|
|
101
|
+
},
|
|
102
|
+
exists: (rel) => existsSync(join(root, rel)),
|
|
103
|
+
dirHasPrefix(rel, prefix) {
|
|
104
|
+
try {
|
|
105
|
+
return readdirSync(join(root, rel)).some((f) => f.startsWith(prefix))
|
|
106
|
+
} catch {
|
|
107
|
+
return false
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
}
|
|
111
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// 항목 파일이 거의 다 쓰는 레시피 세 갈래. 명령 문법은 2026-10-24 이 머신의
|
|
2
|
+
// --help 실측(claude 2.1.304, codex-cli 0.163.0, agy)과 opencode.ai/docs/cli를
|
|
3
|
+
// 따른다.
|
|
4
|
+
import { rmSync } from 'node:fs'
|
|
5
|
+
import { homedir } from 'node:os'
|
|
6
|
+
import { repoPathStrict } from './context.mjs'
|
|
7
|
+
import { LocalizedError } from './i18n/index.mjs'
|
|
8
|
+
import { readOpencodeConfig } from './probe.mjs'
|
|
9
|
+
import { SHARED } from './recipe.mjs'
|
|
10
|
+
import { SHARED_SKILLS, findSkillDir, isSafeSkillName, lockedSkills, pruneLock, sourceKey } from './skills-registry.mjs'
|
|
11
|
+
|
|
12
|
+
const SKILLS = ['npx', '-y', 'skills@latest']
|
|
13
|
+
|
|
14
|
+
// 전역 설치 에이전트. skills@1.7.4 docs/agents.md의 전역 경로 기준:
|
|
15
|
+
// claude-code → ~/.claude/skills, codex → ~/.agents/skills,
|
|
16
|
+
// antigravity-cli → ~/.gemini/antigravity-cli/skills(agy가 읽는 전역 경로,
|
|
17
|
+
// antigravity.google/docs/skills). OpenCode는 ~/.claude/skills와
|
|
18
|
+
// ~/.agents/skills를 둘 다 읽으므로(opencode.ai/docs/skills) 따로 넣지 않는다 —
|
|
19
|
+
// 넣으면 같은 스킬을 세 번 본다.
|
|
20
|
+
export const SKILLS_AGENTS = ['claude-code', 'codex', 'antigravity-cli']
|
|
21
|
+
|
|
22
|
+
const OPENCODE_FILE = {
|
|
23
|
+
project: 'the project config ./opencode.jsonc (or ./opencode.json if only that one exists)',
|
|
24
|
+
user: 'the global config ~/.config/opencode/opencode.json (or opencode.jsonc there if only that one exists)',
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// `opencode mcp add`는 대화형뿐이라 OpenCode 에이전트에게 편집을 맡긴다.
|
|
28
|
+
// 지시문에는 큰따옴표를 남기지 않는다 — Windows에서 명령 한 줄은 cmd.exe를
|
|
29
|
+
// 거치고, shellQuote는 그 안의 큰따옴표를 안전하게 이스케이프할 수 없어 거부한다.
|
|
30
|
+
export function opencodePrompt(scope, task) {
|
|
31
|
+
return `Edit ${OPENCODE_FILE[scope]}. ${task} Keep comments and every other key unchanged. Do not run any other command. Reply only DONE.`
|
|
32
|
+
.replace(/"/g, "'")
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// JSON을 작은따옴표로 적고 "파일에는 표준 JSON으로"라고 덧붙인다.
|
|
36
|
+
const quotedJson = (value) => `${JSON.stringify(value)} (written with single quotes here; use standard JSON double quotes in the file)`
|
|
37
|
+
|
|
38
|
+
// LLM 실행이라 결과가 비결정적이다 — 파일을 다시 읽는 이 단계가 유일한 성공 근거다.
|
|
39
|
+
function verifyOpencode(scope, check, describe) {
|
|
40
|
+
return {
|
|
41
|
+
describe,
|
|
42
|
+
fn: ({ root }) => {
|
|
43
|
+
const { file, data } = readOpencodeConfig({ root, scope, home: homedir() })
|
|
44
|
+
if (!check(data)) throw new LocalizedError('error.opencodeVerify', { file })
|
|
45
|
+
},
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const fileName = (scope) => (scope === 'project' ? './opencode.jsonc' : '~/.config/opencode/opencode.json')
|
|
50
|
+
|
|
51
|
+
// `codex mcp add --url`은 OAuth를 쓰는 서버면 설정을 쓴 뒤 로그인 콜백을 기다리다
|
|
52
|
+
// 약 5분 뒤 실패로 끝난다(2026-10-24 supabase 실측, codex-cli 0.163.0). 등록은 이미
|
|
53
|
+
// 끝났으므로 짧게 끊고, 목록 명령으로 등록됐는지 확인한다. 로그인은 사용자가
|
|
54
|
+
// `codex mcp login <이름>`으로 따로 한다(postInstall 안내).
|
|
55
|
+
const CODEX_LOGIN_WAIT_MS = 20 * 1000
|
|
56
|
+
|
|
57
|
+
function verifyCodexMcp(name) {
|
|
58
|
+
return {
|
|
59
|
+
describe: `check codex mcp list has ${name}`,
|
|
60
|
+
fn: async ({ root, exec }) => {
|
|
61
|
+
const r = await exec('codex', ['mcp', 'list', '--json'], { cwd: root })
|
|
62
|
+
let names = []
|
|
63
|
+
try { names = JSON.parse(r.output).map((s) => s.name) } catch { /* 목록을 못 읽으면 아래에서 실패로 보고한다 */ }
|
|
64
|
+
if (!r.ok || !names.includes(name)) throw new LocalizedError('error.codexMcpVerify', { name })
|
|
65
|
+
},
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function marketplacePlugin({ claude, codex, opencode, antigravity }) {
|
|
70
|
+
const recipes = {}
|
|
71
|
+
const detect = {}
|
|
72
|
+
if (claude) {
|
|
73
|
+
const at = (scope) => ({
|
|
74
|
+
install: [
|
|
75
|
+
...(claude.marketplace ? [{ argv: ['claude', 'plugin', 'marketplace', 'add', claude.marketplace, '--scope', scope], tolerateFail: true }] : []),
|
|
76
|
+
['claude', 'plugin', 'install', claude.id, '--scope', scope],
|
|
77
|
+
],
|
|
78
|
+
uninstall: [['claude', 'plugin', 'uninstall', claude.id, '--scope', scope]],
|
|
79
|
+
})
|
|
80
|
+
recipes.claude = { project: at('project'), user: at('user') }
|
|
81
|
+
detect.claude = (p) => p.claudePlugin(claude.detectIds ?? [claude.id])
|
|
82
|
+
}
|
|
83
|
+
if (codex) {
|
|
84
|
+
// Codex는 범위 플래그가 없다 — 활성 CODEX_HOME 하나에 깔린다(전역).
|
|
85
|
+
recipes.codex = {
|
|
86
|
+
user: {
|
|
87
|
+
install: [{ argv: ['codex', 'plugin', 'marketplace', 'add', codex.marketplace], tolerateFail: true }, ['codex', 'plugin', 'add', codex.id]],
|
|
88
|
+
uninstall: [['codex', 'plugin', 'remove', codex.id]],
|
|
89
|
+
},
|
|
90
|
+
}
|
|
91
|
+
detect.codex = (p) => p.codexPlugin(codex.id)
|
|
92
|
+
}
|
|
93
|
+
if (opencode) {
|
|
94
|
+
// 설치 명령은 있지만 제거 명령은 문서에 없다 — 제거는 run 지시문 + 검증이다.
|
|
95
|
+
const at = (scope) => ({
|
|
96
|
+
install: [['opencode', 'plugin', opencode.module, ...(scope === 'user' ? ['-g'] : [])]],
|
|
97
|
+
uninstall: [
|
|
98
|
+
['opencode', 'run', opencodePrompt(scope, `Remove the string '${opencode.module}' from the top-level 'plugin' array; delete the array if it becomes empty.`)],
|
|
99
|
+
verifyOpencode(scope, (d) => !(Array.isArray(d?.plugin) && d.plugin.includes(opencode.module)), `check ${fileName(scope)} no longer lists plugin ${opencode.module}`),
|
|
100
|
+
],
|
|
101
|
+
})
|
|
102
|
+
recipes.opencode = { project: at('project'), user: at('user') }
|
|
103
|
+
detect.opencode = (p) => p.opencodePlugin(opencode.module)
|
|
104
|
+
}
|
|
105
|
+
if (antigravity) {
|
|
106
|
+
// agy plugin install에도 범위 플래그가 없다 — ~/.gemini 아래 전역에 깔린다.
|
|
107
|
+
recipes.antigravity = {
|
|
108
|
+
user: {
|
|
109
|
+
install: [['agy', 'plugin', 'install', antigravity.source]],
|
|
110
|
+
uninstall: [['agy', 'plugin', 'uninstall', antigravity.name]],
|
|
111
|
+
},
|
|
112
|
+
}
|
|
113
|
+
detect.antigravity = (p) => p.agyPlugin(antigravity.name)
|
|
114
|
+
}
|
|
115
|
+
return { recipes, detect }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function mcpServer({ name, server, clis = ['claude', 'codex', 'opencode', 'antigravity'] }) {
|
|
119
|
+
const http = server.kind === 'http'
|
|
120
|
+
// stdio 명령 앞의 `--`: 인자가 `-y`처럼 대시로 시작해도 CLI가 자기 플래그로 읽지 않는다.
|
|
121
|
+
const tail = http ? [] : ['--', server.command, ...server.args]
|
|
122
|
+
const entry = http
|
|
123
|
+
? { type: 'remote', url: server.url, enabled: true }
|
|
124
|
+
: { type: 'local', command: [server.command, ...server.args], enabled: true }
|
|
125
|
+
const opencodeAt = (scope) => ({
|
|
126
|
+
install: [
|
|
127
|
+
['opencode', 'run', opencodePrompt(scope, `Under the top-level 'mcp' object (create it, and the file with '$schema': 'https://opencode.ai/config.json', if missing) set the key '${name}' to this value: ${quotedJson(entry)}.`)],
|
|
128
|
+
verifyOpencode(scope, (d) => d?.mcp?.[name] !== undefined, `check ${fileName(scope)} has mcp.${name}`),
|
|
129
|
+
],
|
|
130
|
+
uninstall: [
|
|
131
|
+
['opencode', 'run', opencodePrompt(scope, `Delete the key '${name}' from the top-level 'mcp' object.`)],
|
|
132
|
+
verifyOpencode(scope, (d) => d?.mcp?.[name] === undefined, `check ${fileName(scope)} no longer has mcp.${name}`),
|
|
133
|
+
],
|
|
134
|
+
})
|
|
135
|
+
const claudeAt = (scope) => ({
|
|
136
|
+
install: [http ? ['claude', 'mcp', 'add', '-s', scope, '-t', 'http', name, server.url] : ['claude', 'mcp', 'add', '-s', scope, name, ...tail]],
|
|
137
|
+
uninstall: [['claude', 'mcp', 'remove', '-s', scope, name]],
|
|
138
|
+
})
|
|
139
|
+
const all = {
|
|
140
|
+
claude: { project: claudeAt('project'), user: claudeAt('user') },
|
|
141
|
+
// Codex·Antigravity의 mcp add에는 범위 플래그가 없다 — 사용자 전역 설정에 쓴다.
|
|
142
|
+
codex: {
|
|
143
|
+
user: {
|
|
144
|
+
install: http
|
|
145
|
+
? [{ argv: ['codex', 'mcp', 'add', name, '--url', server.url], tolerateFail: true, timeout: CODEX_LOGIN_WAIT_MS }, verifyCodexMcp(name)]
|
|
146
|
+
: [['codex', 'mcp', 'add', name, ...tail]],
|
|
147
|
+
uninstall: [['codex', 'mcp', 'remove', name]],
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
opencode: { project: opencodeAt('project'), user: opencodeAt('user') },
|
|
151
|
+
antigravity: { user: { install: [http ? ['agy', 'mcp', 'add', name, server.url] : ['agy', 'mcp', 'add', name, ...tail]], uninstall: [['agy', 'mcp', 'remove', name]] } },
|
|
152
|
+
}
|
|
153
|
+
const recipes = Object.fromEntries(clis.map((c) => [c, all[c]]))
|
|
154
|
+
const detect = Object.fromEntries(clis.map((c) => [c, (p) => p.mcp(c, name)]))
|
|
155
|
+
return { recipes, detect }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// 레지스트리의 remove는 --agent universal에서 "Done!"을 찍고도 디렉터리와
|
|
159
|
+
// 잠금 항목을 남긴다(실측) — 남은 것을 우리가 치운다. 남겨 두면 detect가
|
|
160
|
+
// 계속 설치됨으로 읽고, 동료가 잠금 파일로 복원하면 지운 스킬이 되살아난다.
|
|
161
|
+
// 잠금 항목은 lockNames 전부를 지운다 — 인자·경로로 못 써 건너뛴 이름까지.
|
|
162
|
+
// 남기면 다음 제거가 같은 자리에서 또 멈춘다.
|
|
163
|
+
function cleanup(root, names, lockNames = names) {
|
|
164
|
+
for (const name of names) {
|
|
165
|
+
const dir = findSkillDir(root, name)
|
|
166
|
+
if (dir) rmSync(repoPathStrict(root, `${SHARED_SKILLS}/${dir}`), { recursive: true, force: true })
|
|
167
|
+
}
|
|
168
|
+
pruneLock(root, lockNames)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function skillsAdd({ source, skill, anchor = null }) {
|
|
172
|
+
const wildcard = skill === '*'
|
|
173
|
+
// 스킬 전부(*)를 넣으면 detect가 볼 이름을 정해 둬야 한다 — 없으면 설치해도
|
|
174
|
+
// 계속 absent로 읽히는 항목이 조용히 카탈로그에 들어간다.
|
|
175
|
+
if (wildcard && !anchor) throw new LocalizedError('error.registrySkillAnchor', { id: sourceKey(source) })
|
|
176
|
+
const probeName = anchor ?? skill
|
|
177
|
+
const project = {
|
|
178
|
+
install: [[...SKILLS, 'add', source, '--skill', skill, '--agent', 'universal', '--yes', '--copy']],
|
|
179
|
+
uninstall: wildcard
|
|
180
|
+
? [{
|
|
181
|
+
describe: `npx -y skills@latest remove <each skill from ${sourceKey(source)} in skills-lock.json> --agent universal --yes, then remove leftover ${SHARED_SKILLS} dirs`,
|
|
182
|
+
fn: async ({ root, exec }) => {
|
|
183
|
+
// 잠금 파일의 이름은 상류 frontmatter에서 온다 — 인자·경로로 못 쓰는 이름은 건너뛴다.
|
|
184
|
+
const locked = lockedSkills(root, source)
|
|
185
|
+
const all = locked.length > 0 ? locked : [probeName]
|
|
186
|
+
const names = all.filter(isSafeSkillName)
|
|
187
|
+
for (const name of names) await exec('npx', ['-y', 'skills@latest', 'remove', name, '--agent', 'universal', '--yes'], { cwd: root })
|
|
188
|
+
cleanup(root, names, all)
|
|
189
|
+
},
|
|
190
|
+
}]
|
|
191
|
+
: [
|
|
192
|
+
{ argv: [...SKILLS, 'remove', skill, '--agent', 'universal', '--yes'], tolerateFail: true },
|
|
193
|
+
{ describe: `remove leftover ${SHARED_SKILLS}/${skill} and its skills-lock.json entry`, fn: ({ root }) => cleanup(root, [skill]) },
|
|
194
|
+
],
|
|
195
|
+
}
|
|
196
|
+
const byScope = { project }
|
|
197
|
+
// 한 상류의 스킬 전부(*)는 전역에서 무엇을 지울지 추적할 근거가 없다.
|
|
198
|
+
if (!wildcard) {
|
|
199
|
+
byScope.user = {
|
|
200
|
+
install: [[...SKILLS, 'add', source, '--skill', skill, '-g', '-a', ...SKILLS_AGENTS, '--yes', '--copy']],
|
|
201
|
+
uninstall: [[...SKILLS, 'remove', skill, '-g', '-a', ...SKILLS_AGENTS, '--yes']],
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return { recipes: { [SHARED]: byScope }, detect: { [SHARED]: (p) => p.skill(probeName) } }
|
|
205
|
+
}
|
package/lib/recipe.mjs
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// 레시피 = CLI·범위별 설치 명령 목록. 항목 파일은 데이터만 적고, 이 모듈이
|
|
2
|
+
// 그것을 엔진·TUI가 아는 항목 모양으로 바꾼다. 실행(install)과 출력(--print)이
|
|
3
|
+
// 같은 steps()에서 명령을 얻으므로 둘이 어긋날 수 없다.
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { CLIS, CLI_IDS as RECIPE_CLIS } from './clis.mjs'
|
|
6
|
+
import { LocalizedError, msg } from './i18n/index.mjs'
|
|
7
|
+
|
|
8
|
+
export const SCOPES = ['project', 'user']
|
|
9
|
+
// 한 단계 묶음이 네 CLI를 한꺼번에 덮는 자리(.agents/skills).
|
|
10
|
+
export const SHARED = 'shared'
|
|
11
|
+
// 실패 보고에 싣는 출력 줄 수. 설치 로그 전체는 화면을 덮는다.
|
|
12
|
+
const TAIL_LINES = 5
|
|
13
|
+
|
|
14
|
+
export function normalizeStep(step, cli) {
|
|
15
|
+
if (Array.isArray(step)) return { cli, argv: step, cwd: null, tolerateFail: false, undo: null, timeout: null }
|
|
16
|
+
if (step && Array.isArray(step.argv)) {
|
|
17
|
+
return { cli, argv: step.argv, cwd: step.cwd ?? null, tolerateFail: Boolean(step.tolerateFail), undo: step.undo ?? null, timeout: step.timeout ?? null }
|
|
18
|
+
}
|
|
19
|
+
if (step && typeof step.fn === 'function' && step.describe) return { cli, fn: step.fn, describe: String(step.describe) }
|
|
20
|
+
return null
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function assertRecipe(def) {
|
|
24
|
+
const bad = (why) => { throw new LocalizedError('error.recipeInvalid', { id: def?.id ?? '?', why }) }
|
|
25
|
+
for (const field of ['id', 'category', 'label']) if (!def?.[field]) bad(`missing ${field}`)
|
|
26
|
+
const entries = Object.entries(def.recipes ?? {})
|
|
27
|
+
if (entries.length === 0) bad('no recipes')
|
|
28
|
+
if (def.recipes[SHARED] && entries.length > 1) bad('shared cannot be combined with per-CLI recipes')
|
|
29
|
+
for (const [cli, byScope] of entries) {
|
|
30
|
+
if (cli !== SHARED && !RECIPE_CLIS.includes(cli)) bad(`unknown CLI ${cli}`)
|
|
31
|
+
for (const [scope, recipe] of Object.entries(byScope)) {
|
|
32
|
+
if (!SCOPES.includes(scope)) bad(`unknown scope ${scope}`)
|
|
33
|
+
for (const action of ['install', 'uninstall']) {
|
|
34
|
+
if (!Array.isArray(recipe?.[action])) bad(`${cli}.${scope}.${action} is not a list`)
|
|
35
|
+
for (const raw of recipe[action]) {
|
|
36
|
+
const step = normalizeStep(raw, cli)
|
|
37
|
+
if (!step || (step.argv && (step.argv.length === 0 || !step.argv.every((a) => typeof a === 'string')))) {
|
|
38
|
+
bad(`${cli}.${scope}.${action} has a bad step`)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (typeof def.detect?.[cli] !== 'function') bad(`detect.${cli} missing`)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function defineRecipe(def) {
|
|
48
|
+
assertRecipe(def)
|
|
49
|
+
return { ...def, kind: 'recipe' }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const reasonOf = (r) => (typeof r === 'string' ? msg(r) : r)
|
|
53
|
+
|
|
54
|
+
export function bindScope(def, scope) {
|
|
55
|
+
const shared = def.recipes[SHARED]
|
|
56
|
+
if (shared && !shared[scope]) return null
|
|
57
|
+
const supports = shared ? [...RECIPE_CLIS] : RECIPE_CLIS.filter((c) => def.recipes[c]?.[scope])
|
|
58
|
+
if (supports.length === 0) return null
|
|
59
|
+
|
|
60
|
+
// 다른 범위엔 레시피가 있으면 "범위를 바꾸면 된다"가 사유다. 어느 범위에도
|
|
61
|
+
// 없을 때만 항목이 사유를 직접 대야 한다 — 빠뜨리면 로드 오류로 막는다.
|
|
62
|
+
const other = scope === 'project' ? 'user' : 'project'
|
|
63
|
+
const unsupported = {}
|
|
64
|
+
for (const cli of RECIPE_CLIS) {
|
|
65
|
+
if (supports.includes(cli)) continue
|
|
66
|
+
if (def.recipes[cli]?.[other]) unsupported[cli] = msg(`item.unsupported.${other}Only`)
|
|
67
|
+
else if (def.reasons?.[cli]) unsupported[cli] = reasonOf(def.reasons[cli])
|
|
68
|
+
else throw new LocalizedError('error.itemReasonMissing', { id: def.id, cli })
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const recipeOf = (cli) => def.recipes[cli][scope]
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
id: def.id, category: def.category, label: def.label,
|
|
75
|
+
group: def.group ?? null, exclusive: def.exclusive ?? null, note: def.note ?? null,
|
|
76
|
+
verified: def.verified ?? null, hooks: Boolean(def.hooks), postInstall: def.postInstall ?? null,
|
|
77
|
+
scope, supports, unsupported,
|
|
78
|
+
|
|
79
|
+
async detect({ probe }) {
|
|
80
|
+
const reach = supports.filter((c) => probe.has(c))
|
|
81
|
+
// 공유 스킬은 CLI가 아니라 디렉터리에 깔린다 — PATH와 무관하다.
|
|
82
|
+
const excluded = shared ? [] : supports.filter((c) => !reach.includes(c))
|
|
83
|
+
const targets = shared ? [SHARED] : reach
|
|
84
|
+
const present = []
|
|
85
|
+
const failed = []
|
|
86
|
+
for (const cli of targets) {
|
|
87
|
+
try {
|
|
88
|
+
if (await def.detect[cli](probe)) present.push(cli)
|
|
89
|
+
} catch {
|
|
90
|
+
// 목록 명령 실패(로그인 만료 등)를 "없음"으로 읽으면 깔린 것을 또 깐다.
|
|
91
|
+
failed.push(cli)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
const live = targets.filter((c) => !failed.includes(c))
|
|
95
|
+
const status = present.length === 0 ? 'absent' : present.length === live.length ? 'installed' : 'partial'
|
|
96
|
+
let detail = null
|
|
97
|
+
if (failed.length > 0) detail = msg('item.probeFailed', { list: failed.join(', ') })
|
|
98
|
+
else if (status === 'partial') {
|
|
99
|
+
detail = msg('item.plugin.partial', { present: present.join(', '), missing: live.filter((c) => !present.includes(c)).join(', ') })
|
|
100
|
+
} else if (targets.length === 0) detail = msg('item.global.noCli', { list: excluded.join(', ') })
|
|
101
|
+
return { status, detail, excluded, present, failed, targets, reach }
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
steps(action, state = {}) {
|
|
105
|
+
const present = state.present ?? []
|
|
106
|
+
const failed = state.failed ?? []
|
|
107
|
+
const targets = state.targets ?? (shared ? [SHARED] : supports)
|
|
108
|
+
const removing = action === 'uninstall'
|
|
109
|
+
const clis = removing
|
|
110
|
+
? targets.filter((c) => present.includes(c))
|
|
111
|
+
: targets.filter((c) => !present.includes(c) && !failed.includes(c))
|
|
112
|
+
return clis.flatMap((cli) => recipeOf(cli)[removing ? 'uninstall' : 'install'].map((s) => normalizeStep(s, cli)))
|
|
113
|
+
},
|
|
114
|
+
|
|
115
|
+
async install(ctx) {
|
|
116
|
+
const state = ctx.state ?? {}
|
|
117
|
+
const steps = this.steps('install', state)
|
|
118
|
+
// 실행할 명령이 없는데 ✔로 끝나면 --set을 쓰는 CI가 무동작을 성공으로 받는다.
|
|
119
|
+
// 감지 실패는 실패로, CLI가 이 머신에 없는 것은 사연 있는 성공으로 돌려준다.
|
|
120
|
+
if (steps.length === 0) {
|
|
121
|
+
const failed = state.failed ?? []
|
|
122
|
+
const excluded = state.excluded ?? []
|
|
123
|
+
if (failed.length > 0) throw new LocalizedError('item.probeFailed', { list: failed.join(', ') })
|
|
124
|
+
if (excluded.length > 0) return { message: msg('item.global.noCli', { list: excluded.join(', ') }) }
|
|
125
|
+
return undefined
|
|
126
|
+
}
|
|
127
|
+
await runSteps(steps, ctx)
|
|
128
|
+
return undefined
|
|
129
|
+
},
|
|
130
|
+
|
|
131
|
+
async uninstall(ctx) {
|
|
132
|
+
await runSteps(this.steps('uninstall', ctx.state), ctx)
|
|
133
|
+
},
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function tail(output) {
|
|
138
|
+
return String(output ?? '').trim().split(/\r?\n/).slice(-TAIL_LINES).join('\n ')
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const cliLabel = (cli) => CLIS[cli]?.label ?? cli
|
|
142
|
+
|
|
143
|
+
// 한 CLI의 단계가 실패하면 그 CLI의 남은 단계만 건너뛴다. 다른 CLI는 계속
|
|
144
|
+
// 간다 — 로그인이 풀린 Codex 하나 때문에 Claude 설치까지 막을 이유가 없다.
|
|
145
|
+
export async function runSteps(steps, ctx) {
|
|
146
|
+
const { root, exec, dryRun = false, log = () => {} } = ctx
|
|
147
|
+
const broken = new Set()
|
|
148
|
+
// 참아 준 실패(마켓플레이스 "이미 등록됨")는 뒤 단계가 실패할 때만 함께 보인다.
|
|
149
|
+
const soft = new Map()
|
|
150
|
+
const failures = []
|
|
151
|
+
for (const step of steps) {
|
|
152
|
+
if (broken.has(step.cli)) continue
|
|
153
|
+
if (step.fn) {
|
|
154
|
+
if (dryRun) { log(` [dry-run] ${step.describe}`); continue }
|
|
155
|
+
try {
|
|
156
|
+
await step.fn(ctx)
|
|
157
|
+
} catch (err) {
|
|
158
|
+
failures.push({ cli: step.cli, what: step.describe, output: err.message })
|
|
159
|
+
broken.add(step.cli)
|
|
160
|
+
}
|
|
161
|
+
continue
|
|
162
|
+
}
|
|
163
|
+
const [cmd, ...args] = step.argv
|
|
164
|
+
const opts = { cwd: step.cwd ? join(root, step.cwd) : root }
|
|
165
|
+
if (step.timeout) opts.timeout = step.timeout
|
|
166
|
+
const r = await exec(cmd, args, opts)
|
|
167
|
+
if (r.ok) continue
|
|
168
|
+
const failure = { cli: step.cli, what: step.argv.join(' '), output: tail(r.output) }
|
|
169
|
+
if (step.tolerateFail) { soft.set(step.cli, failure); continue }
|
|
170
|
+
if (step.undo && !dryRun) {
|
|
171
|
+
try { await step.undo(ctx) } catch { /* 되돌리기 실패가 원래 실패를 가리면 안 된다 */ }
|
|
172
|
+
}
|
|
173
|
+
if (soft.has(step.cli)) failures.push(soft.get(step.cli))
|
|
174
|
+
failures.push(failure)
|
|
175
|
+
broken.add(step.cli)
|
|
176
|
+
}
|
|
177
|
+
if (failures.length > 0) {
|
|
178
|
+
const details = failures.map((f) => ` ${cliLabel(f.cli)}: ${f.what}${f.output ? `\n ${f.output}` : ''}`).join('\n')
|
|
179
|
+
throw new LocalizedError('error.stepsFailed', { details })
|
|
180
|
+
}
|
|
181
|
+
}
|
|
@@ -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
|
}
|