diff --git a/.github/workflows/scout.yml b/.github/workflows/scout.yml index 45c2c47..9ee4ea1 100644 --- a/.github/workflows/scout.yml +++ b/.github/workflows/scout.yml @@ -1,6 +1,6 @@ -# Weekly discovery of OpenCode V2 plugins on npm that are not in the catalog yet. +# Weekly discovery of OpenCode V2 plugins and SDK tools on npm that are not in the catalog yet. # Opens a "Scout" issue; ticking a candidate's box opens a PR (see issues.yml). -name: Scout V2 plugins +name: Scout V2 packages on: schedule: diff --git a/scripts/scout.js b/scripts/scout.js index 885196d..8dba76f 100644 --- a/scripts/scout.js +++ b/scripts/scout.js @@ -22,7 +22,7 @@ const { getNpmLatest, getNpmWeeklyDownloads, getRepos, rest } = require('./utils const { openPullRequest } = require('./utils/git-pr'); const { REPO, ensureLabels, listOpenPulls } = require('./utils/repo'); const { buildEntry } = require('./utils/submission'); -const { suggestTopics } = require('./utils/suggest'); +const { suggestKind, suggestTopics } = require('./utils/suggest'); const V2_RELEASED = '2026-09-02'; const SEARCHES = ['keywords:opencode-plugin', '@opencode/plugin', 'keywords:opencode']; @@ -144,13 +144,16 @@ async function findCandidates(limit) { function candidateInput(c) { const description = truncate(c.info.description || c.manifest.description || c.pkg.description || '', 600); const text = [c.pkg.name, description, (c.manifest.keywords || []).join(' ')].join(' '); + // Packages built on the plugin API are plugins; other @opencode/* consumers (SDK/client) are projects + const isPlugin = c.markers.v2.some(m => /^@opencode(-ai)?\/plugin\b/.test(m)); + const category = isPlugin ? 'plugins' : 'projects'; return { name: c.pkg.name, repo: c.source.repo, path: c.source.path, npm: c.pkg.name, - category: 'plugins', - kind: 'plugin', + category, + kind: isPlugin ? 'plugin' : suggestKind('projects', text), topics: suggestTopics(text), tagline: truncate(c.manifest.description || description || c.pkg.name, 120), description: description || c.pkg.name @@ -164,10 +167,11 @@ async function run({ dryRun, limit }) { const lines = candidates.map((c, i) => { const id = `c${i + 1}`; data[id] = candidateInput(c); - return `- [ ] **${c.pkg.name}** — ${truncate(c.manifest.description || '', 140)} · ⭐ ${c.info.stargazerCount} · ⬇ ${c.downloads}/wk · [repo](${c.source.repo}) · [npm](https://www.npmjs.com/package/${c.pkg.name}) `; + const label = data[id].category === 'plugins' ? 'plugin' : data[id].kind; + return `- [ ] **${c.pkg.name}** (${label}) — ${truncate(c.manifest.description || '', 140)} · ⭐ ${c.info.stargazerCount} · ⬇ ${c.downloads}/wk · [repo](${c.source.repo}) · [npm](https://www.npmjs.com/package/${c.pkg.name}) `; }); const body = [ - `OpenCode V2 plugins on npm that are not in the catalog yet: published since ${V2_RELEASED}, depending on \`@opencode/*\`, public, licensed, not archived or forked. Ranked by weekly downloads, then stars.`, + `OpenCode V2 packages on npm that are not in the catalog yet: published since ${V2_RELEASED}, depending on \`@opencode/*\` (plugins on \`@opencode/plugin\`, other tools on the SDK/client), public, licensed, not archived or forked. Ranked by weekly downloads, then stars.`, '', '**Tick a box to open a pull request for that candidate** (maintainers only; topics are a keyword guess, fix them in the PR). To stop suggesting a package, add it to [`discovery/declined.txt`](https://github.com/Heretek-AI/awesome-opencode/blob/main/discovery/declined.txt).', '', @@ -183,7 +187,7 @@ async function run({ dryRun, limit }) { for (const old of Array.isArray(open) ? open : []) { if (!old.pull_request) await rest(`/repos/${REPO}/issues/${old.number}`, { method: 'PATCH', body: { state: 'closed', state_reason: 'completed' } }); } - const res = await rest(`/repos/${REPO}/issues`, { method: 'POST', body: { title: `Scout: V2 plugin candidates (${week})`, body, labels: ['scout'] } }); + const res = await rest(`/repos/${REPO}/issues`, { method: 'POST', body: { title: `Scout: V2 candidates (${week})`, body, labels: ['scout'] } }); console.log(`Opened scout issue #${res.data.number} with ${candidates.length} candidate(s)`); } @@ -211,11 +215,11 @@ async function accept({ issueNumber, sender, dryRun }) { note = ` → not added: ${result.problems.join('; ').slice(0, 200)}`; } else { const pr = await openPullRequest({ - branch: `scout/${result.file.replace(/^data\/plugins\/|\.yaml$/g, '')}`, + branch: `scout/${result.file.replace(/^data\/[a-z]+\/|\.yaml$/g, '')}`, files: { [result.file]: result.text }, - title: `docs: add ${input.name} to plugins`, + title: `docs: add ${input.name} to ${input.category}`, body: [`Found by the weekly scout in #${issueNumber}.`, '', ...result.notes.map(n => `- ${n}`), '', 'Topics are a keyword guess; adjust them before merging.'].join('\n'), - labels: ['scout', 'category: plugins'], + labels: ['scout', `category: ${input.category}`], dryRun }); note = pr ? ` → #${pr.number}` : ' → (no changes)'; diff --git a/scripts/utils/pr-checks.js b/scripts/utils/pr-checks.js index b775e69..e0a5bc3 100644 --- a/scripts/utils/pr-checks.js +++ b/scripts/utils/pr-checks.js @@ -7,8 +7,9 @@ const path = require('path'); const yaml = require('js-yaml'); -const { CATEGORIES, canonicalKey, checkCatalog } = require('./catalog'); +const { CATEGORIES, canonicalKey, checkCatalog, parseRepoUrl } = require('./catalog'); const { probeCompat } = require('./compat'); +const { getRepos } = require('./github'); // Generated files a PR must never edit by hand const GENERATED_FILES = ['README.md', 'dist/registry.json']; @@ -47,6 +48,7 @@ async function analyzePullRequest({ changes, baseEntries, probe = true }) { // Overlay the PR's data changes onto the base catalog const head = new Map(baseEntries.map(e => [e.file, e])); const changed = []; + const retargeted = []; for (const change of changes.filter(c => DATA_FILE_RE.test(c.path))) { head.delete(change.path); if (change.status === 'removed') continue; @@ -69,13 +71,26 @@ async function analyzePullRequest({ changes, baseEntries, probe = true }) { if (change.status === 'modified' && change.baseContent) { try { const before = yaml.load(change.baseContent); - if (before && before.repo && data.repo && canonicalKey(before) !== canonicalKey(data)) { - warnings.push({ file: change.path, kind: 'human', message: `changes this entry's target from ${before.repo} to ${data.repo}. If this is a different project, add a new file instead of reusing an existing filename.` }); + // Adding or changing `path` within the same repo is not a new target + if (before && before.repo && data.repo && canonicalKey({ repo: before.repo }) !== canonicalKey({ repo: data.repo })) { + retargeted.push({ file: change.path, from: before.repo, to: data.repo }); } } catch { /* base file was already broken */ } } } + // A changed repo URL is fine when GitHub redirects the old name to the new one (rename/transfer) + if (retargeted.length) { + const olds = retargeted.map(r => parseRepoUrl(r.from)).filter(p => p && p.host === 'github.com'); + const resolved = probe ? await getRepos(olds.map(p => ({ owner: p.owner, name: p.name }))).catch(() => new Map()) : new Map(); + for (const r of retargeted) { + const old = parseRepoUrl(r.from); + const info = old && resolved.get(`${old.owner}/${old.name}`); + if (info && canonicalKey({ repo: `https://github.com/${info.nameWithOwner}` }) === canonicalKey({ repo: r.to })) continue; + warnings.push({ file: r.file, kind: 'human', message: `changes this entry's target from ${r.from} to ${r.to}. If this is a different project, add a new file instead of reusing an existing filename.` }); + } + } + // YAML files outside data// for (const change of changes) { if (change.path.startsWith('data/') && change.path.endsWith('.yaml') && !DATA_FILE_RE.test(change.path) && !change.path.startsWith('data/examples/') && change.status !== 'removed') {