pi-codex-marketplace 0.6.1 → 0.7.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 CHANGED
@@ -53,6 +53,28 @@ Nine subcommands, no arguments = 總覽:
53
53
  - 安裝成功後由指令層主動要求 reload;reload 失敗不影響已記錄狀態,下次 session start 或 `/reload` 仍生效。
54
54
  - `--no-skills` 啟動 Pi 不影響 Bridge 投影。
55
55
 
56
+ ### Autocomplete(Pi 原生,TUI 限定)
57
+
58
+ 互動(TUI)模式下,`/codex-marketplace` 以 **Pi 原生 autocomplete** 提供兩層、狀態感知的候選。**純文字指令表面維持權威不變**:九個子命令、指令參數、輸出與語意完全不受 autocomplete 影響;RPC/JSON/print 模式根本不註冊 terminal-only provider。
59
+
60
+ **第一層——九個根層子命令。** 輸入完整的 `/codex-marketplace` 後按 Tab,候選清單顯示全部九個子命令(`add`/`list`/`install`/`update`/`disable`/`enable`/`remove`/`forget`/`help`)與各自說明,支援不分大小寫的模糊搜尋。選取需要參數的子命令(`add`/`list`/`install`/`disable`/`enable`/`remove`/`forget`)會自動補上一個尾隨空格,可直接繼續輸入;`update` 與 `help` 不加。
61
+
62
+ **第二層——再按一次 Tab 開啟狀態感知候選。** 需要參數的子命令套用後,**再按一次 Tab** 依當下 Bridge State 只列出當下可執行的選項(空集合不給假候選);**不承諾自動重開 selector**(Pi 0.84.2 在套用候選後不會自動再開一層補完選單,鍵盤流程固定是「輸入 command → Tab 選子命令 → 需要參數時再按一次 Tab」):
63
+
64
+ | 子命令 | 候選範圍 | 歧義處理 |
65
+ |--------|----------|----------|
66
+ | `install` | 可安裝/可重裝的 plugin(**不含 Unavailable Entry**) | 名稱在完整 enumeration 唯一=插入名稱;同名(含 unavailable sibling)=插入 enumeration 編號(`#N`),描述顯示 `[marketplace]` 與狀態 |
67
+ | `enable` | 僅**已停用**的 Installation | 名稱無法唯一解析的記錄不給候選 |
68
+ | `disable` | 僅**已啟用**的 Installation | 同上 |
69
+ | `remove` | 全部已安裝 plugin(不分啟用/停用) | 同上 |
70
+ | `list` | Marketplace Registrations | 名稱無法唯一解析=依序改插唯一可解析的 alias、其次 Registration id |
71
+ | `forget` | Marketplace Registrations | 同上 |
72
+ | `add` | **不提供 Bridge 候選**:Tab 委派 Pi 原生路徑 completion,Git locator 維持自由輸入 | — |
73
+
74
+ 補完只提議當下可執行的動作,候選反映最新 Bridge State,且**被動讀取**——按 Tab 絕不會重置或重寫損壞的 state 文件。其餘輸入(其他 slash 指令、一般文字、檔案/路徑補完)一律原樣委派 Pi 既有 provider;安裝本套件不影響任何其他指令的 autocomplete。
75
+
76
+ > 沒有 custom TUI、沒有自動第二層 selector:所有操作也都可以照舊以純文字輸入完成,autocomplete 只是 discoverability 與輸入效率層。
77
+
56
78
  ### 私有 Git repo:Credentialed Acquisition(核准式取得)
57
79
 
58
80
  對私有 HTTPS repo,`add`/`update` **開箱即用**:預設會自動偵測本機已存在的憑證來源並逐次核准(**固定白名單**,不讀本機 gitconfig 的任意 helper;偵測結果只限該次呼叫、永不持久化):
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Narrow Bridge autocomplete provider — thin Pi adapter (TUI session only) (#119, #121–#124).
3
+ *
4
+ * Pi 0.84.2's built-in combined provider completes the slash-command name and inserts a
5
+ * trailing space without exposing empty-prefix argument completion (root subcommands) when
6
+ * the editor holds exactly `/codex-marketplace`. This wrapper intercepts that exact editor
7
+ * content and presents the nine root candidates. It also owns the second-level argument
8
+ * contexts (`install` #122, the Installation lifecycle `enable` / `disable` / `remove` #123,
9
+ * and the Marketplace Registration `list` / `forget` #124) on forced (Tab) requests, which
10
+ * Pi 0.84.2 routes to file completion instead of slash-command argument completion (its
11
+ * argument path only runs when `force` is false) — the wrapper returns the state-aware
12
+ * candidates there. `add` stays free-form (#124): its forced Tab keeps Pi's native
13
+ * filesystem completion, and a typed Git locator or path is never constrained by Bridge
14
+ * candidates. Everything else — other slash commands, text, file/path completion, suggestion
15
+ * generation and completion application — delegates unchanged to the host's current provider.
16
+ *
17
+ * The wrapper is installed from a `session_start` handler via `ctx.ui.addAutocompleteProvider`,
18
+ * which interactive (TUI) mode wires into the editor and RPC/JSON/print modes no-op, so a
19
+ * terminal-only provider is never registered outside a TUI session.
20
+ */
21
+
22
+ import type { AutocompleteProvider } from '@earendil-works/pi-tui';
23
+
24
+ import { completeArguments, type CompletionReadOptions } from '../../src/bridge/completion.js';
25
+
26
+ /** The exact editor content this provider owns. */
27
+ export const EXACT_COMMAND = '/codex-marketplace';
28
+
29
+ /**
30
+ * The Bridge-owned second-level argument contexts on forced requests: `install` plus the
31
+ * installation lifecycle actions plus the Marketplace Registration commands — each followed
32
+ * by the trailing space root candidates insert. `add` is deliberately absent (#124): its
33
+ * argument is an arbitrary path or Git locator that stays under Pi's native completion.
34
+ */
35
+ export const SECOND_LEVEL_ARGUMENT_RE = /^\/codex-marketplace (?:install|list|forget|enable|disable|remove) /;
36
+
37
+ /**
38
+ * Wrap the host's current autocomplete provider. Suggestion generation intercepts only the
39
+ * Bridge-owned editor contexts (the exact `/codex-marketplace` line and the forced `install `
40
+ * argument context); completion application and file-trigger decisions delegate to the
41
+ * current provider's semantics, so selection behavior stays Pi-native.
42
+ */
43
+ export function createBridgeAutocompleteProvider(
44
+ current: AutocompleteProvider,
45
+ readOptions: CompletionReadOptions = {},
46
+ ): AutocompleteProvider {
47
+ return {
48
+ async getSuggestions(lines, cursorLine, cursorCol, options) {
49
+ const line = lines[cursorLine] ?? '';
50
+ // Root-level interception (#121): only when the editor content is exactly the complete
51
+ // command text — a mid-command cursor or trailing text after the command is not
52
+ // "editor 內容恰為 /codex-marketplace" and must fall through to the host provider.
53
+ if (line === EXACT_COMMAND && cursorCol === EXACT_COMMAND.length) {
54
+ const items = completeArguments('', readOptions);
55
+ if (items && items.length > 0) {
56
+ // The intercepted prefix is the whole line before the cursor (prefix ''), so the
57
+ // insertion value must carry the separator space the editor content lacks.
58
+ return {
59
+ items: items.map((item) => ({ ...item, value: ` ${item.value}` })),
60
+ prefix: '',
61
+ };
62
+ }
63
+ }
64
+ // Second-level argument interception (#122, #123, #124): forced (Tab) requests inside a
65
+ // Bridge-owned argument context (`install ` / `enable ` / `disable ` / `remove ` /
66
+ // `list ` / `forget `) are Bridge-owned — the host's combined provider would route them
67
+ // to file completion. Natural typing (force=false) is delegated and reaches the same
68
+ // candidates through the command's getArgumentCompletions. Like the root branch, the
69
+ // cursor must be at the end of the line: a mid-line cursor with trailing text would
70
+ // otherwise produce a malformed line after applying a candidate. The prefix returned is
71
+ // the text after the command name (`install <query>` etc.), matching the host's
72
+ // argument-text semantics. `add ` is not Bridge-owned (#124), so a forced Tab there
73
+ // falls through to the host provider's filesystem completion unchanged.
74
+ if (options.force && cursorCol === line.length && SECOND_LEVEL_ARGUMENT_RE.test(line)) {
75
+ const argumentText = line.slice(EXACT_COMMAND.length + 1, cursorCol);
76
+ const items = completeArguments(argumentText, readOptions);
77
+ if (items) {
78
+ return { items, prefix: argumentText };
79
+ }
80
+ }
81
+ return current.getSuggestions(lines, cursorLine, cursorCol, options);
82
+ },
83
+ applyCompletion(lines, cursorLine, cursorCol, item, prefix) {
84
+ return current.applyCompletion(lines, cursorLine, cursorCol, item, prefix);
85
+ },
86
+ shouldTriggerFileCompletion(lines, cursorLine, cursorCol) {
87
+ return current.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ?? true;
88
+ },
89
+ };
90
+ }
@@ -14,8 +14,22 @@ import type { ExtensionAPI, ExtensionCommandContext } from '@earendil-works/pi-c
14
14
 
15
15
  import { runCommand } from '../../src/bridge/command.js';
16
16
  import { discoverProjectedSkillPaths } from '../../src/projection/exposure.js';
17
+ import { completeArguments } from '../../src/bridge/completion.js';
18
+ import { createBridgeAutocompleteProvider } from './autocomplete.js';
17
19
 
18
20
  export default function (pi: ExtensionAPI) {
21
+ // Root-level autocomplete (#121) + second-level autocomplete (#122–#124): stack a narrow
22
+ // provider for the exact `/codex-marketplace` editor text and the forced `install `,
23
+ // Installation lifecycle (`enable ` / `disable ` / `remove `), and Marketplace Registration
24
+ // (`list ` / `forget `) argument contexts (Pi otherwise completes just the command name
25
+ // plus a space, and routes forced argument Tabs to file completion). `add ` stays free-form
26
+ // (#124): forced Tabs keep Pi's native filesystem completion and typed Git locators are
27
+ // never constrained. session_start supplies the full TUI ui context;
28
+ // RPC/JSON/print modes no-op ctx.ui.addAutocompleteProvider, so a terminal-only provider is
29
+ // never registered outside a TUI session and command execution is unchanged.
30
+ pi.on('session_start', (_event, ctx) => {
31
+ ctx.ui.addAutocompleteProvider((current) => createBridgeAutocompleteProvider(current));
32
+ });
19
33
  // Runtime Skill Exposure (ADR 0001): contribute Projected Skills through Pi's
20
34
  // resource-discovery seam at every startup and reload. Passive existence inspection over the
21
35
  // current Effective State only — no fingerprint validation and no Bridge State mutation.
@@ -33,6 +47,11 @@ export default function (pi: ExtensionAPI) {
33
47
 
34
48
  pi.registerCommand('codex-marketplace', {
35
49
  description: 'codex / claude marketplace 管理(add/list/install/update/disable/enable/remove/forget/help)',
50
+ // Standard argument completion (#121–#124): typed subcommand and `install <query>` /
51
+ // lifecycle `<query>` / Registration (`list` / `forget`) `<query>` prefixes go through
52
+ // Pi's normal autocomplete; unowned syntax (including `add`'s free-form argument) returns
53
+ // null so Pi falls through to its own behavior.
54
+ getArgumentCompletions: (argumentPrefix: string) => completeArguments(argumentPrefix),
36
55
  handler: async (args: string, ctx: ExtensionCommandContext) => {
37
56
  const rawArgs = (args ?? '').trim();
38
57
  const argv = rawArgs.length > 0 ? rawArgs.split(/\s+/) : [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-codex-marketplace",
3
- "version": "0.6.1",
3
+ "version": "0.7.0",
4
4
  "description": "Bridge Package for Codex and Claude Marketplace compatibility in Pi — 極簡 /codex-marketplace 純文字指令(add/list/install/update/disable/enable/remove/forget)、單一 Global Bridge State、當下最新安裝與即時投影",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -16,19 +16,22 @@ import {
16
16
  readMinimalBridgeState,
17
17
  writeMinimalBridgeState,
18
18
  type MinimalBridgeState,
19
- type MarketplaceFormat,
20
19
  } from './state.js';
21
- import { BUDGET } from '../registration/budget.js';
22
20
  import { localSourceKey } from '../registration/source-key.js';
23
21
  import {
24
- catalogContractFor,
25
22
  detectMarketplaceFormat,
26
23
  CODEX_MARKETPLACE_CATALOG_RELPATH,
27
24
  CLAUDE_MARKETPLACE_CATALOG_RELPATH,
28
25
  } from '../registration/format.js';
29
26
  import { resolveContained } from '../registration/contained.js';
30
- import { findEntryByManifestName, GIT_FAMILY_UNAVAILABLE_REASON, type Catalog, type MarketplaceEntry } from '../registration/catalog.js';
31
- import type { ValidationFinding } from '../registration/findings.js';
27
+ import { findEntryByManifestName } from '../registration/catalog.js';
28
+ import {
29
+ queryMarketplacePlugins,
30
+ readMarketplaceCatalog,
31
+ resolveMarketplaceRoot,
32
+ type MarketplaceCatalogReadResult,
33
+ type MarketplacePluginCandidate,
34
+ } from './plugin-query.js';
32
35
  import { normalizeGitLocator } from '../registration/git-locator.js';
33
36
  import { acquireGitSource, cleanupAcquisition, type GitExecutor } from '../registration/git-acquisition.js';
34
37
  import {
@@ -39,7 +42,6 @@ import {
39
42
  import { gitSourceKey } from '../registration/source-key.js';
40
43
  import { buildGitSnapshot } from '../registration/snapshot.js';
41
44
  import { SourceCache } from '../cache/source-cache.js';
42
- import { getCacheDir, getCacheEntriesDir } from '../cache/paths.js';
43
45
 
44
46
  export interface CommandOptions {
45
47
  statePath?: string;
@@ -61,7 +63,9 @@ export interface CommandResult {
61
63
 
62
64
  const USAGE_LINE = '用法:/codex-marketplace <add|list|install|update|disable|enable|remove|forget|help>';
63
65
 
64
- const HELP_TEXT = [
66
+ // Exported so the Bridge completion seam can be verified against the same description
67
+ // vocabulary it mirrors (#121) — alignment is pinned by tests, not by manual syncing.
68
+ export const HELP_TEXT = [
65
69
  '用法:/codex-marketplace <子命令> [參數]',
66
70
  '',
67
71
  '子命令:',
@@ -94,29 +98,6 @@ function expandOwnerRepo(input: string): string {
94
98
  return `https://github.com/${input.trim()}`;
95
99
  }
96
100
 
97
- function catalogRootForReg(
98
- reg: MinimalBridgeState['registrations'][number],
99
- opts: CommandOptions,
100
- ): string | undefined {
101
- if (reg.sourceKind === 'git') {
102
- const snap = (reg as unknown as { snapshot?: string }).snapshot;
103
- if (!snap || !/^[0-9a-f]{64}$/.test(snap)) return undefined;
104
- const entriesDir = getCacheEntriesDir(getCacheDir(opts.agentDir));
105
- const p = join(entriesDir, snap);
106
- if (!existsSync(p)) return undefined;
107
- return p;
108
- }
109
- return reg.source;
110
- }
111
-
112
- function sourceRootForReg(
113
- reg: MinimalBridgeState['registrations'][number],
114
- opts: CommandOptions,
115
- ): string | undefined {
116
- if (reg.sourceKind === 'git') return catalogRootForReg(reg, opts);
117
- return reg.source;
118
- }
119
-
120
101
  function padRight(str: string, length: number): string {
121
102
  return str.length >= length ? str : str + ' '.repeat(length - str.length);
122
103
  }
@@ -173,138 +154,18 @@ function formatCatalogError(findings: { code?: string; outcome?: string; rule?:
173
154
  return detail;
174
155
  }
175
156
 
176
- // ---- Plugin enumeration helpers (#90, #91) ----
177
-
178
- interface EnumeratedPlugin {
179
- number: number;
180
- reg: MinimalBridgeState['registrations'][number];
181
- entry: MarketplaceEntry;
182
- pluginName: string;
183
- status: '可安裝' | '已裝啟用' | '已裝停用' | 'unavailable';
184
- unavailableReason?: string;
185
- }
186
-
187
- /**
188
- * Whether an entry can supply a locally installable plugin on the command surface. Git-family
189
- * and unsupported source kinds are Unavailable Entries (#91): disclosed with a reason, never
190
- * silently skipped and never installable.
191
- */
192
- function entryLocallyInstallable(entry: MarketplaceEntry): boolean {
193
- return (
194
- entry.type === 'local' && entry.available === true && typeof entry.path === 'string' && entry.path.length > 0
195
- );
196
- }
197
-
198
- function entryUnavailableReason(entry: MarketplaceEntry): string {
199
- if (entry.type === 'git' && entry.available !== false) return GIT_FAMILY_UNAVAILABLE_REASON;
200
- return entry.unavailableReason ?? 'unsupported source kind';
201
- }
202
-
203
- interface CatalogReadResult {
204
- /** Parsed catalog on success (name + entries); also present for some parse failures. */
205
- catalog?: Catalog;
206
- /** Structural findings from the format-bound parser (present for parse failures too). */
207
- findings: ValidationFinding[];
208
- /** Disclosed read failure (catalog 缺失/超上限/malformed); never silently skipped. */
209
- error?: string;
210
- }
211
-
212
- /**
213
- * The single catalog reader for the command surface: budget-bounded read + format-bound parse.
214
- * Read failures produce a disclosed `error` instead of silently skipping (#91).
215
- */
216
- function readCatalogForReg(root: string, format: MarketplaceFormat): CatalogReadResult {
217
- const contract = catalogContractFor(format);
218
- const catalogPath = join(root, ...contract.relPath.split('/'));
219
- let raw: string;
220
- try {
221
- if (!existsSync(catalogPath)) {
222
- return { findings: [], error: `catalog 缺失(${contract.relPath})` };
223
- }
224
- const size = statSync(catalogPath).size;
225
- if (size > BUDGET.maxCatalogBytes) {
226
- return {
227
- findings: [],
228
- error: `catalog 檔案過大(${size} bytes > ${BUDGET.maxCatalogBytes})— 超過 Validation Budget 上限,catalog 無法解析`,
229
- };
230
- }
231
- raw = readFileSync(catalogPath, 'utf-8');
232
- } catch (e) {
233
- const msg = e instanceof Error ? e.message : String(e);
234
- return { findings: [], error: `catalog 無法讀取:${msg}` };
235
- }
236
- if (!raw.trim()) {
237
- return { findings: [], error: `catalog 解析失敗:檔案為空 — catalog malformed` };
238
- }
239
- let parsed: unknown;
240
- try {
241
- parsed = JSON.parse(raw);
242
- } catch (e) {
243
- const msg = e instanceof Error ? e.message : String(e);
244
- return { findings: [], error: `catalog 解析失敗:${msg} — catalog malformed` };
245
- }
246
- const res = contract.parse(parsed);
247
- if (!res.ok) {
248
- const codes = res.findings.map((f) => f.code).join(', ');
249
- return {
250
- catalog: res.catalog,
251
- findings: res.findings,
252
- error: `catalog 解析失敗${codes ? ` (${codes})` : ''} — catalog malformed`,
253
- };
254
- }
255
- return { catalog: res.catalog, findings: res.findings };
256
- }
257
-
258
- interface Enumeration {
259
- plugins: EnumeratedPlugin[];
260
- catalogErrors: Array<{ marketplace: string; error: string }>;
261
- }
157
+ // ---- Plugin enumeration presentation (#90, #91, #120) ----
262
158
 
263
- function enumeratePlugins(state: MinimalBridgeState, opts: CommandOptions = {}): Enumeration {
264
- const result: EnumeratedPlugin[] = [];
265
- const catalogErrors: Enumeration['catalogErrors'] = [];
266
- let counter = 1;
267
- for (const reg of state.registrations) {
268
- const catalogRoot = catalogRootForReg(reg, opts);
269
- if (!catalogRoot) {
270
- const isGit = reg.sourceKind === 'git';
271
- const snap = (reg as unknown as { snapshot?: string }).snapshot;
272
- const reason = isGit
273
- ? (!snap ? 'git marketplace 缺少 cache 指紋' : `cache 快照缺失(${snap.slice(0, 12)}…)`)
274
- : 'catalog 根路徑無法解析';
275
- catalogErrors.push({ marketplace: reg.marketplaceName || reg.alias || reg.id, error: reason });
276
- continue;
277
- }
278
- const read = readCatalogForReg(catalogRoot, reg.format ?? 'codex');
279
- if (read.error) {
280
- // Disclosed read failure (#91): the error line is shown and the marketplace contributes
281
- // no entries — a broken catalog never yields installable plugins.
282
- catalogErrors.push({ marketplace: reg.marketplaceName || reg.alias || reg.id, error: read.error });
283
- continue;
284
- }
285
- for (const entry of read.catalog?.entries ?? []) {
286
- const pluginName = entry.name ?? (entry.path ? basename(entry.path) : `plugin-${entry.ordinal}`);
287
- const inst = state.installations.find(
288
- (i) => i.registrationId === reg.id && (i.manifestName === pluginName || i.pluginId === pluginName),
289
- );
290
- let status: EnumeratedPlugin['status'];
291
- let unavailableReason: string | undefined;
292
- if (!entryLocallyInstallable(entry)) {
293
- status = 'unavailable';
294
- unavailableReason = entryUnavailableReason(entry);
295
- } else if (!inst) status = '可安裝';
296
- else if (isInstallationEnabled(inst as any)) status = '已裝啟用';
297
- else status = '已裝停用';
298
- result.push({ number: counter, reg, entry, pluginName, status, unavailableReason });
299
- counter++;
300
- }
301
- }
302
- return { plugins: result, catalogErrors };
159
+ function pluginStatus(plugin: MarketplacePluginCandidate): '可安裝' | '已裝啟用' | '已裝停用' | 'unavailable' {
160
+ if (!plugin.structurallyInstallable) return 'unavailable';
161
+ if (plugin.installationState === 'enabled') return '已裝啟用';
162
+ if (plugin.installationState === 'disabled') return '已裝停用';
163
+ return '可安裝';
303
164
  }
304
165
 
305
166
  function formatPluginListLines(state: MinimalBridgeState, filter?: string, opts: CommandOptions = {}): string[] {
306
- const { plugins: enumeration, catalogErrors } = enumeratePlugins(state, opts);
307
- const errorLines = catalogErrors.map((e) => `⚠ marketplace [${e.marketplace}] ${e.error}`);
167
+ const { plugins: enumeration, diagnostics } = queryMarketplacePlugins(state, opts);
168
+ const errorLines = diagnostics.map((diagnostic) => `⚠ marketplace [${diagnostic.marketplace}] ${diagnostic.error}`);
308
169
  if (enumeration.length === 0) {
309
170
  // No plugins at all (e.g., empty catalog, no registrations, or unreadable catalogs):
310
171
  // read failures are disclosed, never silently skipped (#91).
@@ -313,7 +174,9 @@ function formatPluginListLines(state: MinimalBridgeState, filter?: string, opts:
313
174
  let filtered = enumeration;
314
175
  if (filter) {
315
176
  filtered = enumeration.filter(
316
- (e) => e.reg.marketplaceName === filter || e.reg.alias === filter || e.reg.id === filter,
177
+ (plugin) => plugin.registration.marketplaceName === filter
178
+ || plugin.registration.alias === filter
179
+ || plugin.registration.id === filter,
317
180
  );
318
181
  if (filtered.length === 0) {
319
182
  return [
@@ -324,11 +187,14 @@ function formatPluginListLines(state: MinimalBridgeState, filter?: string, opts:
324
187
  }
325
188
  const lines: string[] = ['Plugins(編號/所屬 marketplace/狀態)'];
326
189
  const show = filter ? filtered : enumeration;
327
- for (const e of show) {
328
- const num = padRight(` ${e.number}`, 4);
329
- const name = padRight(e.pluginName, 18);
330
- const mkt = padRight(`[${e.reg.marketplaceName || e.reg.alias || e.reg.id}]`, 20);
331
- const status = e.status === 'unavailable' ? `unavailable(${e.unavailableReason ?? 'unavailable'})` : e.status;
190
+ for (const plugin of show) {
191
+ const num = padRight(` ${plugin.number}`, 4);
192
+ const name = padRight(plugin.candidateName, 18);
193
+ const mkt = padRight(`[${plugin.marketplaceName}]`, 20);
194
+ const stateLabel = pluginStatus(plugin);
195
+ const status = stateLabel === 'unavailable'
196
+ ? `unavailable(${plugin.unavailableReason ?? 'unavailable'})`
197
+ : stateLabel;
332
198
  lines.push(`${num}${name}${mkt}${status}`);
333
199
  }
334
200
  lines.push(...errorLines);
@@ -367,21 +233,20 @@ function findRegistrationIndex(state: MinimalBridgeState, name: string): number
367
233
  }
368
234
 
369
235
  /**
370
- * Best-effort reread for enable: mirrors install Step1-3 (sourceRoot -> catalogRoot -> readCatalog -> entry -> resolveContained -> collectSkillNames).
236
+ * Best-effort reread for enable: mirrors install Step1-3 (Marketplace Root -> readCatalog -> entry -> resolveContained -> collectSkillNames).
371
237
  * Returns undefined on any missing cache/catalog/entry/path — caller falls back to stored skills.
372
- * Pure-logic branches (find/entry.path/resolveContained outcome) do not throw; only I/O (readCatalogForReg/collectSkillNames) is try/catch guarded.
238
+ * Pure-logic branches (find/entry.path/resolveContained outcome) do not throw; only I/O (readMarketplaceCatalog/collectSkillNames) is try/catch guarded.
373
239
  */
374
240
  function tryRereadSkills(
375
241
  reg: MinimalBridgeState['registrations'][number],
376
242
  inst: MinimalBridgeState['installations'][number],
377
243
  opts: CommandOptions,
378
244
  ): string[] | undefined {
379
- const sourceRoot = sourceRootForReg(reg, opts);
380
- const catalogRoot = catalogRootForReg(reg, opts);
381
- if (!sourceRoot || !catalogRoot) return undefined;
382
- let read: CatalogReadResult | undefined;
245
+ const marketplaceRoot = resolveMarketplaceRoot(reg, opts);
246
+ if (!marketplaceRoot) return undefined;
247
+ let read: MarketplaceCatalogReadResult | undefined;
383
248
  try {
384
- read = readCatalogForReg(catalogRoot, reg.format ?? 'codex');
249
+ read = readMarketplaceCatalog(marketplaceRoot, reg.format ?? 'codex');
385
250
  } catch {
386
251
  // best-effort: catalog 讀取/解析失敗(cache 缺失或損毀)則沿用舊 skills
387
252
  return undefined;
@@ -389,7 +254,7 @@ function tryRereadSkills(
389
254
  if (read.error || !read.catalog) return undefined;
390
255
  const entry = findEntryByManifestName(read.catalog, inst.manifestName);
391
256
  if (!entry?.path) return undefined;
392
- const contained = resolveContained(sourceRoot, entry.path, 'directory');
257
+ const contained = resolveContained(marketplaceRoot, entry.path, 'directory');
393
258
  if (contained.outcome.kind !== 'ok') return undefined;
394
259
  try {
395
260
  return collectSkillNames(contained.outcome.canonicalPath, (reg.format ?? 'codex') as 'codex' | 'claude');
@@ -426,7 +291,7 @@ interface PluginRereadOutcome {
426
291
  /**
427
292
  * 重裝=更新(#94):對已安裝 plugin 在最新材料root(本機 live 路徑或 git 新 cache entry)重讀
428
293
  * catalog → 重解析 manifest+skills,回傳刷新結果由呼叫端套用。純邏輯分支不拋;
429
- * 僅 I/O(readCatalogForReg/readManifestName/collectSkillNames)由各函式內部 try/catch 守護。
294
+ * 僅 I/O(readMarketplaceCatalog/readManifestName/collectSkillNames)由各函式內部 try/catch 守護。
430
295
  */
431
296
  function rereadInstalledPlugin(
432
297
  root: string,
@@ -434,7 +299,7 @@ function rereadInstalledPlugin(
434
299
  inst: MinimalBridgeState['installations'][number],
435
300
  installations: MinimalBridgeState['installations'],
436
301
  ): PluginRereadOutcome {
437
- const read = readCatalogForReg(root, format);
302
+ const read = readMarketplaceCatalog(root, format);
438
303
  if (read.error || !read.catalog) {
439
304
  return {
440
305
  ok: false,
@@ -725,7 +590,7 @@ export async function runCommand(
725
590
  break;
726
591
  }
727
592
 
728
- const catalogResult = readCatalogForReg(acquiredPath, detectedFormat);
593
+ const catalogResult = readMarketplaceCatalog(acquiredPath, detectedFormat);
729
594
  if (catalogResult.error || !catalogResult.catalog) {
730
595
  messages.push(`錯誤:${catalogResult.error ?? 'catalog 解析失敗'}`);
731
596
  if (catalogResult.findings.length > 0) {
@@ -821,7 +686,7 @@ export async function runCommand(
821
686
  break;
822
687
  }
823
688
 
824
- const catalogResult = readCatalogForReg(canonicalPath, detectedFormat);
689
+ const catalogResult = readMarketplaceCatalog(canonicalPath, detectedFormat);
825
690
  if (catalogResult.error || !catalogResult.catalog) {
826
691
  messages.push(`錯誤:${catalogResult.error ?? 'catalog 解析失敗'}`);
827
692
  if (catalogResult.findings.length > 0) {
@@ -910,9 +775,11 @@ export async function runCommand(
910
775
  messages.push('錯誤:尚未註冊任何 marketplace,請先使用 `/codex-marketplace add <路徑>` 註冊');
911
776
  break;
912
777
  }
913
- const { plugins: enumeration, catalogErrors } = enumeratePlugins(state, opts);
778
+ const { plugins: enumeration, diagnostics } = queryMarketplacePlugins(state, opts);
914
779
  const pushCatalogErrors = (): void => {
915
- for (const e of catalogErrors) messages.push(`⚠ marketplace [${e.marketplace}] ${e.error}`);
780
+ for (const diagnostic of diagnostics) {
781
+ messages.push(`⚠ marketplace [${diagnostic.marketplace}] ${diagnostic.error}`);
782
+ }
916
783
  };
917
784
  if (enumeration.length === 0) {
918
785
  messages.push('錯誤:目前沒有可安裝的 plugin(marketplace 內無可用 entry)');
@@ -920,7 +787,7 @@ export async function runCommand(
920
787
  break;
921
788
  }
922
789
 
923
- let target: EnumeratedPlugin | undefined;
790
+ let target: MarketplacePluginCandidate | undefined;
924
791
  const num = Number(arg);
925
792
  const isNumeric = !isNaN(num) && String(num) === arg && Number.isInteger(num) && num >= 1;
926
793
  if (isNumeric) {
@@ -931,8 +798,8 @@ export async function runCommand(
931
798
  break;
932
799
  }
933
800
  } else {
934
- // treat as name: match pluginName exactly
935
- const candidates = enumeration.filter((e) => e.pluginName === arg);
801
+ // treat as name: match the query's Plugin candidate name exactly
802
+ const candidates = enumeration.filter((plugin) => plugin.candidateName === arg);
936
803
  if (candidates.length === 0) {
937
804
  // Also try to match manifestName of already installed? But enumeration already covers all catalog entries.
938
805
  messages.push(`錯誤:找不到名稱 "${arg}" 對應的 plugin`);
@@ -941,32 +808,26 @@ export async function runCommand(
941
808
  }
942
809
  if (candidates.length > 1) {
943
810
  // Ambiguous: list candidates
944
- const list = candidates.map((c) => `${c.number}:${c.pluginName}[${c.reg.marketplaceName}]`).join('、');
811
+ const list = candidates
812
+ .map((candidate) => `${candidate.number}:${candidate.candidateName}[${candidate.registration.marketplaceName}]`)
813
+ .join('、');
945
814
  messages.push(`錯誤:名稱 "${arg}" 對應多個 plugin(${list}),請改用編號安裝`);
946
815
  break;
947
816
  }
948
817
  target = candidates[0];
949
818
  }
950
819
 
951
- const targetReg = target.reg;
820
+ const targetReg = target.registration;
952
821
  const targetEntry = target.entry;
953
822
 
954
823
  // ---- Step 1: Unavailable Entry refusal (#91) + contained path check ----
955
- if (!entryLocallyInstallable(targetEntry)) {
824
+ if (!target.structurallyInstallable) {
956
825
  messages.push(
957
- `錯誤:plugin "${target.pluginName}" unavailable,無法安裝:${entryUnavailableReason(targetEntry)}`,
826
+ `錯誤:plugin "${target.candidateName}" unavailable,無法安裝:${target.unavailableReason}`,
958
827
  );
959
828
  break;
960
829
  }
961
- const sourceRoot = sourceRootForReg(targetReg, opts);
962
- if (!sourceRoot) {
963
- const snap = (targetReg as unknown as { snapshot?: string }).snapshot;
964
- const reason = targetReg.sourceKind === 'git'
965
- ? (!snap ? 'git cache 指紋缺失' : `cache 快照缺失(${snap.slice(0, 12)}…)請先重新 add`)
966
- : 'marketplace 根路徑無法解析';
967
- messages.push(`錯誤:無法解析 marketplace 根 — ${reason}`);
968
- break;
969
- }
830
+ const sourceRoot = target.marketplaceRoot;
970
831
  const contained = resolveContained(sourceRoot, targetEntry.path!, 'directory');
971
832
  if (contained.outcome.kind === 'blocking') {
972
833
  messages.push(`錯誤:plugin 路徑檢查失敗(Contained Path 違規)— ${contained.outcome.reason}`);
@@ -1176,7 +1037,7 @@ export async function runCommand(
1176
1037
  gitAdvanced = true;
1177
1038
 
1178
1039
  // 重裝=更新:從最新 cache 材料重裝全部已安裝 plugin(投影直讀新位址)
1179
- const cacheRoot = catalogRootForReg(reg, opts);
1040
+ const cacheRoot = resolveMarketplaceRoot(reg, opts);
1180
1041
  if (cacheRoot) {
1181
1042
  for (const inst of insts) {
1182
1043
  const outcome = rereadInstalledPlugin(cacheRoot, format, inst, state.installations);
@@ -1212,7 +1073,7 @@ export async function runCommand(
1212
1073
  continue;
1213
1074
  }
1214
1075
  // 先 probe catalog:不可讀時不能聲稱「無變化」,必須明示(不靜默略過)
1215
- const probe = readCatalogForReg(reg.source, format);
1076
+ const probe = readMarketplaceCatalog(reg.source, format);
1216
1077
  if (probe.error) {
1217
1078
  updateLines.push(`⚠ marketplace [${display}] ${probe.error}`);
1218
1079
  continue;