@rch4com/agent-setup 1.9.0 → 1.10.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.
Files changed (49) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +199 -199
  3. package/install.mjs +163 -163
  4. package/lib/args.mjs +276 -276
  5. package/lib/bootstrap/adapter.mjs +96 -96
  6. package/lib/bootstrap/apply.mjs +380 -380
  7. package/lib/bootstrap/flow.mjs +140 -140
  8. package/lib/bootstrap/record.mjs +154 -154
  9. package/lib/bootstrap/templates.mjs +148 -148
  10. package/lib/bootstrap/text.mjs +19 -19
  11. package/lib/catalog.mjs +234 -234
  12. package/lib/claude-plugins.mjs +64 -64
  13. package/lib/clis.mjs +111 -111
  14. package/lib/context.mjs +66 -66
  15. package/lib/deps.mjs +17 -17
  16. package/lib/design-md/catalog.json +454 -454
  17. package/lib/design-md/catalog.mjs +266 -266
  18. package/lib/design-md/flow.mjs +192 -192
  19. package/lib/design-md/open.mjs +51 -51
  20. package/lib/design-md/providers/awesome-design-md.mjs +124 -124
  21. package/lib/design-md/providers/index.mjs +9 -9
  22. package/lib/design-md/scan.mjs +295 -295
  23. package/lib/engine.mjs +115 -115
  24. package/lib/gitignore.mjs +18 -18
  25. package/lib/i18n/catalog/en.mjs +426 -425
  26. package/lib/i18n/catalog/ko.mjs +411 -410
  27. package/lib/i18n/detect.mjs +39 -39
  28. package/lib/i18n/index.mjs +93 -93
  29. package/lib/items/mcp.codebase-memory.mjs +6 -6
  30. package/lib/items/mcp.notion.mjs +6 -6
  31. package/lib/items/mcp.supabase.mjs +6 -6
  32. package/lib/items/mcp.vercel.mjs +6 -6
  33. package/lib/items/plugin.mattpocock-skills.mjs +8 -8
  34. package/lib/items/plugin.ponytail.mjs +104 -104
  35. package/lib/items/skill.gsd.mjs +44 -44
  36. package/lib/items/skill.gstack.mjs +54 -54
  37. package/lib/items/skill.hallmark.mjs +10 -0
  38. package/lib/jsonfile.mjs +40 -40
  39. package/lib/status.mjs +93 -93
  40. package/lib/tomlfile.mjs +60 -60
  41. package/lib/tui/detail.mjs +127 -127
  42. package/lib/tui/progress.mjs +160 -160
  43. package/lib/tui/render.mjs +268 -268
  44. package/lib/tui/rows.mjs +317 -317
  45. package/lib/tui/run.mjs +396 -396
  46. package/lib/tui/state.mjs +249 -249
  47. package/lib/update.mjs +85 -85
  48. package/lib/width.mjs +115 -115
  49. package/package.json +36 -36
@@ -1,39 +1,39 @@
1
- // OS 언어 감지. 순수 함수다 — env와 Intl 결과를 인자로 받는다.
2
- // 여기서 지원 여부를 판단하지 않는다: OS가 뭐라고 하는지만 그대로 보고하고,
3
- // 그중 무엇을 쓸지는 resolveLocale이 정한다. 둘을 섞으면 "감지는 됐는데
4
- // 지원하지 않아 영어로 갔다"와 "감지 자체가 실패했다"를 구별할 수 없다.
5
-
6
- // POSIX 관례상 뒤에 오는 것이 앞을 덮지 않는다 — 먼저 잡히는 것이 이긴다.
7
- const ENV_KEYS = ['LC_ALL', 'LC_MESSAGES', 'LANG', 'LANGUAGE']
8
-
9
- // 로케일을 지정하지 않았다는 뜻의 값. 여기서 멈추면 Intl로 내려가지 못한다.
10
- const NEUTRAL = new Set(['c', 'posix'])
11
-
12
- // `ko_KR.UTF-8` · `en_US:en` · `ko-KR` 을 모두 `ko`/`en`으로 줄인다.
13
- // `.`은 인코딩, `@`는 변형(modifier), `:`은 LANGUAGE의 목록 구분자다.
14
- export function baseTag(raw) {
15
- if (typeof raw !== 'string') return null
16
- const head = raw.trim().split(/[.:@]/)[0]
17
- if (!head) return null
18
- if (NEUTRAL.has(head.toLowerCase())) return null
19
- const tag = head.replace(/_/g, '-').split('-')[0].toLowerCase()
20
- return tag || null
21
- }
22
-
23
- // Node 20+는 full-icu가 기본이라 Windows에서도 OS 언어를 반영한다.
24
- // 축소 빌드에서는 'en-US'로 고정되는데, 그때는 영어로 시작하는 것이 맞다.
25
- function intlLocale() {
26
- try {
27
- return Intl.DateTimeFormat().resolvedOptions().locale
28
- } catch {
29
- return null
30
- }
31
- }
32
-
33
- export function detectLocale(env = {}, intl = intlLocale()) {
34
- for (const key of ENV_KEYS) {
35
- const tag = baseTag(env[key])
36
- if (tag) return tag
37
- }
38
- return baseTag(intl)
39
- }
1
+ // OS 언어 감지. 순수 함수다 — env와 Intl 결과를 인자로 받는다.
2
+ // 여기서 지원 여부를 판단하지 않는다: OS가 뭐라고 하는지만 그대로 보고하고,
3
+ // 그중 무엇을 쓸지는 resolveLocale이 정한다. 둘을 섞으면 "감지는 됐는데
4
+ // 지원하지 않아 영어로 갔다"와 "감지 자체가 실패했다"를 구별할 수 없다.
5
+
6
+ // POSIX 관례상 뒤에 오는 것이 앞을 덮지 않는다 — 먼저 잡히는 것이 이긴다.
7
+ const ENV_KEYS = ['LC_ALL', 'LC_MESSAGES', 'LANG', 'LANGUAGE']
8
+
9
+ // 로케일을 지정하지 않았다는 뜻의 값. 여기서 멈추면 Intl로 내려가지 못한다.
10
+ const NEUTRAL = new Set(['c', 'posix'])
11
+
12
+ // `ko_KR.UTF-8` · `en_US:en` · `ko-KR` 을 모두 `ko`/`en`으로 줄인다.
13
+ // `.`은 인코딩, `@`는 변형(modifier), `:`은 LANGUAGE의 목록 구분자다.
14
+ export function baseTag(raw) {
15
+ if (typeof raw !== 'string') return null
16
+ const head = raw.trim().split(/[.:@]/)[0]
17
+ if (!head) return null
18
+ if (NEUTRAL.has(head.toLowerCase())) return null
19
+ const tag = head.replace(/_/g, '-').split('-')[0].toLowerCase()
20
+ return tag || null
21
+ }
22
+
23
+ // Node 20+는 full-icu가 기본이라 Windows에서도 OS 언어를 반영한다.
24
+ // 축소 빌드에서는 'en-US'로 고정되는데, 그때는 영어로 시작하는 것이 맞다.
25
+ function intlLocale() {
26
+ try {
27
+ return Intl.DateTimeFormat().resolvedOptions().locale
28
+ } catch {
29
+ return null
30
+ }
31
+ }
32
+
33
+ export function detectLocale(env = {}, intl = intlLocale()) {
34
+ for (const key of ENV_KEYS) {
35
+ const tag = baseTag(env[key])
36
+ if (tag) return tag
37
+ }
38
+ return baseTag(intl)
39
+ }
@@ -1,93 +1,93 @@
1
- // 로케일 해석과 문구 조회. 잎(leaf) 모듈이라 아무것도 import 하지 않는다 —
2
- // args.mjs·context.mjs·bootstrap/*가 전부 여기에 닿으므로, 외부 의존성이
3
- // 하나라도 들어오면 npm install 없이 도는 부트스트랩이 깨진다.
4
- // bootstrap.isolation.test.mjs가 이 불변식을 지킨다.
5
- //
6
- // 전역 싱글턴을 두지 않는다. t는 만들어서 넘기는 값이다 — 그래야 순수
7
- // 모듈(state.mjs)이 계속 아무것도 import 하지 않고, 테스트가 파일 병렬로
8
- // 돌아도 로케일이 서로를 오염시키지 않는다.
9
- import EN from './catalog/en.mjs'
10
- import KO from './catalog/ko.mjs'
11
-
12
- export const LOCALES = ['en', 'ko']
13
-
14
- const CATALOGS = { en: EN, ko: KO }
15
- const PLACEHOLDER = /\{(\w+)\}/g
16
-
17
- // 자리에 값이 없으면 던진다. `12 of undefined files`가 화면에 남는 것보다
18
- // 즉시 실패가 낫다 — 이건 번역 누락이 아니라 호출부의 버그다.
19
- function fill(template, params, key) {
20
- return template.replace(PLACEHOLDER, (_, name) => {
21
- if (!Object.hasOwn(params, name)) {
22
- throw new Error(`i18n: '${key}'의 자리 {${name}}에 값이 없습니다`)
23
- }
24
- return String(params[name])
25
- })
26
- }
27
-
28
- // 활성 카탈로그 → 영어 폴백 → 예외.
29
- // 번역 누락은 사용자 화면을 깨뜨리지 않아야 하지만, 영어에도 없는 키는
30
- // 개발자 오타다. 조용히 넘기면 화면에 'log.file.creat'가 그대로 찍힌다.
31
- function lookup(locale, key) {
32
- const value = CATALOGS[locale]?.[key]
33
- if (value !== undefined) return value
34
- const base = EN[key]
35
- if (base === undefined) throw new Error(`i18n: 알 수 없는 키입니다: ${key}`)
36
- return base
37
- }
38
-
39
- export function createT(locale) {
40
- const use = LOCALES.includes(locale) ? locale : 'en'
41
- const t = (key, params = {}) => {
42
- const value = lookup(use, key)
43
- return Array.isArray(value)
44
- ? value.map((line) => fill(line, params, key)).join('\n')
45
- : fill(value, params, key)
46
- }
47
- t.locale = use
48
- return t
49
- }
50
-
51
- // 앞이 이긴다: 플래그 > 환경변수 > 설치 기록 > OS 감지 > 영어.
52
- // 지원하지 않는 값은 조용히 건너뛴다 — 손으로 편집된 기록이나 환경에 남은
53
- // 값 때문에 도구가 죽으면 안 된다. 사용자가 --lang으로 명시한 경우만
54
- // args.mjs가 따로 거부한다.
55
- export function resolveLocale({ flag = null, env = {}, record = null, detected = null } = {}) {
56
- for (const value of [flag, env.AGENT_SETUP_LANG, record?.lang, detected]) {
57
- if (typeof value === 'string' && LOCALES.includes(value)) return value
58
- }
59
- return 'en'
60
- }
61
-
62
- // 플래그나 환경변수가 로케일을 못박았는가. TUI에서 고른 언어는 이번 실행에는
63
- // 적용되지만 기록에만 남고 다음 실행에서 다시 밀리므로, 화면이 그 사실을
64
- // 알려야 한다. 판정 규칙이 resolveLocale과 갈리면 안 되므로 같은 모듈에 둔다 —
65
- // 지원하지 않는 값은 resolveLocale이 이미 건너뛰었고, 셸에 남은
66
- // AGENT_SETUP_LANG=ja까지 "환경변수가 이긴다"고 말하면 거짓말이 된다.
67
- export function isLocaleForced({ flag = null, env = {} } = {}) {
68
- return [flag, env.AGENT_SETUP_LANG].some((v) => typeof v === 'string' && LOCALES.includes(v))
69
- }
70
-
71
- // 옵션 주머니가 없어 t를 꿸 수 없는 곳(context.mjs의 repoPath,
72
- // record.mjs의 readRecord)이 쓴다. .message는 영어로 즉시 채워 스택
73
- // 트레이스가 그대로 읽히게 하고, 진입점이 .key를 보고 다시 렌더한다.
74
- export class LocalizedError extends Error {
75
- constructor(key, params = {}) {
76
- super(createT('en')(key, params))
77
- this.name = 'LocalizedError'
78
- this.key = key
79
- this.params = params
80
- }
81
- }
82
-
83
- // 데이터 계층이 만들어 나중에 표시되는 문구용. detect()의 detail이나
84
- // unsupported 사유는 만들어지는 시점에 로케일을 모른다 — 키를 담아 두고
85
- // 렌더 시점에 toText로 푼다.
86
- export function msg(key, params = {}) {
87
- return { key, params }
88
- }
89
-
90
- export function toText(t, value) {
91
- if (value === null || value === undefined) return value
92
- return typeof value === 'string' ? value : t(value.key, value.params)
93
- }
1
+ // 로케일 해석과 문구 조회. 잎(leaf) 모듈이라 아무것도 import 하지 않는다 —
2
+ // args.mjs·context.mjs·bootstrap/*가 전부 여기에 닿으므로, 외부 의존성이
3
+ // 하나라도 들어오면 npm install 없이 도는 부트스트랩이 깨진다.
4
+ // bootstrap.isolation.test.mjs가 이 불변식을 지킨다.
5
+ //
6
+ // 전역 싱글턴을 두지 않는다. t는 만들어서 넘기는 값이다 — 그래야 순수
7
+ // 모듈(state.mjs)이 계속 아무것도 import 하지 않고, 테스트가 파일 병렬로
8
+ // 돌아도 로케일이 서로를 오염시키지 않는다.
9
+ import EN from './catalog/en.mjs'
10
+ import KO from './catalog/ko.mjs'
11
+
12
+ export const LOCALES = ['en', 'ko']
13
+
14
+ const CATALOGS = { en: EN, ko: KO }
15
+ const PLACEHOLDER = /\{(\w+)\}/g
16
+
17
+ // 자리에 값이 없으면 던진다. `12 of undefined files`가 화면에 남는 것보다
18
+ // 즉시 실패가 낫다 — 이건 번역 누락이 아니라 호출부의 버그다.
19
+ function fill(template, params, key) {
20
+ return template.replace(PLACEHOLDER, (_, name) => {
21
+ if (!Object.hasOwn(params, name)) {
22
+ throw new Error(`i18n: '${key}'의 자리 {${name}}에 값이 없습니다`)
23
+ }
24
+ return String(params[name])
25
+ })
26
+ }
27
+
28
+ // 활성 카탈로그 → 영어 폴백 → 예외.
29
+ // 번역 누락은 사용자 화면을 깨뜨리지 않아야 하지만, 영어에도 없는 키는
30
+ // 개발자 오타다. 조용히 넘기면 화면에 'log.file.creat'가 그대로 찍힌다.
31
+ function lookup(locale, key) {
32
+ const value = CATALOGS[locale]?.[key]
33
+ if (value !== undefined) return value
34
+ const base = EN[key]
35
+ if (base === undefined) throw new Error(`i18n: 알 수 없는 키입니다: ${key}`)
36
+ return base
37
+ }
38
+
39
+ export function createT(locale) {
40
+ const use = LOCALES.includes(locale) ? locale : 'en'
41
+ const t = (key, params = {}) => {
42
+ const value = lookup(use, key)
43
+ return Array.isArray(value)
44
+ ? value.map((line) => fill(line, params, key)).join('\n')
45
+ : fill(value, params, key)
46
+ }
47
+ t.locale = use
48
+ return t
49
+ }
50
+
51
+ // 앞이 이긴다: 플래그 > 환경변수 > 설치 기록 > OS 감지 > 영어.
52
+ // 지원하지 않는 값은 조용히 건너뛴다 — 손으로 편집된 기록이나 환경에 남은
53
+ // 값 때문에 도구가 죽으면 안 된다. 사용자가 --lang으로 명시한 경우만
54
+ // args.mjs가 따로 거부한다.
55
+ export function resolveLocale({ flag = null, env = {}, record = null, detected = null } = {}) {
56
+ for (const value of [flag, env.AGENT_SETUP_LANG, record?.lang, detected]) {
57
+ if (typeof value === 'string' && LOCALES.includes(value)) return value
58
+ }
59
+ return 'en'
60
+ }
61
+
62
+ // 플래그나 환경변수가 로케일을 못박았는가. TUI에서 고른 언어는 이번 실행에는
63
+ // 적용되지만 기록에만 남고 다음 실행에서 다시 밀리므로, 화면이 그 사실을
64
+ // 알려야 한다. 판정 규칙이 resolveLocale과 갈리면 안 되므로 같은 모듈에 둔다 —
65
+ // 지원하지 않는 값은 resolveLocale이 이미 건너뛰었고, 셸에 남은
66
+ // AGENT_SETUP_LANG=ja까지 "환경변수가 이긴다"고 말하면 거짓말이 된다.
67
+ export function isLocaleForced({ flag = null, env = {} } = {}) {
68
+ return [flag, env.AGENT_SETUP_LANG].some((v) => typeof v === 'string' && LOCALES.includes(v))
69
+ }
70
+
71
+ // 옵션 주머니가 없어 t를 꿸 수 없는 곳(context.mjs의 repoPath,
72
+ // record.mjs의 readRecord)이 쓴다. .message는 영어로 즉시 채워 스택
73
+ // 트레이스가 그대로 읽히게 하고, 진입점이 .key를 보고 다시 렌더한다.
74
+ export class LocalizedError extends Error {
75
+ constructor(key, params = {}) {
76
+ super(createT('en')(key, params))
77
+ this.name = 'LocalizedError'
78
+ this.key = key
79
+ this.params = params
80
+ }
81
+ }
82
+
83
+ // 데이터 계층이 만들어 나중에 표시되는 문구용. detect()의 detail이나
84
+ // unsupported 사유는 만들어지는 시점에 로케일을 모른다 — 키를 담아 두고
85
+ // 렌더 시점에 toText로 푼다.
86
+ export function msg(key, params = {}) {
87
+ return { key, params }
88
+ }
89
+
90
+ export function toText(t, value) {
91
+ if (value === null || value === undefined) return value
92
+ return typeof value === 'string' ? value : t(value.key, value.params)
93
+ }
@@ -1,6 +1,6 @@
1
- import { defineMcp } from '../catalog.mjs'
2
- export default defineMcp({
3
- id: 'mcp.codebase-memory', label: 'Codebase Memory MCP', group: '__context',
4
- server: { kind: 'stdio', command: 'codebase-memory-mcp', args: [] },
5
- note: 'item.mcp.codebase-memory.note',
6
- })
1
+ import { defineMcp } from '../catalog.mjs'
2
+ export default defineMcp({
3
+ id: 'mcp.codebase-memory', label: 'Codebase Memory MCP', group: '__context',
4
+ server: { kind: 'stdio', command: 'codebase-memory-mcp', args: [] },
5
+ note: 'item.mcp.codebase-memory.note',
6
+ })
@@ -1,6 +1,6 @@
1
- import { defineMcp } from '../catalog.mjs'
2
- export default defineMcp({
3
- id: 'mcp.notion', label: 'Notion MCP', group: '__service',
4
- server: { kind: 'http', url: 'https://mcp.notion.com/mcp' },
5
- note: 'item.mcp.notion.note',
6
- })
1
+ import { defineMcp } from '../catalog.mjs'
2
+ export default defineMcp({
3
+ id: 'mcp.notion', label: 'Notion MCP', group: '__service',
4
+ server: { kind: 'http', url: 'https://mcp.notion.com/mcp' },
5
+ note: 'item.mcp.notion.note',
6
+ })
@@ -1,6 +1,6 @@
1
- import { defineMcp } from '../catalog.mjs'
2
- export default defineMcp({
3
- id: 'mcp.supabase', label: 'Supabase MCP', group: '__service',
4
- server: { kind: 'http', url: 'https://mcp.supabase.com/mcp' },
5
- note: 'item.mcp.supabase.note',
6
- })
1
+ import { defineMcp } from '../catalog.mjs'
2
+ export default defineMcp({
3
+ id: 'mcp.supabase', label: 'Supabase MCP', group: '__service',
4
+ server: { kind: 'http', url: 'https://mcp.supabase.com/mcp' },
5
+ note: 'item.mcp.supabase.note',
6
+ })
@@ -1,6 +1,6 @@
1
- import { defineMcp } from '../catalog.mjs'
2
- export default defineMcp({
3
- id: 'mcp.vercel', label: 'Vercel MCP', group: '__service',
4
- server: { kind: 'http', url: 'https://mcp.vercel.com' },
5
- note: 'item.mcp.vercel.note',
6
- })
1
+ import { defineMcp } from '../catalog.mjs'
2
+ export default defineMcp({
3
+ id: 'mcp.vercel', label: 'Vercel MCP', group: '__service',
4
+ server: { kind: 'http', url: 'https://mcp.vercel.com' },
5
+ note: 'item.mcp.vercel.note',
6
+ })
@@ -1,8 +1,8 @@
1
- import { definePlugin } from '../catalog.mjs'
2
- export default definePlugin({
3
- id: 'plugin.mattpocock-skills', label: 'Matt Pocock skills', group: '__flow',
4
- installId: 'mattpocock-skills@mattpocock',
5
- detectIds: ['mattpocock-skills@mattpocock'],
6
- marketplace: { name: 'mattpocock', repo: 'mattpocock/skills' },
7
- note: 'item.plugin.mattpocock-skills.note',
8
- })
1
+ import { definePlugin } from '../catalog.mjs'
2
+ export default definePlugin({
3
+ id: 'plugin.mattpocock-skills', label: 'Matt Pocock skills', group: '__flow',
4
+ installId: 'mattpocock-skills@mattpocock',
5
+ detectIds: ['mattpocock-skills@mattpocock'],
6
+ marketplace: { name: 'mattpocock', repo: 'mattpocock/skills' },
7
+ note: 'item.plugin.mattpocock-skills.note',
8
+ })
@@ -1,104 +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
- await exec('claude', ['plugin', 'marketplace', 'add', MARKETPLACE.repo], { cwd: root })
71
- const r = await 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 = await 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
+ 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
+ await exec('claude', ['plugin', 'marketplace', 'add', MARKETPLACE.repo], { cwd: root })
71
+ const r = await 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 = await 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,44 +1,44 @@
1
- import { existsSync, readdirSync } from 'node:fs'
2
- import { join } from 'node:path'
3
- import { defineSkill } from '../catalog.mjs'
4
- import { CLI_IDS } from '../clis.mjs'
5
- import { LocalizedError, msg } from '../i18n/index.mjs'
6
-
7
- function hasGsdFiles(dir) {
8
- return existsSync(dir) && readdirSync(dir).some((f) => f.startsWith('gsd-'))
9
- }
10
-
11
- // 상류(v1.9.1)는 런타임 플래그 18개(--codex·--antigravity 등)를 지원하지만
12
- // 이 항목은 --claude만 배선한다. Gemini CLI는 상류가 명시 제거했다(2026-06-18
13
- // Google 종료, --gemini는 --antigravity 안내 후 종료). 상류의 --kilo는
14
- // OpenCode 파생 "Kilo"(~/.config/kilo)라 이 저장소의 Kilo Code(.kilocode)와
15
- // 같은 제품인지 미확인이라 지원으로 표기하지 않는다(2026-08-02 검증).
16
- const FLAGS = ['codex', 'opencode', 'copilot', 'kimi']
17
-
18
- export default defineSkill({
19
- id: 'skill.gsd', label: 'GSD (Get Shit Done)', group: '__flow', scope: 'project',
20
- note: 'item.skill.gsd.note',
21
- unsupported: Object.fromEntries(
22
- CLI_IDS.filter((c) => c !== 'claude').map((c) => [
23
- c,
24
- FLAGS.includes(c) ? msg('item.unsupported.gsdFlag')
25
- : c === 'gemini' ? msg('item.unsupported.gsdGemini')
26
- : msg('item.unsupported.upstreamNone'),
27
- ]),
28
- ),
29
- async detect({ root }) {
30
- const found = hasGsdFiles(join(root, '.claude', 'commands')) || hasGsdFiles(join(root, '.claude', 'skills'))
31
- return { status: found ? 'installed' : 'absent' }
32
- },
33
- async install({ root, exec }) {
34
- const r = await exec('npx', ['-y', '@opengsd/gsd-core@latest', '--claude', '--local'], { cwd: root })
35
- if (!r.ok) throw new LocalizedError('error.gsdInstall', { output: r.output })
36
- },
37
- async uninstall({ root, exec }) {
38
- // 런타임 플래그가 없으면 상류가 기본값 claude를 지운다 — 지금은 --claude만
39
- // 설치하므로 맞지만, 다른 런타임을 설치하게 되면 여기에도 같은 플래그를
40
- // 넘겨야 엉뚱한 설치본이 지워지지 않는다.
41
- const r = await exec('npx', ['-y', '@opengsd/gsd-core@latest', '--uninstall'], { cwd: root })
42
- if (!r.ok) throw new LocalizedError('error.gsdUninstall', { output: r.output })
43
- },
44
- })
1
+ import { existsSync, readdirSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { defineSkill } from '../catalog.mjs'
4
+ import { CLI_IDS } from '../clis.mjs'
5
+ import { LocalizedError, msg } from '../i18n/index.mjs'
6
+
7
+ function hasGsdFiles(dir) {
8
+ return existsSync(dir) && readdirSync(dir).some((f) => f.startsWith('gsd-'))
9
+ }
10
+
11
+ // 상류(v1.9.1)는 런타임 플래그 18개(--codex·--antigravity 등)를 지원하지만
12
+ // 이 항목은 --claude만 배선한다. Gemini CLI는 상류가 명시 제거했다(2026-06-18
13
+ // Google 종료, --gemini는 --antigravity 안내 후 종료). 상류의 --kilo는
14
+ // OpenCode 파생 "Kilo"(~/.config/kilo)라 이 저장소의 Kilo Code(.kilocode)와
15
+ // 같은 제품인지 미확인이라 지원으로 표기하지 않는다(2026-08-02 검증).
16
+ const FLAGS = ['codex', 'opencode', 'copilot', 'kimi']
17
+
18
+ export default defineSkill({
19
+ id: 'skill.gsd', label: 'GSD (Get Shit Done)', group: '__flow', scope: 'project',
20
+ note: 'item.skill.gsd.note',
21
+ unsupported: Object.fromEntries(
22
+ CLI_IDS.filter((c) => c !== 'claude').map((c) => [
23
+ c,
24
+ FLAGS.includes(c) ? msg('item.unsupported.gsdFlag')
25
+ : c === 'gemini' ? msg('item.unsupported.gsdGemini')
26
+ : msg('item.unsupported.upstreamNone'),
27
+ ]),
28
+ ),
29
+ async detect({ root }) {
30
+ const found = hasGsdFiles(join(root, '.claude', 'commands')) || hasGsdFiles(join(root, '.claude', 'skills'))
31
+ return { status: found ? 'installed' : 'absent' }
32
+ },
33
+ async install({ root, exec }) {
34
+ const r = await exec('npx', ['-y', '@opengsd/gsd-core@latest', '--claude', '--local'], { cwd: root })
35
+ if (!r.ok) throw new LocalizedError('error.gsdInstall', { output: r.output })
36
+ },
37
+ async uninstall({ root, exec }) {
38
+ // 런타임 플래그가 없으면 상류가 기본값 claude를 지운다 — 지금은 --claude만
39
+ // 설치하므로 맞지만, 다른 런타임을 설치하게 되면 여기에도 같은 플래그를
40
+ // 넘겨야 엉뚱한 설치본이 지워지지 않는다.
41
+ const r = await exec('npx', ['-y', '@opengsd/gsd-core@latest', '--uninstall'], { cwd: root })
42
+ if (!r.ok) throw new LocalizedError('error.gsdUninstall', { output: r.output })
43
+ },
44
+ })