superacli 1.1.2 → 1.1.4

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.
Files changed (61) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +10 -1
  3. package/__tests__/blogwatcher-plugin.test.js +157 -0
  4. package/__tests__/clix-plugin.test.js +143 -0
  5. package/__tests__/config.test.js +46 -1
  6. package/__tests__/himalaya-plugin.test.js +121 -0
  7. package/__tests__/mcp-adapter.test.js +79 -0
  8. package/__tests__/mcp-local.test.js +43 -1
  9. package/__tests__/mongosh-plugin.test.js +106 -0
  10. package/__tests__/mysql-plugin.test.js +94 -0
  11. package/__tests__/plugin-blogwatcher.test.js +55 -0
  12. package/__tests__/plugin-clix.test.js +51 -0
  13. package/__tests__/plugin-xurl.test.js +51 -0
  14. package/__tests__/server-config-service.test.js +8 -1
  15. package/__tests__/skills.test.js +26 -0
  16. package/__tests__/wacli-plugin.test.js +132 -0
  17. package/__tests__/xurl-plugin.test.js +176 -0
  18. package/cli/adapter-schema.js +7 -0
  19. package/cli/adapters/mcp.js +82 -20
  20. package/cli/config.js +65 -8
  21. package/cli/mcp-local.js +50 -4
  22. package/cli/plugin-install-guidance.js +100 -0
  23. package/cli/skills.js +55 -0
  24. package/cli/supercli.js +1 -1
  25. package/docs/features/adapters.md +6 -2
  26. package/docs/initial/mcp-local-mode.md +3 -0
  27. package/docs/skills-catalog.md +50 -0
  28. package/docs/supported-harnesses.md +20 -0
  29. package/package.json +2 -1
  30. package/plugins/blogwatcher/README.md +52 -0
  31. package/plugins/blogwatcher/plugin.json +195 -0
  32. package/plugins/blogwatcher/scripts/post-install.js +66 -0
  33. package/plugins/blogwatcher/scripts/post-uninstall.js +25 -0
  34. package/plugins/clix/README.md +44 -0
  35. package/plugins/clix/plugin.json +126 -0
  36. package/plugins/clix/scripts/post-install.js +66 -0
  37. package/plugins/clix/scripts/post-uninstall.js +25 -0
  38. package/plugins/himalaya/README.md +48 -0
  39. package/plugins/himalaya/plugin.json +157 -0
  40. package/plugins/mongosh/README.md +56 -0
  41. package/plugins/mongosh/plugin.json +88 -0
  42. package/plugins/mysql/README.md +48 -0
  43. package/plugins/mysql/plugin.json +64 -0
  44. package/plugins/plugins.json +63 -0
  45. package/plugins/wacli/README.md +52 -0
  46. package/plugins/wacli/plugin.json +260 -0
  47. package/plugins/xurl/README.md +52 -0
  48. package/plugins/xurl/plugin.json +239 -0
  49. package/plugins/xurl/scripts/post-install.js +66 -0
  50. package/plugins/xurl/scripts/post-uninstall.js +25 -0
  51. package/server/routes/mcp.js +30 -4
  52. package/server/services/configService.js +9 -1
  53. package/tests/test-blogwatcher-smoke.sh +48 -0
  54. package/tests/test-clix-smoke.sh +44 -0
  55. package/tests/test-himalaya-smoke.sh +47 -0
  56. package/tests/test-mcp-browser-use-smoke.sh +141 -0
  57. package/tests/test-mongosh-smoke.sh +40 -0
  58. package/tests/test-mysql-smoke.sh +37 -0
  59. package/tests/test-plugins-registry.js +35 -0
  60. package/tests/test-wacli-smoke.sh +46 -0
  61. package/tests/test-xurl-smoke.sh +46 -0
@@ -0,0 +1,176 @@
1
+ const fs = require("fs")
2
+ const os = require("os")
3
+ const path = require("path")
4
+ const { execSync } = require("child_process")
5
+
6
+ const CLI = path.join(__dirname, "..", "cli", "supercli.js")
7
+
8
+ function runNoServer(args, options = {}) {
9
+ try {
10
+ const env = { ...process.env }
11
+ delete env.SUPERCLI_SERVER
12
+ const out = execSync(`node ${CLI} ${args}`, {
13
+ encoding: "utf-8",
14
+ timeout: 15000,
15
+ env: { ...env, ...(options.env || {}) }
16
+ })
17
+ return { ok: true, output: out.trim(), code: 0 }
18
+ } catch (err) {
19
+ return {
20
+ ok: false,
21
+ output: (err.stdout || "").trim(),
22
+ stderr: (err.stderr || "").trim(),
23
+ code: err.status
24
+ }
25
+ }
26
+ }
27
+
28
+ function writeFakeCurlBinary(dir) {
29
+ const bin = path.join(dir, "curl")
30
+ fs.writeFileSync(bin, [
31
+ "#!/usr/bin/env node",
32
+ "const args = process.argv.slice(2);",
33
+ "const url = args[args.length - 1] || '';",
34
+ "if (args.includes('--version')) { console.log('curl 8.0.0-test'); process.exit(0); }",
35
+ "if (url.endsWith('/SKILL.md')) { console.log('---\\nname: xurl\\ndescription: Test skill\\n---\\n# xurl Skill'); process.exit(0); }",
36
+ "if (url.endsWith('/README.md')) { console.log('# xurl\\n\\nTest readme.'); process.exit(0); }",
37
+ "console.error(`unsupported url: ${url}`);",
38
+ "process.exit(22);"
39
+ ].join("\n"), "utf-8")
40
+ fs.chmodSync(bin, 0o755)
41
+ return bin
42
+ }
43
+
44
+ function writeFakeXurlBinary(dir) {
45
+ const bin = path.join(dir, "xurl")
46
+ fs.writeFileSync(bin, [
47
+ "#!/usr/bin/env node",
48
+ "const args = process.argv.slice(2);",
49
+ "const emitJson = value => {",
50
+ " const payload = JSON.stringify(value);",
51
+ " if (process.env.NO_COLOR === '1') console.log(payload);",
52
+ " else console.log(`\\u001b[32m${payload}\\u001b[0m`);",
53
+ " process.exit(0);",
54
+ "};",
55
+ "if (args[0] === 'version') { console.log('xurl 1.0.3-test'); process.exit(0); }",
56
+ "if (args[0] === 'auth' && args[1] === 'status') { console.log('▸ my-app [client_id: abc123…]'); process.exit(0); }",
57
+ "if (args[0] === 'auth' && args[1] === 'apps' && args[2] === 'list') { console.log('▸ my-app (client_id: abc123…)'); process.exit(0); }",
58
+ "if (args[0] === 'whoami') emitJson({ data: { id: '42', username: 'tester' } });",
59
+ "if (args[0] === 'user') emitJson({ data: { id: '84', username: args[1].replace(/^@/, '') } });",
60
+ "if (args[0] === 'read') emitJson({ data: { id: '123', text: 'hello world', ref: args[1] } });",
61
+ "if (args[0] === 'search') emitJson({ data: [{ id: '1', text: args[1] }] });",
62
+ "if (args[0] === 'timeline') emitJson({ data: [{ id: '2', text: 'timeline item' }] });",
63
+ "if (args[0] === 'mentions') emitJson({ data: [{ id: '3', text: 'mention item' }] });",
64
+ "if (args[0] === 'followers') emitJson({ data: [{ username: args.includes('--of') ? args[args.indexOf('--of') + 1] : 'alice' }] });",
65
+ "if (args[0] === 'following') emitJson({ data: [{ username: args.includes('--of') ? args[args.indexOf('--of') + 1] : 'bob' }] });",
66
+ "emitJson({ ok: true, args });"
67
+ ].join("\n"), "utf-8")
68
+ fs.chmodSync(bin, 0o755)
69
+ return bin
70
+ }
71
+
72
+ describe("xurl hybrid plugin", () => {
73
+ const fakeDir = fs.mkdtempSync(path.join(os.tmpdir(), "dcli-xurl-"))
74
+ const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "dcli-home-xurl-"))
75
+ writeFakeCurlBinary(fakeDir)
76
+ writeFakeXurlBinary(fakeDir)
77
+ const env = { ...process.env, PATH: `${fakeDir}:${process.env.PATH || ""}`, SUPERCLI_HOME: tempHome }
78
+ let removed = false
79
+
80
+ beforeAll(() => {
81
+ const install = runNoServer("plugins install ./plugins/xurl --on-conflict replace --json", { env })
82
+ expect(install.ok).toBe(true)
83
+ })
84
+
85
+ afterAll(() => {
86
+ if (!removed) runNoServer("plugins remove xurl --json", { env })
87
+ fs.rmSync(fakeDir, { recursive: true, force: true })
88
+ fs.rmSync(tempHome, { recursive: true, force: true })
89
+ })
90
+
91
+ test("indexes xurl skills provider", () => {
92
+ const provider = runNoServer("skills providers show --name xurl --json", { env })
93
+ expect(provider.ok).toBe(true)
94
+ expect(JSON.parse(provider.output).provider.name).toBe("xurl")
95
+
96
+ const list = runNoServer("skills list --catalog --provider xurl --json", { env })
97
+ expect(list.ok).toBe(true)
98
+ const listData = JSON.parse(list.output)
99
+ expect(listData.skills.some(skill => skill.id === "xurl:root.skill")).toBe(true)
100
+ expect(listData.skills.some(skill => skill.id === "xurl:root.readme")).toBe(true)
101
+ })
102
+
103
+ test("fetches indexed remote skill markdown", () => {
104
+ const skill = runNoServer("skills get xurl:root.skill", { env })
105
+ expect(skill.ok).toBe(true)
106
+ expect(skill.output).toContain("xurl Skill")
107
+ })
108
+
109
+ test("routes wrapped raw and parsed commands", () => {
110
+ const version = runNoServer("xurl cli version --json", { env })
111
+ expect(version.ok).toBe(true)
112
+ expect(JSON.parse(version.output).data.raw).toBe("xurl 1.0.3-test")
113
+
114
+ const status = runNoServer("xurl auth status --json", { env })
115
+ expect(status.ok).toBe(true)
116
+ expect(JSON.parse(status.output).data.raw).toContain("my-app")
117
+
118
+ const apps = runNoServer("xurl apps list --json", { env })
119
+ expect(apps.ok).toBe(true)
120
+ expect(JSON.parse(apps.output).data.raw).toContain("my-app")
121
+
122
+ const whoami = runNoServer("xurl account whoami --json", { env })
123
+ expect(whoami.ok).toBe(true)
124
+ expect(JSON.parse(whoami.output).data.data.username).toBe("tester")
125
+
126
+ const user = runNoServer("xurl users show --target @XDevelopers --json", { env })
127
+ expect(user.ok).toBe(true)
128
+ expect(JSON.parse(user.output).data.data.username).toBe("XDevelopers")
129
+
130
+ const read = runNoServer("xurl posts show --target 1234567890 --json", { env })
131
+ expect(read.ok).toBe(true)
132
+ expect(JSON.parse(read.output).data.data.ref).toBe("1234567890")
133
+
134
+ const search = runNoServer("xurl posts search --query \"from:XDevelopers\" --max-results 10 --json", { env })
135
+ expect(search.ok).toBe(true)
136
+ expect(JSON.parse(search.output).data.data[0].text).toBe("from:XDevelopers")
137
+ })
138
+
139
+ test("routes timeline mentions and social wrappers", () => {
140
+ const timeline = runNoServer("xurl timeline list --max-results 10 --json", { env })
141
+ expect(timeline.ok).toBe(true)
142
+ expect(JSON.parse(timeline.output).data.data[0].text).toBe("timeline item")
143
+
144
+ const mentions = runNoServer("xurl mentions list --max-results 10 --json", { env })
145
+ expect(mentions.ok).toBe(true)
146
+ expect(JSON.parse(mentions.output).data.data[0].text).toBe("mention item")
147
+
148
+ const followers = runNoServer("xurl social followers --of XDevelopers --max-results 20 --json", { env })
149
+ expect(followers.ok).toBe(true)
150
+ expect(JSON.parse(followers.output).data.data[0].username).toBe("XDevelopers")
151
+
152
+ const following = runNoServer("xurl social following --of XDevelopers --max-results 20 --json", { env })
153
+ expect(following.ok).toBe(true)
154
+ expect(JSON.parse(following.output).data.data[0].username).toBe("XDevelopers")
155
+ })
156
+
157
+ test("does not expose passthrough and reports dependencies as healthy", () => {
158
+ const manifest = JSON.parse(fs.readFileSync(path.join(__dirname, "..", "plugins", "xurl", "plugin.json"), "utf-8"))
159
+ expect(manifest.commands.some(command => command.resource === "_" && command.action === "_")).toBe(false)
160
+
161
+ const doctor = runNoServer("plugins doctor xurl --json", { env })
162
+ expect(doctor.ok).toBe(true)
163
+ const doctorData = JSON.parse(doctor.output)
164
+ expect(doctorData.checks.some(c => c.type === "binary" && c.binary === "curl" && c.ok === true)).toBe(true)
165
+ expect(doctorData.checks.some(c => c.type === "binary" && c.binary === "xurl" && c.ok === true)).toBe(true)
166
+ })
167
+
168
+ test("removal cleans up the skills provider", () => {
169
+ const remove = runNoServer("plugins remove xurl --json", { env })
170
+ expect(remove.ok).toBe(true)
171
+ removed = true
172
+
173
+ const provider = runNoServer("skills providers show --name xurl --json", { env })
174
+ expect(provider.ok).toBe(false)
175
+ })
176
+ })
@@ -69,6 +69,13 @@ function validateAdapterConfig(cmd) {
69
69
  if (!config.tool || typeof config.tool !== "string") throw asInvalid("MCP adapter requires adapterConfig.tool")
70
70
  const sources = [config.server, config.url, config.command].filter(Boolean)
71
71
  if (sources.length === 0) throw asInvalid("MCP adapter requires one source: adapterConfig.server, adapterConfig.url, or adapterConfig.command")
72
+ if (config.server !== undefined && typeof config.server !== "string") throw asInvalid("adapterConfig.server must be string")
73
+ if (config.url !== undefined && typeof config.url !== "string") throw asInvalid("adapterConfig.url must be string")
74
+ if (config.command !== undefined && typeof config.command !== "string") throw asInvalid("adapterConfig.command must be string")
75
+ if (config.args !== undefined && !Array.isArray(config.args)) throw asInvalid("adapterConfig.args must be an array")
76
+ if (config.commandArgs !== undefined && !Array.isArray(config.commandArgs)) throw asInvalid("adapterConfig.commandArgs must be an array")
77
+ if (config.headers !== undefined && (typeof config.headers !== "object" || Array.isArray(config.headers))) throw asInvalid("adapterConfig.headers must be object")
78
+ if (config.env !== undefined && (typeof config.env !== "object" || Array.isArray(config.env))) throw asInvalid("adapterConfig.env must be object")
72
79
  return
73
80
  }
74
81
 
@@ -3,11 +3,63 @@
3
3
 
4
4
  const { spawn } = require("child_process");
5
5
 
6
- async function callStdioTool(command, args, payload, timeoutMs) {
6
+ function asObject(value) {
7
+ return value && typeof value === "object" && !Array.isArray(value)
8
+ ? value
9
+ : {};
10
+ }
11
+
12
+ function asStringMap(value) {
13
+ const obj = asObject(value);
14
+ const out = {};
15
+ for (const [k, v] of Object.entries(obj)) {
16
+ if (typeof k === "string" && typeof v === "string") out[k] = v;
17
+ }
18
+ return out;
19
+ }
20
+
21
+ function asStringArray(value) {
22
+ if (!Array.isArray(value)) return [];
23
+ return value.filter((v) => typeof v === "string");
24
+ }
25
+
26
+ function mergeMcpConfig(cmdConfig, serverEntry) {
27
+ const mergedHeaders = {
28
+ ...asStringMap(serverEntry && serverEntry.headers),
29
+ ...asStringMap(cmdConfig.headers),
30
+ };
31
+ const mergedEnv = {
32
+ ...asStringMap(serverEntry && serverEntry.env),
33
+ ...asStringMap(cmdConfig.env),
34
+ };
35
+ const mergedArgs = [
36
+ ...asStringArray(serverEntry && serverEntry.args),
37
+ ...asStringArray(serverEntry && serverEntry.commandArgs),
38
+ ...asStringArray(cmdConfig.args),
39
+ ...asStringArray(cmdConfig.commandArgs),
40
+ ];
41
+
42
+ return {
43
+ tool: cmdConfig.tool,
44
+ server: cmdConfig.server,
45
+ url: cmdConfig.url || (serverEntry && serverEntry.url),
46
+ command: cmdConfig.command || (serverEntry && serverEntry.command),
47
+ timeout_ms:
48
+ cmdConfig.timeout_ms !== undefined
49
+ ? cmdConfig.timeout_ms
50
+ : serverEntry && serverEntry.timeout_ms,
51
+ headers: mergedHeaders,
52
+ env: mergedEnv,
53
+ args: mergedArgs,
54
+ };
55
+ }
56
+
57
+ async function callStdioTool(command, args, payload, timeoutMs, env) {
7
58
  return new Promise((resolve, reject) => {
8
59
  const child = spawn(command, args || [], {
9
60
  stdio: ["pipe", "pipe", "pipe"],
10
61
  shell: !Array.isArray(args) || args.length === 0,
62
+ env: env && typeof env === "object" ? { ...process.env, ...env } : process.env,
11
63
  });
12
64
 
13
65
  let stdout = "";
@@ -95,17 +147,15 @@ async function callStdioTool(command, args, payload, timeoutMs) {
95
147
  });
96
148
  }
97
149
 
98
- async function resolveHttpServerUrl(config, context) {
99
- if (config.url) return config.url;
100
-
101
- if (context.config && Array.isArray(context.config.mcp_servers)) {
150
+ async function resolveServerEntry(config, context) {
151
+ if (config.server && context.config && Array.isArray(context.config.mcp_servers)) {
102
152
  const local = context.config.mcp_servers.find(
103
153
  (s) => s && s.name === config.server,
104
154
  );
105
- if (local) return local.url;
155
+ if (local) return local;
106
156
  }
107
157
 
108
- if (context.server) {
158
+ if (config.server && context.server) {
109
159
  const r = await fetch(`${context.server}/api/mcp?format=json`);
110
160
  if (!r.ok) {
111
161
  throw Object.assign(
@@ -118,10 +168,12 @@ async function resolveHttpServerUrl(config, context) {
118
168
  );
119
169
  }
120
170
  const servers = await r.json();
121
- const srv = servers.find((s) => s.name === config.server);
122
- if (srv) return srv.url;
171
+ const srv = servers.find((s) => s && s.name === config.server);
172
+ if (srv) return srv;
123
173
  }
124
174
 
175
+ if (config.url || config.command) return {};
176
+
125
177
  throw Object.assign(
126
178
  new Error(
127
179
  `MCP server '${config.server}' not found in local config. Add one with: supercli mcp add ${config.server} --url <mcp_url> or run supercli sync`,
@@ -135,17 +187,23 @@ async function resolveHttpServerUrl(config, context) {
135
187
  }
136
188
 
137
189
  async function execute(cmd, flags, context) {
138
- const config = cmd.adapterConfig || {};
139
- const toolName = config.tool;
140
- const hasHttpSource = !!(config.server || config.url);
141
- const hasStdioSource = !!config.command;
190
+ const cmdConfig = cmd.adapterConfig || {};
191
+ const toolName = cmdConfig.tool;
192
+ const hasDeclaredSource = !!(cmdConfig.server || cmdConfig.url || cmdConfig.command);
142
193
 
143
- if (!toolName || (!hasHttpSource && !hasStdioSource)) {
194
+ if (!toolName || !hasDeclaredSource) {
144
195
  throw new Error(
145
196
  "MCP adapter requires 'tool' and one of: 'server', 'url', or 'command' in adapterConfig",
146
197
  );
147
198
  }
148
199
 
200
+ const serverEntry = cmdConfig.server
201
+ ? await resolveServerEntry(cmdConfig, context)
202
+ : {};
203
+ const config = mergeMcpConfig(cmdConfig, serverEntry);
204
+ const hasStdioSource = !!config.command;
205
+ const hasHttpSource = !!config.url;
206
+
149
207
  const input = {};
150
208
  for (const [k, v] of Object.entries(flags)) {
151
209
  if (!["human", "json", "compact"].includes(k)) {
@@ -154,9 +212,7 @@ async function execute(cmd, flags, context) {
154
212
  }
155
213
 
156
214
  if (hasStdioSource) {
157
- const commandArgs = Array.isArray(config.commandArgs)
158
- ? config.commandArgs
159
- : [];
215
+ const commandArgs = Array.isArray(config.args) ? config.args : [];
160
216
  const timeoutMs =
161
217
  Number(config.timeout_ms) > 0 ? Number(config.timeout_ms) : 10000;
162
218
  return callStdioTool(
@@ -164,14 +220,20 @@ async function execute(cmd, flags, context) {
164
220
  commandArgs,
165
221
  { tool: toolName, input },
166
222
  timeoutMs,
223
+ config.env,
224
+ );
225
+ }
226
+
227
+ if (!hasHttpSource) {
228
+ throw new Error(
229
+ "MCP adapter could not resolve a source. Define adapterConfig.url/command or configure the named server with url/command.",
167
230
  );
168
231
  }
169
232
 
170
- const resolvedUrl = await resolveHttpServerUrl(config, context);
171
- const toolUrl = resolvedUrl.replace(/\/+$/, "");
233
+ const toolUrl = config.url.replace(/\/+$/, "");
172
234
  const tr = await fetch(`${toolUrl}/tool`, {
173
235
  method: "POST",
174
- headers: { "Content-Type": "application/json" },
236
+ headers: { "Content-Type": "application/json", ...(config.headers || {}) },
175
237
  body: JSON.stringify({ tool: toolName, input }),
176
238
  });
177
239
 
package/cli/config.js CHANGED
@@ -16,7 +16,8 @@ function readCache() {
16
16
  try {
17
17
  if (fs.existsSync(CACHE_FILE)) {
18
18
  const raw = fs.readFileSync(CACHE_FILE, "utf-8")
19
- return JSON.parse(raw)
19
+ const parsed = JSON.parse(raw)
20
+ return normalizeConfig(parsed)
20
21
  }
21
22
  } catch (e) {
22
23
  // Corrupted cache, ignore
@@ -24,10 +25,60 @@ function readCache() {
24
25
  return null
25
26
  }
26
27
 
28
+ function normalizeMcpServerEntry(name, entry) {
29
+ if (!name || typeof name !== "string") return null
30
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) return null
31
+ const out = { name }
32
+ if (typeof entry.url === "string") out.url = entry.url
33
+ if (typeof entry.command === "string") out.command = entry.command
34
+ if (Array.isArray(entry.args)) out.args = entry.args.filter(v => typeof v === "string")
35
+ if (Array.isArray(entry.commandArgs)) out.commandArgs = entry.commandArgs.filter(v => typeof v === "string")
36
+ if (entry.headers && typeof entry.headers === "object" && !Array.isArray(entry.headers)) {
37
+ out.headers = Object.fromEntries(Object.entries(entry.headers).filter(([k, v]) => typeof k === "string" && typeof v === "string"))
38
+ }
39
+ if (entry.env && typeof entry.env === "object" && !Array.isArray(entry.env)) {
40
+ out.env = Object.fromEntries(Object.entries(entry.env).filter(([k, v]) => typeof k === "string" && typeof v === "string"))
41
+ }
42
+ if (typeof entry.timeout_ms === "number" && entry.timeout_ms > 0) out.timeout_ms = entry.timeout_ms
43
+ return out
44
+ }
45
+
46
+ function normalizeMcpServers(config) {
47
+ const out = []
48
+ const byName = new Map()
49
+
50
+ if (config && config.mcpServers && typeof config.mcpServers === "object" && !Array.isArray(config.mcpServers)) {
51
+ for (const [name, value] of Object.entries(config.mcpServers)) {
52
+ const normalized = normalizeMcpServerEntry(name, value)
53
+ if (normalized) byName.set(name, normalized)
54
+ }
55
+ }
56
+
57
+ if (Array.isArray(config && config.mcp_servers)) {
58
+ for (const entry of config.mcp_servers) {
59
+ if (!entry || typeof entry !== "object") continue
60
+ const normalized = normalizeMcpServerEntry(entry.name, entry)
61
+ if (normalized) byName.set(normalized.name, normalized)
62
+ }
63
+ }
64
+
65
+ for (const value of byName.values()) out.push(value)
66
+ out.sort((a, b) => a.name.localeCompare(b.name))
67
+ return out
68
+ }
69
+
70
+ function normalizeConfig(config) {
71
+ const base = config && typeof config === "object" ? { ...config } : emptyConfig()
72
+ base.mcp_servers = normalizeMcpServers(base)
73
+ if (!Array.isArray(base.specs)) base.specs = []
74
+ if (!Array.isArray(base.commands)) base.commands = []
75
+ return base
76
+ }
77
+
27
78
  function writeCache(config) {
28
79
  ensureCacheDir()
29
80
  const data = {
30
- ...config,
81
+ ...normalizeConfig(config),
31
82
  fetchedAt: Date.now()
32
83
  }
33
84
  fs.writeFileSync(CACHE_FILE, JSON.stringify(data, null, 2))
@@ -85,19 +136,25 @@ async function syncConfig(server) {
85
136
  }
86
137
 
87
138
  if (!Array.isArray(config.commands)) config.commands = []
88
- return writeCache(config)
139
+ return writeCache(normalizeConfig(config))
89
140
  }
90
141
 
91
- async function setMcpServer(name, url) {
142
+ async function setMcpServer(name, value) {
92
143
  const cfg = readCache() || emptyConfig()
93
144
  const servers = Array.isArray(cfg.mcp_servers) ? cfg.mcp_servers.slice() : []
94
145
  const idx = servers.findIndex(s => s && s.name === name)
95
- const next = { name, url }
146
+ const incoming = typeof value === "string" ? { url: value } : (value && typeof value === "object" ? value : {})
147
+ const next = normalizeMcpServerEntry(name, { name, ...incoming })
148
+ if (!next) {
149
+ throw Object.assign(new Error("Invalid MCP server definition"), {
150
+ code: 85,
151
+ type: "invalid_argument",
152
+ recoverable: false
153
+ })
154
+ }
96
155
  if (idx >= 0) servers[idx] = next
97
156
  else servers.push(next)
98
- cfg.mcp_servers = servers
99
- .filter(s => s && typeof s.name === "string")
100
- .sort((a, b) => a.name.localeCompare(b.name))
157
+ cfg.mcp_servers = normalizeMcpServers({ mcp_servers: servers })
101
158
  return writeCache(cfg)
102
159
  }
103
160
 
package/cli/mcp-local.js CHANGED
@@ -10,8 +10,25 @@ async function handleMcpRegistryCommand(options) {
10
10
  removeMcpServer,
11
11
  listMcpServers,
12
12
  } = options;
13
+ const cliFlags = flags || {};
13
14
 
14
15
  const subcommand = positional[1];
16
+
17
+ function parseJsonFlag(name, raw) {
18
+ if (raw === undefined) return undefined;
19
+ try {
20
+ return JSON.parse(raw);
21
+ } catch {
22
+ outputError({
23
+ code: 85,
24
+ type: "invalid_argument",
25
+ message: `Invalid JSON for --${name}`,
26
+ recoverable: false,
27
+ });
28
+ return null;
29
+ }
30
+ }
31
+
15
32
  if (subcommand === "list") {
16
33
  const servers = await listMcpServers();
17
34
  if (humanMode) {
@@ -29,17 +46,46 @@ async function handleMcpRegistryCommand(options) {
29
46
 
30
47
  if (subcommand === "add") {
31
48
  const name = positional[2];
32
- const url = flags.url;
33
- if (!name || !url) {
49
+ const url = cliFlags.url;
50
+ const command = cliFlags.command;
51
+
52
+ if (!name || (!url && !command)) {
34
53
  outputError({
35
54
  code: 85,
36
55
  type: "invalid_argument",
37
- message: "Usage: supercli mcp add <name> --url <mcp_url>",
56
+ message:
57
+ "Usage: supercli mcp add <name> (--url <mcp_url> | --command <binary>) [--args-json '[]'] [--headers-json '{}'] [--env-json '{}'] [--timeout-ms <ms>]",
38
58
  recoverable: false,
39
59
  });
40
60
  return true;
41
61
  }
42
- await setMcpServer(name, url);
62
+
63
+ const args = parseJsonFlag("args-json", cliFlags["args-json"]);
64
+ if (args === null) return true;
65
+ const headers = parseJsonFlag("headers-json", cliFlags["headers-json"]);
66
+ if (headers === null) return true;
67
+ const env = parseJsonFlag("env-json", cliFlags["env-json"]);
68
+ if (env === null) return true;
69
+ const timeoutMs =
70
+ cliFlags["timeout-ms"] !== undefined ? Number(cliFlags["timeout-ms"]) : undefined;
71
+ if (cliFlags["timeout-ms"] !== undefined && (!Number.isFinite(timeoutMs) || timeoutMs <= 0)) {
72
+ outputError({
73
+ code: 85,
74
+ type: "invalid_argument",
75
+ message: "--timeout-ms must be a positive number",
76
+ recoverable: false,
77
+ });
78
+ return true;
79
+ }
80
+
81
+ await setMcpServer(name, {
82
+ url,
83
+ command,
84
+ args,
85
+ headers,
86
+ env,
87
+ timeout_ms: timeoutMs,
88
+ });
43
89
  output({ ok: true, message: `MCP server '${name}' saved locally` });
44
90
  return true;
45
91
  }
@@ -256,6 +256,106 @@ const PLUGIN_INSTALL_GUIDANCE = {
256
256
  ],
257
257
  note: "Install cargo-nextest with prebuilt binaries or cargo. Most commands are intended for Rust workspaces, but version and help commands work anywhere."
258
258
  },
259
+ mysql: {
260
+ plugin: "mysql",
261
+ binary: "mysql",
262
+ check: "mysql --version",
263
+ install_steps: [
264
+ "mysql --version",
265
+ "supercli plugins install mysql",
266
+ "supercli mysql cli version --json",
267
+ "supercli mysql query execute --execute \"select 1\" --host 127.0.0.1 --user root --database mysql --json"
268
+ ],
269
+ note: "Install the MySQL client with your platform package manager. The wrapped query command is tuned for batch output and works best when connection settings come from flags or standard MySQL environment variables like MYSQL_HOST, MYSQL_USER, MYSQL_DATABASE, and MYSQL_PWD."
270
+ },
271
+ mongosh: {
272
+ plugin: "mongosh",
273
+ binary: "mongosh",
274
+ check: "mongosh --version",
275
+ install_steps: [
276
+ "mongosh --version",
277
+ "supercli plugins install mongosh",
278
+ "supercli mongosh cli version --json",
279
+ "supercli mongosh server ping --host 127.0.0.1 --port 27017 --json",
280
+ "supercli mongosh eval run --javascript \"db.adminCommand({ ping: 1 })\" --json"
281
+ ],
282
+ note: "This plugin targets the current mongosh shell rather than the legacy mongo shell. Wrapped commands prefer relaxed JSON output for automation, while raw passthrough remains available for connection-string-based flows."
283
+ },
284
+ blogwatcher: {
285
+ plugin: "blogwatcher",
286
+ binary: "blogwatcher",
287
+ check: "blogwatcher --version",
288
+ install_steps: [
289
+ "brew install Hyaxia/tap/blogwatcher",
290
+ "go install github.com/Hyaxia/blogwatcher/cmd/blogwatcher@latest",
291
+ "blogwatcher --version",
292
+ "supercli plugins install blogwatcher",
293
+ "supercli skills list --catalog --provider blogwatcher --json",
294
+ "supercli skills get blogwatcher:root.skill",
295
+ "supercli blogwatcher blogs list --json"
296
+ ],
297
+ note: "This hybrid plugin indexes the upstream BlogWatcher README and SKILL documents into the skills catalog and exposes non-interactive wrappers for the local CLI. BlogWatcher stores data under ~/.blogwatcher, so use an isolated HOME when you want disposable test data."
298
+ },
299
+ himalaya: {
300
+ plugin: "himalaya",
301
+ binary: "himalaya",
302
+ check: "himalaya --version",
303
+ install_steps: [
304
+ "brew install himalaya",
305
+ "cargo install himalaya --locked",
306
+ "himalaya --version",
307
+ "supercli plugins install himalaya",
308
+ "supercli himalaya account list --json",
309
+ "supercli himalaya folder list --account personal --json",
310
+ "supercli himalaya envelope list --account personal --folder INBOX --page 1 --json"
311
+ ],
312
+ note: "Prefer the wrapped read-only commands for automation. Himalaya itself uses --output json rather than --json, and interactive or write-side flows like account configure, message send, and mailbox mutation are intentionally left out of wrapped v1."
313
+ },
314
+ wacli: {
315
+ plugin: "wacli",
316
+ binary: "wacli",
317
+ check: "wacli --version",
318
+ install_steps: [
319
+ "brew install steipete/tap/wacli",
320
+ "go build -tags sqlite_fts5 -o ./dist/wacli ./cmd/wacli",
321
+ "wacli --version",
322
+ "supercli plugins install wacli",
323
+ "supercli wacli doctor run --json",
324
+ "supercli wacli auth status --json",
325
+ "supercli wacli chats list --store ~/.wacli --json"
326
+ ],
327
+ note: "This plugin intentionally wraps only read-only diagnostics and local-store inspection commands. Use the upstream wacli CLI directly for QR auth, sync loops, sending messages, media download, and group/contact mutations."
328
+ },
329
+ xurl: {
330
+ plugin: "xurl",
331
+ binary: "xurl",
332
+ check: "xurl version",
333
+ install_steps: [
334
+ "brew install --cask xdevplatform/tap/xurl",
335
+ "npm install -g @xdevplatform/xurl",
336
+ "go install github.com/xdevplatform/xurl@latest",
337
+ "xurl version",
338
+ "supercli plugins install xurl",
339
+ "supercli skills list --catalog --provider xurl --json",
340
+ "supercli xurl auth status --json",
341
+ "supercli xurl account whoami --json"
342
+ ],
343
+ note: "This hybrid plugin indexes upstream xurl docs and exposes a curated set of read-only wrappers. Use the upstream xurl CLI directly for auth setup, posting, likes, follows, raw requests, streams, webhooks, and any command that can mutate X state."
344
+ },
345
+ clix: {
346
+ plugin: "clix",
347
+ binary: "clix",
348
+ check: "clix auth status --json",
349
+ install_steps: [
350
+ "uv pip install clix0",
351
+ "clix auth",
352
+ "supercli plugins install clix",
353
+ "supercli skills list --catalog --provider clix --json",
354
+ "supercli clix auth status --json",
355
+ "supercli clix timeline list --count 10 --json"
356
+ ],
357
+ note: "This hybrid plugin indexes upstream clix docs and exposes curated read-only wrappers. Use upstream clix directly for cookie login, account switching, posting, deleting, likes, retweets, and bookmark mutations."
358
+ },
259
359
  cline: {
260
360
  plugin: "cline",
261
361
  binary: "cline",