dsh-plugin-resolver 0.0.0-stage → 0.1.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/logic/api.js ADDED
@@ -0,0 +1,270 @@
1
+ /**
2
+ * Reloadable logic entry: every tool body lives here, behind one cache-busted
3
+ * import. The host shell asks for this module with `?v=<logic mtime>` on each
4
+ * call, so editing any file under `logic/` takes effect on the next tool call
5
+ * without restarting the host.
6
+ *
7
+ * Nothing in here touches the loader or the tool registry; it receives the
8
+ * profile's `pluginManager` and a Cordis context and returns plain text.
9
+ *
10
+ * @module dsh-plugin-resolver/logic/api
11
+ */
12
+
13
+ // Local dependencies carry this module's own `?v=` query, so one cache-busted
14
+ // import of `api.js` reloads catalog/resolve/install/util together.
15
+ const __v = new URL(import.meta.url).searchParams.get('v') ?? ''
16
+ const __q = (name) => `./${name}${__v === '' ? '' : `?v=${encodeURIComponent(__v)}`}`
17
+ const { baseName, loadCatalog, searchCatalog } = await import(__q('catalog.js'))
18
+ const { installGithubSource, installRegistry, verifyInstall } = await import(__q('install.js'))
19
+ const { resolveTarget } = await import(__q('resolve.js'))
20
+ const { oneLine, stars } = await import(__q('util.js'))
21
+
22
+ /** Catalog load honoring the `refresh` flag and config overrides. */
23
+ async function catalogFor(options, refresh) {
24
+ try {
25
+ const combined = { ...options }
26
+ if (refresh) return await loadCatalog(combined, { force: true })
27
+ return await loadCatalog(combined)
28
+ } catch (error) {
29
+ return { entries: [], fetchedAt: 0, fromCache: false, source: 'unavailable', error: error?.message ?? String(error) }
30
+ }
31
+ }
32
+
33
+ /** Bundle names already installed in this profile. */
34
+ async function installedNames(manager) {
35
+ const set = new Set()
36
+ if (!manager) return set
37
+ try {
38
+ const bundles = await manager.listBundles()
39
+ for (const bundle of bundles) {
40
+ if (bundle?.installed && typeof bundle.name === 'string') set.add(bundle.name)
41
+ }
42
+ } catch {
43
+ // inventory is best-effort
44
+ }
45
+ return set
46
+ }
47
+
48
+ /**
49
+ * `plugin_search`: rank catalog entries and keep same-name ambiguity visible.
50
+ *
51
+ * @param {any} ctx
52
+ * @param {any} manager
53
+ * @param {Record<string, unknown>} options
54
+ * @param {any} args
55
+ * @returns {Promise<{text: string}>}
56
+ */
57
+ export async function pluginSearch(ctx, manager, options, args) {
58
+ const query = String(args?.query ?? '').trim()
59
+ if (query === '') return { text: 'plugin_search needs a non-empty query.' }
60
+ const installed = await installedNames(manager)
61
+ const catalog = await catalogFor(options, args?.refresh === true)
62
+ if (catalog.entries.length === 0) {
63
+ return { text: `Catalog is unavailable right now: ${catalog.error ?? 'empty catalog'}` }
64
+ }
65
+ const { matches, ambiguous } = searchCatalog(catalog.entries, query, {
66
+ limit: Number(args?.limit) || 8,
67
+ installed,
68
+ })
69
+ if (matches.length === 0) {
70
+ return { text: `No catalog entry matches "${query}" (${catalog.entries.length} entries, ${catalog.fromCache ? 'cached' : 'fresh'}).` }
71
+ }
72
+ const lines = [
73
+ `${matches.length} match(es) for "${query}" — catalog ${catalog.entries.length} entries, ${catalog.fromCache ? 'cached' : `fresh from ${catalog.source}`}:`,
74
+ '',
75
+ ]
76
+ matches.forEach(({ entry, installed: isInstalled }, index) => {
77
+ lines.push(`${index + 1}. ${entry.name} ★${stars(entry.stars)} owner=${entry.owner || '?'}${isInstalled ? ' [installed]' : ''}`)
78
+ if (entry.npm) lines.push(` npm: ${entry.npm}${entry.version ? `@${entry.version}` : ''}`)
79
+ else lines.push(' npm: (not published — GitHub source)')
80
+ if (entry.install) lines.push(` install: ${entry.install}`)
81
+ if (entry.description) lines.push(` ${oneLine(entry.description, 150)}`)
82
+ })
83
+ if (ambiguous) {
84
+ lines.push('', 'Same name has several owners. Disambiguate with owner or a full spec, e.g. owner="wqty123".')
85
+ }
86
+ return { text: lines.join('\n') }
87
+ }
88
+
89
+ /**
90
+ * `plugin_resolve`: produce the install plan without mutating anything.
91
+ */
92
+ export async function pluginResolve(ctx, manager, options, args) {
93
+ if (!manager) return { text: 'The pluginManager service is not mounted in this profile; resolve is unavailable.' }
94
+ const catalog = await catalogFor(options, args?.refresh === true)
95
+ const plan = await resolveTarget(ctx, manager, String(args?.target ?? ''), {
96
+ owner: typeof args?.owner === 'string' ? args.owner : undefined,
97
+ version: typeof args?.version === 'string' ? args.version : undefined,
98
+ registry: typeof args?.registry === 'string' && args.registry !== '' ? args.registry : options.registry,
99
+ catalog: catalog.entries,
100
+ })
101
+ return { text: formatPlan(plan, 'resolve') }
102
+ }
103
+
104
+ /**
105
+ * `plugin_install`: resolve, install through the manager, verify, and report
106
+ * whether a refresh or restart is needed.
107
+ */
108
+ export async function pluginInstall(ctx, manager, options, args) {
109
+ if (!manager) return { text: 'The pluginManager service is not mounted in this profile; install is unavailable.' }
110
+ try {
111
+ const catalog = await catalogFor(options, args?.refresh === true)
112
+ const plan = await resolveTarget(ctx, manager, String(args?.target ?? ''), {
113
+ owner: typeof args?.owner === 'string' ? args.owner : undefined,
114
+ version: typeof args?.version === 'string' ? args.version : undefined,
115
+ registry: typeof args?.registry === 'string' && args.registry !== '' ? args.registry : options.registry,
116
+ catalog: catalog.entries,
117
+ })
118
+ if (plan.kind === 'ambiguous') return { text: formatPlan(plan, 'install') }
119
+
120
+ const lines = [formatPlan(plan, 'install'), '']
121
+ const logLines = []
122
+ let packageName = plan.name
123
+ let steps = []
124
+ let application = 'unknown'
125
+
126
+ if (plan.kind === 'github') {
127
+ const result = await installGithubSource(manager, plan, {
128
+ githubProxy: options.githubProxy,
129
+ skipBuild: args?.skipBuild === true,
130
+ log: (line) => {
131
+ logLines.push(line)
132
+ if (logLines.length > 12) logLines.shift()
133
+ },
134
+ })
135
+ packageName = result.packageName
136
+ steps = result.steps
137
+ application = result.summary.application
138
+ lines.push(`install: ${result.summary.application} (${result.summary.stage})`)
139
+ if (result.summary.error) lines.push(`error: ${result.summary.error}`)
140
+ if (steps.length > 0) lines.push(`steps: ${steps.join(' → ')}`)
141
+ } else {
142
+ const { summary } = await installRegistry(manager, plan)
143
+ application = summary.application
144
+ if (packageName === null && summary.bundle) packageName = summary.bundle
145
+ lines.push(`install: ${summary.application} (${summary.stage})`)
146
+ if (summary.error) lines.push(`error: ${summary.error}`)
147
+ if (summary.pendingBuilds.length > 0) {
148
+ lines.push(`pending build scripts (needs approval): ${summary.pendingBuilds.join(', ')}`)
149
+ }
150
+ }
151
+
152
+ if (packageName) {
153
+ const health = await verifyInstall(ctx, manager, packageName)
154
+ lines.push('', 'verification:')
155
+ if (health.entry) {
156
+ const phase = health.entry.fiberPhase ?? 'unknown'
157
+ const flag = phase === 'active' ? 'OK' : phase === 'failed' ? 'FAILED' : phase
158
+ lines.push(`- loader entry ${health.entry.entryId}: enabled=${health.entry.enabled} fiberPhase=${phase} [${flag}]`)
159
+ } else {
160
+ lines.push('- loader entry: not found — the host half may need a restart to mount')
161
+ }
162
+ if (health.bundle) {
163
+ lines.push(`- bundle: ${health.bundle.name}@${health.bundle.version ?? '?'} enabled=${health.bundle.enabled}`)
164
+ }
165
+ lines.push(`- package: ${packageName}${health.hasClient ? ` (client half via ${health.clientSource})` : ' (host-only)'}`)
166
+ const next = []
167
+ if (application === 'restart-required') {
168
+ next.push('restart DSH fully: replacing an already-mounted plugin cannot be hot-swapped safely')
169
+ }
170
+ if (health.entry?.fiberPhase === 'failed') next.push('host half failed to load; inspect the harness log for the error')
171
+ if (health.hasClient && application !== 'restart-required') next.push('client half exists: refresh the GUI (Ctrl+R) to load it')
172
+ if (health.entry?.fiberPhase === 'active' && !health.hasClient && application !== 'restart-required') {
173
+ next.push('host-only: no GUI refresh required')
174
+ }
175
+ if (next.length > 0) lines.push('', `next: ${next.join('; ')}`)
176
+ }
177
+ if (logLines.length > 0) lines.push('', `build log (tail):\n ${logLines.join('\n ')}`)
178
+ return { text: lines.join('\n') }
179
+ } catch (error) {
180
+ return { text: `plugin_install failed: ${error?.message ?? String(error)}` }
181
+ }
182
+ }
183
+
184
+ /**
185
+ * `plugin_status`: third-party bundles and their loader entries.
186
+ */
187
+ export async function pluginStatus(ctx, manager, options, args) {
188
+ if (!manager) return { text: 'The pluginManager service is not mounted in this profile; status is unavailable.' }
189
+ const filter = String(args?.query ?? '').trim().toLowerCase()
190
+ const lines = []
191
+ try {
192
+ const bundles = await manager.listBundles()
193
+ const installed = bundles.filter((bundle) => bundle.installed && !String(bundle.name).startsWith('@deepseek-ai/'))
194
+ lines.push(`installed third-party bundles: ${installed.length}`)
195
+ for (const bundle of installed) {
196
+ if (filter !== '' && !String(bundle.name).toLowerCase().includes(filter)) continue
197
+ lines.push(`- ${bundle.name}@${bundle.version ?? '?'} enabled=${bundle.enabled} optional=${bundle.optional}`)
198
+ const rows = Array.isArray(bundle.rows) ? bundle.rows.filter((row) => row.entryId) : []
199
+ if (rows.length > 0) lines.push(` rows: ${rows.map((row) => `${row.entryId}(${row.moduleName})`).join(', ')}`)
200
+ }
201
+ } catch (error) {
202
+ lines.push(`bundle inventory failed: ${error.message}`)
203
+ }
204
+ try {
205
+ const plugins = await manager.listPlugins()
206
+ const thirdParty = plugins.filter((plugin) => {
207
+ const name = String(plugin.moduleName ?? '')
208
+ return name !== '' && !name.startsWith('@deepseek-ai/') && !name.startsWith('file:///')
209
+ })
210
+ lines.push('', `third-party loader entries: ${thirdParty.length}`)
211
+ for (const plugin of thirdParty) {
212
+ if (filter !== '' && !plugin.moduleName.toLowerCase().includes(filter)) continue
213
+ lines.push(`- ${plugin.entryId}: ${plugin.moduleName} enabled=${plugin.enabled} fiberPhase=${plugin.fiberPhase ?? 'null'}`)
214
+ }
215
+ } catch (error) {
216
+ lines.push(`plugin inventory failed: ${error.message}`)
217
+ }
218
+ return { text: lines.join('\n') }
219
+ }
220
+
221
+ /**
222
+ * Render a resolution plan as compact text.
223
+ *
224
+ * @param {any} plan
225
+ * @param {'resolve' | 'install'} mode
226
+ * @returns {string}
227
+ */
228
+ export function formatPlan(plan, mode) {
229
+ if (plan.kind === 'ambiguous') {
230
+ const lines = [`"${plan.name}" is ambiguous: ${plan.candidates.length} catalog entries share this name.`, '']
231
+ plan.candidates.forEach((candidate, index) => {
232
+ lines.push(
233
+ `${index + 1}. owner=${candidate.owner || '?'} ★${stars(candidate.stars)} ${
234
+ candidate.npm ? `npm: ${candidate.npm}` : 'npm: (GitHub source)'
235
+ }`,
236
+ )
237
+ if (candidate.install) lines.push(` install: ${candidate.install}`)
238
+ if (candidate.description) lines.push(` ${oneLine(candidate.description, 130)}`)
239
+ })
240
+ lines.push('', `Re-run ${mode === 'install' ? 'plugin_install' : 'plugin_resolve'} with owner="<owner>" or a full spec.`)
241
+ return lines.join('\n')
242
+ }
243
+ const lines = []
244
+ const runtime = plan.runtime ?? 'unknown'
245
+ lines.push(`target kind: ${plan.kind} running DSH: ${runtime}`)
246
+ if (plan.name) lines.push(`package: ${plan.name}${plan.version ? `@${plan.version}` : ''}`)
247
+ if (plan.spec) lines.push(`install spec: ${plan.spec}`)
248
+ if (plan.registry) lines.push(`registry: ${plan.registry}`)
249
+ else lines.push('registry: pnpm default (no first registry named)')
250
+ if (plan.latest && plan.latest !== plan.version) lines.push(`latest published: ${plan.latest} (not used: incompatible or newer)`)
251
+ if (plan.kind === 'github') {
252
+ lines.push(`source: github.com/${plan.repo}${plan.ref ? ` @ ${plan.ref}` : ''}${plan.subpath ? ` (${plan.subpath})` : ''}`)
253
+ }
254
+ if (plan.catalogEntry) {
255
+ lines.push(`catalog: ${plan.catalogEntry.name} by ${plan.catalogEntry.owner || '?'} ★${stars(plan.catalogEntry.stars)}`)
256
+ if (plan.catalogEntry.description) lines.push(`about: ${oneLine(plan.catalogEntry.description, 160)}`)
257
+ }
258
+ if (Array.isArray(plan.rejected) && plan.rejected.length > 0) {
259
+ lines.push('skipped versions:')
260
+ for (const item of plan.rejected) lines.push(`- ${item.version}: ${item.reason}`)
261
+ }
262
+ if (Array.isArray(plan.notes) && plan.notes.length > 0) {
263
+ lines.push('notes:')
264
+ for (const note of plan.notes) lines.push(`- ${note}`)
265
+ }
266
+ if (mode === 'install') lines.push('', 'proceeding with install…')
267
+ return lines.join('\n')
268
+ }
269
+
270
+ export { baseName }
@@ -0,0 +1,281 @@
1
+ // Reloadable module: every local dependency is imported with this module's own
2
+ // `?v=` query, so a cache-busted entry import reloads the whole graph together.
3
+ const __v = new URL(import.meta.url).searchParams.get('v') ?? ''
4
+ const __q = (name) => `./${name}${__v === '' ? '' : `?v=${encodeURIComponent(__v)}`}`
5
+ const { cacheDir, dirExists, fetchBuffer, fetchJson, oneLine, readJson, writeJson } = await import(__q('util.js'))
6
+ /**
7
+ * The community plugin catalog: fetch, normalize, cache, and search it.
8
+ *
9
+ * The curated list is the same one the in-app market reads. Its home is a
10
+ * GitHub Pages URL, which is slow and sometimes unreachable from mainland
11
+ * China, so the primary source is the `dsh-plugin-catalog` npm package: every
12
+ * npm mirror carries it, and the `dist.tarball` field keeps the download on
13
+ * whichever registry answered. The origin URL stays as the last fallback for
14
+ * deployments that cannot reach a registry.
15
+ *
16
+ * Same display names repeat across owners, which is why search keeps the owner
17
+ * in view and reports ambiguity instead of guessing.
18
+ *
19
+ * @module dsh-plugin-resolver/lib/catalog
20
+ */
21
+
22
+ import { gunzipSync } from 'node:zlib'
23
+ import path from 'node:path'
24
+
25
+ const DEFAULT_CATALOG_URL = 'https://awesome-dsh-plugin.com/plugins.json'
26
+ const DEFAULT_TTL_MS = 12 * 60 * 60 * 1000
27
+ const CATALOG_PACKAGE = 'dsh-plugin-catalog'
28
+ const DEFAULT_CATALOG_REGISTRIES = ['https://registry.npmmirror.com', 'https://registry.npmjs.org']
29
+
30
+ /**
31
+ * Load the catalog: fresh cache first, then the npm carrier, then the origin.
32
+ *
33
+ * @param {{catalogUrl?: string, catalogTtlMs?: number, catalogRegistries?: string[], registry?: string}} options
34
+ * @param {{force?: boolean}} [control]
35
+ * @returns {Promise<{entries: CatalogEntry[], fetchedAt: number, fromCache: boolean, source: string, version?: string, error?: string}>}
36
+ */
37
+ export async function loadCatalog(options, control = {}) {
38
+ const file = path.join(cacheDir(), 'catalog.json')
39
+ const ttl = options.catalogTtlMs ?? DEFAULT_TTL_MS
40
+ if (!control.force) {
41
+ const cached = await readJson(file)
42
+ if (cached && Array.isArray(cached.entries) && Number.isFinite(cached.fetchedAt)) {
43
+ if (Date.now() - cached.fetchedAt < ttl) {
44
+ return {
45
+ entries: cached.entries,
46
+ fetchedAt: cached.fetchedAt,
47
+ fromCache: true,
48
+ source: typeof cached.source === 'string' ? cached.source : 'cache',
49
+ version: cached.version,
50
+ }
51
+ }
52
+ }
53
+ }
54
+
55
+ const attempts = []
56
+ const registries = [
57
+ ...(Array.isArray(options.catalogRegistries) ? options.catalogRegistries : []),
58
+ options.registry,
59
+ ...DEFAULT_CATALOG_REGISTRIES,
60
+ ].filter((value, index, list) => typeof value === 'string' && value !== '' && list.indexOf(value) === index)
61
+
62
+ for (const registry of registries) {
63
+ try {
64
+ const carried = await catalogFromPackage(registry)
65
+ const entries = normalizeCatalog(carried.raw)
66
+ const fetchedAt = Date.now()
67
+ await writeJson(file, { fetchedAt, source: 'npm', version: carried.version, entries })
68
+ return { entries, fetchedAt, fromCache: false, source: 'npm', version: carried.version }
69
+ } catch (error) {
70
+ attempts.push(`${registry}: ${error.message}`)
71
+ }
72
+ }
73
+
74
+ const urls = [options.catalogUrl, DEFAULT_CATALOG_URL].filter(
75
+ (value, index, list) => typeof value === 'string' && value !== '' && list.indexOf(value) === index,
76
+ )
77
+ for (const url of urls) {
78
+ try {
79
+ const raw = await fetchJson(url, { timeoutMs: 180_000 })
80
+ const entries = normalizeCatalog(raw)
81
+ const fetchedAt = Date.now()
82
+ await writeJson(file, { fetchedAt, source: 'url', entries })
83
+ return { entries, fetchedAt, fromCache: false, source: 'url' }
84
+ } catch (error) {
85
+ attempts.push(`${url}: ${error.message}`)
86
+ }
87
+ }
88
+
89
+ return {
90
+ entries: [],
91
+ fetchedAt: 0,
92
+ fromCache: false,
93
+ source: 'unavailable',
94
+ error: `catalog unavailable (${attempts.join(' | ')})`,
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Read `plugins.json` out of the published catalog package via one registry.
100
+ *
101
+ * @param {string} registry
102
+ * @returns {Promise<{raw: any, version: string | null}>}
103
+ */
104
+ async function catalogFromPackage(registry) {
105
+ const base = registry.replace(/\/+$/, '')
106
+ const meta = await fetchJson(`${base}/${CATALOG_PACKAGE}/latest`, { timeoutMs: 20_000 })
107
+ const version = typeof meta?.version === 'string' ? meta.version : null
108
+ const tarball = typeof meta?.dist?.tarball === 'string' ? meta.dist.tarball : null
109
+ if (tarball === null) throw new Error('package metadata names no tarball')
110
+ const bytes = await fetchBuffer(tarball, { timeoutMs: 90_000 })
111
+ const file = fileFromTarball(bytes, 'package/plugins.json')
112
+ if (file === null) throw new Error('catalog package carries no package/plugins.json')
113
+ return { raw: JSON.parse(file.toString('utf8')), version }
114
+ }
115
+
116
+ /**
117
+ * Extract one regular file from a gzipped tar.
118
+ *
119
+ * @param {Buffer} gz
120
+ * @param {string} wanted
121
+ * @returns {Buffer | null}
122
+ */
123
+ export function fileFromTarball(gz, wanted) {
124
+ const buffer = gunzipSync(gz)
125
+ const BLOCK = 512
126
+ let offset = 0
127
+ while (offset + BLOCK <= buffer.length) {
128
+ const name = buffer
129
+ .toString('utf8', offset, offset + 100)
130
+ .replace(/\0[\s\S]*$/, '')
131
+ if (name === '') break
132
+ const rawSize = buffer
133
+ .toString('ascii', offset + 124, offset + 136)
134
+ .replace(/\0[\s\S]*$/, '')
135
+ .trim()
136
+ const size = Number.parseInt(rawSize, 8)
137
+ if (!Number.isFinite(size) || size < 0) break
138
+ const type = String.fromCharCode(buffer[offset + 156] ?? 0)
139
+ offset += BLOCK
140
+ if ((type === '0' || type === '\0') && name === wanted) {
141
+ return buffer.subarray(offset, offset + size)
142
+ }
143
+ offset += Math.ceil(size / BLOCK) * BLOCK
144
+ }
145
+ return null
146
+ }
147
+
148
+ /**
149
+ * Normalize one raw catalog payload.
150
+ *
151
+ * @typedef {object} CatalogEntry
152
+ * @property {string} name display name, possibly `name#subpath`
153
+ * @property {string} owner repository owner
154
+ * @property {string} url repository URL
155
+ * @property {string | null} npm published npm package name
156
+ * @property {string | null} version catalogued version
157
+ * @property {number} stars
158
+ * @property {string | null} install the documented install command
159
+ * @property {string} description one-line, English preferred
160
+ * @property {string} category
161
+ * @property {string} added
162
+ *
163
+ * @param {any} raw
164
+ * @returns {CatalogEntry[]}
165
+ */
166
+ export function normalizeCatalog(raw) {
167
+ const list = Array.isArray(raw) ? raw : raw?.plugins ?? raw?.entries ?? []
168
+ const entries = []
169
+ for (const item of list) {
170
+ if (!item || typeof item.name !== 'string' || item.name === '') continue
171
+ entries.push({
172
+ name: item.name,
173
+ owner: typeof item.owner === 'string' ? item.owner : '',
174
+ url: typeof item.url === 'string' ? item.url : '',
175
+ npm: typeof item.npm === 'string' && item.npm !== '' ? item.npm : null,
176
+ version: typeof item.version === 'string' && item.version !== '' ? item.version : null,
177
+ stars: Number(item.stars) || 0,
178
+ install: typeof item.install === 'string' ? item.install : null,
179
+ description: oneLine(item.description?.en ?? item.description?.zh ?? '', 160),
180
+ category: typeof item.category === 'string' ? item.category : '',
181
+ added: typeof item.added === 'string' ? item.added : '',
182
+ })
183
+ }
184
+ return entries
185
+ }
186
+
187
+ /** Base display name without the monorepo subpath suffix. */
188
+ export function baseName(entry) {
189
+ const at = entry.name.indexOf('#')
190
+ return at === -1 ? entry.name : entry.name.slice(0, at)
191
+ }
192
+
193
+ /**
194
+ * Rank catalog entries for one query.
195
+ *
196
+ * Scoring prefers an exact display name, then a shared prefix, then tokens in
197
+ * the name/owner/description; star count only breaks ties. Same-name results
198
+ * are all returned so the caller can present the ambiguity instead of guessing.
199
+ *
200
+ * @param {CatalogEntry[]} entries
201
+ * @param {string} query
202
+ * @param {{limit?: number, installed?: Set<string>}} [options]
203
+ * @returns {{matches: Array<{entry: CatalogEntry, score: number, installed: boolean}>, ambiguous: boolean}}
204
+ */
205
+ export function searchCatalog(entries, query, options = {}) {
206
+ const limit = Math.max(1, Math.min(options.limit ?? 8, 25))
207
+ const raw = String(query ?? '').trim().toLowerCase()
208
+ if (raw === '') return { matches: [], ambiguous: false }
209
+ const tokens = raw.split(/[\s/]+/).filter(Boolean)
210
+ const matches = []
211
+ for (const entry of entries) {
212
+ const name = entry.name.toLowerCase()
213
+ const base = baseName(entry).toLowerCase()
214
+ const owner = entry.owner.toLowerCase()
215
+ const npmName = (entry.npm ?? '').toLowerCase()
216
+ const description = entry.description.toLowerCase()
217
+ let score = 0
218
+ if (base === raw) score = 1000
219
+ else if (name === raw) score = 990
220
+ else if (base.startsWith(raw)) score = 700
221
+ else if (name.startsWith(raw)) score = 690
222
+ else if (npmName !== '' && npmName === raw) score = 800
223
+ else if (name.includes(raw)) score = 400
224
+ else if (owner === raw) score = 350
225
+ else if (description.includes(raw)) score = 120
226
+ else {
227
+ let tokenHits = 0
228
+ for (const token of tokens) {
229
+ if (name.includes(token)) tokenHits += 1
230
+ else if (owner.includes(token)) tokenHits += 0.5
231
+ else if (description.includes(token)) tokenHits += 0.25
232
+ }
233
+ if (tokenHits === 0) continue
234
+ score = 200 + tokenHits * 20
235
+ }
236
+ score += Math.min(entry.stars, 10_000) / 100_000
237
+ if (entry.npm && options.installed?.has(entry.npm)) score += 0.5
238
+ matches.push({ entry, score, installed: Boolean(entry.npm && options.installed?.has(entry.npm)) })
239
+ }
240
+ matches.sort((a, b) => b.score - a.score || b.entry.stars - a.entry.stars)
241
+ const limited = matches.slice(0, limit)
242
+ const names = new Map()
243
+ for (const { entry } of limited) {
244
+ const key = baseName(entry).toLowerCase()
245
+ names.set(key, (names.get(key) ?? 0) + 1)
246
+ }
247
+ const ambiguous = [...names.values()].some((count) => count > 1)
248
+ return { matches: limited, ambiguous }
249
+ }
250
+
251
+ /** Extract `github:owner/repo#...` from a documented install command. */
252
+ export function githubSpecOf(entry) {
253
+ if (entry.install) {
254
+ const match = /github:[^\s"']+/.exec(entry.install)
255
+ if (match) return match[0]
256
+ }
257
+ const urlMatch = /^https:\/\/github\.com\/([^/]+)\/([^/]+)(?:\/tree\/[^/]+\/(.+))?/.exec(entry.url)
258
+ if (!urlMatch) return null
259
+ const subpath = urlMatch[3] ? `#path:/${urlMatch[3]}` : ''
260
+ return `github:${urlMatch[1]}/${urlMatch[2]}${subpath}`
261
+ }
262
+
263
+ /** Extract the repository slug (`owner/repo`) for a catalog entry. */
264
+ export function repoSlugOf(entry) {
265
+ const match = /github\.com\/([^/]+)\/([^/]+)/.exec(entry.url ?? '')
266
+ if (!match) return null
267
+ return `${match[1]}/${match[2].replace(/\.git$/, '')}`
268
+ }
269
+
270
+ /** Repository checkout directory for a GitHub source build. */
271
+ export function sourceDirOf(repoSlug) {
272
+ const safe = repoSlug.replace(/[^A-Za-z0-9._-]+/g, '__')
273
+ const home = process.env.DSH_HOME && process.env.DSH_HOME !== '' ? process.env.DSH_HOME : ''
274
+ return path.join(home, 'plugin-sources', safe)
275
+ }
276
+
277
+ /** Whether a repository checkout already exists. */
278
+ export function hasSourceCheckout(dir) {
279
+ return dir !== '' && dirExists(dir)
280
+ }
281
+