pi-codex-marketplace 0.6.2 → 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 +22 -0
- package/extensions/pi/autocomplete.ts +90 -0
- package/extensions/pi/index.ts +19 -0
- package/package.json +1 -1
- package/src/bridge/command.ts +57 -196
- package/src/bridge/completion.ts +521 -0
- package/src/bridge/plugin-query.ts +233 -0
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
|
+
}
|
package/extensions/pi/index.ts
CHANGED
|
@@ -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.
|
|
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": [
|
package/src/bridge/command.ts
CHANGED
|
@@ -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
|
|
31
|
-
import
|
|
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
|
-
|
|
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
|
|
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
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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,
|
|
307
|
-
const errorLines =
|
|
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
|
-
(
|
|
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
|
|
328
|
-
const num = padRight(` ${
|
|
329
|
-
const name = padRight(
|
|
330
|
-
const mkt = padRight(`[${
|
|
331
|
-
const
|
|
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 (
|
|
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 (
|
|
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
|
|
380
|
-
|
|
381
|
-
|
|
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 =
|
|
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(
|
|
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(
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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,
|
|
778
|
+
const { plugins: enumeration, diagnostics } = queryMarketplacePlugins(state, opts);
|
|
914
779
|
const pushCatalogErrors = (): void => {
|
|
915
|
-
for (const
|
|
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:
|
|
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
|
|
935
|
-
const candidates = enumeration.filter((
|
|
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
|
|
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.
|
|
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 (!
|
|
824
|
+
if (!target.structurallyInstallable) {
|
|
956
825
|
messages.push(
|
|
957
|
-
`錯誤:plugin "${target.
|
|
826
|
+
`錯誤:plugin "${target.candidateName}" unavailable,無法安裝:${target.unavailableReason}`,
|
|
958
827
|
);
|
|
959
828
|
break;
|
|
960
829
|
}
|
|
961
|
-
const sourceRoot =
|
|
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 =
|
|
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 =
|
|
1076
|
+
const probe = readMarketplaceCatalog(reg.source, format);
|
|
1216
1077
|
if (probe.error) {
|
|
1217
1078
|
updateLines.push(`⚠ marketplace [${display}] ${probe.error}`);
|
|
1218
1079
|
continue;
|
|
@@ -0,0 +1,521 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bridge completion seam (#121, #122, #123, #124).
|
|
3
|
+
*
|
|
4
|
+
* Pure autocomplete for `/codex-marketplace`: root-level subcommands (#121), state-aware
|
|
5
|
+
* second-level `install` candidates (#122), Installation lifecycle `enable` / `disable` /
|
|
6
|
+
* `remove` candidates (#123), and Marketplace Registration candidates for `list` / `forget`
|
|
7
|
+
* (#124). This module owns no terminal, TUI, rendering, or Pi host types: its input is the
|
|
8
|
+
* complete argument prefix plus replaceable read-only options, and its output is only the
|
|
9
|
+
* insertion value, display label, and optional description that Pi autocomplete needs — or
|
|
10
|
+
* `null` when the argument prefix is not Bridge-owned syntax.
|
|
11
|
+
*
|
|
12
|
+
* Not owned: the `add` argument (arbitrary path or Git locator — free-form by design #124),
|
|
13
|
+
* arbitrary text, file and path completion. When the module does not own the syntax, callers
|
|
14
|
+
* fall through to Pi's normal behavior unchanged.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { queryMarketplacePlugins, type MarketplacePluginInstallationState } from './plugin-query.js';
|
|
18
|
+
import {
|
|
19
|
+
isInstallationEnabled,
|
|
20
|
+
readMinimalBridgeStatePassive,
|
|
21
|
+
type MinimalBridgeState,
|
|
22
|
+
type MinimalInstallation,
|
|
23
|
+
} from './state.js';
|
|
24
|
+
|
|
25
|
+
export interface CompletionItem {
|
|
26
|
+
/** Insertion value. Argument-taking subcommands carry a trailing space (#121). */
|
|
27
|
+
value: string;
|
|
28
|
+
/** Display label shown in Pi's candidate list. */
|
|
29
|
+
label: string;
|
|
30
|
+
/** Optional short description, matching the command surface's HELP_TEXT vocabulary. */
|
|
31
|
+
description?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Replaceable read-only options for composing candidates (Bridge State read seam).
|
|
36
|
+
*
|
|
37
|
+
* Root-level (#121) candidates never read Bridge State; state-aware `install` candidates
|
|
38
|
+
* (#122) read it passively through `readMinimalBridgeStatePassive`, so a damaged document is
|
|
39
|
+
* never written, reset, or repaired (#119 stories 22–23). The adapter passes them through
|
|
40
|
+
* from registrable sources only.
|
|
41
|
+
*/
|
|
42
|
+
export interface CompletionReadOptions {
|
|
43
|
+
statePath?: string;
|
|
44
|
+
agentDir?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface RootCommandCandidate {
|
|
48
|
+
label: string;
|
|
49
|
+
description: string;
|
|
50
|
+
/** Whether applying the subcommand expects an argument (inserts a trailing space). */
|
|
51
|
+
takesArgument: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The nine root subcommands, descriptions aligned with the command surface's HELP_TEXT vocabulary. */
|
|
55
|
+
const ROOT_CANDIDATES: RootCommandCandidate[] = [
|
|
56
|
+
{ label: 'add', description: '註冊 marketplace', takesArgument: true },
|
|
57
|
+
{ label: 'list', description: '列出 plugins', takesArgument: true },
|
|
58
|
+
{ label: 'install', description: '安裝最新版本並立即啟用', takesArgument: true },
|
|
59
|
+
{ label: 'update', description: '全部更新', takesArgument: false },
|
|
60
|
+
{ label: 'disable', description: '停用 plugin(不再投影)', takesArgument: true },
|
|
61
|
+
{ label: 'enable', description: '啟用 plugin(恢復投影)', takesArgument: true },
|
|
62
|
+
{ label: 'remove', description: '移除 plugin', takesArgument: true },
|
|
63
|
+
{ label: 'forget', description: '移除 marketplace(含其全部安裝)', takesArgument: true },
|
|
64
|
+
{ label: 'help', description: '這份說明清單', takesArgument: false },
|
|
65
|
+
];
|
|
66
|
+
|
|
67
|
+
function toItem(candidate: RootCommandCandidate): CompletionItem {
|
|
68
|
+
return {
|
|
69
|
+
value: candidate.takesArgument ? `${candidate.label} ` : candidate.label,
|
|
70
|
+
label: candidate.label,
|
|
71
|
+
description: candidate.description,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Scoring: earlier first-match wins, then a tighter char span within the label. */
|
|
76
|
+
const FIRST_MATCH_WEIGHT = 1000;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Case-insensitive non-contiguous fuzzy match: every query character must appear in order
|
|
80
|
+
* within the label. The score prefers earlier first-match position then a tighter span.
|
|
81
|
+
*/
|
|
82
|
+
function fuzzyScore(query: string, label: string): number | null {
|
|
83
|
+
const q = query.toLowerCase();
|
|
84
|
+
const target = label.toLowerCase();
|
|
85
|
+
let queryIndex = 0;
|
|
86
|
+
let firstMatch = -1;
|
|
87
|
+
let lastMatch = -1;
|
|
88
|
+
for (let i = 0; i < target.length && queryIndex < q.length; i += 1) {
|
|
89
|
+
if (target[i] === q[queryIndex]) {
|
|
90
|
+
if (firstMatch === -1) firstMatch = i;
|
|
91
|
+
lastMatch = i;
|
|
92
|
+
queryIndex += 1;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (queryIndex < q.length) return null;
|
|
96
|
+
return firstMatch * FIRST_MATCH_WEIGHT + (lastMatch - firstMatch);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The owned second-level syntax: `install` followed by whitespace and a single-token query
|
|
101
|
+
* (plugin names are lowercase kebab-case; a second token is not Bridge-owned and falls
|
|
102
|
+
* through). `install` without a trailing space stays at the root level so the trailing-space
|
|
103
|
+
* root candidate can be applied first.
|
|
104
|
+
*/
|
|
105
|
+
const INSTALL_SECOND_LEVEL_RE = /^install\s+(\S*)$/;
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The owned Installation lifecycle second-level syntax (#123): `enable` / `disable` /
|
|
109
|
+
* `remove` followed by whitespace and a single-token query. Like `install`, each command
|
|
110
|
+
* without a trailing space stays at the root level; a second token is not Bridge-owned.
|
|
111
|
+
*/
|
|
112
|
+
const LIFECYCLE_SECOND_LEVEL_RE = /^(enable|disable|remove)\s+(\S*)$/;
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The owned Registration second-level syntax (#124): `list` / `forget` followed by
|
|
116
|
+
* whitespace and a single-token query. Both commands resolve their argument against the same
|
|
117
|
+
* Registration identity fields (marketplaceName OR alias OR id). Each command without a
|
|
118
|
+
* trailing space stays at the root level; a second token is not Bridge-owned. `add` is
|
|
119
|
+
* deliberately absent: its argument is free-form input (path or Git locator) and stays
|
|
120
|
+
* Pi-native.
|
|
121
|
+
*/
|
|
122
|
+
const REGISTRATION_SECOND_LEVEL_RE = /^(list|forget)\s+(\S*)$/;
|
|
123
|
+
|
|
124
|
+
export type LifecycleAction = 'enable' | 'disable' | 'remove';
|
|
125
|
+
|
|
126
|
+
/** Status vocabulary aligned with the `list` command surface (#90). */
|
|
127
|
+
function installStatusLabel(state: MarketplacePluginInstallationState): string {
|
|
128
|
+
if (state === 'enabled') return '已裝啟用';
|
|
129
|
+
if (state === 'disabled') return '已裝停用';
|
|
130
|
+
return '可安裝';
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Positive canonical integer without leading zeros — the exact argument shape `install`
|
|
135
|
+
* parses as an enumeration number (`String(Number(arg)) === arg && Number.isInteger && >= 1`),
|
|
136
|
+
* so a unique plugin name with this shape can never be resolved by name.
|
|
137
|
+
*/
|
|
138
|
+
const CANONICAL_INTEGER_RE = /^[1-9]\d*$/;
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Whether a name-typed `install <name>` invocation would actually resolve this candidate
|
|
142
|
+
* name: it must be unique in the full enumeration, contain no whitespace (the command splits
|
|
143
|
+
* arguments on whitespace), and not look like a canonical integer (the command parses those
|
|
144
|
+
* as enumeration numbers). Otherwise the insertion must be the enumeration number instead.
|
|
145
|
+
*/
|
|
146
|
+
function nameInsertionUsable(unique: boolean, name: string): boolean {
|
|
147
|
+
return unique && !/\s/.test(name) && !CANONICAL_INTEGER_RE.test(name);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
interface InstallCandidate {
|
|
151
|
+
/** Plugin candidate name (entry name → path basename → ordinal fallback). */
|
|
152
|
+
name: string;
|
|
153
|
+
/** Marketplace provenance shown in the candidate description. */
|
|
154
|
+
marketplaceName: string;
|
|
155
|
+
/** 可安裝 / 已裝啟用 / 已裝停用 — install and reinstall are both selectable. */
|
|
156
|
+
status: string;
|
|
157
|
+
/** Case-insensitive fuzzy search target: plugin name + marketplace provenance. */
|
|
158
|
+
searchText: string;
|
|
159
|
+
/** Insertion token: the name when usable, else the enumeration number. */
|
|
160
|
+
insertion: string;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Compose install candidates from the shared Marketplace Plugin enumeration, restricted to
|
|
165
|
+
* structurally installable entries. Unavailable Entries (unsupported source, unresolvable
|
|
166
|
+
* source, invalid plugin, identity collision) never become candidates.
|
|
167
|
+
*
|
|
168
|
+
* Name insertion is allowed only when a name-typed `install <名稱>` would actually resolve
|
|
169
|
+
* the plugin: the candidate name must be unique against the *full* enumeration (the same
|
|
170
|
+
* domain `install <名稱>` resolves against — a same-named sibling, even an unavailable one,
|
|
171
|
+
* forces the number insertion because the name would be rejected as ambiguous), contain no
|
|
172
|
+
* whitespace, and not parse as a canonical enumeration number. The inserted number is the
|
|
173
|
+
* plugin's number in that full enumeration, matching `list` and `install <編號>` exactly.
|
|
174
|
+
*/
|
|
175
|
+
function composeInstallCandidates(state: MinimalBridgeState, options: CompletionReadOptions): InstallCandidate[] {
|
|
176
|
+
const { plugins } = queryMarketplacePlugins(state, { agentDir: options.agentDir });
|
|
177
|
+
const nameCounts = new Map<string, number>();
|
|
178
|
+
for (const plugin of plugins) {
|
|
179
|
+
nameCounts.set(plugin.candidateName, (nameCounts.get(plugin.candidateName) ?? 0) + 1);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const candidates: InstallCandidate[] = [];
|
|
183
|
+
for (const plugin of plugins) {
|
|
184
|
+
if (!plugin.structurallyInstallable) continue;
|
|
185
|
+
candidates.push({
|
|
186
|
+
name: plugin.candidateName,
|
|
187
|
+
marketplaceName: plugin.marketplaceName,
|
|
188
|
+
status: installStatusLabel(plugin.installationState),
|
|
189
|
+
searchText: `${plugin.candidateName} ${plugin.marketplaceName}`,
|
|
190
|
+
insertion: nameInsertionUsable((nameCounts.get(plugin.candidateName) ?? 0) === 1, plugin.candidateName)
|
|
191
|
+
? plugin.candidateName
|
|
192
|
+
: String(plugin.number),
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
return candidates;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Compose the candidate label. A name insertion shows the name itself; a number insertion
|
|
200
|
+
* keeps the enumeration number visible but labels the plugin so the user can tell which
|
|
201
|
+
* same-named entry each candidate selects (the description carries provenance + status).
|
|
202
|
+
*/
|
|
203
|
+
function installLabel(candidate: InstallCandidate): string {
|
|
204
|
+
return candidate.insertion === candidate.name
|
|
205
|
+
? candidate.name
|
|
206
|
+
: `${candidate.name} (#${candidate.insertion})`;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function toInstallItem(candidate: InstallCandidate): CompletionItem {
|
|
210
|
+
return {
|
|
211
|
+
value: `install ${candidate.insertion}`,
|
|
212
|
+
label: installLabel(candidate),
|
|
213
|
+
description: `[${candidate.marketplaceName}] ${candidate.status}`,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Second-level `install` candidates for `/codex-marketplace install <query>`.
|
|
219
|
+
*
|
|
220
|
+
* - Empty query → every currently installable or reinstallable plugin in enumeration order.
|
|
221
|
+
* - A query → case-insensitive fuzzy match over the plugin name and Marketplace provenance;
|
|
222
|
+
* `[]` when nothing matches (the syntax is still Bridge-owned, there are simply no
|
|
223
|
+
* candidates).
|
|
224
|
+
*
|
|
225
|
+
* The Bridge State read is passive: empty, damaged, unreadable, or incompatible state or
|
|
226
|
+
* marketplace material contributes no candidates and is never written, reset, or repaired.
|
|
227
|
+
*/
|
|
228
|
+
function completeInstallArguments(query: string, options: CompletionReadOptions): CompletionItem[] {
|
|
229
|
+
const state = readMinimalBridgeStatePassive({ statePath: options.statePath, agentDir: options.agentDir });
|
|
230
|
+
const candidates = composeInstallCandidates(state, options);
|
|
231
|
+
if (query.length === 0) {
|
|
232
|
+
return candidates.map(toInstallItem);
|
|
233
|
+
}
|
|
234
|
+
const scored: { item: CompletionItem; score: number }[] = [];
|
|
235
|
+
for (const candidate of candidates) {
|
|
236
|
+
const score = fuzzyScore(query, candidate.searchText);
|
|
237
|
+
if (score !== null) {
|
|
238
|
+
scored.push({ item: toInstallItem(candidate), score });
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
scored.sort((a, b) => a.score - b.score || a.item.label.localeCompare(b.item.label));
|
|
242
|
+
return scored.map((entry) => entry.item);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
interface LifecycleCandidate {
|
|
246
|
+
/** The Installation name the command surface resolves on (manifestName → pluginId → id). */
|
|
247
|
+
name: string;
|
|
248
|
+
/** Marketplace provenance shown in the candidate description. */
|
|
249
|
+
marketplaceName: string;
|
|
250
|
+
/** 已裝啟用 / 已裝停用 — the Installation lifecycle status (#123). */
|
|
251
|
+
status: string;
|
|
252
|
+
/** Case-insensitive fuzzy search target: plugin name + marketplace provenance. */
|
|
253
|
+
searchText: string;
|
|
254
|
+
/** Durable Installation state — consumed by the action filter, not rendered. */
|
|
255
|
+
enabled: boolean;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function lifecycleStatusLabel(enabled: boolean): string {
|
|
259
|
+
return enabled ? '已裝啟用' : '已裝停用';
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function installationName(inst: MinimalInstallation): string {
|
|
263
|
+
return inst.manifestName || inst.pluginId || inst.id;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Whether a name-typed `enable|disable|remove <name>` invocation would resolve exactly this
|
|
268
|
+
* Installation: the command matches `manifestName` OR `pluginId` OR `id` over every
|
|
269
|
+
* Installation (#93), so the token must be unique across all records, and must survive the
|
|
270
|
+
* command's whitespace token split.
|
|
271
|
+
*/
|
|
272
|
+
function lifecycleNameUsable(state: MinimalBridgeState, name: string): boolean {
|
|
273
|
+
if (name.length === 0 || /\s/.test(name)) return false;
|
|
274
|
+
let matches = 0;
|
|
275
|
+
for (const other of state.installations) {
|
|
276
|
+
if (other.manifestName === name || other.pluginId === name || other.id === name) matches += 1;
|
|
277
|
+
}
|
|
278
|
+
return matches === 1;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Compose Installation lifecycle candidates directly from Bridge State (#123). Lifecycle
|
|
283
|
+
* commands resolve on Installation records — not on catalog entries — so a record stays
|
|
284
|
+
* selectable even when its registration or marketplace material has become unreadable; only
|
|
285
|
+
* the provenance display degrades to the registration id.
|
|
286
|
+
*
|
|
287
|
+
* Ambiguity uses the full command resolution predicate over every Installation: a token that
|
|
288
|
+
* could resolve more than one record (cross-Marketplace same name, or a pluginId/id
|
|
289
|
+
* collision) is never offered, because the typed command would be rejected as ambiguous.
|
|
290
|
+
*/
|
|
291
|
+
function composeLifecycleCandidates(state: MinimalBridgeState): LifecycleCandidate[] {
|
|
292
|
+
const regNames = new Map(state.registrations.map((reg) => [reg.id, reg.marketplaceName || reg.alias || reg.id]));
|
|
293
|
+
const candidates: LifecycleCandidate[] = [];
|
|
294
|
+
for (const inst of state.installations) {
|
|
295
|
+
const name = installationName(inst);
|
|
296
|
+
if (!lifecycleNameUsable(state, name)) continue;
|
|
297
|
+
const marketplaceName = regNames.get(inst.registrationId) ?? inst.registrationId;
|
|
298
|
+
const enabled = isInstallationEnabled(inst);
|
|
299
|
+
candidates.push({
|
|
300
|
+
name,
|
|
301
|
+
marketplaceName,
|
|
302
|
+
status: lifecycleStatusLabel(enabled),
|
|
303
|
+
searchText: `${name} ${marketplaceName}`,
|
|
304
|
+
enabled,
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
return candidates;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function lifecycleCandidateIncluded(action: LifecycleAction, enabled: boolean): boolean {
|
|
311
|
+
if (action === 'enable') return !enabled;
|
|
312
|
+
if (action === 'disable') return enabled;
|
|
313
|
+
return true; // remove: every Installed Plugin, regardless of state
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function toLifecycleItem(action: LifecycleAction, candidate: LifecycleCandidate): CompletionItem {
|
|
317
|
+
return {
|
|
318
|
+
value: `${action} ${candidate.name}`,
|
|
319
|
+
label: candidate.name,
|
|
320
|
+
description: `[${candidate.marketplaceName}] ${candidate.status}`,
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
interface RegistrationCandidate {
|
|
325
|
+
/** Registration display name — marketplaceName, else alias, else id (command surface display). */
|
|
326
|
+
name: string;
|
|
327
|
+
/** Unique resolvable token the command executes on: the name, else its alias, else its id. */
|
|
328
|
+
insertion: string;
|
|
329
|
+
/** Marketplace format ('codex' | 'claude') shown as provenance. */
|
|
330
|
+
format: string;
|
|
331
|
+
/** 本地 / git source-kind vocabulary matching the `list` overview surface (#90). */
|
|
332
|
+
sourceKindLabel: string;
|
|
333
|
+
/** Raw source (local path or Git URL) shown as provenance. */
|
|
334
|
+
source: string;
|
|
335
|
+
/** Case-insensitive fuzzy search target: name + alias + format + source provenance. */
|
|
336
|
+
searchText: string;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Whether a name-typed `list|forget <token>` invocation would resolve exactly this
|
|
341
|
+
* Registration: the command matches marketplaceName OR alias OR id over every Registration
|
|
342
|
+
* (the same predicate as `matchesRegistration` in the command surface), so the token must be
|
|
343
|
+
* unique across all identity fields and survive the command's whitespace token split.
|
|
344
|
+
*/
|
|
345
|
+
function registrationTokenUsable(registrations: MinimalBridgeState['registrations'], token: string): boolean {
|
|
346
|
+
if (token.length === 0 || /\s/.test(token)) return false;
|
|
347
|
+
let matches = 0;
|
|
348
|
+
for (const reg of registrations) {
|
|
349
|
+
if (reg.marketplaceName === token || reg.alias === token || reg.id === token) matches += 1;
|
|
350
|
+
}
|
|
351
|
+
return matches === 1;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Compose Registration candidates directly from Bridge State (#124). `list` and `forget`
|
|
356
|
+
* both resolve their argument on Registration records, so a candidate is offered only when
|
|
357
|
+
* some token uniquely names that record — the readable name first, then its alias, then its
|
|
358
|
+
* Registration id — mirroring the exact predicate the commands execute with. Ambiguity uses
|
|
359
|
+
* the full predicate over every Registration identity field; a Registration with no uniquely
|
|
360
|
+
* resolvable token contributes no candidate, because the typed command could never select it.
|
|
361
|
+
*/
|
|
362
|
+
function composeRegistrationCandidates(state: MinimalBridgeState): RegistrationCandidate[] {
|
|
363
|
+
const candidates: RegistrationCandidate[] = [];
|
|
364
|
+
for (const reg of state.registrations) {
|
|
365
|
+
const name = reg.marketplaceName || reg.alias || reg.id;
|
|
366
|
+
const insertion =
|
|
367
|
+
(registrationTokenUsable(state.registrations, name) && name) ||
|
|
368
|
+
(reg.alias && reg.alias !== name && registrationTokenUsable(state.registrations, reg.alias) && reg.alias) ||
|
|
369
|
+
(registrationTokenUsable(state.registrations, reg.id) && reg.id);
|
|
370
|
+
if (!insertion) continue;
|
|
371
|
+
const format = reg.format ?? 'codex';
|
|
372
|
+
const sourceKindLabel = reg.sourceKind === 'local' ? '本地' : 'git';
|
|
373
|
+
candidates.push({
|
|
374
|
+
name,
|
|
375
|
+
insertion,
|
|
376
|
+
format,
|
|
377
|
+
sourceKindLabel,
|
|
378
|
+
source: reg.source,
|
|
379
|
+
searchText: [name, reg.alias, format, sourceKindLabel, reg.source].filter(Boolean).join(' '),
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
return candidates;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Compose the candidate label: the readable Registration name; when the insertion is not the
|
|
387
|
+
* name (ambiguous name, alias, or id), it stays visible so the user sees which token the
|
|
388
|
+
* candidate inserts.
|
|
389
|
+
*/
|
|
390
|
+
function registrationLabel(candidate: RegistrationCandidate): string {
|
|
391
|
+
return candidate.insertion === candidate.name
|
|
392
|
+
? candidate.name
|
|
393
|
+
: `${candidate.name} (${candidate.insertion})`;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function toRegistrationItem(action: 'list' | 'forget', candidate: RegistrationCandidate): CompletionItem {
|
|
397
|
+
return {
|
|
398
|
+
value: `${action} ${candidate.insertion}`,
|
|
399
|
+
label: registrationLabel(candidate),
|
|
400
|
+
description: `[${candidate.format}] ${candidate.sourceKindLabel} ${candidate.source}`,
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Second-level `list` / `forget` candidates for `/codex-marketplace <action> <query>` (#124).
|
|
406
|
+
*
|
|
407
|
+
* - Empty query → every Registration with a uniquely resolvable token, in state order.
|
|
408
|
+
* - A query → case-insensitive fuzzy match over the name, alias, and source/format
|
|
409
|
+
* provenance; `[]` when nothing matches.
|
|
410
|
+
*
|
|
411
|
+
* The Bridge State read is passive: empty, damaged, unreadable, or incompatible state or
|
|
412
|
+
* marketplace material contributes no candidates and is never written, reset, or repaired
|
|
413
|
+
* (#119 stories 22–23). `add` is not owned here by design.
|
|
414
|
+
*/
|
|
415
|
+
function completeRegistrationArguments(
|
|
416
|
+
action: 'list' | 'forget',
|
|
417
|
+
query: string,
|
|
418
|
+
options: CompletionReadOptions,
|
|
419
|
+
): CompletionItem[] {
|
|
420
|
+
const state = readMinimalBridgeStatePassive({ statePath: options.statePath, agentDir: options.agentDir });
|
|
421
|
+
const candidates = composeRegistrationCandidates(state);
|
|
422
|
+
if (query.length === 0) {
|
|
423
|
+
return candidates.map((candidate) => toRegistrationItem(action, candidate));
|
|
424
|
+
}
|
|
425
|
+
const scored: { item: CompletionItem; score: number }[] = [];
|
|
426
|
+
for (const candidate of candidates) {
|
|
427
|
+
const score = fuzzyScore(query, candidate.searchText);
|
|
428
|
+
if (score !== null) {
|
|
429
|
+
scored.push({ item: toRegistrationItem(action, candidate), score });
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
scored.sort((a, b) => a.score - b.score || a.item.label.localeCompare(b.item.label));
|
|
433
|
+
return scored.map((entry) => entry.item);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Second-level `enable` / `disable` / `remove` candidates for `/codex-marketplace <action> <query>`.
|
|
438
|
+
*
|
|
439
|
+
* - `enable <query>` → disabled Installations only.
|
|
440
|
+
* - `disable <query>` → enabled Installations only.
|
|
441
|
+
* - `remove <query>` → every Installed Plugin, whatever its state.
|
|
442
|
+
* - Empty query → every actionable Installation in state order; a query → case-insensitive
|
|
443
|
+
* fuzzy match over the name and Marketplace provenance; `[]` when nothing matches.
|
|
444
|
+
*
|
|
445
|
+
* The Bridge State read is passive: empty, damaged, unreadable, or incompatible state
|
|
446
|
+
* contributes no candidates and is never written, reset, or repaired (#119 stories 22–23).
|
|
447
|
+
*/
|
|
448
|
+
function completeLifecycleArguments(
|
|
449
|
+
action: LifecycleAction,
|
|
450
|
+
query: string,
|
|
451
|
+
options: CompletionReadOptions,
|
|
452
|
+
): CompletionItem[] {
|
|
453
|
+
const state = readMinimalBridgeStatePassive({ statePath: options.statePath, agentDir: options.agentDir });
|
|
454
|
+
const candidates = composeLifecycleCandidates(state).filter((candidate) =>
|
|
455
|
+
lifecycleCandidateIncluded(action, candidate.enabled),
|
|
456
|
+
);
|
|
457
|
+
if (query.length === 0) {
|
|
458
|
+
return candidates.map((candidate) => toLifecycleItem(action, candidate));
|
|
459
|
+
}
|
|
460
|
+
const scored: { item: CompletionItem; score: number }[] = [];
|
|
461
|
+
for (const candidate of candidates) {
|
|
462
|
+
const score = fuzzyScore(query, candidate.searchText);
|
|
463
|
+
if (score !== null) {
|
|
464
|
+
scored.push({ item: toLifecycleItem(action, candidate), score });
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
scored.sort((a, b) => a.score - b.score || a.item.label.localeCompare(b.item.label));
|
|
468
|
+
return scored.map((entry) => entry.item);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Compose completion candidates for `/codex-marketplace <argumentPrefix>`.
|
|
473
|
+
*
|
|
474
|
+
* - `install ` / `install <query>` → state-aware second-level install candidates (#122).
|
|
475
|
+
* - `enable|disable|remove <query>` → Installation lifecycle candidates (#123).
|
|
476
|
+
* - `list|forget <query>` → Registration candidates (#124); `add` is never owned.
|
|
477
|
+
* - Empty prefix → all nine root candidates (Pi's exact-command interception surface).
|
|
478
|
+
* - A single token → case-insensitive fuzzy-filtered subcommands; `[]` when nothing matches.
|
|
479
|
+
* - Any other whitespace-containing prefix (unowned second-level syntax) → `null`, so callers
|
|
480
|
+
* fall through to Pi's own completion unchanged.
|
|
481
|
+
*
|
|
482
|
+
* The module never writes Bridge State; root candidates are derived without reading it at all,
|
|
483
|
+
* and state-aware candidates read it passively.
|
|
484
|
+
*/
|
|
485
|
+
export function completeArguments(
|
|
486
|
+
argumentPrefix: string,
|
|
487
|
+
options: CompletionReadOptions = {},
|
|
488
|
+
): CompletionItem[] | null {
|
|
489
|
+
const installMatch = INSTALL_SECOND_LEVEL_RE.exec(argumentPrefix);
|
|
490
|
+
if (installMatch) {
|
|
491
|
+
return completeInstallArguments(installMatch[1], options);
|
|
492
|
+
}
|
|
493
|
+
const lifecycleMatch = LIFECYCLE_SECOND_LEVEL_RE.exec(argumentPrefix);
|
|
494
|
+
if (lifecycleMatch) {
|
|
495
|
+
return completeLifecycleArguments(lifecycleMatch[1] as LifecycleAction, lifecycleMatch[2], options);
|
|
496
|
+
}
|
|
497
|
+
const registrationMatch = REGISTRATION_SECOND_LEVEL_RE.exec(argumentPrefix);
|
|
498
|
+
if (registrationMatch) {
|
|
499
|
+
return completeRegistrationArguments(
|
|
500
|
+
registrationMatch[1] as 'list' | 'forget',
|
|
501
|
+
registrationMatch[2],
|
|
502
|
+
options,
|
|
503
|
+
);
|
|
504
|
+
}
|
|
505
|
+
if (/\s/.test(argumentPrefix)) return null;
|
|
506
|
+
|
|
507
|
+
const query = argumentPrefix.trim();
|
|
508
|
+
if (query.length === 0) {
|
|
509
|
+
return ROOT_CANDIDATES.map(toItem);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
const scored: { item: CompletionItem; score: number }[] = [];
|
|
513
|
+
for (const candidate of ROOT_CANDIDATES) {
|
|
514
|
+
const score = fuzzyScore(query, candidate.label);
|
|
515
|
+
if (score !== null) {
|
|
516
|
+
scored.push({ item: toItem(candidate), score });
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
scored.sort((a, b) => a.score - b.score || a.item.label.localeCompare(b.item.label));
|
|
520
|
+
return scored.map((entry) => entry.item);
|
|
521
|
+
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-only Marketplace Plugin enumeration query.
|
|
3
|
+
*
|
|
4
|
+
* This is the shared read model for command surfaces that need to select or display catalog
|
|
5
|
+
* entries. It owns registration material resolution, format-bound bounded catalog reads,
|
|
6
|
+
* global numbering, candidate naming, structural installability, Installation matching/state,
|
|
7
|
+
* Unavailable reasons, and disclosed diagnostics. It never mutates Bridge State or source data.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
11
|
+
import { basename, join } from 'node:path';
|
|
12
|
+
|
|
13
|
+
import { getCacheDir, getCacheEntriesDir } from '../cache/paths.js';
|
|
14
|
+
import { BUDGET } from '../registration/budget.js';
|
|
15
|
+
import {
|
|
16
|
+
GIT_FAMILY_UNAVAILABLE_REASON,
|
|
17
|
+
type Catalog,
|
|
18
|
+
type MarketplaceEntry,
|
|
19
|
+
} from '../registration/catalog.js';
|
|
20
|
+
import type { ValidationFinding } from '../registration/findings.js';
|
|
21
|
+
import { catalogContractFor } from '../registration/format.js';
|
|
22
|
+
import {
|
|
23
|
+
isInstallationEnabled,
|
|
24
|
+
type MarketplaceFormat,
|
|
25
|
+
type MinimalBridgeState,
|
|
26
|
+
type MinimalInstallation,
|
|
27
|
+
type MinimalRegistration,
|
|
28
|
+
} from './state.js';
|
|
29
|
+
|
|
30
|
+
export interface MarketplacePluginQueryOptions {
|
|
31
|
+
agentDir?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface MarketplaceCatalogReadResult {
|
|
35
|
+
/** Parsed catalog on success; also present for structural parser failures that retain entries. */
|
|
36
|
+
catalog?: Catalog;
|
|
37
|
+
/** Structural findings from the registration's fixed Marketplace Format parser. */
|
|
38
|
+
findings: ValidationFinding[];
|
|
39
|
+
/** Existing command-surface diagnostic wording for a denied catalog read. */
|
|
40
|
+
error?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type MarketplacePluginInstallationState = 'not-installed' | 'enabled' | 'disabled';
|
|
44
|
+
|
|
45
|
+
export interface MarketplacePluginCandidate {
|
|
46
|
+
/** Global 1-based number across registrations and entries in their original order. */
|
|
47
|
+
number: number;
|
|
48
|
+
registration: MinimalRegistration;
|
|
49
|
+
entry: MarketplaceEntry;
|
|
50
|
+
/** Entry-declared name, then local path basename, then ordinal fallback. */
|
|
51
|
+
candidateName: string;
|
|
52
|
+
marketplaceName: string;
|
|
53
|
+
marketplaceSource: string;
|
|
54
|
+
/** Live local Marketplace Root or the registration's pinned Git Source Cache material. */
|
|
55
|
+
marketplaceRoot: string;
|
|
56
|
+
structurallyInstallable: boolean;
|
|
57
|
+
installation?: MinimalInstallation;
|
|
58
|
+
installationState: MarketplacePluginInstallationState;
|
|
59
|
+
unavailableReason?: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface MarketplacePluginDiagnostic {
|
|
63
|
+
registration: MinimalRegistration;
|
|
64
|
+
marketplace: string;
|
|
65
|
+
marketplaceSource: string;
|
|
66
|
+
marketplaceRoot?: string;
|
|
67
|
+
findings: ValidationFinding[];
|
|
68
|
+
error: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface MarketplacePluginQueryResult {
|
|
72
|
+
plugins: MarketplacePluginCandidate[];
|
|
73
|
+
diagnostics: MarketplacePluginDiagnostic[];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Budget-bounded, format-bound Marketplace Catalog read used by the query and existing lifecycle
|
|
78
|
+
* rereads. Error literals intentionally retain the command grammar established by #91.
|
|
79
|
+
*/
|
|
80
|
+
export function readMarketplaceCatalog(
|
|
81
|
+
root: string,
|
|
82
|
+
format: MarketplaceFormat,
|
|
83
|
+
): MarketplaceCatalogReadResult {
|
|
84
|
+
const contract = catalogContractFor(format);
|
|
85
|
+
const catalogPath = join(root, ...contract.relPath.split('/'));
|
|
86
|
+
let raw: string;
|
|
87
|
+
try {
|
|
88
|
+
if (!existsSync(catalogPath)) {
|
|
89
|
+
return { findings: [], error: `catalog 缺失(${contract.relPath})` };
|
|
90
|
+
}
|
|
91
|
+
// Preserve the established command behavior exactly: the stat supplies the Validation Budget
|
|
92
|
+
// diagnostic, while readFileSync retains native diagnostics for directories/special files.
|
|
93
|
+
const size = statSync(catalogPath).size;
|
|
94
|
+
if (size > BUDGET.maxCatalogBytes) {
|
|
95
|
+
return {
|
|
96
|
+
findings: [],
|
|
97
|
+
error: `catalog 檔案過大(${size} bytes > ${BUDGET.maxCatalogBytes})— 超過 Validation Budget 上限,catalog 無法解析`,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
raw = readFileSync(catalogPath, 'utf-8');
|
|
101
|
+
} catch (error) {
|
|
102
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
103
|
+
return { findings: [], error: `catalog 無法讀取:${message}` };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (!raw.trim()) {
|
|
107
|
+
return { findings: [], error: 'catalog 解析失敗:檔案為空 — catalog malformed' };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
let parsed: unknown;
|
|
111
|
+
try {
|
|
112
|
+
parsed = JSON.parse(raw);
|
|
113
|
+
} catch (error) {
|
|
114
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
115
|
+
return { findings: [], error: `catalog 解析失敗:${message} — catalog malformed` };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const result = contract.parse(parsed);
|
|
119
|
+
if (!result.ok) {
|
|
120
|
+
const codes = result.findings.map((finding) => finding.code).join(', ');
|
|
121
|
+
return {
|
|
122
|
+
catalog: result.catalog,
|
|
123
|
+
findings: result.findings,
|
|
124
|
+
error: `catalog 解析失敗${codes ? ` (${codes})` : ''} — catalog malformed`,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
return { catalog: result.catalog, findings: result.findings };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function marketplaceName(registration: MinimalRegistration): string {
|
|
131
|
+
return registration.marketplaceName || registration.alias || registration.id;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function resolveMarketplaceRoot(
|
|
135
|
+
registration: MinimalRegistration,
|
|
136
|
+
options: MarketplacePluginQueryOptions = {},
|
|
137
|
+
): string | undefined {
|
|
138
|
+
if (registration.sourceKind !== 'git') return registration.source;
|
|
139
|
+
const snapshot = registration.snapshot;
|
|
140
|
+
if (!snapshot || !/^[0-9a-f]{64}$/.test(snapshot)) return undefined;
|
|
141
|
+
const root = join(getCacheEntriesDir(getCacheDir(options.agentDir)), snapshot);
|
|
142
|
+
return existsSync(root) ? root : undefined;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function materialUnavailableReason(registration: MinimalRegistration): string {
|
|
146
|
+
if (registration.sourceKind !== 'git') return 'catalog 根路徑無法解析';
|
|
147
|
+
const snapshot = registration.snapshot;
|
|
148
|
+
return !snapshot
|
|
149
|
+
? 'git marketplace 缺少 cache 指紋'
|
|
150
|
+
: `cache 快照缺失(${snapshot.slice(0, 12)}…)`;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function isMarketplaceEntryStructurallyInstallable(entry: MarketplaceEntry): boolean {
|
|
154
|
+
return entry.type === 'local'
|
|
155
|
+
&& entry.available === true
|
|
156
|
+
&& typeof entry.path === 'string'
|
|
157
|
+
&& entry.path.length > 0;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function marketplaceEntryUnavailableReason(entry: MarketplaceEntry): string {
|
|
161
|
+
if (entry.type === 'git' && entry.available !== false) return GIT_FAMILY_UNAVAILABLE_REASON;
|
|
162
|
+
return entry.unavailableReason ?? 'unsupported source kind';
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Enumerate every readable Marketplace Catalog without mutating Bridge State or material. */
|
|
166
|
+
export function queryMarketplacePlugins(
|
|
167
|
+
state: MinimalBridgeState,
|
|
168
|
+
options: MarketplacePluginQueryOptions = {},
|
|
169
|
+
): MarketplacePluginQueryResult {
|
|
170
|
+
const plugins: MarketplacePluginCandidate[] = [];
|
|
171
|
+
const diagnostics: MarketplacePluginDiagnostic[] = [];
|
|
172
|
+
let number = 1;
|
|
173
|
+
|
|
174
|
+
for (const registration of state.registrations) {
|
|
175
|
+
const name = marketplaceName(registration);
|
|
176
|
+
const root = resolveMarketplaceRoot(registration, options);
|
|
177
|
+
if (!root) {
|
|
178
|
+
diagnostics.push({
|
|
179
|
+
registration,
|
|
180
|
+
marketplace: name,
|
|
181
|
+
marketplaceSource: registration.source,
|
|
182
|
+
marketplaceRoot: undefined,
|
|
183
|
+
findings: [],
|
|
184
|
+
error: materialUnavailableReason(registration),
|
|
185
|
+
});
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const read = readMarketplaceCatalog(root, registration.format ?? 'codex');
|
|
190
|
+
if (read.error) {
|
|
191
|
+
diagnostics.push({
|
|
192
|
+
registration,
|
|
193
|
+
marketplace: name,
|
|
194
|
+
marketplaceSource: registration.source,
|
|
195
|
+
marketplaceRoot: root,
|
|
196
|
+
findings: read.findings,
|
|
197
|
+
error: read.error,
|
|
198
|
+
});
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
for (const entry of read.catalog?.entries ?? []) {
|
|
203
|
+
const candidateName = entry.name ?? (entry.path ? basename(entry.path) : `plugin-${entry.ordinal}`);
|
|
204
|
+
const installation = state.installations.find(
|
|
205
|
+
(candidate) => candidate.registrationId === registration.id
|
|
206
|
+
&& (candidate.manifestName === candidateName || candidate.pluginId === candidateName),
|
|
207
|
+
);
|
|
208
|
+
const structurallyInstallable = isMarketplaceEntryStructurallyInstallable(entry);
|
|
209
|
+
const installationState: MarketplacePluginInstallationState = !installation
|
|
210
|
+
? 'not-installed'
|
|
211
|
+
: isInstallationEnabled(installation)
|
|
212
|
+
? 'enabled'
|
|
213
|
+
: 'disabled';
|
|
214
|
+
|
|
215
|
+
plugins.push({
|
|
216
|
+
number,
|
|
217
|
+
registration,
|
|
218
|
+
entry,
|
|
219
|
+
candidateName,
|
|
220
|
+
marketplaceName: name,
|
|
221
|
+
marketplaceSource: registration.source,
|
|
222
|
+
marketplaceRoot: root,
|
|
223
|
+
structurallyInstallable,
|
|
224
|
+
installation,
|
|
225
|
+
installationState,
|
|
226
|
+
unavailableReason: structurallyInstallable ? undefined : marketplaceEntryUnavailableReason(entry),
|
|
227
|
+
});
|
|
228
|
+
number += 1;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return { plugins, diagnostics };
|
|
233
|
+
}
|