@rch4com/agent-setup 1.28.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 -48
- package/lib/i18n/catalog/ko.mjs +50 -48
- 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 +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/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/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,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rch4com/agent-setup",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.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
11
|
"mcp",
|
|
12
12
|
"agent-skills"
|
|
13
13
|
],
|
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
|
-
}
|
package/lib/global-plugin.mjs
DELETED
|
@@ -1,239 +0,0 @@
|
|
|
1
|
-
// 사용자 전역(scope: 'user') 플러그인 항목의 공통 기구. 상류가 하니스별 전역
|
|
2
|
-
// 설치만 지원하는 도구(superpowers, ponytail)가 공유한다 — 항목 파일
|
|
3
|
-
// (lib/items/global.*.mjs)은 상류별 좌표와 미배선 사유만 든다.
|
|
4
|
-
//
|
|
5
|
-
// 전역 파일을 만지므로 repoPath 계열 가드를 일부러 안 쓴다 — 저장소 이탈이
|
|
6
|
-
// 목적인 기구다. 항목 파일에 두지 않는 이유는 gitmessage.mjs와 같다:
|
|
7
|
-
// loadItems가 items/의 모든 .mjs를 항목으로 읽는다.
|
|
8
|
-
//
|
|
9
|
-
// 하니스별 기록 위치(2026-08-15 실측, 상세는 global.superpowers.mjs):
|
|
10
|
-
// codex $CODEX_HOME/config.toml의 [plugins."<이름>@…"] — CODEX_HOME
|
|
11
|
-
// 재지정을 실제로 쓰는 환경(Orca)이 있어 환경변수를 따라간다.
|
|
12
|
-
// copilot ~/.copilot/config.json의 installedPlugins[] (주석 있는 JSONC).
|
|
13
|
-
// gemini ~/.gemini/extensions/<이름>/ (공식 extensions 문서).
|
|
14
|
-
// opencode 전역 opencode.json의 plugin 배열 (XDG_CONFIG_HOME 존중).
|
|
15
|
-
// grok ~/.grok/installed-plugins/registry.json의 repos[*].plugins 키
|
|
16
|
-
// (2026-09-05 grok 1.0.5로 설치·제거 왕복 실측). 설치는
|
|
17
|
-
// `grok plugin install <소스> --trust`가 registry와 디렉터리를 만들고
|
|
18
|
-
// config.toml [plugins].enabled에도 이름을 넣는다. 제거
|
|
19
|
-
// (`grok plugin uninstall <이름> --confirm`)는 디렉터리·registry만
|
|
20
|
-
// 지우고 enabled 항목은 남기므로, 감지의 근거는 registry여야 한다 —
|
|
21
|
-
// enabled를 보면 제거한 뒤에도 영원히 설치됨으로 읽힌다.
|
|
22
|
-
import { existsSync, readFileSync } from 'node:fs'
|
|
23
|
-
import { homedir } from 'node:os'
|
|
24
|
-
import { delimiter, join } from 'node:path'
|
|
25
|
-
import { parse as parseToml } from 'smol-toml'
|
|
26
|
-
import { CLIS } from './clis.mjs'
|
|
27
|
-
import { readJson, setKey, removeKey } from './jsonfile.mjs'
|
|
28
|
-
import { msg, LocalizedError } from './i18n/index.mjs'
|
|
29
|
-
|
|
30
|
-
// 실행은 cmd 셸을 거치므로(catalog.mjs makeExec) win32에서는 PATHEXT 확장자가
|
|
31
|
-
// 있어야 실제로 부를 수 있다 — copilot.ps1만 있는 PATH는 "없음"이 맞다.
|
|
32
|
-
function defaultHasBinary(name, env = process.env) {
|
|
33
|
-
const dirs = (env.PATH ?? '').split(delimiter).filter(Boolean)
|
|
34
|
-
const exts = process.platform === 'win32'
|
|
35
|
-
? (env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean)
|
|
36
|
-
: ['']
|
|
37
|
-
return dirs.some((dir) => exts.some((ext) => existsSync(join(dir, name + ext))))
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
// superpowers@openai-curated처럼 다른 마켓플레이스로 설치된 판도 잡아야
|
|
41
|
-
// 하므로(실측: 이런 머신이 실제로 있다) 이름이 아니라 접두사로 고른다.
|
|
42
|
-
// 제거도 여기서 찾은 id를 그대로 쓴다 — 감지가 "있다"고 말한 판을 지워야
|
|
43
|
-
// 상태가 맞아떨어진다.
|
|
44
|
-
function codexPluginIds(file, pluginName) {
|
|
45
|
-
if (!existsSync(file)) return []
|
|
46
|
-
try {
|
|
47
|
-
const data = parseToml(readFileSync(file, 'utf8'))
|
|
48
|
-
return Object.keys(data.plugins ?? {}).filter((id) => id.startsWith(`${pluginName}@`))
|
|
49
|
-
} catch {
|
|
50
|
-
// 깨진 config.toml은 미설치로 둔다 — 설치를 시도하면 codex가 스스로
|
|
51
|
-
// 진단하고, "설치됨"으로 오판해 제거를 그리로 보내는 것보다 낫다.
|
|
52
|
-
return []
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
function copilotInstalled(file, pluginName) {
|
|
57
|
-
const list = readJson(file)?.installedPlugins
|
|
58
|
-
return Array.isArray(list) && list.some((p) => p?.name === pluginName)
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
// ponytail(items/plugin.ponytail.mjs)과 같은 규칙: plugin 키가 배열이 아니면
|
|
62
|
-
// 손대지 않는다.
|
|
63
|
-
function opencodePlugins(file) {
|
|
64
|
-
const value = readJson(file)?.plugin
|
|
65
|
-
if (value === undefined) return []
|
|
66
|
-
if (!Array.isArray(value)) return null
|
|
67
|
-
return value
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
// config:
|
|
71
|
-
// pluginName 감지·제거가 쓰는 상류 이름 (codex 접두사, copilot name,
|
|
72
|
-
// gemini 확장 디렉터리 이름)
|
|
73
|
-
// marketplaceRepo codex·copilot 마켓플레이스 (owner/repo)
|
|
74
|
-
// installId 마켓플레이스 설치 id (<이름>@<마켓>)
|
|
75
|
-
// gemini gemini extensions install에 줄 git URL, 없으면 null
|
|
76
|
-
// opencode 전역 opencode.json plugin 배열에 넣을 항목, 없으면 null
|
|
77
|
-
// grok grok plugin install에 줄 소스(마켓 id 또는 owner/repo), 없으면 null
|
|
78
|
-
// supports는 어댑터 유무로 정해진다 — codex·copilot은 항상, gemini·opencode·grok은
|
|
79
|
-
// 좌표가 있을 때만.
|
|
80
|
-
export function defineGlobalPlugin(
|
|
81
|
-
{ id, label, group, note, exclusive = null, unsupported = {}, pluginName, marketplaceRepo, installId, gemini = null, opencode = null, grok = null, verified = null },
|
|
82
|
-
{ home = null, env = process.env, hasBinary = defaultHasBinary } = {},
|
|
83
|
-
) {
|
|
84
|
-
const supports = ['codex', ...(gemini ? ['gemini'] : []), ...(opencode ? ['opencode'] : []), 'copilot', ...(grok ? ['grok'] : [])]
|
|
85
|
-
|
|
86
|
-
const files = () => {
|
|
87
|
-
const base = home ?? homedir()
|
|
88
|
-
return {
|
|
89
|
-
codex: join(env.CODEX_HOME || join(base, '.codex'), 'config.toml'),
|
|
90
|
-
copilot: join(base, '.copilot', 'config.json'),
|
|
91
|
-
gemini: join(base, '.gemini', 'extensions', pluginName),
|
|
92
|
-
opencode: join(env.XDG_CONFIG_HOME || join(base, '.config'), 'opencode', 'opencode.json'),
|
|
93
|
-
grok: join(base, '.grok', 'installed-plugins', 'registry.json'),
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
// registry.json: { repos: { "<이름>-<해시>": { plugins: { "<이름>": {...} } } } }.
|
|
98
|
-
// 이름은 repos 키(해시가 붙는다)가 아니라 plugins 키에서 본다.
|
|
99
|
-
const grokInstalled = (file) => {
|
|
100
|
-
const repos = readJson(file)?.repos
|
|
101
|
-
if (!repos || typeof repos !== 'object') return false
|
|
102
|
-
return Object.values(repos).some((r) => Object.hasOwn(r?.plugins ?? {}, pluginName))
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
const isOurEntry = (p) => String(p).startsWith(`${pluginName}@`)
|
|
106
|
-
const wired = {
|
|
107
|
-
codex: (f) => codexPluginIds(f.codex, pluginName).length > 0,
|
|
108
|
-
copilot: (f) => copilotInstalled(f.copilot, pluginName),
|
|
109
|
-
gemini: (f) => existsSync(f.gemini),
|
|
110
|
-
opencode: (f) => (opencodePlugins(f.opencode) ?? []).some(isOurEntry),
|
|
111
|
-
grok: (f) => grokInstalled(f.grok),
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
return {
|
|
115
|
-
id, category: 'plugin', label, scope: 'user', group, exclusive, verified,
|
|
116
|
-
supports: [...supports],
|
|
117
|
-
unsupported,
|
|
118
|
-
note,
|
|
119
|
-
|
|
120
|
-
// 상태는 "이 머신에 있는 하니스" 기준이다. CLI가 없는 하니스까지 누락으로
|
|
121
|
-
// 세면 gemini 없는 머신은 영원히 partial이라 apply가 매번 헛돈다.
|
|
122
|
-
async detect() {
|
|
123
|
-
const f = files()
|
|
124
|
-
const present = supports.filter((c) => wired[c](f))
|
|
125
|
-
const machine = supports.filter((c) => hasBinary(c))
|
|
126
|
-
const noCli = supports.filter((c) => !machine.includes(c))
|
|
127
|
-
const missing = machine.filter((c) => !present.includes(c))
|
|
128
|
-
const detail = noCli.length > 0 ? msg('item.global.noCli', { list: noCli.join(', ') }) : undefined
|
|
129
|
-
// excluded는 detail과 별도로 구조체로 넘긴다 — 행 힌트가 CLI 이름을
|
|
130
|
-
// 나열할 때 이 머신에 없는 것을 빼고 따로 적으려면 문장이 아니라 목록이 필요하다.
|
|
131
|
-
if (present.length === 0) return { status: 'absent', detail, excluded: noCli }
|
|
132
|
-
if (missing.length === 0) return { status: 'installed', detail, excluded: noCli }
|
|
133
|
-
return {
|
|
134
|
-
status: 'partial',
|
|
135
|
-
detail: msg('item.plugin.partial', { present: present.join(', '), missing: missing.join(', ') }),
|
|
136
|
-
excluded: noCli,
|
|
137
|
-
}
|
|
138
|
-
},
|
|
139
|
-
|
|
140
|
-
async install({ dryRun, exec, log = () => {}, t }) {
|
|
141
|
-
const f = files()
|
|
142
|
-
// opencode 설정이 손댈 수 없는 꼴이면 아무것도 만지기 전에 멈춘다 —
|
|
143
|
-
// 절반만 설치된 채 실패하는 것보다 통째로 거절이 낫다.
|
|
144
|
-
const needsOpencode = opencode && !wired.opencode(f) && hasBinary('opencode')
|
|
145
|
-
if (needsOpencode && !dryRun && opencodePlugins(f.opencode) === null) {
|
|
146
|
-
throw new LocalizedError('error.globalOpencodePlugin', { file: f.opencode, entry: opencode })
|
|
147
|
-
}
|
|
148
|
-
const skipped = []
|
|
149
|
-
const failures = []
|
|
150
|
-
const fail = (cli, output) => failures.push(`${cli}: ${String(output).trim().split('\n')[0]}`)
|
|
151
|
-
for (const cli of supports) {
|
|
152
|
-
if (wired[cli](f)) continue
|
|
153
|
-
if (!hasBinary(cli)) {
|
|
154
|
-
skipped.push(cli)
|
|
155
|
-
continue
|
|
156
|
-
}
|
|
157
|
-
if (cli === 'codex') {
|
|
158
|
-
// 마켓 등록은 이미 있어도 해가 없다 — 성패는 add가 판정한다.
|
|
159
|
-
await exec('codex', ['plugin', 'marketplace', 'add', marketplaceRepo])
|
|
160
|
-
const r = await exec('codex', ['plugin', 'add', installId])
|
|
161
|
-
if (!r.ok) fail(cli, r.output)
|
|
162
|
-
} else if (cli === 'copilot') {
|
|
163
|
-
await exec('copilot', ['plugin', 'marketplace', 'add', marketplaceRepo])
|
|
164
|
-
// 실측(2026-08-15): 다른 copilot 세션이 떠 있으면 os error 5로 한 번
|
|
165
|
-
// 실패하고 재시도에 성공한다. 한 번만 다시 시도한다.
|
|
166
|
-
let r = await exec('copilot', ['plugin', 'install', installId])
|
|
167
|
-
if (!r.ok) r = await exec('copilot', ['plugin', 'install', installId])
|
|
168
|
-
if (!r.ok) fail(cli, r.output)
|
|
169
|
-
} else if (cli === 'gemini') {
|
|
170
|
-
const r = await exec('gemini', ['extensions', 'install', gemini])
|
|
171
|
-
if (!r.ok) fail(cli, r.output)
|
|
172
|
-
} else if (cli === 'grok') {
|
|
173
|
-
// --trust는 확인 프롬프트를 건너뛴다 — stdin이 닫힌 채 도는 실행이라
|
|
174
|
-
// 프롬프트가 뜨면 영원히 멈춘다.
|
|
175
|
-
const r = await exec('grok', ['plugin', 'install', grok, '--trust'])
|
|
176
|
-
if (!r.ok) fail(cli, r.output)
|
|
177
|
-
} else {
|
|
178
|
-
if (dryRun) {
|
|
179
|
-
log(t('log.plugin.add', { cli: CLIS.opencode.label, name: opencode }))
|
|
180
|
-
continue
|
|
181
|
-
}
|
|
182
|
-
const list = opencodePlugins(f.opencode) ?? []
|
|
183
|
-
if (list.length === 0) setKey(f.opencode, ['plugin'], [opencode])
|
|
184
|
-
else setKey(f.opencode, ['plugin', list.length], opencode)
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
if (failures.length > 0) throw new LocalizedError('error.globalExec', { detail: failures.join(' / ') })
|
|
188
|
-
if (skipped.length > 0) return { message: msg('item.global.skipped', { list: skipped.join(', ') }) }
|
|
189
|
-
},
|
|
190
|
-
|
|
191
|
-
async uninstall({ dryRun, exec, log = () => {}, t }) {
|
|
192
|
-
const f = files()
|
|
193
|
-
const skipped = []
|
|
194
|
-
const failures = []
|
|
195
|
-
const fail = (cli, output) => failures.push(`${cli}: ${String(output).trim().split('\n')[0]}`)
|
|
196
|
-
for (const cli of supports) {
|
|
197
|
-
if (!wired[cli](f)) continue
|
|
198
|
-
if (cli === 'opencode') {
|
|
199
|
-
if (dryRun) {
|
|
200
|
-
log(t('log.plugin.remove', { cli: CLIS.opencode.label, name: opencode }))
|
|
201
|
-
continue
|
|
202
|
-
}
|
|
203
|
-
const kept = (opencodePlugins(f.opencode) ?? []).filter((p) => !isOurEntry(p))
|
|
204
|
-
// 우리 항목만 빼고 비면 키째 지운다 — 빈 배열은 우리가 남긴 흔적이다.
|
|
205
|
-
if (kept.length === 0) removeKey(f.opencode, ['plugin'])
|
|
206
|
-
else setKey(f.opencode, ['plugin'], kept)
|
|
207
|
-
continue
|
|
208
|
-
}
|
|
209
|
-
if (!hasBinary(cli)) {
|
|
210
|
-
skipped.push(cli)
|
|
211
|
-
continue
|
|
212
|
-
}
|
|
213
|
-
if (cli === 'codex') {
|
|
214
|
-
for (const pid of codexPluginIds(f.codex, pluginName)) {
|
|
215
|
-
const r = await exec('codex', ['plugin', 'remove', pid])
|
|
216
|
-
if (!r.ok) fail(cli, r.output)
|
|
217
|
-
}
|
|
218
|
-
// 마켓플레이스 등록은 남긴다 — 우리가 등록했다는 보장이 없고,
|
|
219
|
-
// 같은 마켓의 다른 플러그인이 쓰고 있을 수 있다.
|
|
220
|
-
} else if (cli === 'copilot') {
|
|
221
|
-
let r = await exec('copilot', ['plugin', 'uninstall', pluginName])
|
|
222
|
-
if (!r.ok) r = await exec('copilot', ['plugin', 'uninstall', pluginName])
|
|
223
|
-
if (!r.ok) fail(cli, r.output)
|
|
224
|
-
} else if (cli === 'grok') {
|
|
225
|
-
// --confirm은 한 저장소에 플러그인이 여럿일 때의 확인을 건너뛴다.
|
|
226
|
-
// config.toml [plugins].enabled의 이름은 상류가 남긴다 — 우리가
|
|
227
|
-
// 등록한 줄이라는 보장이 없어 건드리지 않는다(note에 적는다).
|
|
228
|
-
const r = await exec('grok', ['plugin', 'uninstall', pluginName, '--confirm'])
|
|
229
|
-
if (!r.ok) fail(cli, r.output)
|
|
230
|
-
} else {
|
|
231
|
-
const r = await exec('gemini', ['extensions', 'uninstall', pluginName])
|
|
232
|
-
if (!r.ok) fail(cli, r.output)
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
if (failures.length > 0) throw new LocalizedError('error.globalExec', { detail: failures.join(' / ') })
|
|
236
|
-
if (skipped.length > 0) return { message: msg('item.global.skipped', { list: skipped.join(', ') }) }
|
|
237
|
-
},
|
|
238
|
-
}
|
|
239
|
-
}
|