@shnitzel/plugscout 0.3.10 → 0.3.12

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.
@@ -1,24 +1,111 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import os from 'node:os';
4
- const PLUGSCOUT_MCP_VALUE = { command: 'npx', args: ['plugscout', 'mcp'] };
4
+ const PLUGSCOUT_MCP_STDIO = { command: 'npx', args: ['plugscout', 'mcp'] };
5
+ const PLUGSCOUT_MCP_ZED = { command: { path: 'npx', args: ['plugscout', 'mcp'] } };
6
+ function claudeDesktopConfigPath() {
7
+ if (process.platform === 'win32') {
8
+ return path.join(process.env['APPDATA'] ?? os.homedir(), 'Claude', 'claude_desktop_config.json');
9
+ }
10
+ if (process.platform === 'darwin') {
11
+ return path.join(os.homedir(), 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json');
12
+ }
13
+ return path.join(os.homedir(), '.config', 'claude-desktop', 'claude_desktop_config.json');
14
+ }
15
+ function openCodeConfigPath() {
16
+ if (process.platform === 'win32') {
17
+ return path.join(process.env['APPDATA'] ?? os.homedir(), 'opencode', 'config.json');
18
+ }
19
+ return path.join(os.homedir(), '.config', 'opencode', 'config.json');
20
+ }
21
+ export const CLIENT_DEFS = {
22
+ cursor: {
23
+ label: 'Cursor IDE',
24
+ supportsProjectScope: true,
25
+ getConfigPath(scope) {
26
+ return scope === 'project'
27
+ ? path.join(process.cwd(), '.cursor', 'mcp.json')
28
+ : path.join(os.homedir(), '.cursor', 'mcp.json');
29
+ },
30
+ containerPath: ['mcpServers'],
31
+ entryValue: PLUGSCOUT_MCP_STDIO,
32
+ },
33
+ gemini: {
34
+ label: 'Gemini CLI',
35
+ supportsProjectScope: false,
36
+ getConfigPath() {
37
+ return path.join(os.homedir(), '.gemini', 'settings.json');
38
+ },
39
+ containerPath: ['mcpServers'],
40
+ entryValue: PLUGSCOUT_MCP_STDIO,
41
+ },
42
+ 'claude-desktop': {
43
+ label: 'Claude Desktop',
44
+ supportsProjectScope: false,
45
+ getConfigPath() {
46
+ return claudeDesktopConfigPath();
47
+ },
48
+ containerPath: ['mcpServers'],
49
+ entryValue: PLUGSCOUT_MCP_STDIO,
50
+ },
51
+ windsurf: {
52
+ label: 'Windsurf',
53
+ supportsProjectScope: false,
54
+ getConfigPath() {
55
+ return path.join(os.homedir(), '.codeium', 'windsurf', 'mcp_config.json');
56
+ },
57
+ containerPath: ['mcpServers'],
58
+ entryValue: PLUGSCOUT_MCP_STDIO,
59
+ },
60
+ opencode: {
61
+ label: 'OpenCode',
62
+ supportsProjectScope: false,
63
+ getConfigPath() {
64
+ return openCodeConfigPath();
65
+ },
66
+ containerPath: ['mcp'],
67
+ entryValue: PLUGSCOUT_MCP_STDIO,
68
+ },
69
+ zed: {
70
+ label: 'Zed',
71
+ supportsProjectScope: false,
72
+ getConfigPath() {
73
+ return path.join(os.homedir(), '.config', 'zed', 'settings.json');
74
+ },
75
+ containerPath: ['context_servers'],
76
+ entryValue: PLUGSCOUT_MCP_ZED,
77
+ },
78
+ };
79
+ export const VALID_CLIENT_KINDS = Object.keys(CLIENT_DEFS);
5
80
  export function getConfigPath(client, scope) {
6
- if (client === 'cursor') {
7
- if (scope === 'project') {
8
- return path.join(process.cwd(), '.cursor', 'mcp.json');
81
+ return CLIENT_DEFS[client].getConfigPath(scope);
82
+ }
83
+ function navigateContainer(obj, keys) {
84
+ let current = obj;
85
+ for (const key of keys) {
86
+ const next = current[key];
87
+ if (next === undefined || next === null || typeof next !== 'object' || Array.isArray(next)) {
88
+ current[key] = {};
9
89
  }
10
- return path.join(os.homedir(), '.cursor', 'mcp.json');
90
+ current = current[key];
11
91
  }
12
- return path.join(os.homedir(), '.gemini', 'settings.json');
92
+ return current;
13
93
  }
14
94
  export async function getClientMcpConfigStatus(client, scope) {
15
- const configPath = getConfigPath(client, scope);
95
+ const def = CLIENT_DEFS[client];
96
+ const configPath = def.getConfigPath(scope);
16
97
  try {
17
98
  const raw = await fs.readFile(configPath, 'utf8');
18
99
  const config = JSON.parse(raw);
19
- const mcpServers = config.mcpServers;
20
- const configured = !!(mcpServers?.plugscout);
21
- return { configured, configPath };
100
+ let container = config;
101
+ for (const key of def.containerPath) {
102
+ const next = container[key];
103
+ if (next === undefined || typeof next !== 'object' || next === null || Array.isArray(next)) {
104
+ return { configured: false, configPath };
105
+ }
106
+ container = next;
107
+ }
108
+ return { configured: !!container['plugscout'], configPath };
22
109
  }
23
110
  catch {
24
111
  return { configured: false, configPath };
@@ -26,8 +113,9 @@ export async function getClientMcpConfigStatus(client, scope) {
26
113
  }
27
114
  export async function writeClientMcpConfig(options) {
28
115
  const { client, force = false } = options;
29
- const scope = client === 'gemini' ? 'user' : options.scope;
30
- const configPath = getConfigPath(client, scope);
116
+ const def = CLIENT_DEFS[client];
117
+ const scope = def.supportsProjectScope ? options.scope : 'user';
118
+ const configPath = def.getConfigPath(scope);
31
119
  let existing = {};
32
120
  try {
33
121
  const raw = await fs.readFile(configPath, 'utf8');
@@ -36,25 +124,21 @@ export async function writeClientMcpConfig(options) {
36
124
  catch {
37
125
  // file doesn't exist yet — start empty
38
126
  }
39
- const mcpServers = existing.mcpServers ?? {};
40
- const current = mcpServers.plugscout;
127
+ const container = navigateContainer(existing, def.containerPath);
128
+ const current = container['plugscout'];
41
129
  if (current !== undefined) {
42
- const isSame = JSON.stringify(current) === JSON.stringify(PLUGSCOUT_MCP_VALUE);
43
- if (isSame) {
130
+ const isSame = JSON.stringify(current) === JSON.stringify(def.entryValue);
131
+ if (isSame)
44
132
  return { status: 'already-configured', configPath };
45
- }
46
133
  if (!force) {
47
134
  throw new Error(`plugscout already exists in ${configPath} with a different value. Use --force to overwrite.`);
48
135
  }
49
136
  }
50
- const updated = {
51
- ...existing,
52
- mcpServers: { ...mcpServers, plugscout: PLUGSCOUT_MCP_VALUE }
53
- };
137
+ container['plugscout'] = def.entryValue;
54
138
  const dir = path.dirname(configPath);
55
139
  await fs.mkdir(dir, { recursive: true });
56
140
  const tmpPath = `${configPath}.plugscout.tmp`;
57
- await fs.writeFile(tmpPath, `${JSON.stringify(updated, null, 2)}\n`, 'utf8');
141
+ await fs.writeFile(tmpPath, `${JSON.stringify(existing, null, 2)}\n`, 'utf8');
58
142
  await fs.rename(tmpPath, configPath);
59
143
  return { status: 'written', configPath };
60
144
  }
@@ -5,7 +5,7 @@ import fs from 'node:fs/promises';
5
5
  import { getStaleRegistries, loadSyncState } from '../../catalog/sync-state.js';
6
6
  import { loadCatalogItems } from '../../catalog/repository.js';
7
7
  import { hasLegacySkillSh, resolveSkillsRuntime } from '../../install/dependencies.js';
8
- import { getClientMcpConfigStatus } from './client-setup.js';
8
+ import { getClientMcpConfigStatus, CLIENT_DEFS } from './client-setup.js';
9
9
  export async function runDoctorChecks(projectPath = '.') {
10
10
  const checks = [];
11
11
  checks.push(checkSkillsRuntime());
@@ -88,6 +88,64 @@ export async function runDoctorChecks(projectPath = '.') {
88
88
  catch {
89
89
  checks.push({ name: 'Gemini MCP config', status: 'warn', message: 'Could not read Gemini MCP config', suggestion: 'Run: plugscout client setup --client gemini' });
90
90
  }
91
+ // Claude Desktop check
92
+ const claudeDesktopConfigPath = CLIENT_DEFS['claude-desktop'].getConfigPath('user');
93
+ const claudeDesktopPresent = spawnSync('which', ['claude'], { encoding: 'utf8' }).status === 0 ||
94
+ await fs.access(path.dirname(claudeDesktopConfigPath)).then(() => true).catch(() => false) ||
95
+ await fs.access(path.join('/', 'Applications', 'Claude.app')).then(() => true).catch(() => false);
96
+ checks.push(claudeDesktopPresent
97
+ ? { name: 'Claude Desktop', status: 'pass', message: 'Claude Desktop detected' }
98
+ : { name: 'Claude Desktop', status: 'warn', message: 'Claude Desktop not detected', suggestion: 'Install from https://claude.ai/download' });
99
+ // Claude Desktop MCP config check
100
+ try {
101
+ const claudeStatus = await getClientMcpConfigStatus('claude-desktop', 'user');
102
+ checks.push(claudeStatus.configured
103
+ ? { name: 'Claude Desktop MCP', status: 'pass', message: `plugscout wired in ${claudeStatus.configPath}` }
104
+ : { name: 'Claude Desktop MCP', status: 'warn', message: 'plugscout not in Claude Desktop config', suggestion: 'Run: plugscout client setup --client claude-desktop' });
105
+ }
106
+ catch {
107
+ checks.push({ name: 'Claude Desktop MCP', status: 'warn', message: 'Could not read Claude Desktop config', suggestion: 'Run: plugscout client setup --client claude-desktop' });
108
+ }
109
+ // Windsurf check
110
+ checks.push(checkBinary('windsurf', { suggestion: 'Install Windsurf from https://windsurf.ai' }));
111
+ // Windsurf MCP config check
112
+ try {
113
+ const windsurfStatus = await getClientMcpConfigStatus('windsurf', 'user');
114
+ checks.push(windsurfStatus.configured
115
+ ? { name: 'Windsurf MCP config', status: 'pass', message: `plugscout wired in ${windsurfStatus.configPath}` }
116
+ : { name: 'Windsurf MCP config', status: 'warn', message: 'plugscout not in Windsurf MCP config', suggestion: 'Run: plugscout client setup --client windsurf' });
117
+ }
118
+ catch {
119
+ checks.push({ name: 'Windsurf MCP config', status: 'warn', message: 'Could not read Windsurf MCP config', suggestion: 'Run: plugscout client setup --client windsurf' });
120
+ }
121
+ // OpenCode check
122
+ checks.push(checkBinary('opencode', { suggestion: 'Install OpenCode: npm install -g opencode-ai' }));
123
+ // OpenCode MCP config check
124
+ try {
125
+ const opencodeStatus = await getClientMcpConfigStatus('opencode', 'user');
126
+ checks.push(opencodeStatus.configured
127
+ ? { name: 'OpenCode MCP config', status: 'pass', message: `plugscout wired in ${opencodeStatus.configPath}` }
128
+ : { name: 'OpenCode MCP config', status: 'warn', message: 'plugscout not in OpenCode config', suggestion: 'Run: plugscout client setup --client opencode' });
129
+ }
130
+ catch {
131
+ checks.push({ name: 'OpenCode MCP config', status: 'warn', message: 'Could not read OpenCode config', suggestion: 'Run: plugscout client setup --client opencode' });
132
+ }
133
+ // Zed check
134
+ const zedInstalled = spawnSync('which', ['zed'], { encoding: 'utf8' }).status === 0 ||
135
+ await fs.access(path.join('/', 'Applications', 'Zed.app')).then(() => true).catch(() => false);
136
+ checks.push(zedInstalled
137
+ ? { name: 'Zed', status: 'pass', message: 'Zed detected' }
138
+ : { name: 'Zed', status: 'warn', message: 'Zed not detected', suggestion: 'Install Zed from https://zed.dev' });
139
+ // Zed MCP config check
140
+ try {
141
+ const zedStatus = await getClientMcpConfigStatus('zed', 'user');
142
+ checks.push(zedStatus.configured
143
+ ? { name: 'Zed MCP config', status: 'pass', message: `plugscout wired in ${zedStatus.configPath}` }
144
+ : { name: 'Zed MCP config', status: 'warn', message: 'plugscout not in Zed settings', suggestion: 'Run: plugscout client setup --client zed' });
145
+ }
146
+ catch {
147
+ checks.push({ name: 'Zed MCP config', status: 'warn', message: 'Could not read Zed settings', suggestion: 'Run: plugscout client setup --client zed' });
148
+ }
91
149
  return checks;
92
150
  }
93
151
  function checkSkillsRuntime() {
@@ -30,7 +30,7 @@ import { hasFlag, readCsvList, readFlag, readKinds, readLimit, readSort } from '
30
30
  import { renderHomeScreen, renderInteractiveHome } from './ui/home.js';
31
31
  import { handleMcp } from './mcp.js';
32
32
  import { writeWebReport } from './ui/web-report.js';
33
- import { checkForUpdateNow, maybeNotifyAboutUpdate, RELEASE_DOWNLOAD_URL } from './update-check.js';
33
+ import { applyUpdate, checkForUpdateNow, maybeNotifyAboutUpdate, RELEASE_DOWNLOAD_URL } from './update-check.js';
34
34
  const COMMAND_ALIASES = {
35
35
  home: 'home',
36
36
  about: 'about',
@@ -860,38 +860,65 @@ async function handleQuarantine(args) {
860
860
  async function handleClient(args) {
861
861
  const subcommand = args[0];
862
862
  if (subcommand !== 'setup') {
863
- throw new Error('Usage: client setup --client cursor|gemini [--scope user|project] [--force]');
863
+ throw new Error('Usage: client setup --client cursor|gemini|claude-desktop|windsurf|opencode|zed [--scope user|project] [--force]');
864
864
  }
865
+ const { writeClientMcpConfig, CLIENT_DEFS, VALID_CLIENT_KINDS } = await import('./client-setup.js');
865
866
  const clientFlag = readFlag(args, '--client');
866
- if (clientFlag !== 'cursor' && clientFlag !== 'gemini') {
867
- throw new Error('Usage: client setup --client cursor|gemini');
867
+ if (!clientFlag || !VALID_CLIENT_KINDS.includes(clientFlag)) {
868
+ throw new Error(`Usage: client setup --client ${VALID_CLIENT_KINDS.join('|')}`);
868
869
  }
869
870
  const scopeFlag = readFlag(args, '--scope') ?? 'user';
870
871
  if (scopeFlag !== 'user' && scopeFlag !== 'project') {
871
872
  throw new Error('--scope must be user or project');
872
873
  }
873
- if (clientFlag === 'gemini' && scopeFlag === 'project') {
874
- logger.warn('Gemini CLI only supports user scope; falling back to user scope.');
874
+ const def = CLIENT_DEFS[clientFlag];
875
+ if (!def.supportsProjectScope && scopeFlag === 'project') {
876
+ logger.warn(`${def.label} only supports user scope; falling back to user scope.`);
875
877
  }
876
878
  const force = hasFlag(args, '--force');
877
- const { writeClientMcpConfig } = await import('./client-setup.js');
878
- const result = await writeClientMcpConfig({ client: clientFlag, scope: scopeFlag, force });
879
+ const result = await writeClientMcpConfig({
880
+ client: clientFlag,
881
+ scope: scopeFlag,
882
+ force
883
+ });
879
884
  if (result.status === 'already-configured') {
880
885
  console.log(`plugscout already configured in ${result.configPath}`);
881
886
  }
882
887
  else {
883
888
  console.log(`plugscout MCP config written: ${result.configPath}`);
884
- printHint(`Restart ${clientFlag === 'cursor' ? 'Cursor IDE' : 'Gemini CLI'} for the change to take effect.`);
889
+ printHint(`Restart ${def.label} for the change to take effect.`);
885
890
  }
886
891
  }
887
892
  async function handleUpgrade(args) {
888
893
  const subcommand = args[0] ?? 'check';
894
+ if (subcommand === 'apply') {
895
+ const result = await applyUpdate();
896
+ renderApplyResult(result);
897
+ return;
898
+ }
889
899
  if (subcommand !== 'check') {
890
- throw new Error('Usage: upgrade check');
900
+ throw new Error('Usage: upgrade check | upgrade apply');
891
901
  }
892
902
  const result = await checkForUpdateNow();
893
903
  renderUpgradeResult(result);
894
904
  }
905
+ function renderApplyResult(result) {
906
+ if (result.status === 'upgraded') {
907
+ console.log(`PlugScout upgraded: v${result.fromVersion} -> v${result.toVersion}`);
908
+ return;
909
+ }
910
+ if (result.status === 'already-latest') {
911
+ console.log(`PlugScout is already up to date (v${result.currentVersion}).`);
912
+ return;
913
+ }
914
+ if (result.status === 'no-release') {
915
+ console.log('No published release found yet.');
916
+ console.log(`Releases: ${RELEASE_DOWNLOAD_URL}`);
917
+ return;
918
+ }
919
+ console.log(`Upgrade failed: ${result.detail}`);
920
+ console.log(`Manual install: npm install -g @shnitzel/plugscout@latest`);
921
+ }
895
922
  function renderUpgradeResult(result) {
896
923
  if (result.status === 'no-release') {
897
924
  console.log('No published release found yet.');
@@ -910,7 +937,7 @@ function renderUpgradeResult(result) {
910
937
  return;
911
938
  }
912
939
  console.log(`New PlugScout version available: v${result.currentVersion} -> v${result.latestVersion}`);
913
- console.log(`Download: ${RELEASE_DOWNLOAD_URL}`);
940
+ console.log(`Upgrade: plugscout upgrade apply | Download: ${RELEASE_DOWNLOAD_URL}`);
914
941
  }
915
942
  function sortRecommendations(recommendations, sort) {
916
943
  const sorted = [...recommendations];
@@ -1036,8 +1063,9 @@ function printHelp() {
1036
1063
  console.log('Other');
1037
1064
  console.log(' about');
1038
1065
  console.log(' web [--out .plugscout/report.html] [--kind ...] [--limit n] [--open]');
1039
- console.log(' client setup --client cursor|gemini [--scope user|project] [--force]');
1066
+ console.log(' client setup --client cursor|gemini|claude-desktop|windsurf|opencode|zed [--scope user|project] [--force]');
1040
1067
  console.log(' upgrade check');
1068
+ console.log(' upgrade apply auto-install latest version via npm');
1041
1069
  console.log(' help');
1042
1070
  console.log('');
1043
1071
  console.log('Kind aliases');
@@ -1057,6 +1085,10 @@ function printHelp() {
1057
1085
  console.log(' plugscout show --id claude-connector:asana');
1058
1086
  console.log(' plugscout client setup --client cursor');
1059
1087
  console.log(' plugscout client setup --client gemini');
1088
+ console.log(' plugscout client setup --client claude-desktop');
1089
+ console.log(' plugscout client setup --client windsurf');
1090
+ console.log(' plugscout client setup --client opencode');
1091
+ console.log(' plugscout client setup --client zed');
1060
1092
  console.log(' plugscout sync --kind cursor-extension,gemini-extension');
1061
1093
  console.log('');
1062
1094
  console.log('Global options');
@@ -1,4 +1,5 @@
1
1
  import fs from 'node:fs/promises';
2
+ import { spawnSync } from 'node:child_process';
2
3
  import semver from 'semver';
3
4
  import { readJsonFile, writeJsonFile } from '../../lib/json.js';
4
5
  import { getPackagePath, getStatePath } from '../../lib/paths.js';
@@ -80,7 +81,7 @@ export async function maybeNotifyAboutUpdate(options = {}) {
80
81
  return;
81
82
  }
82
83
  console.log(`New PlugScout version available: v${currentVersion} -> v${state.latestVersion}`);
83
- console.log(`Download: ${RELEASE_DOWNLOAD_URL}`);
84
+ console.log(`Upgrade: plugscout upgrade apply | Download: ${RELEASE_DOWNLOAD_URL}`);
84
85
  await saveUpdateCheckState({
85
86
  ...state,
86
87
  source: 'github-releases',
@@ -113,6 +114,32 @@ export async function checkForUpdateNow() {
113
114
  }
114
115
  return { status: 'up-to-date', currentVersion, latestVersion: result.latestVersion };
115
116
  }
117
+ export async function applyUpdate() {
118
+ const currentVersion = await loadCurrentVersion();
119
+ const release = await lookupLatestReleaseVersion();
120
+ if (release.status === 'error') {
121
+ return { status: 'error', currentVersion, detail: 'Could not reach GitHub releases API.' };
122
+ }
123
+ if (release.status === 'no-release') {
124
+ return { status: 'no-release', currentVersion };
125
+ }
126
+ if (!isVersionNewer(release.latestVersion, currentVersion)) {
127
+ return { status: 'already-latest', currentVersion, latestVersion: release.latestVersion };
128
+ }
129
+ const pkg = '@shnitzel/plugscout@latest';
130
+ const result = spawnSync('npm', ['install', '-g', pkg], { stdio: 'inherit' });
131
+ if (result.status !== 0) {
132
+ return { status: 'error', currentVersion, detail: `npm install -g ${pkg} exited with ${result.status ?? 'signal'}` };
133
+ }
134
+ await saveUpdateCheckState({
135
+ ...(await loadUpdateCheckState()),
136
+ source: 'github-releases',
137
+ lastCheckedAt: new Date().toISOString(),
138
+ latestVersion: release.latestVersion,
139
+ lastNotifiedVersion: release.latestVersion
140
+ });
141
+ return { status: 'upgraded', fromVersion: currentVersion, toVersion: release.latestVersion };
142
+ }
116
143
  async function lookupLatestReleaseVersion() {
117
144
  const controller = new AbortController();
118
145
  const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shnitzel/plugscout",
3
- "version": "0.3.10",
3
+ "version": "0.3.12",
4
4
  "description": "Claude plugins + Claude connectors + Copilot extensions + Skills + MCP security intelligence framework",
5
5
  "private": false,
6
6
  "type": "module",