claude-novice 0.2.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 (37) hide show
  1. package/.claude-plugin/marketplace.json +25 -0
  2. package/.claude-plugin/plugin.json +22 -0
  3. package/ARCHITECTURE.md +59 -0
  4. package/LICENSE +21 -0
  5. package/README.md +372 -0
  6. package/config/bootstrap-manifests/github-cli.json +80 -0
  7. package/config/bootstrap-manifests/supabase.json +71 -0
  8. package/config/bootstrap-manifests/vercel.json +69 -0
  9. package/config/levels.json +33 -0
  10. package/config/safety-rules.json +263 -0
  11. package/config/service-capabilities.json +51 -0
  12. package/config/terms.json +198 -0
  13. package/hooks/hooks.json +107 -0
  14. package/package.json +45 -0
  15. package/scripts/bootstrap-engine.js +240 -0
  16. package/scripts/lib/capability-router.js +170 -0
  17. package/scripts/lib/capsule.js +178 -0
  18. package/scripts/lib/fingerprint.js +17 -0
  19. package/scripts/lib/grammar.js +287 -0
  20. package/scripts/lib/hookio.js +49 -0
  21. package/scripts/lib/manifest.js +154 -0
  22. package/scripts/lib/safety.js +370 -0
  23. package/scripts/lib/secrets.js +104 -0
  24. package/scripts/lib/state.js +256 -0
  25. package/scripts/post-tool-batch.js +108 -0
  26. package/scripts/post-tool-use-failure.js +48 -0
  27. package/scripts/post-tool-use.js +114 -0
  28. package/scripts/pre-tool-use.js +58 -0
  29. package/scripts/session-end.js +21 -0
  30. package/scripts/session-start.js +48 -0
  31. package/scripts/stop.js +69 -0
  32. package/scripts/user-prompt-expansion.js +66 -0
  33. package/scripts/user-prompt-submit.js +158 -0
  34. package/scripts/verify-docs.mjs +93 -0
  35. package/skills/mode/SKILL.md +48 -0
  36. package/skills/novice/SKILL.md +41 -0
  37. package/skills/setup-service/SKILL.md +88 -0
@@ -0,0 +1,107 @@
1
+ {
2
+ "hooks": {
3
+ "SessionStart": [
4
+ {
5
+ "matcher": "startup|resume|clear|compact",
6
+ "hooks": [
7
+ {
8
+ "type": "command",
9
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/session-start.js\"",
10
+ "timeout": 10
11
+ }
12
+ ]
13
+ }
14
+ ],
15
+ "UserPromptSubmit": [
16
+ {
17
+ "hooks": [
18
+ {
19
+ "type": "command",
20
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/user-prompt-submit.js\"",
21
+ "timeout": 10
22
+ }
23
+ ]
24
+ }
25
+ ],
26
+ "UserPromptExpansion": [
27
+ {
28
+ "hooks": [
29
+ {
30
+ "type": "command",
31
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/user-prompt-expansion.js\"",
32
+ "timeout": 10
33
+ }
34
+ ]
35
+ }
36
+ ],
37
+ "PreToolUse": [
38
+ {
39
+ "matcher": "Bash|mcp__.*",
40
+ "hooks": [
41
+ {
42
+ "type": "command",
43
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/pre-tool-use.js\"",
44
+ "timeout": 10
45
+ }
46
+ ]
47
+ }
48
+ ],
49
+ "PostToolUse": [
50
+ {
51
+ "matcher": "Bash|mcp__.*",
52
+ "hooks": [
53
+ {
54
+ "type": "command",
55
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/post-tool-use.js\"",
56
+ "timeout": 10
57
+ }
58
+ ]
59
+ }
60
+ ],
61
+ "PostToolUseFailure": [
62
+ {
63
+ "matcher": "Bash|mcp__.*",
64
+ "hooks": [
65
+ {
66
+ "type": "command",
67
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/post-tool-use-failure.js\"",
68
+ "timeout": 10
69
+ }
70
+ ]
71
+ }
72
+ ],
73
+ "PostToolBatch": [
74
+ {
75
+ "hooks": [
76
+ {
77
+ "type": "command",
78
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/post-tool-batch.js\"",
79
+ "timeout": 10
80
+ }
81
+ ]
82
+ }
83
+ ],
84
+ "Stop": [
85
+ {
86
+ "hooks": [
87
+ {
88
+ "type": "command",
89
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/stop.js\"",
90
+ "timeout": 10
91
+ }
92
+ ]
93
+ }
94
+ ],
95
+ "SessionEnd": [
96
+ {
97
+ "hooks": [
98
+ {
99
+ "type": "command",
100
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/session-end.js\"",
101
+ "timeout": 10
102
+ }
103
+ ]
104
+ }
105
+ ]
106
+ }
107
+ }
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "claude-novice",
3
+ "version": "0.2.0",
4
+ "description": "바이브 코딩 입문자용 Claude Code 플러그인 — 용어 스캐폴딩·안전 게이트·서비스 CLI 부트스트랩 | A Claude Code plugin for vibe-coding beginners — term scaffolding, safety gate, service CLI bootstrap",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "SHC (hjsh200219)",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/hjsh200219/novice.git"
11
+ },
12
+ "homepage": "https://github.com/hjsh200219/novice#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/hjsh200219/novice/issues"
15
+ },
16
+ "keywords": [
17
+ "claude-code",
18
+ "claude-code-plugin",
19
+ "beginner",
20
+ "onboarding",
21
+ "korean",
22
+ "safety",
23
+ "vibe-coding"
24
+ ],
25
+ "files": [
26
+ ".claude-plugin/",
27
+ "config/",
28
+ "hooks/",
29
+ "scripts/",
30
+ "skills/",
31
+ "ARCHITECTURE.md"
32
+ ],
33
+ "engines": {
34
+ "node": ">=18"
35
+ },
36
+ "scripts": {
37
+ "verify-docs": "node scripts/verify-docs.mjs",
38
+ "pretest": "node scripts/verify-docs.mjs",
39
+ "test": "node --test tests/unit/*.test.js tests/integration/*.test.js",
40
+ "test:unit": "node --test tests/unit/*.test.js",
41
+ "test:integration": "node --test tests/integration/*.test.js",
42
+ "test:coverage": "node --test --experimental-test-coverage tests/unit/*.test.js tests/integration/*.test.js",
43
+ "prepublishOnly": "npm test"
44
+ }
45
+ }
@@ -0,0 +1,240 @@
1
+ #!/usr/bin/env node
2
+ // Common bootstrap engine: resolve → preflight → plan → approve → apply → verify → recover.
3
+ // One engine for every provider — the differences live in versioned manifest data.
4
+ // Automation boundary: detect → install → login → auth verify. Nothing beyond.
5
+ //
6
+ // Invariants:
7
+ // - exec-form argv only; no shell strings, no curl|bash.
8
+ // - preflight is strictly read-only (detect/version_check/auth_status argv only).
9
+ // - install and login each require their own explicit approval before apply.
10
+ // - plaintext credential fallback aborts auto-login when the manifest says so.
11
+ // - audit state stores service id, manifest revision, step names, exit statuses — never
12
+ // argv text, stdout/stderr, or credential values.
13
+ import path from 'node:path';
14
+ import { validateManifest, loadTier1Manifest, buildAdHocManifest } from './lib/manifest.js';
15
+ import { dataDir, writeJsonAtomic, readJsonSafe, assertSafeId } from './lib/state.js';
16
+ import { planCapability } from './lib/capability-router.js';
17
+
18
+ function auditPath(sessionId, serviceId, env) {
19
+ return path.join(
20
+ dataDir(env),
21
+ 'sessions',
22
+ assertSafeId(sessionId, 'session id'),
23
+ 'bootstrap',
24
+ `${assertSafeId(serviceId, 'service id')}.json`,
25
+ );
26
+ }
27
+
28
+ function matchSuccess(spec, result) {
29
+ if (!spec) return result.code === 0;
30
+ if (spec.exit_code !== undefined && result.code !== spec.exit_code) return false;
31
+ if (spec.exit_code_not !== undefined && result.code === spec.exit_code_not) return false;
32
+ if (spec.stdout_regex && !new RegExp(spec.stdout_regex).test(result.stdout ?? '')) return false;
33
+ return true;
34
+ }
35
+
36
+ export function createEngine({
37
+ exec,
38
+ platform = process.platform,
39
+ interactive = true,
40
+ env = process.env,
41
+ now = () => new Date().toISOString(),
42
+ } = {}) {
43
+ if (typeof exec !== 'function') throw new Error('createEngine requires an injected exec(argv)');
44
+
45
+ async function run(argv) {
46
+ if (!Array.isArray(argv) || argv.some((t) => typeof t !== 'string')) {
47
+ throw new Error('engine only executes exec-form argv arrays');
48
+ }
49
+ return exec(argv);
50
+ }
51
+
52
+ function audit(sessionId, manifest, step, exitStatus) {
53
+ if (!sessionId) return;
54
+ try {
55
+ const file = auditPath(sessionId, manifest.service_id, env);
56
+ const current = readJsonSafe(file, {
57
+ service_id: manifest.service_id,
58
+ manifest_revision: manifest.manifest_revision ?? null,
59
+ tier: manifest.tier ?? null,
60
+ steps: [],
61
+ });
62
+ current.steps.push({ step, exit_status: exitStatus, at: now() });
63
+ writeJsonAtomic(file, current);
64
+ } catch {
65
+ // audit is best-effort; it must never carry more than step/exit metadata anyway
66
+ }
67
+ }
68
+
69
+ return {
70
+ resolve(serviceId, adHocInput = null) {
71
+ const tier1 = loadTier1Manifest(serviceId, env);
72
+ if (tier1) return { tier: 1, manifest: tier1 };
73
+ if (adHocInput) {
74
+ const built = buildAdHocManifest(adHocInput);
75
+ if (built.ok) return { tier: 2, manifest: built.manifest, requiresUserApproval: true };
76
+ return { mode: 'guided_manual', reason: built.reason };
77
+ }
78
+ return {
79
+ mode: 'guided_manual',
80
+ reason: `'${serviceId}'는 검토된 manifest에 없어요. 공식 문서 URL과 package coordinate를 조사해 사용자 승인을 받거나 guided manual로 진행하세요.`,
81
+ };
82
+ },
83
+
84
+ async preflight(manifest, { plaintextDetected = false } = {}) {
85
+ const detect = await run(manifest.detect.argv);
86
+ const installed = matchSuccess(manifest.detect.success, detect);
87
+ let versionOk = false;
88
+ let authenticated = false;
89
+ if (installed) {
90
+ const version = await run(manifest.version_check.argv);
91
+ versionOk = matchSuccess(manifest.version_check.success, version);
92
+ const auth = await run(manifest.auth_status.argv);
93
+ authenticated = matchSuccess(manifest.auth_status.success, auth);
94
+ }
95
+ const cs = manifest.credential_store;
96
+ const plaintextRisk = plaintextDetected || cs.secure_storage === false;
97
+ return {
98
+ installed,
99
+ version_ok: versionOk,
100
+ authenticated,
101
+ interactive,
102
+ credential_store: cs,
103
+ plaintext_risk: plaintextRisk,
104
+ abort_login: plaintextRisk && cs.abort_auto_login_on_plaintext === true,
105
+ };
106
+ },
107
+
108
+ plan(manifest, preflight) {
109
+ const steps = [];
110
+ if (!preflight.installed || !preflight.version_ok) {
111
+ const installer = manifest.installers.find((i) => i.os.includes(platform)) ?? manifest.installers[0];
112
+ steps.push({
113
+ kind: 'install',
114
+ approval_required: true,
115
+ argv: installer.argv,
116
+ disclosure:
117
+ `설치: ${manifest.binary} — package coordinate '${installer.package_coordinate}' (${installer.package_manager})\n` +
118
+ `공식 근거: ${manifest.docs_url}\n` +
119
+ `전역 변경: ${(installer.global_changes ?? []).join(', ') || '없음'}\n` +
120
+ `되돌리기: ${JSON.stringify(manifest.uninstall[installer.package_manager] ?? Object.values(manifest.uninstall)[0])}`,
121
+ });
122
+ }
123
+ if (!preflight.authenticated) {
124
+ if (!preflight.interactive) {
125
+ steps.push({
126
+ kind: manifest.noninteractive_policy.login === 'deny' ? 'denied_login' : 'guided_manual',
127
+ policy: manifest.noninteractive_policy.login,
128
+ guidance: `비대화형 환경이라 자동 로그인하지 않아요. ${manifest.noninteractive_policy.reason}`,
129
+ });
130
+ } else if (preflight.abort_login) {
131
+ steps.push({
132
+ kind: 'aborted_login',
133
+ reason: 'plaintext credential fallback',
134
+ guidance:
135
+ `이 환경에서는 인증 정보가 평문으로 저장될 수 있어 자동 로그인을 중단했어요.\n` +
136
+ `저장 위치: ${manifest.credential_store.plaintext_fallback_location ?? manifest.credential_store.storage}\n` +
137
+ `직접 로그인한 경우 제거: ${JSON.stringify(manifest.logout.argv)}\n${manifest.credential_store.abort_note ?? ''}`,
138
+ });
139
+ } else {
140
+ steps.push({
141
+ kind: 'login',
142
+ approval_required: true,
143
+ argv: manifest.login.argv,
144
+ disclosure:
145
+ `로그인: ${JSON.stringify(manifest.login.argv)}\n` +
146
+ `인증 저장: ${manifest.credential_store.storage}\n` +
147
+ `사용자가 직접 완료: ${(manifest.login.user_completes ?? []).join(', ')}\n` +
148
+ `되돌리기(logout): ${JSON.stringify(manifest.logout.argv)}`,
149
+ });
150
+ }
151
+ }
152
+ return { service_id: manifest.service_id, steps };
153
+ },
154
+
155
+ async apply(manifest, plan, approvals = {}, sessionId = null) {
156
+ const results = [];
157
+ for (const step of plan.steps) {
158
+ if (step.kind === 'guided_manual' || step.kind === 'aborted_login' || step.kind === 'denied_login') {
159
+ results.push({ step: step.kind, ok: false, skipped: true, guidance: step.guidance });
160
+ continue;
161
+ }
162
+ if (step.approval_required && approvals[step.kind] !== true) {
163
+ results.push({ step: step.kind, ok: false, skipped: true, reason: 'approval_missing' });
164
+ continue;
165
+ }
166
+ const result = await run(step.argv);
167
+ audit(sessionId, manifest, step.kind, result.code);
168
+ const ok = result.code === 0;
169
+ results.push({ step: step.kind, ok, exit_status: result.code });
170
+ if (!ok) break; // stop on first failure; recover() reports the rest
171
+ }
172
+ return results;
173
+ },
174
+
175
+ async verify(manifest) {
176
+ const version = await run(manifest.version_check.argv);
177
+ const auth = await run(manifest.auth_status.argv);
178
+ return {
179
+ installed_ok: matchSuccess(manifest.version_check.success, version),
180
+ auth_ok: matchSuccess(manifest.auth_status.success, auth),
181
+ };
182
+ },
183
+
184
+ recover(manifest, sessionId = null) {
185
+ const auditState = sessionId
186
+ ? readJsonSafe(auditPath(sessionId, manifest.service_id, env), { steps: [] })
187
+ : { steps: [] };
188
+ return {
189
+ completed_steps: auditState.steps,
190
+ remaining_side_effects: manifest.side_effects,
191
+ retry_guidance: '완료된 단계는 재실행 시 preflight가 건너뛰어요. 실패한 단계만 다시 승인해 진행하세요.',
192
+ logout_argv: manifest.logout.argv,
193
+ uninstall_options: manifest.uninstall,
194
+ auto_cleanup: false,
195
+ };
196
+ },
197
+ };
198
+ }
199
+
200
+ // Full state-machine convenience wrapper.
201
+ export async function runBootstrap(engine, serviceId, { approvals = {}, adHocInput = null, sessionId = null, plaintextDetected = false } = {}) {
202
+ const resolved = engine.resolve(serviceId, adHocInput);
203
+ if (resolved.mode === 'guided_manual') return { phase: 'resolve', ...resolved };
204
+
205
+ const manifest = resolved.manifest;
206
+ const { valid, errors } = validateManifest(manifest);
207
+ if (!valid) return { phase: 'resolve', mode: 'guided_manual', reason: `manifest invalid: ${errors.join('; ')}` };
208
+
209
+ const preflight = await engine.preflight(manifest, { plaintextDetected });
210
+ const plan = engine.plan(manifest, preflight);
211
+ if (plan.steps.length === 0) {
212
+ const verified = await engine.verify(manifest);
213
+ return { phase: 'verify', tier: resolved.tier, already_complete: true, preflight, verified };
214
+ }
215
+ const applied = await engine.apply(manifest, plan, approvals, sessionId);
216
+ const failed = applied.some((r) => r.ok === false && r.skipped !== true);
217
+ const verified = failed ? null : await engine.verify(manifest);
218
+ return {
219
+ phase: failed ? 'recover' : 'verify',
220
+ tier: resolved.tier,
221
+ preflight,
222
+ plan,
223
+ applied,
224
+ verified,
225
+ recover: failed ? engine.recover(manifest, sessionId) : null,
226
+ };
227
+ }
228
+
229
+ // Capability-routed entry point: pick the path (CLI → allowlisted MCP → visible Chrome →
230
+ // guided manual) per PRD §4.3, then only the `cli` path drives the manifest engine here.
231
+ // mcp/chrome/guided_manual return a plan for the model/user to carry out — the plugin never
232
+ // invokes MCP tools or drives Chrome itself.
233
+ export async function setupService(engine, serviceId, capability = 'bootstrap', ctx = {}, opts = {}) {
234
+ const plan = planCapability(serviceId, capability, ctx);
235
+ if (plan.path !== 'cli') {
236
+ return { routed: plan.path, plan };
237
+ }
238
+ const bootstrap = await runBootstrap(engine, serviceId, opts);
239
+ return { routed: 'cli', plan, bootstrap };
240
+ }
@@ -0,0 +1,170 @@
1
+ // Capability router for external-service setup.
2
+ //
3
+ // PRD §4.3: capability priority is CLI → allowlisted 공식 MCP → visible Chrome → guided manual.
4
+ // This module is the deterministic decision/validation layer — it picks the path, validates an
5
+ // MCP server candidate against the allowlist, judges Chrome availability, and downgrades safely.
6
+ //
7
+ // What it does NOT do (by design / platform reality): it does not spawn MCP servers, invoke MCP
8
+ // tools, or drive Chrome. Those side effects stay model-driven (the model calls an allowlisted
9
+ // MCP tool; the PreToolUse gate still guards it) and user-driven (login/CAPTCHA/MFA/final submit
10
+ // happen in visible Chrome by the user). The router produces the plan and the refusals.
11
+ import fs from 'node:fs';
12
+ import path from 'node:path';
13
+ import { pluginRoot } from './secrets.js';
14
+
15
+ let cached = null;
16
+ export function loadCapabilities(env = process.env) {
17
+ if (cached) return cached;
18
+ const file = path.join(pluginRoot(env), 'config', 'service-capabilities.json');
19
+ cached = JSON.parse(fs.readFileSync(file, 'utf8'));
20
+ return cached;
21
+ }
22
+
23
+ // ---- MCP candidate validation (two acceptance paths) ----
24
+ //
25
+ // candidate: what the runtime reports about an available MCP server plus the tools the model
26
+ // wants to use:
27
+ // { service_id?, server, transport, publisher, tools:[baseName],
28
+ // provenance:{verified}, // for the static-allowlist path
29
+ // registered:boolean, // server is registered/connected in the user's Claude runtime
30
+ // userConsent:boolean } // user explicitly approved using this server for this task
31
+ //
32
+ // Path 1 — static allowlist (pre-reviewed in service-capabilities.json): auto-usable, no
33
+ // per-use consent required. Strongest: server/transport/publisher/tools all pinned.
34
+ // Path 2 — runtime-registered + explicit user consent: the user already added the server to
35
+ // their Claude config (that registration IS the provenance) AND explicitly consented to use
36
+ // it now. Limited to the tools the user consented to. Destructive tool calls are still
37
+ // blocked by the PreToolUse safety gate, and nothing is auto-installed.
38
+ export function validateMcpCandidate(candidate, allowlist = [], capability = null) {
39
+ if (!candidate || typeof candidate !== 'object') return { allowed: false, reason: 'no-candidate' };
40
+
41
+ // Path 1: static allowlist.
42
+ const entry = Array.isArray(allowlist)
43
+ ? allowlist.find((e) => e.server === candidate.server && (!candidate.service_id || e.service_id === candidate.service_id))
44
+ : null;
45
+ if (entry) {
46
+ if (entry.transport !== candidate.transport) return { allowed: false, reason: 'transport-mismatch' };
47
+ if (entry.publisher !== candidate.publisher) return { allowed: false, reason: 'publisher-mismatch' };
48
+ if (candidate.provenance?.verified !== true) return { allowed: false, reason: 'provenance-unverified' };
49
+ if (capability && Array.isArray(entry.capabilities) && !entry.capabilities.includes(capability)) {
50
+ return { allowed: false, reason: 'capability-not-served' };
51
+ }
52
+ const wanted = Array.isArray(candidate.tools) ? candidate.tools : [];
53
+ const allowedSet = new Set(entry.tools || []);
54
+ const rejectedTool = wanted.find((t) => !allowedSet.has(t));
55
+ if (rejectedTool) return { allowed: false, reason: `tool-not-allowlisted:${rejectedTool}` };
56
+ return { allowed: true, basis: 'allowlist', entry, tools: wanted };
57
+ }
58
+
59
+ // Path 2: runtime-registered + explicit user consent.
60
+ const registered = candidate.registered === true;
61
+ const consented = candidate.userConsent === true;
62
+ if (registered && consented) {
63
+ return { allowed: true, basis: 'user-consent', tools: Array.isArray(candidate.tools) ? candidate.tools : [] };
64
+ }
65
+ if (registered && !consented) return { allowed: false, reason: 'registered-no-consent' };
66
+ if (consented && !registered) return { allowed: false, reason: 'consented-not-registered' };
67
+
68
+ return { allowed: false, reason: 'not-in-allowlist-and-no-consent' };
69
+ }
70
+
71
+ // ---- Chrome availability ----
72
+ //
73
+ // chromeEnv: { available: boolean (official Claude in Chrome connected), thirdPartyProvider: boolean }
74
+ export function chromeDecision(chromeEnv, policy = {}) {
75
+ if (!chromeEnv || chromeEnv.available !== true) return { usable: false, reason: 'chrome-not-connected' };
76
+ if (chromeEnv.thirdPartyProvider === true) return { usable: false, reason: 'third-party-provider' };
77
+ return {
78
+ usable: true,
79
+ mode: policy.mode ?? 'visible-only',
80
+ user_completes: policy.user_completes ?? ['login', 'captcha', 'mfa', 'final-submit'],
81
+ reason: 'ok',
82
+ };
83
+ }
84
+
85
+ // ---- path resolution ----
86
+ //
87
+ // ctx: {
88
+ // cliAvailable: boolean, // manifest preflight ok AND user did not refuse install
89
+ // cliRefusedOrFailed: boolean, // user refused CLI install, or preflight failed
90
+ // mcpCandidate: object|null,
91
+ // chromeEnv: object|null,
92
+ // }
93
+ export function resolveCapability(serviceId, capability, ctx = {}, caps = loadCapabilities()) {
94
+ const priority = Array.isArray(caps.capability_priority)
95
+ ? caps.capability_priority
96
+ : ['cli', 'mcp', 'chrome', 'guided_manual'];
97
+
98
+ // A per-service capability may be pinned (e.g. provisioning/deploy → guided_manual).
99
+ const pinned = caps.services?.[serviceId]?.capabilities?.[capability];
100
+ const startIndex = pinned && priority.includes(pinned) ? priority.indexOf(pinned) : 0;
101
+
102
+ const ordered = [];
103
+ let chosen = null;
104
+
105
+ for (let i = startIndex; i < priority.length; i++) {
106
+ const p = priority[i];
107
+ let usable = false;
108
+ let reason = '';
109
+ let detail = null;
110
+
111
+ if (p === 'cli') {
112
+ // CLI is usable when it is available (Tier 1 preflight ok) OR the user explicitly
113
+ // consented to it (Tier 2 ad-hoc manifest confirmed_by_user), and was not refused/failed.
114
+ usable = (ctx.cliAvailable === true || ctx.cliUserConsent === true) && ctx.cliRefusedOrFailed !== true;
115
+ reason = usable ? 'ok' : ctx.cliRefusedOrFailed ? 'cli-refused-or-failed' : 'cli-unavailable';
116
+ } else if (p === 'mcp') {
117
+ const v = validateMcpCandidate(ctx.mcpCandidate, caps.mcp_allowlist, capability);
118
+ usable = v.allowed;
119
+ reason = v.allowed ? `ok:${v.basis}` : v.reason;
120
+ detail = v.allowed ? { basis: v.basis, entry: v.entry ?? null, tools: v.tools } : null;
121
+ } else if (p === 'chrome') {
122
+ const d = chromeDecision(ctx.chromeEnv, caps.chrome_policy);
123
+ usable = d.usable;
124
+ reason = d.reason;
125
+ detail = d.usable ? d : null;
126
+ } else if (p === 'guided_manual') {
127
+ usable = true;
128
+ reason = 'terminal-fallback';
129
+ }
130
+
131
+ ordered.push({ path: p, usable, reason });
132
+ if (usable && !chosen) chosen = { path: p, detail };
133
+ }
134
+
135
+ // guided_manual is always in the walk, so chosen is never null.
136
+ return { service_id: serviceId, capability, path: chosen.path, detail: chosen.detail, ordered };
137
+ }
138
+
139
+ // Human/model-readable plan (Korean) describing what runs and who does what.
140
+ export function planCapability(serviceId, capability, ctx = {}, caps = loadCapabilities()) {
141
+ const r = resolveCapability(serviceId, capability, ctx, caps);
142
+ const skipped = r.ordered.filter((o) => !o.usable && o.path !== r.path).map((o) => `${o.path}(${o.reason})`);
143
+ let guidance;
144
+ switch (r.path) {
145
+ case 'cli':
146
+ guidance = 'CLI 부트스트랩(setup-service manifest 엔진)으로 진행합니다. 설치·로그인 각각 승인이 필요합니다.';
147
+ break;
148
+ case 'mcp': {
149
+ const server = r.detail.entry?.server ?? ctx.mcpCandidate?.server ?? '등록된 서버';
150
+ const source =
151
+ r.detail.basis === 'allowlist'
152
+ ? `allowlist에 검증된 공식 MCP(${server})`
153
+ : `사용자가 명시적으로 동의한 등록된 MCP(${server})`;
154
+ guidance =
155
+ `${source}로 진행합니다. ` +
156
+ `사용 가능한 tool: ${r.detail.tools.join(', ') || '(동의한 tool 범위)'}. ` +
157
+ 'MCP tool 호출은 모델이 수행하며 PreToolUse 안전 게이트가 계속 검사합니다.';
158
+ break;
159
+ }
160
+ case 'chrome':
161
+ guidance =
162
+ 'visible Chrome(공식 Claude in Chrome)으로 진행합니다. ' +
163
+ `login/CAPTCHA/MFA와 최종 submit(${r.detail.user_completes.join(', ')})은 사용자가 직접 완료합니다.`;
164
+ break;
165
+ default:
166
+ guidance =
167
+ 'guided manual로 안내합니다. 무엇을·왜·어느 명령/화면에서 하는지 단계별로 설명하고 사용자가 직접 실행합니다.';
168
+ }
169
+ return { ...r, skipped, guidance };
170
+ }