@shnitzel/plugscout 0.3.1
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/LICENSE +21 -0
- package/README.md +228 -0
- package/assets/cli/logo.txt +24 -0
- package/config/item-insights.json +316 -0
- package/config/providers.json +46 -0
- package/config/ranking-policy.json +14 -0
- package/config/recommendation-weights.json +7 -0
- package/config/registries.json +1423 -0
- package/config/security-policy.json +19 -0
- package/config/sources.json +30 -0
- package/data/catalog/items.json +182109 -0
- package/data/catalog/mcps.json +163843 -0
- package/data/catalog/skills.json +4768 -0
- package/data/catalog/sync-state.json +62 -0
- package/data/curated/mcps.json +78 -0
- package/data/curated/skills.json +174 -0
- package/data/quarantine/quarantined.json +3 -0
- package/data/raw/2024-05-15/mcps.json +20 -0
- package/data/raw/2024-05-20/skills.json +20 -0
- package/data/raw/2024-06-05/mcps.json +20 -0
- package/data/raw/2024-06-05/skills.json +29 -0
- package/data/security-reports/.gitkeep +0 -0
- package/data/security-reports/2026-02-06/report.json +8 -0
- package/data/security-reports/2026-02-10/report.json +9 -0
- package/data/security-reports/2026-02-11/report.json +9 -0
- package/data/security-reports/2026-02-12/report.json +9 -0
- package/data/security-reports/2026-02-13/report.json +8 -0
- package/data/security-reports/2026-02-14/report.json +8 -0
- package/data/security-reports/2026-02-23/report.json +8 -0
- package/data/security-reports/2026-02-25/report.json +8 -0
- package/data/security-reports/2026-02-26/report.json +8 -0
- package/data/security-reports/2026-03-10/report.json +8 -0
- package/data/security-reports/audits/.gitkeep +0 -0
- package/data/security-reports/audits/2026-02-06T10-17-33-872Z-mcp_remote-browser.json +8 -0
- package/data/security-reports/audits/2026-02-06T10-17-33-881Z-mcp_remote-browser.json +8 -0
- package/data/security-reports/audits/2026-02-10T20-22-24-474Z-mcp_remote-browser.json +8 -0
- package/data/security-reports/audits/2026-02-10T20-22-24-483Z-mcp_remote-browser.json +8 -0
- package/data/security-reports/audits/2026-02-10T20-42-12-305Z-mcp_remote-browser.json +8 -0
- package/data/security-reports/audits/2026-02-10T20-42-12-319Z-mcp_remote-browser.json +8 -0
- package/data/security-reports/audits/2026-02-10T20-43-15-728Z-mcp_remote-browser.json +8 -0
- package/data/security-reports/audits/2026-02-10T20-43-15-738Z-mcp_remote-browser.json +8 -0
- package/data/security-reports/audits/2026-02-10T21-22-14-047Z-mcp_remote-browser.json +8 -0
- package/data/security-reports/audits/2026-02-10T21-22-14-051Z-mcp_remote-browser.json +8 -0
- package/data/security-reports/audits/2026-02-10T21-29-59-237Z-mcp_remote-browser.json +8 -0
- package/data/security-reports/audits/2026-02-10T21-29-59-243Z-mcp_remote-browser.json +8 -0
- package/data/security-reports/audits/2026-02-11T20-21-51-074Z-mcp_remote-browser.json +8 -0
- package/data/security-reports/audits/2026-02-11T20-21-51-123Z-mcp_remote-browser.json +8 -0
- package/data/security-reports/audits/2026-02-11T20-28-33-021Z-mcp_remote-browser.json +8 -0
- package/data/security-reports/audits/2026-02-11T20-28-33-026Z-mcp_remote-browser.json +8 -0
- package/data/security-reports/audits/2026-02-11T20-34-43-623Z-mcp_remote-browser.json +8 -0
- package/data/security-reports/audits/2026-02-11T20-34-43-625Z-mcp_remote-browser.json +8 -0
- package/data/security-reports/audits/2026-02-11T21-06-33-281Z-mcp_remote-browser.json +8 -0
- package/data/security-reports/audits/2026-02-11T21-06-33-285Z-mcp_remote-browser.json +8 -0
- package/data/security-reports/audits/2026-02-11T21-08-58-836Z-mcp_remote-browser.json +8 -0
- package/data/security-reports/audits/2026-02-11T21-08-58-843Z-mcp_remote-browser.json +8 -0
- package/data/security-reports/audits/2026-02-12T12-26-07-150Z-mcp_remote-browser.json +8 -0
- package/data/security-reports/audits/2026-02-12T12-26-07-159Z-mcp_remote-browser.json +8 -0
- package/data/security-reports/audits/2026-02-12T14-37-36-565Z-mcp_remote-browser.json +8 -0
- package/data/security-reports/audits/2026-02-12T14-37-36-569Z-mcp_remote-browser.json +8 -0
- package/data/security-reports/audits/2026-02-12T14-47-32-103Z-mcp_remote-browser.json +8 -0
- package/data/security-reports/audits/2026-02-12T14-47-32-213Z-mcp_remote-browser.json +8 -0
- package/data/security-reports/audits/2026-02-12T14-47-47-769Z-mcp_filesystem.json +8 -0
- package/data/security-reports/audits/2026-02-12T15-05-49-085Z-mcp_remote-browser.json +8 -0
- package/data/security-reports/audits/2026-02-12T15-05-49-087Z-mcp_remote-browser.json +8 -0
- package/data/security-reports/audits/2026-02-12T16-37-42-204Z-mcp_remote-browser.json +8 -0
- package/data/security-reports/audits/2026-02-12T16-37-42-243Z-mcp_remote-browser.json +8 -0
- package/data/security-reports/audits/2026-02-12T16-47-16-589Z-mcp_remote-browser.json +8 -0
- package/data/security-reports/audits/2026-02-12T16-47-16-596Z-mcp_remote-browser.json +8 -0
- package/data/security-reports/audits/2026-02-12T17-38-24-899Z-mcp_remote-browser.json +8 -0
- package/data/security-reports/audits/2026-02-12T17-38-24-905Z-mcp_remote-browser.json +8 -0
- package/data/security-reports/audits/2026-02-12T17-56-00-835Z-mcp_remote-browser.json +8 -0
- package/data/security-reports/audits/2026-02-12T17-56-00-840Z-mcp_remote-browser.json +8 -0
- package/data/security-reports/audits/2026-02-12T18-19-26-005Z-mcp_remote-browser.json +8 -0
- package/data/security-reports/audits/2026-02-12T18-19-26-008Z-mcp_remote-browser.json +8 -0
- package/data/security-reports/audits/2026-02-12T18-34-38-642Z-mcp_remote-browser.json +8 -0
- package/data/security-reports/audits/2026-02-12T18-34-38-645Z-mcp_remote-browser.json +8 -0
- package/data/security-reports/audits/2026-02-13T05-44-27-648Z-mcp_remote-browser.json +8 -0
- package/data/security-reports/audits/2026-02-13T05-44-27-656Z-mcp_remote-browser.json +8 -0
- package/data/security-reports/audits/2026-02-13T05-48-50-827Z-mcp_remote-browser.json +8 -0
- package/data/security-reports/audits/2026-02-13T05-48-50-900Z-mcp_remote-browser.json +8 -0
- package/data/security-reports/audits/2026-02-13T10-53-33-850Z-mcp_remote-browser.json +8 -0
- package/data/security-reports/audits/2026-02-13T10-53-33-853Z-mcp_remote-browser.json +8 -0
- package/data/security-reports/audits/2026-02-14T17-51-27-279Z-mcp_remote-browser.json +8 -0
- package/data/security-reports/audits/2026-02-14T17-51-27-282Z-mcp_remote-browser.json +8 -0
- package/data/security-reports/audits/2026-02-14T19-43-39-991Z-mcp_remote-browser.json +8 -0
- package/data/security-reports/audits/2026-02-14T19-43-39-997Z-mcp_remote-browser.json +8 -0
- package/data/security-reports/audits/2026-02-23T19-24-43-515Z-mcp_remote-browser.json +8 -0
- package/data/security-reports/audits/2026-02-23T19-24-43-518Z-mcp_remote-browser.json +8 -0
- package/data/security-reports/audits/2026-02-25T14-45-02-763Z-mcp_remote-browser.json +8 -0
- package/data/security-reports/audits/2026-02-25T14-45-02-778Z-mcp_remote-browser.json +8 -0
- package/data/security-reports/audits/2026-02-25T14-46-58-957Z-mcp_remote-browser.json +8 -0
- package/data/security-reports/audits/2026-02-25T14-46-58-960Z-mcp_remote-browser.json +8 -0
- package/data/security-reports/audits/2026-02-25T14-57-37-133Z-mcp_remote-browser.json +8 -0
- package/data/security-reports/audits/2026-02-25T14-57-37-139Z-mcp_remote-browser.json +8 -0
- package/data/security-reports/audits/2026-02-25T15-03-23-507Z-mcp_remote-browser.json +8 -0
- package/data/security-reports/audits/2026-02-25T15-03-23-513Z-mcp_remote-browser.json +8 -0
- package/data/security-reports/audits/2026-02-25T15-03-41-157Z-mcp_remote-browser.json +8 -0
- package/data/security-reports/audits/2026-02-25T15-03-41-162Z-mcp_remote-browser.json +8 -0
- package/data/security-reports/audits/2026-02-25T15-05-18-042Z-mcp_remote-browser.json +8 -0
- package/data/security-reports/audits/2026-02-25T15-05-18-048Z-mcp_remote-browser.json +8 -0
- package/data/security-reports/audits/2026-02-25T15-39-08-519Z-mcp_remote-browser.json +8 -0
- package/data/security-reports/audits/2026-02-25T15-39-08-526Z-mcp_remote-browser.json +8 -0
- package/data/security-reports/audits/2026-02-25T18-35-54-463Z-mcp_remote-browser.json +8 -0
- package/data/security-reports/audits/2026-02-25T18-35-54-466Z-mcp_remote-browser.json +8 -0
- package/data/security-reports/audits/2026-02-26T05-52-21-092Z-mcp_remote-browser.json +8 -0
- package/data/security-reports/audits/2026-02-26T05-52-21-093Z-mcp_remote-browser.json +8 -0
- package/data/security-reports/audits/2026-02-26T05-52-27-076Z-mcp_remote-browser.json +8 -0
- package/data/security-reports/audits/2026-02-26T05-52-27-079Z-mcp_remote-browser.json +8 -0
- package/data/security-reports/audits/2026-02-26T05-52-27-084Z-mcp_remote-browser.json +8 -0
- package/data/security-reports/audits/2026-02-26T05-52-27-086Z-mcp_remote-browser.json +8 -0
- package/data/security-reports/audits/2026-02-26T05-52-37-249Z-mcp_remote-browser.json +8 -0
- package/data/security-reports/audits/2026-02-26T05-52-37-258Z-mcp_remote-browser.json +8 -0
- package/data/security-reports/audits/2026-02-26T05-52-37-259Z-mcp_remote-browser.json +8 -0
- package/data/security-reports/audits/2026-02-26T05-52-37-274Z-mcp_remote-browser.json +8 -0
- package/data/security-reports/audits/2026-02-26T05-53-28-389Z-mcp_remote-browser.json +8 -0
- package/data/security-reports/audits/2026-02-26T05-53-28-391Z-mcp_remote-browser.json +8 -0
- package/data/security-reports/audits/2026-02-26T05-53-33-868Z-mcp_remote-browser.json +8 -0
- package/data/security-reports/audits/2026-02-26T05-53-33-880Z-mcp_remote-browser.json +8 -0
- package/data/security-reports/audits/2026-02-26T05-53-33-892Z-mcp_remote-browser.json +8 -0
- package/data/security-reports/audits/2026-02-26T05-53-33-900Z-mcp_remote-browser.json +8 -0
- package/data/security-reports/audits/2026-02-26T05-53-43-064Z-mcp_remote-browser.json +8 -0
- package/data/security-reports/audits/2026-02-26T05-53-43-066Z-mcp_remote-browser.json +8 -0
- package/data/security-reports/audits/2026-02-26T05-53-43-068Z-mcp_remote-browser.json +8 -0
- package/data/security-reports/audits/2026-02-26T14-55-47-466Z-claude-plugin_workspace-ops.json +8 -0
- package/data/security-reports/audits/2026-02-26T14-55-47-468Z-copilot-extension_repo-security.json +8 -0
- package/data/security-reports/audits/2026-02-26T16-55-59-431Z-mcp_remote-browser.json +8 -0
- package/data/security-reports/audits/2026-02-26T16-55-59-432Z-mcp_remote-browser.json +8 -0
- package/data/security-reports/audits/2026-02-26T16-55-59-435Z-mcp_remote-browser.json +8 -0
- package/data/security-reports/audits/2026-02-26T16-55-59-439Z-mcp_remote-browser.json +8 -0
- package/data/security-reports/audits/2026-02-26T16-56-08-566Z-mcp_remote-browser.json +8 -0
- package/data/security-reports/audits/2026-02-26T16-56-08-570Z-mcp_remote-browser.json +8 -0
- package/data/security-reports/audits/2026-02-26T16-56-08-589Z-mcp_remote-browser.json +8 -0
- package/data/security-reports/audits/2026-02-26T16-56-08-591Z-mcp_remote-browser.json +8 -0
- package/data/security-reports/audits/2026-02-26T16-56-47-356Z-mcp_remote-browser.json +8 -0
- package/data/security-reports/audits/2026-02-26T16-56-47-358Z-mcp_remote-browser.json +8 -0
- package/data/security-reports/audits/2026-02-26T16-56-53-607Z-mcp_remote-browser.json +8 -0
- package/data/security-reports/audits/2026-02-26T16-56-53-612Z-mcp_remote-browser.json +8 -0
- package/data/security-reports/audits/2026-02-26T16-56-53-624Z-mcp_remote-browser.json +8 -0
- package/data/security-reports/audits/2026-02-26T16-56-53-628Z-mcp_remote-browser.json +8 -0
- package/data/security-reports/audits/2026-02-26T16-57-09-879Z-mcp_remote-browser.json +8 -0
- package/data/security-reports/audits/2026-02-26T16-57-09-881Z-mcp_remote-browser.json +8 -0
- package/data/security-reports/audits/2026-02-26T16-57-10-846Z-mcp_remote-browser.json +8 -0
- package/data/security-reports/audits/2026-02-26T16-57-10-848Z-mcp_remote-browser.json +8 -0
- package/data/security-reports/audits/2026-03-10T18-15-05-007Z-claude-plugin_playwright.json +8 -0
- package/data/security-reports/audits/2026-03-10T18-36-16-092Z-claude-plugin_playwright.json +8 -0
- package/data/whitelist/approved.json +5 -0
- package/dist/catalog/adapter.js +39 -0
- package/dist/catalog/adapters/claude-code-marketplace-v1.js +260 -0
- package/dist/catalog/adapters/claude-connectors-scrape-v1.js +107 -0
- package/dist/catalog/adapters/claude-plugins-scrape-v1.js +107 -0
- package/dist/catalog/adapters/claude-plugins-v0.1.js +48 -0
- package/dist/catalog/adapters/copilot-extensions-v0.1.js +48 -0
- package/dist/catalog/adapters/copilot-plugin-marketplace-v1.js +117 -0
- package/dist/catalog/adapters/mcp-registry-v0.1.js +211 -0
- package/dist/catalog/adapters/openai-skills-github-v1.js +100 -0
- package/dist/catalog/adapters/openai-skills-v1.js +48 -0
- package/dist/catalog/adapters/shared.js +94 -0
- package/dist/catalog/remote-registry.js +196 -0
- package/dist/catalog/repository.js +161 -0
- package/dist/catalog/sync-state.js +61 -0
- package/dist/catalog/sync.js +153 -0
- package/dist/cli.js +25 -0
- package/dist/commands/ExplainerVideo.js +225 -0
- package/dist/commands/ingest.js +11 -0
- package/dist/commands/validate-data.js +10 -0
- package/dist/config/runtime.js +51 -0
- package/dist/config/sources.js +21 -0
- package/dist/ingestion/mcps.js +77 -0
- package/dist/ingestion/skills.js +76 -0
- package/dist/install/dependencies.js +58 -0
- package/dist/install/review-state.js +70 -0
- package/dist/install/skillsh.js +245 -0
- package/dist/interfaces/cli/doctor.js +90 -0
- package/dist/interfaces/cli/formatters/colors.js +24 -0
- package/dist/interfaces/cli/formatters/csv.js +10 -0
- package/dist/interfaces/cli/formatters/json.js +3 -0
- package/dist/interfaces/cli/formatters/markdown.js +6 -0
- package/dist/interfaces/cli/formatters/table.js +82 -0
- package/dist/interfaces/cli/index.js +1277 -0
- package/dist/interfaces/cli/options.js +93 -0
- package/dist/interfaces/cli/output.js +9 -0
- package/dist/interfaces/cli/types.js +1 -0
- package/dist/interfaces/cli/ui/home.js +114 -0
- package/dist/interfaces/cli/ui/web-report.js +384 -0
- package/dist/interfaces/cli/update-check.js +180 -0
- package/dist/lib/json.js +11 -0
- package/dist/lib/logger.js +13 -0
- package/dist/lib/paths.js +18 -0
- package/dist/lib/validation/contracts.js +245 -0
- package/dist/mcps/normalize.js +38 -0
- package/dist/models/records.js +31 -0
- package/dist/recommendation/engine.js +135 -0
- package/dist/recommendation/project-analysis.js +231 -0
- package/dist/recommendation/requirements.js +58 -0
- package/dist/security/assessment.js +56 -0
- package/dist/security/whitelist.js +70 -0
- package/dist/skills/normalize.js +39 -0
- package/dist/validation/curated.js +72 -0
- package/dist/video/Root.js +6 -0
- package/dist/video/index.js +3 -0
- package/package.json +102 -0
|
@@ -0,0 +1,1277 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import { createInterface } from 'node:readline/promises';
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
import { stdin, stdout } from 'node:process';
|
|
6
|
+
import { loadItemInsights, loadRegistries, loadSecurityPolicy } from '../../config/runtime.js';
|
|
7
|
+
import { syncCatalogs } from '../../catalog/sync.js';
|
|
8
|
+
import { getStaleRegistries, loadSyncState } from '../../catalog/sync-state.js';
|
|
9
|
+
import { loadCatalogItemById, loadCatalogItems, loadQuarantine, loadWhitelist } from '../../catalog/repository.js';
|
|
10
|
+
import { installToolkitDependencies } from '../../install/dependencies.js';
|
|
11
|
+
import { installWithSkillSh } from '../../install/skillsh.js';
|
|
12
|
+
import { recordItemReview } from '../../install/review-state.js';
|
|
13
|
+
import { logger } from '../../lib/logger.js';
|
|
14
|
+
import { getPackagePath } from '../../lib/paths.js';
|
|
15
|
+
import { CatalogKindSchema } from '../../lib/validation/contracts.js';
|
|
16
|
+
import { detectProjectSignals } from '../../recommendation/project-analysis.js';
|
|
17
|
+
import { recommend } from '../../recommendation/engine.js';
|
|
18
|
+
import { loadRequirementsProfile } from '../../recommendation/requirements.js';
|
|
19
|
+
import { assessRisk, buildAssessment } from '../../security/assessment.js';
|
|
20
|
+
import { applyQuarantineFromReport, verifyWhitelist } from '../../security/whitelist.js';
|
|
21
|
+
import { runDoctorChecks } from './doctor.js';
|
|
22
|
+
import { renderCsv } from './formatters/csv.js';
|
|
23
|
+
import { renderJson } from './formatters/json.js';
|
|
24
|
+
import { renderMarkdown } from './formatters/markdown.js';
|
|
25
|
+
import { colorRisk, colors } from './formatters/colors.js';
|
|
26
|
+
import { renderTable, scoreBar } from './formatters/table.js';
|
|
27
|
+
import { printHint, printJson } from './output.js';
|
|
28
|
+
import { hasFlag, readCsvList, readFlag, readKinds, readLimit, readSort } from './options.js';
|
|
29
|
+
import { renderHomeScreen } from './ui/home.js';
|
|
30
|
+
import { writeWebReport } from './ui/web-report.js';
|
|
31
|
+
import { checkForUpdateNow, maybeNotifyAboutUpdate, RELEASE_DOWNLOAD_URL } from './update-check.js';
|
|
32
|
+
const COMMAND_ALIASES = {
|
|
33
|
+
home: 'home',
|
|
34
|
+
about: 'about',
|
|
35
|
+
status: 'status',
|
|
36
|
+
init: 'init',
|
|
37
|
+
doctor: 'doctor',
|
|
38
|
+
list: 'list',
|
|
39
|
+
ls: 'list',
|
|
40
|
+
show: 'show',
|
|
41
|
+
inspect: 'show',
|
|
42
|
+
info: 'show',
|
|
43
|
+
details: 'show',
|
|
44
|
+
search: 'search',
|
|
45
|
+
find: 'search',
|
|
46
|
+
explain: 'explain',
|
|
47
|
+
why: 'explain',
|
|
48
|
+
scan: 'scan',
|
|
49
|
+
top: 'top',
|
|
50
|
+
sync: 'sync',
|
|
51
|
+
recommend: 'recommend',
|
|
52
|
+
rec: 'recommend',
|
|
53
|
+
reco: 'recommend',
|
|
54
|
+
web: 'web',
|
|
55
|
+
assess: 'assess',
|
|
56
|
+
install: 'install',
|
|
57
|
+
whitelist: 'whitelist',
|
|
58
|
+
quarantine: 'quarantine',
|
|
59
|
+
upgrade: 'upgrade',
|
|
60
|
+
setup: 'setup',
|
|
61
|
+
help: 'help'
|
|
62
|
+
};
|
|
63
|
+
export async function runCli(argv) {
|
|
64
|
+
const noUpdateCheck = hasFlag(argv, '--no-update-check');
|
|
65
|
+
const filtered = argv.filter((arg) => arg !== '--no-update-check');
|
|
66
|
+
const [rawCommand = 'home', ...rest] = filtered;
|
|
67
|
+
const command = normalizeCommand(rawCommand);
|
|
68
|
+
if (!command) {
|
|
69
|
+
printUnknownCommand(rawCommand);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
switch (command) {
|
|
73
|
+
case 'home':
|
|
74
|
+
await handleHome();
|
|
75
|
+
break;
|
|
76
|
+
case 'about':
|
|
77
|
+
await handleAbout();
|
|
78
|
+
break;
|
|
79
|
+
case 'status':
|
|
80
|
+
await handleStatus(rest);
|
|
81
|
+
break;
|
|
82
|
+
case 'init':
|
|
83
|
+
await handleInit(rest);
|
|
84
|
+
break;
|
|
85
|
+
case 'doctor':
|
|
86
|
+
await handleDoctor(rest);
|
|
87
|
+
break;
|
|
88
|
+
case 'list':
|
|
89
|
+
await handleList(rest);
|
|
90
|
+
break;
|
|
91
|
+
case 'show':
|
|
92
|
+
await handleShow(rest);
|
|
93
|
+
break;
|
|
94
|
+
case 'search':
|
|
95
|
+
await handleSearch(rest);
|
|
96
|
+
break;
|
|
97
|
+
case 'explain':
|
|
98
|
+
await handleExplain(rest);
|
|
99
|
+
break;
|
|
100
|
+
case 'scan':
|
|
101
|
+
await handleScan(rest);
|
|
102
|
+
break;
|
|
103
|
+
case 'top':
|
|
104
|
+
await handleTop(rest);
|
|
105
|
+
break;
|
|
106
|
+
case 'sync':
|
|
107
|
+
await handleSync(rest);
|
|
108
|
+
break;
|
|
109
|
+
case 'recommend':
|
|
110
|
+
await handleRecommend(rest);
|
|
111
|
+
break;
|
|
112
|
+
case 'web':
|
|
113
|
+
await handleWeb(rest);
|
|
114
|
+
break;
|
|
115
|
+
case 'assess':
|
|
116
|
+
await handleAssess(rest);
|
|
117
|
+
break;
|
|
118
|
+
case 'install':
|
|
119
|
+
await handleInstall(rest);
|
|
120
|
+
break;
|
|
121
|
+
case 'whitelist':
|
|
122
|
+
await handleWhitelist(rest);
|
|
123
|
+
break;
|
|
124
|
+
case 'quarantine':
|
|
125
|
+
await handleQuarantine(rest);
|
|
126
|
+
break;
|
|
127
|
+
case 'upgrade':
|
|
128
|
+
await handleUpgrade(rest);
|
|
129
|
+
break;
|
|
130
|
+
case 'setup':
|
|
131
|
+
await handleSetup(rest);
|
|
132
|
+
break;
|
|
133
|
+
case 'help':
|
|
134
|
+
printHelp();
|
|
135
|
+
break;
|
|
136
|
+
default:
|
|
137
|
+
printHelp();
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
if (command !== 'help' && command !== 'upgrade') {
|
|
141
|
+
await maybeNotifyAboutUpdate({ disableAutoCheck: noUpdateCheck });
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
async function handleHome() {
|
|
145
|
+
const output = await renderHomeScreen();
|
|
146
|
+
console.log(output);
|
|
147
|
+
}
|
|
148
|
+
async function handleAbout() {
|
|
149
|
+
const packageRaw = await fs.readFile(getPackagePath('package.json'), 'utf8');
|
|
150
|
+
const pkg = JSON.parse(packageRaw);
|
|
151
|
+
console.log(`${pkg.name ?? 'plugscout'} v${pkg.version ?? '0.0.0'}`);
|
|
152
|
+
if (pkg.description) {
|
|
153
|
+
console.log(pkg.description);
|
|
154
|
+
}
|
|
155
|
+
if (pkg.author) {
|
|
156
|
+
console.log(`Author: ${pkg.author}`);
|
|
157
|
+
}
|
|
158
|
+
console.log('Scope: Claude plugins, Claude connectors, Copilot extensions, Skills, MCP servers');
|
|
159
|
+
console.log('Ranking: trust-first (fit + trust - risk penalties + freshness bonus)');
|
|
160
|
+
console.log('Meaning: top/recommend output is repo-aware guidance, not a global popularity leaderboard.');
|
|
161
|
+
console.log('Install discipline: review each suggestion, check provenance and risk, and do not install blindly from rank alone.');
|
|
162
|
+
console.log('Install gate: install requires a recent `show` or `assess` for the same item, unless --override-review is used.');
|
|
163
|
+
console.log('Sources: official-first provider registries with local fallback');
|
|
164
|
+
}
|
|
165
|
+
async function handleStatus(args) {
|
|
166
|
+
const verbose = hasFlag(args, '--verbose');
|
|
167
|
+
const [items, whitelist, quarantine, syncState, policy] = await Promise.all([
|
|
168
|
+
loadCatalogItems(),
|
|
169
|
+
loadWhitelist(),
|
|
170
|
+
loadQuarantine(),
|
|
171
|
+
loadSyncState(),
|
|
172
|
+
loadSecurityPolicy()
|
|
173
|
+
]);
|
|
174
|
+
const kindCounts = new Map();
|
|
175
|
+
const providerCounts = new Map();
|
|
176
|
+
items.forEach((item) => {
|
|
177
|
+
kindCounts.set(item.kind, (kindCounts.get(item.kind) ?? 0) + 1);
|
|
178
|
+
providerCounts.set(item.provider, (providerCounts.get(item.provider) ?? 0) + 1);
|
|
179
|
+
});
|
|
180
|
+
console.log('Catalog Status');
|
|
181
|
+
console.log(`Items: ${items.length}`);
|
|
182
|
+
console.log(`Kinds: skill=${kindCounts.get('skill') ?? 0}, mcp=${kindCounts.get('mcp') ?? 0}, claude-plugin=${kindCounts.get('claude-plugin') ?? 0}, claude-connector=${kindCounts.get('claude-connector') ?? 0}, copilot-extension=${kindCounts.get('copilot-extension') ?? 0}`);
|
|
183
|
+
console.log(`Providers: ${Array.from(providerCounts.entries())
|
|
184
|
+
.sort((a, b) => a[0].localeCompare(b[0]))
|
|
185
|
+
.map(([name, count]) => `${name}=${count}`)
|
|
186
|
+
.join(', ')}`);
|
|
187
|
+
console.log(`Whitelist approved: ${whitelist.size}`);
|
|
188
|
+
console.log(`Quarantined: ${quarantine.length}`);
|
|
189
|
+
const stale = getStaleRegistries(syncState);
|
|
190
|
+
console.log(`Stale registries (>48h): ${stale.length === 0 ? 'none' : stale.join(', ')}`);
|
|
191
|
+
if (items.length === 0) {
|
|
192
|
+
printHint('Catalog is empty. Run `plugscout sync` or `npm run sync` to fetch the latest entries.');
|
|
193
|
+
}
|
|
194
|
+
if (verbose) {
|
|
195
|
+
console.log('\nRegistry Sync State');
|
|
196
|
+
const entries = Object.entries(syncState.registries).sort((a, b) => a[0].localeCompare(b[0]));
|
|
197
|
+
if (entries.length === 0) {
|
|
198
|
+
console.log('- none yet');
|
|
199
|
+
}
|
|
200
|
+
else {
|
|
201
|
+
entries.forEach(([id, state]) => {
|
|
202
|
+
console.log(`- ${id}: lastSuccessfulSyncAt=${state.lastSuccessfulSyncAt ?? 'n/a'}, updatedSince=${state.lastUpdatedSince ?? 'n/a'}`);
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
const risks = items
|
|
206
|
+
.map((item) => ({ id: item.id, assessment: buildAssessment(item, policy) }))
|
|
207
|
+
.sort((a, b) => b.assessment.riskScore - a.assessment.riskScore)
|
|
208
|
+
.slice(0, 5);
|
|
209
|
+
console.log('\nTop Risks');
|
|
210
|
+
risks.forEach((entry) => {
|
|
211
|
+
console.log(`- ${entry.id}: ${entry.assessment.riskTier} (${entry.assessment.riskScore.toFixed(0)})`);
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
async function handleInit(args) {
|
|
216
|
+
const project = readFlag(args, '--project') ?? '.';
|
|
217
|
+
const root = path.resolve(project);
|
|
218
|
+
const [items, policy] = await Promise.all([loadCatalogItems(), loadSecurityPolicy()]);
|
|
219
|
+
const providers = Array.from(new Set(items.map((item) => item.provider))).sort((a, b) => a.localeCompare(b));
|
|
220
|
+
const defaultKinds = ['skill', 'mcp', 'claude-plugin', 'claude-connector', 'copilot-extension'];
|
|
221
|
+
const defaults = {
|
|
222
|
+
defaultKinds,
|
|
223
|
+
defaultProviders: providers,
|
|
224
|
+
riskPosture: 'balanced',
|
|
225
|
+
outputStyle: 'rich-table',
|
|
226
|
+
initializedAt: new Date().toISOString()
|
|
227
|
+
};
|
|
228
|
+
if (stdout.isTTY) {
|
|
229
|
+
const rl = createInterface({ input: stdin, output: stdout });
|
|
230
|
+
try {
|
|
231
|
+
const kindsAnswer = await rl.question(`Default kinds [${defaults.defaultKinds.join(',')}] (comma list): `);
|
|
232
|
+
if (kindsAnswer.trim().length > 0) {
|
|
233
|
+
defaults.defaultKinds = kindsAnswer
|
|
234
|
+
.split(',')
|
|
235
|
+
.map((v) => CatalogKindSchema.parse(v.trim()));
|
|
236
|
+
}
|
|
237
|
+
const providerAnswer = await rl.question(`Default providers [${defaults.defaultProviders.join(',')}] (comma list): `);
|
|
238
|
+
if (providerAnswer.trim().length > 0) {
|
|
239
|
+
defaults.defaultProviders = providerAnswer
|
|
240
|
+
.split(',')
|
|
241
|
+
.map((v) => v.trim())
|
|
242
|
+
.filter((v) => v.length > 0);
|
|
243
|
+
}
|
|
244
|
+
const riskAnswer = await rl.question('Risk posture [balanced|strict] (default balanced; strict hides blocked/high-risk by default): ');
|
|
245
|
+
if (riskAnswer.trim() === 'strict') {
|
|
246
|
+
defaults.riskPosture = 'strict';
|
|
247
|
+
}
|
|
248
|
+
const formatAnswer = await rl.question('Default output [rich-table|json] (default rich-table): ');
|
|
249
|
+
if (formatAnswer.trim() === 'json') {
|
|
250
|
+
defaults.outputStyle = 'json';
|
|
251
|
+
}
|
|
252
|
+
const syncAnswer = await rl.question('Run initial sync now? [Y/n]: ');
|
|
253
|
+
if (syncAnswer.trim().toLowerCase() !== 'n') {
|
|
254
|
+
await syncCatalogs();
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
finally {
|
|
258
|
+
rl.close();
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
else {
|
|
262
|
+
logger.info('Non-interactive shell detected; writing default .skills-mcps.json');
|
|
263
|
+
}
|
|
264
|
+
const file = path.join(root, '.skills-mcps.json');
|
|
265
|
+
await fs.writeFile(file, `${JSON.stringify(defaults, null, 2)}\n`, 'utf8');
|
|
266
|
+
console.log(`Initialized local CLI config: ${file}`);
|
|
267
|
+
console.log(`Risk scale (lower is safer): ${formatRiskScale(policy)}`);
|
|
268
|
+
console.log(`Risk posture "${defaults.riskPosture}": ${describeRiskPosture(defaults.riskPosture)}`);
|
|
269
|
+
printHint('Next: run `npm run doctor`, then `npm run recommend -- --project . --only-safe --limit 10`.');
|
|
270
|
+
}
|
|
271
|
+
async function handleSetup(args) {
|
|
272
|
+
const project = readFlag(args, '--project') ?? '.';
|
|
273
|
+
const root = path.resolve(project);
|
|
274
|
+
console.log('PlugScout setup');
|
|
275
|
+
console.log('');
|
|
276
|
+
// Step 1: install dependencies
|
|
277
|
+
console.log('Step 1/3: Installing prerequisites...');
|
|
278
|
+
const installed = await installToolkitDependencies();
|
|
279
|
+
if (installed.length === 0) {
|
|
280
|
+
console.log(' Prerequisites already satisfied.');
|
|
281
|
+
}
|
|
282
|
+
else {
|
|
283
|
+
console.log(` Installed: ${installed.join(', ')}`);
|
|
284
|
+
}
|
|
285
|
+
console.log('');
|
|
286
|
+
// Step 2: write default config (non-interactive)
|
|
287
|
+
console.log('Step 2/3: Initializing local config...');
|
|
288
|
+
const [items, policy] = await Promise.all([loadCatalogItems(), loadSecurityPolicy()]);
|
|
289
|
+
const providers = Array.from(new Set(items.map((item) => item.provider))).sort((a, b) => a.localeCompare(b));
|
|
290
|
+
const defaultKinds = ['skill', 'mcp', 'claude-plugin', 'claude-connector', 'copilot-extension'];
|
|
291
|
+
const defaults = {
|
|
292
|
+
defaultKinds,
|
|
293
|
+
defaultProviders: providers,
|
|
294
|
+
riskPosture: 'balanced',
|
|
295
|
+
outputStyle: 'rich-table',
|
|
296
|
+
initializedAt: new Date().toISOString()
|
|
297
|
+
};
|
|
298
|
+
const configFile = path.join(root, '.skills-mcps.json');
|
|
299
|
+
await fs.writeFile(configFile, `${JSON.stringify(defaults, null, 2)}\n`, 'utf8');
|
|
300
|
+
console.log(` Config written: ${configFile}`);
|
|
301
|
+
console.log(` Risk posture: ${defaults.riskPosture} — ${describeRiskPosture(defaults.riskPosture)}`);
|
|
302
|
+
console.log(` Risk scale: ${formatRiskScale(policy)}`);
|
|
303
|
+
console.log('');
|
|
304
|
+
// Step 3: sync catalogs
|
|
305
|
+
console.log('Step 3/3: Syncing catalogs...');
|
|
306
|
+
const result = await syncCatalogs();
|
|
307
|
+
console.log(` Synced ${result.items.length} items.`);
|
|
308
|
+
if (result.staleRegistries.length > 0) {
|
|
309
|
+
logger.warn(` Stale registries: ${result.staleRegistries.join(', ')}`);
|
|
310
|
+
}
|
|
311
|
+
console.log('');
|
|
312
|
+
// Doctor summary
|
|
313
|
+
console.log('Health check:');
|
|
314
|
+
const checks = await runDoctorChecks(project);
|
|
315
|
+
const failed = checks.filter((c) => c.status === 'fail');
|
|
316
|
+
const warnings = checks.filter((c) => c.status === 'warn');
|
|
317
|
+
checks.forEach((c) => {
|
|
318
|
+
const icon = c.status === 'pass' ? '✓' : c.status === 'warn' ? '⚠' : '✗';
|
|
319
|
+
console.log(` ${icon} ${c.name}: ${c.message}`);
|
|
320
|
+
});
|
|
321
|
+
console.log('');
|
|
322
|
+
if (failed.length > 0) {
|
|
323
|
+
console.log(`Setup complete with ${failed.length} issue(s). Run \`plugscout doctor\` for details.`);
|
|
324
|
+
}
|
|
325
|
+
else if (warnings.length > 0) {
|
|
326
|
+
console.log('Setup complete. Some warnings above — run `plugscout doctor` for details.');
|
|
327
|
+
}
|
|
328
|
+
else {
|
|
329
|
+
console.log('Setup complete. You\'re ready to go!');
|
|
330
|
+
}
|
|
331
|
+
printHint('Next: plugscout recommend --project . --only-safe --limit 10');
|
|
332
|
+
}
|
|
333
|
+
async function handleDoctor(args) {
|
|
334
|
+
const project = readFlag(args, '--project') ?? '.';
|
|
335
|
+
const installDeps = hasFlag(args, '--install-deps');
|
|
336
|
+
if (installDeps) {
|
|
337
|
+
const installed = await installToolkitDependencies();
|
|
338
|
+
if (installed.length === 0) {
|
|
339
|
+
console.log('Dependency bootstrap: nothing to install.');
|
|
340
|
+
}
|
|
341
|
+
else {
|
|
342
|
+
console.log(`Dependency bootstrap installed: ${installed.join(', ')}`);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
const checks = await runDoctorChecks(project);
|
|
346
|
+
const table = renderTable([
|
|
347
|
+
{ key: 'name', header: 'CHECK', width: 22 },
|
|
348
|
+
{ key: 'status', header: 'STATUS', width: 8 },
|
|
349
|
+
{ key: 'message', header: 'MESSAGE', width: 48 },
|
|
350
|
+
{ key: 'suggestion', header: 'SUGGESTION', width: 42 }
|
|
351
|
+
], checks.map((check) => ({
|
|
352
|
+
...check,
|
|
353
|
+
status: check.status === 'pass'
|
|
354
|
+
? colors.green('pass')
|
|
355
|
+
: check.status === 'warn'
|
|
356
|
+
? colors.yellow('warn')
|
|
357
|
+
: colors.red('fail'),
|
|
358
|
+
suggestion: check.suggestion ?? ''
|
|
359
|
+
})));
|
|
360
|
+
console.log(table);
|
|
361
|
+
const failCount = checks.filter((c) => c.status === 'fail').length;
|
|
362
|
+
if (failCount > 0) {
|
|
363
|
+
printHint('Resolve failing checks before installation workflows.');
|
|
364
|
+
throw new Error(`Doctor found ${failCount} failing checks.`);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
async function handleList(args) {
|
|
368
|
+
const kinds = readKinds(args);
|
|
369
|
+
const providers = readCsvList(args, '--provider');
|
|
370
|
+
const search = readFlag(args, '--search')?.toLowerCase();
|
|
371
|
+
const blockedFilter = readFlag(args, '--blocked');
|
|
372
|
+
const riskTierFilter = readFlag(args, '--risk-tier');
|
|
373
|
+
const limit = readLimit(args, 50);
|
|
374
|
+
const sort = readSort(args, 'name');
|
|
375
|
+
const format = readFlag(args, '--format') ?? 'table';
|
|
376
|
+
const readable = hasFlag(args, '--readable');
|
|
377
|
+
const details = hasFlag(args, '--details');
|
|
378
|
+
const [items, quarantine, policy, localConfig, insights] = await Promise.all([
|
|
379
|
+
loadCatalogItems(),
|
|
380
|
+
loadQuarantine(),
|
|
381
|
+
loadSecurityPolicy(),
|
|
382
|
+
loadLocalCliConfig(path.resolve('.')),
|
|
383
|
+
loadItemInsights()
|
|
384
|
+
]);
|
|
385
|
+
const quarantined = new Set(quarantine.map((entry) => entry.id));
|
|
386
|
+
let filtered = items.map((item) => {
|
|
387
|
+
const assessment = buildAssessment(item, policy);
|
|
388
|
+
const blocked = quarantined.has(item.id) || ['high', 'critical'].includes(assessment.riskTier);
|
|
389
|
+
return {
|
|
390
|
+
item,
|
|
391
|
+
assessment,
|
|
392
|
+
blocked
|
|
393
|
+
};
|
|
394
|
+
});
|
|
395
|
+
if (kinds?.length) {
|
|
396
|
+
const set = new Set(kinds);
|
|
397
|
+
filtered = filtered.filter((entry) => set.has(entry.item.kind));
|
|
398
|
+
}
|
|
399
|
+
if (providers?.length) {
|
|
400
|
+
const set = new Set(providers.map((p) => p.toLowerCase()));
|
|
401
|
+
filtered = filtered.filter((entry) => set.has(entry.item.provider.toLowerCase()));
|
|
402
|
+
}
|
|
403
|
+
if (search) {
|
|
404
|
+
filtered = filtered.filter((entry) => {
|
|
405
|
+
const text = `${entry.item.id} ${entry.item.name} ${entry.item.capabilities.join(' ')}`.toLowerCase();
|
|
406
|
+
return text.includes(search);
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
if (blockedFilter) {
|
|
410
|
+
const required = blockedFilter === 'true';
|
|
411
|
+
filtered = filtered.filter((entry) => entry.blocked === required);
|
|
412
|
+
}
|
|
413
|
+
else if (localConfig?.riskPosture === 'strict') {
|
|
414
|
+
filtered = filtered.filter((entry) => !entry.blocked);
|
|
415
|
+
}
|
|
416
|
+
if (riskTierFilter) {
|
|
417
|
+
filtered = filtered.filter((entry) => entry.assessment.riskTier === riskTierFilter);
|
|
418
|
+
}
|
|
419
|
+
filtered = sortCatalogRows(filtered, sort).slice(0, limit ?? 50);
|
|
420
|
+
if (filtered.length === 0) {
|
|
421
|
+
console.log('No catalog items matched your filters.');
|
|
422
|
+
printHint('Try a broader query, remove `--blocked/--risk-tier`, or use `plugscout search <term>`.');
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
if (format === 'json') {
|
|
426
|
+
printJson(filtered.map((entry) => ({
|
|
427
|
+
id: entry.item.id,
|
|
428
|
+
kind: entry.item.kind,
|
|
429
|
+
provider: entry.item.provider,
|
|
430
|
+
source: entry.item.source,
|
|
431
|
+
catalogType: getCatalogType(entry.item),
|
|
432
|
+
sourceConfidence: getSourceConfidence(entry.item),
|
|
433
|
+
riskTier: entry.assessment.riskTier,
|
|
434
|
+
riskScore: entry.assessment.riskScore,
|
|
435
|
+
blocked: entry.blocked
|
|
436
|
+
})));
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
console.log(renderTable([
|
|
440
|
+
{ key: 'id', header: 'ID', width: readable ? 42 : 32 },
|
|
441
|
+
{ key: 'kind', header: 'TYPE', width: 18 },
|
|
442
|
+
{ key: 'provider', header: 'PROVIDER', width: 10 },
|
|
443
|
+
{ key: 'source', header: 'SOURCE', width: readable ? 34 : 24 },
|
|
444
|
+
{ key: 'catalogType', header: 'CATALOG', width: 10 },
|
|
445
|
+
{ key: 'confidence', header: 'CONFIDENCE', width: 14 },
|
|
446
|
+
{ key: 'risk', header: 'RISK', width: 12 },
|
|
447
|
+
{ key: 'blocked', header: 'BLOCKED', width: 8 }
|
|
448
|
+
], filtered.map((entry) => ({
|
|
449
|
+
id: entry.item.id,
|
|
450
|
+
kind: entry.item.kind,
|
|
451
|
+
provider: entry.item.provider,
|
|
452
|
+
source: entry.item.source,
|
|
453
|
+
catalogType: getCatalogType(entry.item),
|
|
454
|
+
confidence: getSourceConfidence(entry.item),
|
|
455
|
+
risk: `${entry.assessment.riskTier}(${entry.assessment.riskScore.toFixed(0)})`,
|
|
456
|
+
blocked: String(entry.blocked)
|
|
457
|
+
})), { wrap: readable }));
|
|
458
|
+
printHint(`Risk scale (lower is safer): ${formatRiskScale(policy)}`);
|
|
459
|
+
if (!blockedFilter && localConfig?.riskPosture === 'strict') {
|
|
460
|
+
printHint('Strict risk posture is active: blocked/high-risk entries are hidden. Use `--blocked true` to inspect them.');
|
|
461
|
+
}
|
|
462
|
+
printHint('Use `show --id <catalog-id>` for full detail.');
|
|
463
|
+
if (details) {
|
|
464
|
+
console.log(renderCatalogDecisionDetails(filtered, policy, insights));
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
async function handleShow(args) {
|
|
468
|
+
const id = readFlag(args, '--id');
|
|
469
|
+
if (!id) {
|
|
470
|
+
throw new Error('Usage: show --id <catalog-id>');
|
|
471
|
+
}
|
|
472
|
+
const [item, whitelist, quarantine, insights] = await Promise.all([
|
|
473
|
+
loadCatalogItemById(id),
|
|
474
|
+
loadWhitelist(),
|
|
475
|
+
loadQuarantine(),
|
|
476
|
+
loadItemInsights()
|
|
477
|
+
]);
|
|
478
|
+
if (!item) {
|
|
479
|
+
throw new Error(await buildCatalogItemNotFoundMessage(id));
|
|
480
|
+
}
|
|
481
|
+
const assessment = await assessRisk(item);
|
|
482
|
+
const isQuarantined = quarantine.some((entry) => entry.id === item.id);
|
|
483
|
+
const insight = insights.get(item.id);
|
|
484
|
+
printJson({
|
|
485
|
+
...item,
|
|
486
|
+
insight: insight ?? null,
|
|
487
|
+
risk: {
|
|
488
|
+
tier: assessment.riskTier,
|
|
489
|
+
score: assessment.riskScore,
|
|
490
|
+
reasons: assessment.reasons
|
|
491
|
+
},
|
|
492
|
+
policyStatus: {
|
|
493
|
+
approvedInWhitelist: whitelist.has(item.id),
|
|
494
|
+
quarantined: isQuarantined
|
|
495
|
+
}
|
|
496
|
+
});
|
|
497
|
+
await recordItemReview(item.id, 'show');
|
|
498
|
+
printHint(`Install with: plugscout install --id ${item.id} --yes`);
|
|
499
|
+
printHint('Review provenance, risk, and capabilities first. Do not install blindly from a suggestion or score.');
|
|
500
|
+
console.log(`Provenance: source=${item.source} catalogType=${getCatalogType(item)} confidence=${getSourceConfidence(item)}`);
|
|
501
|
+
const metadata = getItemMetadata(item);
|
|
502
|
+
const sourceRepo = typeof metadata.sourceRepo === 'string' ? metadata.sourceRepo : undefined;
|
|
503
|
+
const sourcePage = typeof metadata.sourcePage === 'string' ? metadata.sourcePage : undefined;
|
|
504
|
+
if (sourceRepo) {
|
|
505
|
+
console.log(`Source repo: ${sourceRepo}`);
|
|
506
|
+
}
|
|
507
|
+
if (sourcePage) {
|
|
508
|
+
console.log(`Source page: ${sourcePage}`);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
async function handleSearch(args) {
|
|
512
|
+
const query = args[0]?.trim();
|
|
513
|
+
if (!query) {
|
|
514
|
+
throw new Error('Usage: search <query>');
|
|
515
|
+
}
|
|
516
|
+
const items = await loadCatalogItems();
|
|
517
|
+
const needle = query.toLowerCase();
|
|
518
|
+
const matches = items
|
|
519
|
+
.map((item) => ({ item, score: computeSearchScore(item, needle) }))
|
|
520
|
+
.filter((entry) => entry.score > 0)
|
|
521
|
+
.sort((a, b) => b.score - a.score || a.item.id.localeCompare(b.item.id))
|
|
522
|
+
.slice(0, 20);
|
|
523
|
+
if (matches.length === 0) {
|
|
524
|
+
console.log(`No matches for "${query}".`);
|
|
525
|
+
printHint('Try a broader term or browse by kind with `plugscout list --kind connectors`, `plugscout list --kind plugins`, or `plugscout list --kind mcp`.');
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
console.log(renderTable([
|
|
529
|
+
{ key: 'id', header: 'ID', width: 32 },
|
|
530
|
+
{ key: 'kind', header: 'TYPE', width: 18 },
|
|
531
|
+
{ key: 'provider', header: 'PROVIDER', width: 12 },
|
|
532
|
+
{ key: 'score', header: 'MATCH', width: 8 },
|
|
533
|
+
{ key: 'name', header: 'NAME', width: 34 }
|
|
534
|
+
], matches.map((entry) => ({
|
|
535
|
+
id: entry.item.id,
|
|
536
|
+
kind: entry.item.kind,
|
|
537
|
+
provider: entry.item.provider,
|
|
538
|
+
score: entry.score.toFixed(0),
|
|
539
|
+
name: entry.item.name
|
|
540
|
+
}))));
|
|
541
|
+
printHint('Use `show --id <catalog-id>` for full detail.');
|
|
542
|
+
}
|
|
543
|
+
async function handleExplain(args) {
|
|
544
|
+
const kinds = readKinds(args);
|
|
545
|
+
const providers = readCsvList(args, '--provider');
|
|
546
|
+
const format = readFlag(args, '--format') ?? 'table';
|
|
547
|
+
const limit = readLimit(args, 50) ?? 50;
|
|
548
|
+
const [items, insights] = await Promise.all([loadCatalogItems(), loadItemInsights()]);
|
|
549
|
+
let filtered = items;
|
|
550
|
+
if (kinds?.length) {
|
|
551
|
+
const set = new Set(kinds);
|
|
552
|
+
filtered = filtered.filter((item) => set.has(item.kind));
|
|
553
|
+
}
|
|
554
|
+
if (providers?.length) {
|
|
555
|
+
const set = new Set(providers.map((value) => value.toLowerCase()));
|
|
556
|
+
filtered = filtered.filter((item) => set.has(item.provider.toLowerCase()));
|
|
557
|
+
}
|
|
558
|
+
const rows = filtered
|
|
559
|
+
.slice()
|
|
560
|
+
.sort((a, b) => a.id.localeCompare(b.id))
|
|
561
|
+
.slice(0, limit)
|
|
562
|
+
.map((item) => {
|
|
563
|
+
const insight = insights.get(item.id);
|
|
564
|
+
return {
|
|
565
|
+
id: item.id,
|
|
566
|
+
kind: item.kind,
|
|
567
|
+
provider: item.provider,
|
|
568
|
+
benefitSummary: insight?.benefitSummary ?? 'No insight data yet.',
|
|
569
|
+
bestFor: (insight?.bestFor ?? []).join('; '),
|
|
570
|
+
tradeoffs: (insight?.tradeoffs ?? []).join('; ')
|
|
571
|
+
};
|
|
572
|
+
});
|
|
573
|
+
if (format === 'json') {
|
|
574
|
+
printJson(rows);
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
console.log(renderTable([
|
|
578
|
+
{ key: 'id', header: 'ID', width: 34 },
|
|
579
|
+
{ key: 'kind', header: 'TYPE', width: 18 },
|
|
580
|
+
{ key: 'provider', header: 'PROVIDER', width: 12 },
|
|
581
|
+
{ key: 'benefitSummary', header: 'BENEFIT', width: 56 }
|
|
582
|
+
], rows));
|
|
583
|
+
printHint('Use `show --id <catalog-id>` for full best-for, tradeoffs, and usage notes.');
|
|
584
|
+
}
|
|
585
|
+
async function handleScan(args) {
|
|
586
|
+
const project = readFlag(args, '--project') ?? '.';
|
|
587
|
+
const format = readFlag(args, '--format') ?? 'table';
|
|
588
|
+
const llm = hasFlag(args, '--llm');
|
|
589
|
+
const out = readFlag(args, '--out');
|
|
590
|
+
const scan = await detectProjectSignals(path.resolve(project), { llm });
|
|
591
|
+
const payload = {
|
|
592
|
+
project: path.resolve(project),
|
|
593
|
+
inferredArchetype: scan.inferredArchetype,
|
|
594
|
+
inferenceConfidence: scan.inferenceConfidence,
|
|
595
|
+
stack: scan.stack,
|
|
596
|
+
compatibilityTags: scan.compatibilityTags,
|
|
597
|
+
inferredCapabilities: scan.inferredCapabilities,
|
|
598
|
+
archetypeScores: scan.archetypeScores,
|
|
599
|
+
evidence: scan.scanEvidence
|
|
600
|
+
};
|
|
601
|
+
if (out) {
|
|
602
|
+
await fs.writeFile(path.resolve(out), `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
|
|
603
|
+
console.log(`Wrote scan report to ${path.resolve(out)}`);
|
|
604
|
+
}
|
|
605
|
+
if (format === 'json') {
|
|
606
|
+
printJson(payload);
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
console.log('Repository Scan');
|
|
610
|
+
console.log(`Archetype: ${scan.inferredArchetype}`);
|
|
611
|
+
console.log(`Confidence: ${scan.inferenceConfidence}%`);
|
|
612
|
+
console.log(`Stack: ${scan.stack.join(', ') || 'none'}`);
|
|
613
|
+
console.log(`Compatibility tags: ${scan.compatibilityTags.join(', ') || 'none'}`);
|
|
614
|
+
console.log(`Inferred capabilities: ${scan.inferredCapabilities.join(', ') || 'none'}`);
|
|
615
|
+
console.log('');
|
|
616
|
+
if (scan.archetypeScores.length > 0) {
|
|
617
|
+
console.log(renderTable([
|
|
618
|
+
{ key: 'name', header: 'ARCHETYPE', width: 36 },
|
|
619
|
+
{ key: 'score', header: 'SCORE', width: 8 }
|
|
620
|
+
], scan.archetypeScores.slice(0, 8).map((row) => ({
|
|
621
|
+
name: row.name,
|
|
622
|
+
score: String(row.score)
|
|
623
|
+
}))));
|
|
624
|
+
console.log('');
|
|
625
|
+
}
|
|
626
|
+
if (scan.scanEvidence.length > 0) {
|
|
627
|
+
console.log('Evidence');
|
|
628
|
+
scan.scanEvidence.slice(0, 16).forEach((line) => console.log(`- ${line}`));
|
|
629
|
+
if (scan.scanEvidence.length > 16) {
|
|
630
|
+
console.log(`- ...and ${scan.scanEvidence.length - 16} more`);
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
printHint('Use `recommend --project . --explain-scan` to turn scan signals into ranked recommendations.');
|
|
634
|
+
}
|
|
635
|
+
async function handleTop(args) {
|
|
636
|
+
const project = readFlag(args, '--project') ?? '.';
|
|
637
|
+
const requirementsFile = readFlag(args, '--requirements');
|
|
638
|
+
const kinds = readKinds(args);
|
|
639
|
+
const limit = readLimit(args, 10) ?? 10;
|
|
640
|
+
const llm = hasFlag(args, '--llm');
|
|
641
|
+
const readable = hasFlag(args, '--readable');
|
|
642
|
+
const details = hasFlag(args, '--details');
|
|
643
|
+
const [projectSignals, requirements, policy] = await Promise.all([
|
|
644
|
+
detectProjectSignals(path.resolve(project), { llm }),
|
|
645
|
+
loadRequirementsProfile(requirementsFile),
|
|
646
|
+
loadSecurityPolicy()
|
|
647
|
+
]);
|
|
648
|
+
const ranked = await recommend({ projectSignals, requirements, kinds });
|
|
649
|
+
const safe = ranked.filter((entry) => !entry.blocked).slice(0, limit);
|
|
650
|
+
renderRecommendationsTable(safe, 'table', readable);
|
|
651
|
+
printHint('Ranking meaning: these are the best safe matches for this repo, not the globally most popular tools.');
|
|
652
|
+
printHint('Score formula: fit + trust + freshness - security - blocked.');
|
|
653
|
+
printHint('Review each suggestion before installing. Do not install blindly from rank alone.');
|
|
654
|
+
printHint(`Risk scale (lower is safer): ${formatRiskScale(policy)}`);
|
|
655
|
+
printHint('Use `show --id <catalog-id>` or `assess --id <catalog-id>` for deep inspection.');
|
|
656
|
+
if (details) {
|
|
657
|
+
const [catalogItems, insights] = await Promise.all([loadCatalogItems(), loadItemInsights()]);
|
|
658
|
+
const catalogMap = new Map(catalogItems.map((item) => [item.id, item]));
|
|
659
|
+
console.log(renderRecommendationDecisionDetails(safe, catalogMap, policy, insights));
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
async function handleSync(args) {
|
|
663
|
+
const kinds = readKinds(args);
|
|
664
|
+
const dryRun = hasFlag(args, '--dry-run');
|
|
665
|
+
if (dryRun) {
|
|
666
|
+
const registries = await loadRegistries();
|
|
667
|
+
const selected = kinds?.length ? registries.filter((registry) => kinds.includes(registry.kind)) : registries;
|
|
668
|
+
console.log('Sync Dry Run');
|
|
669
|
+
selected.forEach((registry) => {
|
|
670
|
+
console.log(`- ${registry.id} (${registry.kind}) entries=${registry.entries.length} remote=${registry.remote ? 'yes' : 'no'}`);
|
|
671
|
+
});
|
|
672
|
+
printHint('Run without --dry-run to persist synced catalogs.');
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
const result = await syncCatalogs(undefined, { kinds });
|
|
676
|
+
if (result.staleRegistries.length > 0) {
|
|
677
|
+
logger.warn(`Stale registries: ${result.staleRegistries.join(', ')}`);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
async function handleRecommend(args) {
|
|
681
|
+
const project = readFlag(args, '--project') ?? '.';
|
|
682
|
+
const requirementsFile = readFlag(args, '--requirements');
|
|
683
|
+
const format = readFlag(args, '--format') ?? 'table';
|
|
684
|
+
const kinds = readKinds(args);
|
|
685
|
+
const providers = readCsvList(args, '--provider');
|
|
686
|
+
const limit = readLimit(args);
|
|
687
|
+
let onlySafe = hasFlag(args, '--only-safe');
|
|
688
|
+
const sort = readSort(args);
|
|
689
|
+
const exportFormat = readFlag(args, '--export');
|
|
690
|
+
const exportPath = readFlag(args, '--out');
|
|
691
|
+
const explainScan = hasFlag(args, '--explain-scan');
|
|
692
|
+
const llm = hasFlag(args, '--llm');
|
|
693
|
+
const readable = hasFlag(args, '--readable');
|
|
694
|
+
const details = hasFlag(args, '--details');
|
|
695
|
+
const projectRoot = path.resolve(project);
|
|
696
|
+
const [projectSignals, requirements, policy, localConfig] = await Promise.all([
|
|
697
|
+
detectProjectSignals(projectRoot, { llm }),
|
|
698
|
+
loadRequirementsProfile(requirementsFile),
|
|
699
|
+
loadSecurityPolicy(),
|
|
700
|
+
loadLocalCliConfig(projectRoot)
|
|
701
|
+
]);
|
|
702
|
+
if (!onlySafe && localConfig?.riskPosture === 'strict') {
|
|
703
|
+
onlySafe = true;
|
|
704
|
+
printHint('Strict risk posture is active: recommendations default to safe items only.');
|
|
705
|
+
}
|
|
706
|
+
if (explainScan) {
|
|
707
|
+
const previewEvidence = projectSignals.scanEvidence.slice(0, 12);
|
|
708
|
+
console.log('Project Scan');
|
|
709
|
+
console.log(`- archetype: ${projectSignals.inferredArchetype} (${projectSignals.inferenceConfidence}% confidence)`);
|
|
710
|
+
console.log(`- stack: ${projectSignals.stack.join(', ') || 'none'}`);
|
|
711
|
+
console.log(`- compatibility tags: ${projectSignals.compatibilityTags.join(', ') || 'none'}`);
|
|
712
|
+
console.log(`- inferred capabilities: ${projectSignals.inferredCapabilities.join(', ') || 'none'}`);
|
|
713
|
+
if (previewEvidence.length > 0) {
|
|
714
|
+
console.log('- evidence:');
|
|
715
|
+
previewEvidence.forEach((line) => console.log(` - ${line}`));
|
|
716
|
+
if (projectSignals.scanEvidence.length > previewEvidence.length) {
|
|
717
|
+
console.log(` - ...and ${projectSignals.scanEvidence.length - previewEvidence.length} more`);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
console.log('');
|
|
721
|
+
}
|
|
722
|
+
let ranked = await recommend({ projectSignals, requirements, kinds });
|
|
723
|
+
if (providers?.length) {
|
|
724
|
+
const set = new Set(providers.map((provider) => provider.toLowerCase()));
|
|
725
|
+
ranked = ranked.filter((entry) => set.has(entry.provider.toLowerCase()));
|
|
726
|
+
}
|
|
727
|
+
if (onlySafe) {
|
|
728
|
+
ranked = ranked.filter((entry) => !entry.blocked);
|
|
729
|
+
}
|
|
730
|
+
ranked = sortRecommendations(ranked, sort);
|
|
731
|
+
if (limit) {
|
|
732
|
+
ranked = ranked.slice(0, limit);
|
|
733
|
+
}
|
|
734
|
+
if (format === 'json') {
|
|
735
|
+
console.log(renderJson(ranked));
|
|
736
|
+
}
|
|
737
|
+
else {
|
|
738
|
+
renderRecommendationsTable(ranked, format, readable);
|
|
739
|
+
printHint('Ranking meaning: these are the best matches for this repo under the current policy, not a global leaderboard.');
|
|
740
|
+
printHint('Score formula: fit + trust + freshness - security - blocked.');
|
|
741
|
+
printHint('Review each suggestion before installing. Do not install blindly from rank alone.');
|
|
742
|
+
printHint(`Risk scale (lower is safer): ${formatRiskScale(policy)}`);
|
|
743
|
+
if (details) {
|
|
744
|
+
const [catalogItems, insights] = await Promise.all([loadCatalogItems(), loadItemInsights()]);
|
|
745
|
+
const catalogMap = new Map(catalogItems.map((item) => [item.id, item]));
|
|
746
|
+
console.log(renderRecommendationDecisionDetails(ranked, catalogMap, policy, insights));
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
if (details && format === 'json') {
|
|
750
|
+
printHint('Detailed decision view is available in table mode: rerun with `--format table --details`.');
|
|
751
|
+
}
|
|
752
|
+
if (exportFormat) {
|
|
753
|
+
if (!exportPath) {
|
|
754
|
+
throw new Error('Missing --out for export. Example: --export csv --out recommendations.csv');
|
|
755
|
+
}
|
|
756
|
+
await exportRecommendations(ranked, exportFormat, exportPath);
|
|
757
|
+
console.log(`Exported ${ranked.length} recommendations to ${exportPath}`);
|
|
758
|
+
}
|
|
759
|
+
printHint('Next: run `show --id <catalog-id>` or `install --id <catalog-id> --yes`.');
|
|
760
|
+
}
|
|
761
|
+
async function handleWeb(args) {
|
|
762
|
+
const out = readFlag(args, '--out') ?? '.plugscout/report.html';
|
|
763
|
+
const limit = readLimit(args, 400) ?? 400;
|
|
764
|
+
const kinds = readKinds(args);
|
|
765
|
+
const open = hasFlag(args, '--open');
|
|
766
|
+
const result = await writeWebReport({
|
|
767
|
+
outputPath: out,
|
|
768
|
+
kinds,
|
|
769
|
+
limit
|
|
770
|
+
});
|
|
771
|
+
console.log(`Web report written: ${result.outputPath}`);
|
|
772
|
+
console.log(`Items included: ${result.items}`);
|
|
773
|
+
if (open) {
|
|
774
|
+
const opened = await openInBrowser(result.outputPath);
|
|
775
|
+
if (!opened) {
|
|
776
|
+
printHint(`Unable to auto-open browser. Open manually: ${result.outputPath}`);
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
else {
|
|
780
|
+
printHint(`Open in browser: ${result.outputPath}`);
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
async function handleAssess(args) {
|
|
784
|
+
const id = readFlag(args, '--id');
|
|
785
|
+
if (!id) {
|
|
786
|
+
throw new Error('Missing --id for assess');
|
|
787
|
+
}
|
|
788
|
+
const found = await loadCatalogItemById(id);
|
|
789
|
+
if (!found) {
|
|
790
|
+
throw new Error(await buildCatalogItemNotFoundMessage(id));
|
|
791
|
+
}
|
|
792
|
+
const [assessment, policy] = await Promise.all([assessRisk(found), loadSecurityPolicy()]);
|
|
793
|
+
console.log(renderJson(assessment));
|
|
794
|
+
await recordItemReview(found.id, 'assess');
|
|
795
|
+
printHint(`Risk scale (lower is safer): ${formatRiskScale(policy)}`);
|
|
796
|
+
}
|
|
797
|
+
async function handleInstall(args) {
|
|
798
|
+
const id = readFlag(args, '--id');
|
|
799
|
+
if (!id) {
|
|
800
|
+
throw new Error('Missing --id for install');
|
|
801
|
+
}
|
|
802
|
+
const overrideRisk = hasFlag(args, '--override-risk');
|
|
803
|
+
const overrideReview = hasFlag(args, '--override-review');
|
|
804
|
+
const installDeps = hasFlag(args, '--install-deps');
|
|
805
|
+
const yes = hasFlag(args, '--yes');
|
|
806
|
+
const found = await loadCatalogItemById(id);
|
|
807
|
+
if (!found) {
|
|
808
|
+
throw new Error(await buildCatalogItemNotFoundMessage(id));
|
|
809
|
+
}
|
|
810
|
+
if (installDeps) {
|
|
811
|
+
const installed = await installToolkitDependencies();
|
|
812
|
+
if (installed.length > 0) {
|
|
813
|
+
console.log(`Dependency bootstrap installed: ${installed.join(', ')}`);
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
const audit = await installWithSkillSh({ id, overrideRisk, overrideReview, yes });
|
|
817
|
+
console.log(renderJson(audit));
|
|
818
|
+
printHint('Need ranking context? `top` and `recommend` are repo-aware suggestions, not global popularity charts.');
|
|
819
|
+
printHint('Always review the item with `show` or `assess` first. Do not install blindly from a score.');
|
|
820
|
+
printHint('Use `top --project . --details` to see score math for this repository.');
|
|
821
|
+
}
|
|
822
|
+
async function handleWhitelist(args) {
|
|
823
|
+
const subcommand = args[0];
|
|
824
|
+
if (subcommand !== 'verify') {
|
|
825
|
+
throw new Error('Usage: whitelist verify');
|
|
826
|
+
}
|
|
827
|
+
const allowFailures = hasFlag(args, '--allow-failures');
|
|
828
|
+
const result = await verifyWhitelist();
|
|
829
|
+
console.log(renderJson({
|
|
830
|
+
reportPath: result.reportPath,
|
|
831
|
+
failed: result.report.failed.length,
|
|
832
|
+
staleRegistries: result.report.staleRegistries
|
|
833
|
+
}));
|
|
834
|
+
if (result.report.failed.length > 0 && !allowFailures) {
|
|
835
|
+
throw new Error(`Whitelist verification failed (${result.report.failed.length} entries)`);
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
async function handleQuarantine(args) {
|
|
839
|
+
const subcommand = args[0];
|
|
840
|
+
if (subcommand !== 'apply') {
|
|
841
|
+
throw new Error('Usage: quarantine apply --report <path>');
|
|
842
|
+
}
|
|
843
|
+
const report = readFlag(args, '--report');
|
|
844
|
+
if (!report) {
|
|
845
|
+
throw new Error('Missing --report for quarantine apply');
|
|
846
|
+
}
|
|
847
|
+
const result = await applyQuarantineFromReport(report);
|
|
848
|
+
console.log(renderJson(result));
|
|
849
|
+
}
|
|
850
|
+
async function handleUpgrade(args) {
|
|
851
|
+
const subcommand = args[0] ?? 'check';
|
|
852
|
+
if (subcommand !== 'check') {
|
|
853
|
+
throw new Error('Usage: upgrade check');
|
|
854
|
+
}
|
|
855
|
+
const result = await checkForUpdateNow();
|
|
856
|
+
renderUpgradeResult(result);
|
|
857
|
+
}
|
|
858
|
+
function renderUpgradeResult(result) {
|
|
859
|
+
if (result.status === 'no-release') {
|
|
860
|
+
console.log('No published release found yet.');
|
|
861
|
+
console.log(`Releases: ${RELEASE_DOWNLOAD_URL}`);
|
|
862
|
+
return;
|
|
863
|
+
}
|
|
864
|
+
if (result.status === 'error') {
|
|
865
|
+
console.log('Unable to check for updates right now.');
|
|
866
|
+
console.log(`Releases: ${RELEASE_DOWNLOAD_URL}`);
|
|
867
|
+
return;
|
|
868
|
+
}
|
|
869
|
+
if (result.status === 'up-to-date') {
|
|
870
|
+
console.log(`PlugScout is up to date (v${result.currentVersion}).`);
|
|
871
|
+
console.log(`Latest release: v${result.latestVersion}`);
|
|
872
|
+
console.log(`Releases: ${RELEASE_DOWNLOAD_URL}`);
|
|
873
|
+
return;
|
|
874
|
+
}
|
|
875
|
+
console.log(`New PlugScout version available: v${result.currentVersion} -> v${result.latestVersion}`);
|
|
876
|
+
console.log(`Download: ${RELEASE_DOWNLOAD_URL}`);
|
|
877
|
+
}
|
|
878
|
+
function sortRecommendations(recommendations, sort) {
|
|
879
|
+
const sorted = [...recommendations];
|
|
880
|
+
if (sort === 'name') {
|
|
881
|
+
sorted.sort((a, b) => a.id.localeCompare(b.id));
|
|
882
|
+
return sorted;
|
|
883
|
+
}
|
|
884
|
+
if (sort === 'trust') {
|
|
885
|
+
sorted.sort((a, b) => b.scoreBreakdown.trustScore - a.scoreBreakdown.trustScore || b.rankScore - a.rankScore);
|
|
886
|
+
return sorted;
|
|
887
|
+
}
|
|
888
|
+
if (sort === 'risk') {
|
|
889
|
+
sorted.sort((a, b) => a.riskScore - b.riskScore || b.rankScore - a.rankScore);
|
|
890
|
+
return sorted;
|
|
891
|
+
}
|
|
892
|
+
if (sort === 'fit') {
|
|
893
|
+
sorted.sort((a, b) => b.scoreBreakdown.fitScore - a.scoreBreakdown.fitScore || b.rankScore - a.rankScore);
|
|
894
|
+
return sorted;
|
|
895
|
+
}
|
|
896
|
+
sorted.sort((a, b) => b.rankScore - a.rankScore || a.id.localeCompare(b.id));
|
|
897
|
+
return sorted;
|
|
898
|
+
}
|
|
899
|
+
function sortCatalogRows(rows, sort) {
|
|
900
|
+
const copy = [...rows];
|
|
901
|
+
if (sort === 'name') {
|
|
902
|
+
copy.sort((a, b) => a.item.id.localeCompare(b.item.id));
|
|
903
|
+
return copy;
|
|
904
|
+
}
|
|
905
|
+
if (sort === 'risk') {
|
|
906
|
+
copy.sort((a, b) => a.assessment.riskScore - b.assessment.riskScore || a.item.id.localeCompare(b.item.id));
|
|
907
|
+
return copy;
|
|
908
|
+
}
|
|
909
|
+
if (sort === 'trust') {
|
|
910
|
+
copy.sort((a, b) => b.item.maintenanceSignal + b.item.provenanceSignal - (a.item.maintenanceSignal + a.item.provenanceSignal));
|
|
911
|
+
return copy;
|
|
912
|
+
}
|
|
913
|
+
return copy.sort((a, b) => a.item.id.localeCompare(b.item.id));
|
|
914
|
+
}
|
|
915
|
+
function renderRecommendationsTable(recommendations, format, readable = false) {
|
|
916
|
+
if (format !== 'table') {
|
|
917
|
+
console.log(renderJson(recommendations));
|
|
918
|
+
return;
|
|
919
|
+
}
|
|
920
|
+
const rows = recommendations.map((entry) => {
|
|
921
|
+
const riskLabel = `${entry.riskTier}(${entry.riskScore.toFixed(0)})`;
|
|
922
|
+
return {
|
|
923
|
+
id: entry.id,
|
|
924
|
+
kind: entry.kind,
|
|
925
|
+
provider: entry.provider,
|
|
926
|
+
rank: `${entry.rankScore.toFixed(1)} ${scoreBar(entry.rankScore, 8)}`,
|
|
927
|
+
trust: `${entry.scoreBreakdown.trustScore.toFixed(1)} ${scoreBar(entry.scoreBreakdown.trustScore, 8)}`,
|
|
928
|
+
fit: `${entry.scoreBreakdown.fitScore.toFixed(1)}`,
|
|
929
|
+
risk: colorRisk(entry.riskTier, riskLabel),
|
|
930
|
+
blocked: entry.blocked ? colors.red('true') : colors.green('false')
|
|
931
|
+
};
|
|
932
|
+
});
|
|
933
|
+
console.log(renderTable([
|
|
934
|
+
{ key: 'id', header: 'ID', width: readable ? 42 : 32 },
|
|
935
|
+
{ key: 'kind', header: 'TYPE', width: 18 },
|
|
936
|
+
{ key: 'provider', header: 'PROVIDER', width: 10 },
|
|
937
|
+
{ key: 'rank', header: 'RANK', width: 18 },
|
|
938
|
+
{ key: 'trust', header: 'TRUST', width: 18 },
|
|
939
|
+
{ key: 'fit', header: 'FIT', width: 6 },
|
|
940
|
+
{ key: 'risk', header: 'RISK', width: 16 },
|
|
941
|
+
{ key: 'blocked', header: 'BLOCKED', width: 8 }
|
|
942
|
+
], rows, { wrap: readable }));
|
|
943
|
+
}
|
|
944
|
+
async function exportRecommendations(recommendations, exportFormat, outputPath) {
|
|
945
|
+
const headers = ['id', 'kind', 'provider', 'rankScore', 'trustScore', 'fitScore', 'riskTier', 'riskScore', 'blocked'];
|
|
946
|
+
const rows = recommendations.map((entry) => [
|
|
947
|
+
entry.id,
|
|
948
|
+
entry.kind,
|
|
949
|
+
entry.provider,
|
|
950
|
+
entry.rankScore.toFixed(1),
|
|
951
|
+
entry.scoreBreakdown.trustScore.toFixed(1),
|
|
952
|
+
entry.scoreBreakdown.fitScore.toFixed(1),
|
|
953
|
+
entry.riskTier,
|
|
954
|
+
entry.riskScore.toFixed(0),
|
|
955
|
+
String(entry.blocked)
|
|
956
|
+
]);
|
|
957
|
+
let content;
|
|
958
|
+
if (exportFormat === 'csv') {
|
|
959
|
+
content = renderCsv(headers, rows);
|
|
960
|
+
}
|
|
961
|
+
else if (exportFormat === 'md') {
|
|
962
|
+
content = renderMarkdown(headers, rows);
|
|
963
|
+
}
|
|
964
|
+
else {
|
|
965
|
+
throw new Error(`Unsupported export format: ${exportFormat}. Expected csv or md.`);
|
|
966
|
+
}
|
|
967
|
+
await fs.writeFile(path.resolve(outputPath), content, 'utf8');
|
|
968
|
+
}
|
|
969
|
+
function computeSearchScore(item, query) {
|
|
970
|
+
let score = 0;
|
|
971
|
+
const id = item.id.toLowerCase();
|
|
972
|
+
const name = item.name.toLowerCase();
|
|
973
|
+
const capabilities = item.capabilities.map((capability) => capability.toLowerCase());
|
|
974
|
+
if (id === query) {
|
|
975
|
+
score += 120;
|
|
976
|
+
}
|
|
977
|
+
if (id.includes(query)) {
|
|
978
|
+
score += 60;
|
|
979
|
+
}
|
|
980
|
+
if (name.includes(query)) {
|
|
981
|
+
score += 50;
|
|
982
|
+
}
|
|
983
|
+
if (capabilities.some((capability) => capability.includes(query))) {
|
|
984
|
+
score += 30;
|
|
985
|
+
}
|
|
986
|
+
return score;
|
|
987
|
+
}
|
|
988
|
+
function printHelp() {
|
|
989
|
+
console.log('PlugScout commands');
|
|
990
|
+
console.log('');
|
|
991
|
+
console.log('Start here');
|
|
992
|
+
console.log(' setup [--project .] one-step: install deps + init + sync');
|
|
993
|
+
console.log(' init [--project .]');
|
|
994
|
+
console.log(' doctor [--project .] [--install-deps]');
|
|
995
|
+
console.log(' status [--verbose]');
|
|
996
|
+
console.log(' sync [--kind skill,mcp,claude-plugin,claude-connector,copilot-extension] [--dry-run]');
|
|
997
|
+
console.log('');
|
|
998
|
+
console.log('Explore');
|
|
999
|
+
console.log(' list [--kind ...] [--provider ...] [--risk-tier low|medium|high|critical] [--blocked true|false] [--search q] [--limit n] [--sort name|risk|trust] [--format json|table] [--readable] [--details]');
|
|
1000
|
+
console.log(' search <query>');
|
|
1001
|
+
console.log(' show --id <catalog-id>');
|
|
1002
|
+
console.log(' explain [--kind ...] [--provider ...] [--limit n] [--format json|table]');
|
|
1003
|
+
console.log('');
|
|
1004
|
+
console.log('Recommend');
|
|
1005
|
+
console.log(' scan [--project .] [--format table|json] [--out scan-report.json] [--llm]');
|
|
1006
|
+
console.log(' top [--project .] [--requirements requirements.yml] [--kind ...] [--limit n] [--llm] [--readable] [--details]');
|
|
1007
|
+
console.log(' recommend --project . --requirements requirements.yml --format json|table [--kind ...] [--provider ...] [--limit n] [--sort score|trust|risk|fit|name] [--only-safe] [--explain-scan] [--llm] [--export csv|md --out file] [--readable] [--details]');
|
|
1008
|
+
console.log(' note: top/recommend are repo-aware rankings, not global popularity charts');
|
|
1009
|
+
console.log(' note: review each suggestion before install; do not install blindly from rank alone');
|
|
1010
|
+
console.log(' note: install requires recent `show` or `assess`, unless you pass --override-review');
|
|
1011
|
+
console.log('');
|
|
1012
|
+
console.log('Install and policy');
|
|
1013
|
+
console.log(' assess --id <catalog-id>');
|
|
1014
|
+
console.log(' install --id <catalog-id> [--yes] [--override-risk] [--override-review] [--install-deps]');
|
|
1015
|
+
console.log(' whitelist verify');
|
|
1016
|
+
console.log(' quarantine apply --report <path>');
|
|
1017
|
+
console.log('');
|
|
1018
|
+
console.log('Other');
|
|
1019
|
+
console.log(' about');
|
|
1020
|
+
console.log(' web [--out .plugscout/report.html] [--kind ...] [--limit n] [--open]');
|
|
1021
|
+
console.log(' upgrade check');
|
|
1022
|
+
console.log(' help');
|
|
1023
|
+
console.log('');
|
|
1024
|
+
console.log('Kind aliases');
|
|
1025
|
+
console.log(' skills -> skill');
|
|
1026
|
+
console.log(' mcps, servers -> mcp');
|
|
1027
|
+
console.log(' plugins -> claude-plugin');
|
|
1028
|
+
console.log(' connectors -> claude-connector');
|
|
1029
|
+
console.log(' extensions, copilot -> copilot-extension');
|
|
1030
|
+
console.log('');
|
|
1031
|
+
console.log('Examples');
|
|
1032
|
+
console.log(' plugscout recommend --project . --only-safe --limit 10');
|
|
1033
|
+
console.log(' plugscout list --kind connectors --limit 10');
|
|
1034
|
+
console.log(' plugscout show --id claude-connector:asana');
|
|
1035
|
+
console.log('');
|
|
1036
|
+
console.log('Global options');
|
|
1037
|
+
console.log(' --no-update-check');
|
|
1038
|
+
}
|
|
1039
|
+
async function loadLocalCliConfig(projectRoot) {
|
|
1040
|
+
const configPath = path.join(projectRoot, '.skills-mcps.json');
|
|
1041
|
+
try {
|
|
1042
|
+
const raw = await fs.readFile(configPath, 'utf8');
|
|
1043
|
+
const parsed = JSON.parse(raw);
|
|
1044
|
+
if (parsed.riskPosture !== 'balanced' && parsed.riskPosture !== 'strict') {
|
|
1045
|
+
return null;
|
|
1046
|
+
}
|
|
1047
|
+
return {
|
|
1048
|
+
defaultKinds: Array.isArray(parsed.defaultKinds) ? parsed.defaultKinds : ['skill', 'mcp', 'claude-plugin', 'claude-connector', 'copilot-extension'],
|
|
1049
|
+
defaultProviders: Array.isArray(parsed.defaultProviders) ? parsed.defaultProviders : [],
|
|
1050
|
+
riskPosture: parsed.riskPosture,
|
|
1051
|
+
outputStyle: parsed.outputStyle === 'json' ? 'json' : 'rich-table',
|
|
1052
|
+
initializedAt: typeof parsed.initializedAt === 'string' ? parsed.initializedAt : new Date().toISOString()
|
|
1053
|
+
};
|
|
1054
|
+
}
|
|
1055
|
+
catch {
|
|
1056
|
+
return null;
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
function formatRiskScale(policy) {
|
|
1060
|
+
const low = policy.thresholds.lowMax;
|
|
1061
|
+
const medium = policy.thresholds.mediumMax;
|
|
1062
|
+
const high = policy.thresholds.highMax;
|
|
1063
|
+
const critical = policy.thresholds.criticalMax;
|
|
1064
|
+
return `low 0-${low}, medium ${low + 1}-${medium}, high ${medium + 1}-${high}, critical ${high + 1}-${critical}; install blocks ${policy.installGate.blockTiers.join(', ')}`;
|
|
1065
|
+
}
|
|
1066
|
+
function describeRiskPosture(posture) {
|
|
1067
|
+
if (posture === 'strict') {
|
|
1068
|
+
return 'recommend/list flows prefer safe-only results by default.';
|
|
1069
|
+
}
|
|
1070
|
+
return 'show full catalog/recommendation set, including blocked items with flags.';
|
|
1071
|
+
}
|
|
1072
|
+
function normalizeCommand(raw) {
|
|
1073
|
+
const normalized = raw.trim().toLowerCase();
|
|
1074
|
+
return COMMAND_ALIASES[normalized] ?? null;
|
|
1075
|
+
}
|
|
1076
|
+
function printUnknownCommand(command) {
|
|
1077
|
+
console.log(`Unknown command: ${command}`);
|
|
1078
|
+
const suggestions = suggestClosestCommandNames(command);
|
|
1079
|
+
if (suggestions.length > 0) {
|
|
1080
|
+
console.log(`Did you mean: ${suggestions.join(', ')}`);
|
|
1081
|
+
}
|
|
1082
|
+
console.log('');
|
|
1083
|
+
printHelp();
|
|
1084
|
+
}
|
|
1085
|
+
function suggestClosestCommandNames(value) {
|
|
1086
|
+
const needle = value.trim().toLowerCase();
|
|
1087
|
+
if (!needle) {
|
|
1088
|
+
return [];
|
|
1089
|
+
}
|
|
1090
|
+
return Array.from(new Set(Object.values(COMMAND_ALIASES)))
|
|
1091
|
+
.map((command) => ({ command, score: computeTextMatchScore(command, needle) }))
|
|
1092
|
+
.filter((entry) => entry.score > 0)
|
|
1093
|
+
.sort((a, b) => b.score - a.score || a.command.localeCompare(b.command))
|
|
1094
|
+
.slice(0, 3)
|
|
1095
|
+
.map((entry) => entry.command);
|
|
1096
|
+
}
|
|
1097
|
+
async function buildCatalogItemNotFoundMessage(id) {
|
|
1098
|
+
const items = await loadCatalogItems();
|
|
1099
|
+
const suggestions = suggestCatalogItems(items, id);
|
|
1100
|
+
if (suggestions.length === 0) {
|
|
1101
|
+
return `Catalog item not found: ${id}. Try \`plugscout search ${id}\` or \`plugscout list --kind connectors\`.`;
|
|
1102
|
+
}
|
|
1103
|
+
return `Catalog item not found: ${id}. Similar IDs: ${suggestions.join(', ')}.`;
|
|
1104
|
+
}
|
|
1105
|
+
function suggestCatalogItems(items, query) {
|
|
1106
|
+
const needle = query.trim().toLowerCase();
|
|
1107
|
+
if (!needle) {
|
|
1108
|
+
return [];
|
|
1109
|
+
}
|
|
1110
|
+
return items
|
|
1111
|
+
.map((item) => ({ id: item.id, score: computeCatalogSuggestionScore(item, needle) }))
|
|
1112
|
+
.filter((entry) => entry.score > 0)
|
|
1113
|
+
.sort((a, b) => b.score - a.score || a.id.localeCompare(b.id))
|
|
1114
|
+
.slice(0, 4)
|
|
1115
|
+
.map((entry) => entry.id);
|
|
1116
|
+
}
|
|
1117
|
+
function computeCatalogSuggestionScore(item, query) {
|
|
1118
|
+
const id = item.id.toLowerCase();
|
|
1119
|
+
const name = item.name.toLowerCase();
|
|
1120
|
+
const suffix = id.includes(':') ? id.split(':').slice(1).join(':') : id;
|
|
1121
|
+
let score = 0;
|
|
1122
|
+
if (id === query) {
|
|
1123
|
+
return 1000;
|
|
1124
|
+
}
|
|
1125
|
+
if (suffix === query) {
|
|
1126
|
+
score += 300;
|
|
1127
|
+
}
|
|
1128
|
+
if (id.startsWith(query)) {
|
|
1129
|
+
score += 200;
|
|
1130
|
+
}
|
|
1131
|
+
if (id.includes(query)) {
|
|
1132
|
+
score += 120;
|
|
1133
|
+
}
|
|
1134
|
+
if (name.includes(query)) {
|
|
1135
|
+
score += 90;
|
|
1136
|
+
}
|
|
1137
|
+
if (query.includes(':') && suffix.includes(query.split(':').slice(1).join(':'))) {
|
|
1138
|
+
score += 60;
|
|
1139
|
+
}
|
|
1140
|
+
return score;
|
|
1141
|
+
}
|
|
1142
|
+
function computeTextMatchScore(candidate, query) {
|
|
1143
|
+
const value = candidate.toLowerCase();
|
|
1144
|
+
if (value === query) {
|
|
1145
|
+
return 1000;
|
|
1146
|
+
}
|
|
1147
|
+
if (value.startsWith(query)) {
|
|
1148
|
+
return 200;
|
|
1149
|
+
}
|
|
1150
|
+
if (value.includes(query) || query.includes(value)) {
|
|
1151
|
+
return 120;
|
|
1152
|
+
}
|
|
1153
|
+
const sharedPrefix = longestSharedPrefixLength(value, query);
|
|
1154
|
+
if (sharedPrefix >= 2) {
|
|
1155
|
+
return sharedPrefix * 20;
|
|
1156
|
+
}
|
|
1157
|
+
return 0;
|
|
1158
|
+
}
|
|
1159
|
+
function longestSharedPrefixLength(left, right) {
|
|
1160
|
+
const max = Math.min(left.length, right.length);
|
|
1161
|
+
let index = 0;
|
|
1162
|
+
while (index < max && left[index] === right[index]) {
|
|
1163
|
+
index += 1;
|
|
1164
|
+
}
|
|
1165
|
+
return index;
|
|
1166
|
+
}
|
|
1167
|
+
function renderCatalogDecisionDetails(rows, policy, insights) {
|
|
1168
|
+
if (rows.length === 0) {
|
|
1169
|
+
return '\nDecision details\n- none';
|
|
1170
|
+
}
|
|
1171
|
+
const lines = ['', 'Decision details'];
|
|
1172
|
+
rows.forEach((entry, index) => {
|
|
1173
|
+
const trust = computeTrustSignal(entry.item);
|
|
1174
|
+
const insight = insights.get(entry.item.id);
|
|
1175
|
+
const status = entry.blocked ? 'blocked' : 'allowed';
|
|
1176
|
+
const bestFor = insight?.bestFor.length ? insight.bestFor.join('; ') : 'n/a';
|
|
1177
|
+
const tradeoffs = insight?.tradeoffs.length ? insight.tradeoffs.join('; ') : 'n/a';
|
|
1178
|
+
lines.push(`${index + 1}. ${entry.item.id} | ${entry.item.name}`);
|
|
1179
|
+
lines.push(` Decision: trust ${trust.toFixed(1)}/100 (${describeTrustBand(trust)}), risk ${entry.assessment.riskScore.toFixed(0)}/100 (${entry.assessment.riskTier}; ${describeRiskBand(entry.assessment.riskScore, policy)}), status ${status}.`);
|
|
1180
|
+
lines.push(` Why use: ${entry.item.description}`);
|
|
1181
|
+
lines.push(` Capabilities: ${entry.item.capabilities.join(', ') || 'none'}`);
|
|
1182
|
+
lines.push(` Provenance: provider=${entry.item.provider}, source=${entry.item.source}, confidence=${getSourceConfidence(entry.item)}, catalogType=${getCatalogType(entry.item)}.`);
|
|
1183
|
+
lines.push(` Risk reasons: ${entry.assessment.reasons.join('; ')}`);
|
|
1184
|
+
lines.push(` Best for: ${bestFor}`);
|
|
1185
|
+
lines.push(` Tradeoffs: ${tradeoffs}`);
|
|
1186
|
+
lines.push(` Install: plugscout install --id ${entry.item.id} --yes`);
|
|
1187
|
+
});
|
|
1188
|
+
return lines.join('\n');
|
|
1189
|
+
}
|
|
1190
|
+
function renderRecommendationDecisionDetails(recommendations, catalogMap, policy, insights) {
|
|
1191
|
+
if (recommendations.length === 0) {
|
|
1192
|
+
return '\nRecommendation details\n- none';
|
|
1193
|
+
}
|
|
1194
|
+
const lines = ['', 'Recommendation details'];
|
|
1195
|
+
recommendations.forEach((entry, index) => {
|
|
1196
|
+
const item = catalogMap.get(entry.id);
|
|
1197
|
+
const insight = insights.get(entry.id);
|
|
1198
|
+
const blockNote = entry.blocked ? entry.blockReason ?? 'Blocked by policy' : 'Not blocked by policy';
|
|
1199
|
+
lines.push(`${index + 1}. ${entry.id}`);
|
|
1200
|
+
lines.push(` Score: ${entry.rankScore.toFixed(1)} = fit ${entry.scoreBreakdown.fitScore.toFixed(1)} + trust ${entry.scoreBreakdown.trustScore.toFixed(1)} + freshness ${entry.scoreBreakdown.freshnessBonus.toFixed(1)} - security ${entry.scoreBreakdown.securityPenalty.toFixed(1)} - blocked ${entry.scoreBreakdown.blockedPenalty.toFixed(1)}`);
|
|
1201
|
+
lines.push(` Risk: ${entry.riskScore.toFixed(0)}/100 (${entry.riskTier}; ${describeRiskBand(entry.riskScore, policy)}), ${blockNote}.`);
|
|
1202
|
+
lines.push(` Why ranked: ${entry.fitReasons.slice(0, 4).join(' | ')}`);
|
|
1203
|
+
if (item) {
|
|
1204
|
+
lines.push(` Why use: ${item.description}`);
|
|
1205
|
+
lines.push(` Capabilities: ${item.capabilities.join(', ') || 'none'}`);
|
|
1206
|
+
lines.push(` Provenance: provider=${item.provider}, source=${item.source}, confidence=${getSourceConfidence(item)}, catalogType=${getCatalogType(item)}.`);
|
|
1207
|
+
}
|
|
1208
|
+
if (insight?.tradeoffs.length) {
|
|
1209
|
+
lines.push(` Tradeoffs: ${insight.tradeoffs.join('; ')}`);
|
|
1210
|
+
}
|
|
1211
|
+
lines.push(` Install: plugscout install --id ${entry.id} --yes`);
|
|
1212
|
+
});
|
|
1213
|
+
return lines.join('\n');
|
|
1214
|
+
}
|
|
1215
|
+
function computeTrustSignal(item) {
|
|
1216
|
+
return (item.maintenanceSignal + item.provenanceSignal + item.adoptionSignal) / 3;
|
|
1217
|
+
}
|
|
1218
|
+
function describeTrustBand(score) {
|
|
1219
|
+
if (score >= 80) {
|
|
1220
|
+
return 'high confidence';
|
|
1221
|
+
}
|
|
1222
|
+
if (score >= 60) {
|
|
1223
|
+
return 'moderate confidence';
|
|
1224
|
+
}
|
|
1225
|
+
return 'needs manual review';
|
|
1226
|
+
}
|
|
1227
|
+
function describeRiskBand(score, policy) {
|
|
1228
|
+
if (score <= policy.thresholds.lowMax) {
|
|
1229
|
+
return 'low-risk zone';
|
|
1230
|
+
}
|
|
1231
|
+
if (score <= policy.thresholds.mediumMax) {
|
|
1232
|
+
return 'medium-risk zone';
|
|
1233
|
+
}
|
|
1234
|
+
if (score <= policy.thresholds.highMax) {
|
|
1235
|
+
return 'high-risk zone';
|
|
1236
|
+
}
|
|
1237
|
+
return 'critical-risk zone';
|
|
1238
|
+
}
|
|
1239
|
+
async function openInBrowser(filePath) {
|
|
1240
|
+
const resolved = path.resolve(filePath);
|
|
1241
|
+
const target = `file://${resolved}`;
|
|
1242
|
+
const cmd = process.platform === 'darwin'
|
|
1243
|
+
? { bin: 'open', args: [target] }
|
|
1244
|
+
: process.platform === 'win32'
|
|
1245
|
+
? { bin: 'cmd', args: ['/c', 'start', '', target] }
|
|
1246
|
+
: { bin: 'xdg-open', args: [target] };
|
|
1247
|
+
try {
|
|
1248
|
+
const child = spawn(cmd.bin, cmd.args, { detached: true, stdio: 'ignore' });
|
|
1249
|
+
child.unref();
|
|
1250
|
+
return true;
|
|
1251
|
+
}
|
|
1252
|
+
catch {
|
|
1253
|
+
return false;
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
function getItemMetadata(item) {
|
|
1257
|
+
if (!item.metadata || typeof item.metadata !== 'object' || Array.isArray(item.metadata)) {
|
|
1258
|
+
return {};
|
|
1259
|
+
}
|
|
1260
|
+
return item.metadata;
|
|
1261
|
+
}
|
|
1262
|
+
function getCatalogType(item) {
|
|
1263
|
+
const metadata = getItemMetadata(item);
|
|
1264
|
+
const value = metadata.catalogType;
|
|
1265
|
+
if (typeof value === 'string' && value.trim().length > 0) {
|
|
1266
|
+
return value;
|
|
1267
|
+
}
|
|
1268
|
+
return 'standard';
|
|
1269
|
+
}
|
|
1270
|
+
function getSourceConfidence(item) {
|
|
1271
|
+
const metadata = getItemMetadata(item);
|
|
1272
|
+
const value = metadata.sourceConfidence;
|
|
1273
|
+
if (typeof value === 'string' && value.trim().length > 0) {
|
|
1274
|
+
return value;
|
|
1275
|
+
}
|
|
1276
|
+
return 'official';
|
|
1277
|
+
}
|