superacli 1.1.3 → 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 (60) hide show
  1. package/README.md +7 -0
  2. package/__tests__/blogwatcher-plugin.test.js +157 -0
  3. package/__tests__/clix-plugin.test.js +143 -0
  4. package/__tests__/config.test.js +46 -1
  5. package/__tests__/himalaya-plugin.test.js +121 -0
  6. package/__tests__/mcp-adapter.test.js +79 -0
  7. package/__tests__/mcp-local.test.js +43 -1
  8. package/__tests__/mongosh-plugin.test.js +106 -0
  9. package/__tests__/mysql-plugin.test.js +94 -0
  10. package/__tests__/plugin-blogwatcher.test.js +55 -0
  11. package/__tests__/plugin-clix.test.js +51 -0
  12. package/__tests__/plugin-xurl.test.js +51 -0
  13. package/__tests__/server-config-service.test.js +8 -1
  14. package/__tests__/skills.test.js +26 -0
  15. package/__tests__/wacli-plugin.test.js +132 -0
  16. package/__tests__/xurl-plugin.test.js +176 -0
  17. package/cli/adapter-schema.js +7 -0
  18. package/cli/adapters/mcp.js +82 -20
  19. package/cli/config.js +65 -8
  20. package/cli/mcp-local.js +50 -4
  21. package/cli/plugin-install-guidance.js +100 -0
  22. package/cli/skills.js +55 -0
  23. package/cli/supercli.js +1 -1
  24. package/docs/features/adapters.md +6 -2
  25. package/docs/initial/mcp-local-mode.md +3 -0
  26. package/docs/skills-catalog.md +50 -0
  27. package/docs/supported-harnesses.md +20 -0
  28. package/package.json +1 -1
  29. package/plugins/blogwatcher/README.md +52 -0
  30. package/plugins/blogwatcher/plugin.json +195 -0
  31. package/plugins/blogwatcher/scripts/post-install.js +66 -0
  32. package/plugins/blogwatcher/scripts/post-uninstall.js +25 -0
  33. package/plugins/clix/README.md +44 -0
  34. package/plugins/clix/plugin.json +126 -0
  35. package/plugins/clix/scripts/post-install.js +66 -0
  36. package/plugins/clix/scripts/post-uninstall.js +25 -0
  37. package/plugins/himalaya/README.md +48 -0
  38. package/plugins/himalaya/plugin.json +157 -0
  39. package/plugins/mongosh/README.md +56 -0
  40. package/plugins/mongosh/plugin.json +88 -0
  41. package/plugins/mysql/README.md +48 -0
  42. package/plugins/mysql/plugin.json +64 -0
  43. package/plugins/plugins.json +63 -0
  44. package/plugins/wacli/README.md +52 -0
  45. package/plugins/wacli/plugin.json +260 -0
  46. package/plugins/xurl/README.md +52 -0
  47. package/plugins/xurl/plugin.json +239 -0
  48. package/plugins/xurl/scripts/post-install.js +66 -0
  49. package/plugins/xurl/scripts/post-uninstall.js +25 -0
  50. package/server/routes/mcp.js +30 -4
  51. package/server/services/configService.js +9 -1
  52. package/tests/test-blogwatcher-smoke.sh +48 -0
  53. package/tests/test-clix-smoke.sh +44 -0
  54. package/tests/test-himalaya-smoke.sh +47 -0
  55. package/tests/test-mcp-browser-use-smoke.sh +141 -0
  56. package/tests/test-mongosh-smoke.sh +40 -0
  57. package/tests/test-mysql-smoke.sh +37 -0
  58. package/tests/test-plugins-registry.js +35 -0
  59. package/tests/test-wacli-smoke.sh +46 -0
  60. package/tests/test-xurl-smoke.sh +46 -0
package/README.md CHANGED
@@ -8,6 +8,7 @@ Discover and execute skills across CLIs, APIs, MCP servers, workflows, and custo
8
8
  - supercli (Brand)
9
9
  - scli (Brand smaller)
10
10
  - superacli (What was available) (Super Agent/ic CLI)
11
+ - sc (For lazy people)
11
12
 
12
13
  ## What Is a Skill Layer?
13
14
 
@@ -243,6 +244,12 @@ A **plugin harness** bridges dcli to an external CLI tool. Each plugin:
243
244
  - Docker (`docker`)
244
245
  - Kubernetes (`kubectl`)
245
246
  - Terraform (`terraform`)
247
+ - MySQL (`mysql`)
248
+ - MongoDB Shell (`mongosh`)
249
+ - Himalaya Email CLI (`himalaya`)
250
+ - WhatsApp CLI (`wacli`)
251
+ - X API CLI (`xurl`)
252
+ - X Cookie CLI (`clix`)
246
253
  - npm, pip, cargo (package managers)
247
254
  - git, git-cliff (version control)
248
255
  - And many more...
@@ -0,0 +1,157 @@
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: blogwatcher-cli\\ndescription: Test skill\\n---\\n# BlogWatcher CLI'); process.exit(0); }",
36
+ "if (url.endsWith('/README.md')) { console.log('# BlogWatcher\\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 writeFakeBlogwatcherBinary(dir) {
45
+ const bin = path.join(dir, "blogwatcher")
46
+ fs.writeFileSync(bin, [
47
+ "#!/usr/bin/env node",
48
+ "const args = process.argv.slice(2);",
49
+ "if (args[0] === '--version') { console.log('blogwatcher 0.0.2-test'); process.exit(0); }",
50
+ "if (args[0] === 'blogs' && args.length === 1) { console.log('Tracked blogs (0):'); process.exit(0); }",
51
+ "if (args[0] === 'add') { console.log(`Added blog '${args[1]}'`); process.exit(0); }",
52
+ "if (args[0] === 'remove' && args.includes('--yes')) { console.log(`Removed blog '${args[args.length - 1]}'`); process.exit(0); }",
53
+ "if (args[0] === 'scan' && args.includes('--silent')) { console.log('scan done'); process.exit(0); }",
54
+ "if (args[0] === 'articles' && args[1] !== 'read-all') { console.log('No unread articles!'); process.exit(0); }",
55
+ "if (args[0] === 'read') { console.log(`Marked article ${args[1]} as read`); process.exit(0); }",
56
+ "if (args[0] === 'unread') { console.log(`Marked article ${args[1]} as unread`); process.exit(0); }",
57
+ "if (args[0] === 'read-all' && args.includes('--yes')) { console.log('Marked 0 article(s) as read'); process.exit(0); }",
58
+ "console.log(JSON.stringify({ ok: true, args }));"
59
+ ].join("\n"), "utf-8")
60
+ fs.chmodSync(bin, 0o755)
61
+ return bin
62
+ }
63
+
64
+ describe("blogwatcher hybrid plugin", () => {
65
+ const fakeDir = fs.mkdtempSync(path.join(os.tmpdir(), "dcli-blogwatcher-"))
66
+ const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "dcli-home-blogwatcher-"))
67
+ const fakeUserHome = fs.mkdtempSync(path.join(os.tmpdir(), "dcli-user-home-blogwatcher-"))
68
+ writeFakeCurlBinary(fakeDir)
69
+ writeFakeBlogwatcherBinary(fakeDir)
70
+ const env = {
71
+ ...process.env,
72
+ PATH: `${fakeDir}:${process.env.PATH || ""}`,
73
+ SUPERCLI_HOME: tempHome,
74
+ HOME: fakeUserHome
75
+ }
76
+ let removed = false
77
+
78
+ beforeAll(() => {
79
+ const install = runNoServer("plugins install ./plugins/blogwatcher --on-conflict replace --json", { env })
80
+ expect(install.ok).toBe(true)
81
+ })
82
+
83
+ afterAll(() => {
84
+ if (!removed) runNoServer("plugins remove blogwatcher --json", { env })
85
+ fs.rmSync(fakeDir, { recursive: true, force: true })
86
+ fs.rmSync(tempHome, { recursive: true, force: true })
87
+ fs.rmSync(fakeUserHome, { recursive: true, force: true })
88
+ })
89
+
90
+ test("indexes blogwatcher skills provider", () => {
91
+ const provider = runNoServer("skills providers show --name blogwatcher --json", { env })
92
+ expect(provider.ok).toBe(true)
93
+ const providerData = JSON.parse(provider.output)
94
+ expect(providerData.provider.name).toBe("blogwatcher")
95
+
96
+ const list = runNoServer("skills list --catalog --provider blogwatcher --json", { env })
97
+ expect(list.ok).toBe(true)
98
+ const listData = JSON.parse(list.output)
99
+ expect(listData.skills.some(skill => skill.id === "blogwatcher:root.skill")).toBe(true)
100
+ expect(listData.skills.some(skill => skill.id === "blogwatcher:root.readme")).toBe(true)
101
+ })
102
+
103
+ test("fetches indexed remote skill markdown", () => {
104
+ const skill = runNoServer("skills get blogwatcher:root.skill", { env })
105
+ expect(skill.ok).toBe(true)
106
+ expect(skill.output).toContain("BlogWatcher CLI")
107
+ })
108
+
109
+ test("routes wrapped commands", () => {
110
+ const version = runNoServer("blogwatcher cli version --json", { env })
111
+ expect(version.ok).toBe(true)
112
+ expect(JSON.parse(version.output).data.raw).toBe("blogwatcher 0.0.2-test")
113
+
114
+ const add = runNoServer("blogwatcher blogs add --name \"Example\" --url \"https://example.com/blog\" --feed-url \"https://example.com/feed.xml\" --json", { env })
115
+ expect(add.ok).toBe(true)
116
+ expect(JSON.parse(add.output).data.raw).toBe("Added blog 'Example'")
117
+
118
+ const scan = runNoServer("blogwatcher scan run --workers 2 --json", { env })
119
+ expect(scan.ok).toBe(true)
120
+ expect(JSON.parse(scan.output).data.raw).toBe("scan done")
121
+
122
+ const readAll = runNoServer("blogwatcher articles read-all --blog \"Example\" --json", { env })
123
+ expect(readAll.ok).toBe(true)
124
+ expect(JSON.parse(readAll.output).data.raw).toBe("Marked 0 article(s) as read")
125
+ })
126
+
127
+ test("supports blogwatcher namespace passthrough", () => {
128
+ const r = runNoServer("blogwatcher articles --all --json", { env })
129
+ expect(r.ok).toBe(true)
130
+ const data = JSON.parse(r.output)
131
+ expect(data.command).toBe("blogwatcher.passthrough")
132
+ expect(data.data.raw).toBe("No unread articles!")
133
+ })
134
+
135
+ test("doctor reports curl and blogwatcher dependencies as healthy", () => {
136
+ const r = runNoServer("plugins doctor blogwatcher --json", { env })
137
+ expect(r.ok).toBe(true)
138
+ const data = JSON.parse(r.output)
139
+ expect(data.ok).toBe(true)
140
+ expect(data.checks.some(c => c.type === "binary" && c.binary === "curl" && c.ok === true)).toBe(true)
141
+ expect(data.checks.some(c => c.type === "binary" && c.binary === "blogwatcher" && c.ok === true)).toBe(true)
142
+ })
143
+
144
+ test("removal cleans up the skills provider", () => {
145
+ const remove = runNoServer("plugins remove blogwatcher --json", { env })
146
+ expect(remove.ok).toBe(true)
147
+ removed = true
148
+
149
+ const provider = runNoServer("skills providers show --name blogwatcher --json", { env })
150
+ expect(provider.ok).toBe(false)
151
+
152
+ const list = runNoServer("skills list --catalog --provider blogwatcher --json", { env })
153
+ expect(list.ok).toBe(true)
154
+ const listData = JSON.parse(list.output)
155
+ expect(listData.skills).toEqual([])
156
+ })
157
+ })
@@ -0,0 +1,143 @@
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: clix\\ndescription: Test skill\\n---\\n# clix Skill'); process.exit(0); }",
36
+ "if (url.endsWith('/README.md')) { console.log('# clix\\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 writeFakeClixBinary(dir) {
45
+ const bin = path.join(dir, "clix")
46
+ fs.writeFileSync(bin, [
47
+ "#!/usr/bin/env node",
48
+ "const args = process.argv.slice(2);",
49
+ "if (args[0] === 'auth' && args[1] === 'status') { console.log(JSON.stringify({ authenticated: true, account: 'default' })); process.exit(0); }",
50
+ "if (args[0] === 'feed') { console.log(JSON.stringify({ tweets: [{ id: '1', text: 'timeline item' }] })); process.exit(0); }",
51
+ "if (args[0] === 'search') { console.log(JSON.stringify({ tweets: [{ id: '2', text: args[2] || args[1] }] })); process.exit(0); }",
52
+ "if (args[0] === 'tweet') { console.log(JSON.stringify({ tweet: { id: args[2] || args[1], text: 'tweet body' } })); process.exit(0); }",
53
+ "if (args[0] === 'user') { console.log(JSON.stringify({ user: { handle: args[2] || args[1], name: 'OpenAI' } })); process.exit(0); }",
54
+ "if (args[0] === 'bookmarks') { console.log(JSON.stringify({ tweets: [{ id: '3', text: 'bookmark item' }] })); process.exit(0); }",
55
+ "console.log(JSON.stringify({ ok: true, args }));"
56
+ ].join("\n"), "utf-8")
57
+ fs.chmodSync(bin, 0o755)
58
+ return bin
59
+ }
60
+
61
+ describe("clix hybrid plugin", () => {
62
+ const fakeDir = fs.mkdtempSync(path.join(os.tmpdir(), "dcli-clix-"))
63
+ const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "dcli-home-clix-"))
64
+ writeFakeCurlBinary(fakeDir)
65
+ writeFakeClixBinary(fakeDir)
66
+ const env = { ...process.env, PATH: `${fakeDir}:${process.env.PATH || ""}`, SUPERCLI_HOME: tempHome }
67
+ let removed = false
68
+
69
+ beforeAll(() => {
70
+ const install = runNoServer("plugins install ./plugins/clix --on-conflict replace --json", { env })
71
+ expect(install.ok).toBe(true)
72
+ })
73
+
74
+ afterAll(() => {
75
+ if (!removed) runNoServer("plugins remove clix --json", { env })
76
+ fs.rmSync(fakeDir, { recursive: true, force: true })
77
+ fs.rmSync(tempHome, { recursive: true, force: true })
78
+ })
79
+
80
+ test("indexes clix skills provider", () => {
81
+ const provider = runNoServer("skills providers show --name clix --json", { env })
82
+ expect(provider.ok).toBe(true)
83
+ expect(JSON.parse(provider.output).provider.name).toBe("clix")
84
+
85
+ const list = runNoServer("skills list --catalog --provider clix --json", { env })
86
+ expect(list.ok).toBe(true)
87
+ const listData = JSON.parse(list.output)
88
+ expect(listData.skills.some(skill => skill.id === "clix:root.skill")).toBe(true)
89
+ expect(listData.skills.some(skill => skill.id === "clix:root.readme")).toBe(true)
90
+ })
91
+
92
+ test("fetches indexed remote skill markdown", () => {
93
+ const skill = runNoServer("skills get clix:root.skill", { env })
94
+ expect(skill.ok).toBe(true)
95
+ expect(skill.output).toContain("clix Skill")
96
+ })
97
+
98
+ test("routes wrapped read-only commands", () => {
99
+ const status = runNoServer("clix auth status --json", { env })
100
+ expect(status.ok).toBe(true)
101
+ expect(JSON.parse(status.output).data.authenticated).toBe(true)
102
+
103
+ const timeline = runNoServer("clix timeline list --type following --count 20 --json", { env })
104
+ expect(timeline.ok).toBe(true)
105
+ expect(JSON.parse(timeline.output).data.tweets[0].text).toBe("timeline item")
106
+
107
+ const search = runNoServer("clix posts search --query \"from:openai\" --type latest --count 20 --json", { env })
108
+ expect(search.ok).toBe(true)
109
+ expect(JSON.parse(search.output).data.tweets[0].text).toBe("from:openai")
110
+
111
+ const tweet = runNoServer("clix posts show --id 1234567890 --json", { env })
112
+ expect(tweet.ok).toBe(true)
113
+ expect(JSON.parse(tweet.output).data.tweet.id).toBe("1234567890")
114
+
115
+ const user = runNoServer("clix users show --handle openai --json", { env })
116
+ expect(user.ok).toBe(true)
117
+ expect(JSON.parse(user.output).data.user.handle).toBe("openai")
118
+
119
+ const bookmarks = runNoServer("clix bookmarks list --json", { env })
120
+ expect(bookmarks.ok).toBe(true)
121
+ expect(JSON.parse(bookmarks.output).data.tweets[0].text).toBe("bookmark item")
122
+ })
123
+
124
+ test("does not expose passthrough and reports dependencies as healthy", () => {
125
+ const manifest = JSON.parse(fs.readFileSync(path.join(__dirname, "..", "plugins", "clix", "plugin.json"), "utf-8"))
126
+ expect(manifest.commands.some(command => command.resource === "_" && command.action === "_")).toBe(false)
127
+
128
+ const doctor = runNoServer("plugins doctor clix --json", { env })
129
+ expect(doctor.ok).toBe(true)
130
+ const doctorData = JSON.parse(doctor.output)
131
+ expect(doctorData.checks.some(c => c.type === "binary" && c.binary === "curl" && c.ok === true)).toBe(true)
132
+ expect(doctorData.checks.some(c => c.type === "binary" && c.binary === "clix" && c.ok === true)).toBe(true)
133
+ })
134
+
135
+ test("removal cleans up the skills provider", () => {
136
+ const remove = runNoServer("plugins remove clix --json", { env })
137
+ expect(remove.ok).toBe(true)
138
+ removed = true
139
+
140
+ const provider = runNoServer("skills providers show --name clix --json", { env })
141
+ expect(provider.ok).toBe(false)
142
+ })
143
+ })
@@ -70,6 +70,30 @@ describe("config", () => {
70
70
  const config = await loadConfig()
71
71
  expect(config.commands).toEqual([])
72
72
  })
73
+
74
+ test("normalizes claude-style mcpServers object", async () => {
75
+ fs.existsSync.mockReturnValue(true)
76
+ fs.readFileSync.mockReturnValue(
77
+ JSON.stringify({
78
+ version: "2",
79
+ mcpServers: {
80
+ "browser-use": {
81
+ command: "npx",
82
+ args: ["mcp-remote", "https://api.browser-use.com/mcp"]
83
+ }
84
+ }
85
+ })
86
+ )
87
+
88
+ const config = await loadConfig()
89
+ expect(config.mcp_servers).toEqual([
90
+ {
91
+ name: "browser-use",
92
+ command: "npx",
93
+ args: ["mcp-remote", "https://api.browser-use.com/mcp"]
94
+ }
95
+ ])
96
+ })
73
97
  })
74
98
 
75
99
  describe("syncConfig", () => {
@@ -192,6 +216,27 @@ describe("config", () => {
192
216
  expect(lastWrite.mcp_servers).toEqual([{ name: "s1", url: "u1" }])
193
217
  })
194
218
 
219
+ test("setMcpServer supports command, args, headers, env", async () => {
220
+ fs.existsSync.mockReturnValue(false)
221
+ await setMcpServer("browser-use", {
222
+ command: "npx",
223
+ args: ["mcp-remote", "https://api.browser-use.com/mcp"],
224
+ headers: { "X-Browser-Use-API-Key": "key" },
225
+ env: { BROWSER_USE_API_KEY: "key" },
226
+ })
227
+
228
+ const lastWrite = JSON.parse(fs.writeFileSync.mock.calls[0][1])
229
+ expect(lastWrite.mcp_servers).toContainEqual(
230
+ expect.objectContaining({
231
+ name: "browser-use",
232
+ command: "npx",
233
+ args: ["mcp-remote", "https://api.browser-use.com/mcp"],
234
+ headers: { "X-Browser-Use-API-Key": "key" },
235
+ env: { BROWSER_USE_API_KEY: "key" }
236
+ })
237
+ )
238
+ })
239
+
195
240
  test("removeMcpServer removes existing server", async () => {
196
241
  fs.existsSync.mockReturnValue(true)
197
242
  fs.readFileSync.mockReturnValue(JSON.stringify({
@@ -268,7 +313,7 @@ describe("config", () => {
268
313
  ttl: 3600,
269
314
  fetchedAt: 1000000,
270
315
  commands: [1, 2],
271
- mcp_servers: [1],
316
+ mcp_servers: [{ name: "s1", url: "http://s1" }],
272
317
  specs: [1, 2, 3]
273
318
  }))
274
319
  listInstalledPlugins.mockReturnValue([1, 2, 3, 4])
@@ -0,0 +1,121 @@
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 writeFakeHimalayaBinary(dir) {
29
+ const bin = path.join(dir, "himalaya")
30
+ fs.writeFileSync(bin, [
31
+ "#!/usr/bin/env node",
32
+ "const args = process.argv.slice(2);",
33
+ "const outJson = args[0] === '--output' && args[1] === 'json';",
34
+ "const payloadArgs = outJson ? args.slice(2) : args;",
35
+ "if (args[0] === '--version') { console.log('himalaya 1.2.0-test'); process.exit(0); }",
36
+ "if (outJson && payloadArgs[0] === 'account' && payloadArgs[1] === 'list') { console.log(JSON.stringify({ accounts: [{ name: 'personal', default: true }] })); process.exit(0); }",
37
+ "if (payloadArgs[0] === 'account' && payloadArgs[1] === 'doctor') { console.log('Account personal looks healthy'); process.exit(0); }",
38
+ "if (outJson && payloadArgs[0] === 'folder' && payloadArgs[1] === 'list') { console.log(JSON.stringify({ folders: [{ name: 'INBOX' }, { name: 'Archive' }] })); process.exit(0); }",
39
+ "if (outJson && payloadArgs[0] === 'envelope' && payloadArgs[1] === 'list') { console.log(JSON.stringify({ envelopes: [{ id: 42, subject: 'Hello' }] })); process.exit(0); }",
40
+ "if (outJson && payloadArgs[0] === 'envelope' && payloadArgs[1] === 'thread') { console.log(JSON.stringify({ thread: [{ id: 42 }, { id: 43 }] })); process.exit(0); }",
41
+ "if (outJson && payloadArgs[0] === 'message' && payloadArgs[1] === 'read' && payloadArgs.includes('--preview')) { console.log(JSON.stringify({ id: 42, preview: true, subject: 'Hello' })); process.exit(0); }",
42
+ "console.log(JSON.stringify({ ok: true, args }));"
43
+ ].join("\n"), "utf-8")
44
+ fs.chmodSync(bin, 0o755)
45
+ return bin
46
+ }
47
+
48
+ describe("himalaya plugin", () => {
49
+ const fakeDir = fs.mkdtempSync(path.join(os.tmpdir(), "dcli-himalaya-"))
50
+ const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "dcli-home-himalaya-"))
51
+ writeFakeHimalayaBinary(fakeDir)
52
+ const env = { ...process.env, PATH: `${fakeDir}:${process.env.PATH || ""}`, SUPERCLI_HOME: tempHome }
53
+
54
+ beforeAll(() => {
55
+ runNoServer("plugins install ./plugins/himalaya --on-conflict replace --json", { env })
56
+ })
57
+
58
+ afterAll(() => {
59
+ runNoServer("plugins remove himalaya --json", { env })
60
+ fs.rmSync(fakeDir, { recursive: true, force: true })
61
+ fs.rmSync(tempHome, { recursive: true, force: true })
62
+ })
63
+
64
+ test("routes cli version wrapped command", () => {
65
+ const r = runNoServer("himalaya cli version --json", { env })
66
+ expect(r.ok).toBe(true)
67
+ const data = JSON.parse(r.output)
68
+ expect(data.command).toBe("himalaya.cli.version")
69
+ expect(data.data.raw).toBe("himalaya 1.2.0-test")
70
+ })
71
+
72
+ test("routes json account and folder wrappers", () => {
73
+ const accounts = runNoServer("himalaya account list --json", { env })
74
+ expect(accounts.ok).toBe(true)
75
+ expect(JSON.parse(accounts.output).data.accounts[0].name).toBe("personal")
76
+
77
+ const folders = runNoServer("himalaya folder list --account personal --json", { env })
78
+ expect(folders.ok).toBe(true)
79
+ expect(JSON.parse(folders.output).data.folders[0].name).toBe("INBOX")
80
+ })
81
+
82
+ test("routes envelope and message wrappers", () => {
83
+ const envelopes = runNoServer("himalaya envelope list --account personal --folder INBOX --page 1 --page-size 10 --json", { env })
84
+ expect(envelopes.ok).toBe(true)
85
+ expect(JSON.parse(envelopes.output).data.envelopes[0].id).toBe(42)
86
+
87
+ const thread = runNoServer("himalaya envelope thread --account personal --folder INBOX --id 42 --json", { env })
88
+ expect(thread.ok).toBe(true)
89
+ expect(JSON.parse(thread.output).data.thread).toHaveLength(2)
90
+
91
+ const message = runNoServer("himalaya message read-preview --account personal --folder INBOX --id 42 --json", { env })
92
+ expect(message.ok).toBe(true)
93
+ const messageData = JSON.parse(message.output)
94
+ expect(messageData.command).toBe("himalaya.message.read-preview")
95
+ expect(messageData.data.preview).toBe(true)
96
+ })
97
+
98
+ test("routes account doctor wrapper as raw text", () => {
99
+ const r = runNoServer("himalaya account doctor --account personal --json", { env })
100
+ expect(r.ok).toBe(true)
101
+ const data = JSON.parse(r.output)
102
+ expect(data.command).toBe("himalaya.account.doctor")
103
+ expect(data.data.raw).toBe("Account personal looks healthy")
104
+ })
105
+
106
+ test("supports namespace passthrough", () => {
107
+ const r = runNoServer("himalaya --output json account list", { env })
108
+ expect(r.ok).toBe(true)
109
+ const data = JSON.parse(r.output)
110
+ expect(data.command).toBe("himalaya.passthrough")
111
+ expect(data.data.raw).toContain('"accounts"')
112
+ })
113
+
114
+ test("doctor reports himalaya dependency as healthy", () => {
115
+ const r = runNoServer("plugins doctor himalaya --json", { env })
116
+ expect(r.ok).toBe(true)
117
+ const data = JSON.parse(r.output)
118
+ expect(data.ok).toBe(true)
119
+ expect(data.checks.some(c => c.type === "binary" && c.binary === "himalaya" && c.ok === true)).toBe(true)
120
+ })
121
+ })
@@ -42,6 +42,45 @@ describe("mcp adapter", () => {
42
42
  expect(mockChild.stdin.write).toHaveBeenCalledWith(expect.stringContaining('"tool":"calc"'))
43
43
  })
44
44
 
45
+ test("resolves stdio command from named server and merges args/env", async () => {
46
+ const promise = execute(
47
+ {
48
+ adapterConfig: {
49
+ tool: "calc",
50
+ server: "browser-use",
51
+ args: ["--header", "X-Test: 1"],
52
+ env: { MCP_CLIENT: "yes" }
53
+ }
54
+ },
55
+ {},
56
+ {
57
+ config: {
58
+ mcp_servers: [
59
+ {
60
+ name: "browser-use",
61
+ command: "npx",
62
+ args: ["mcp-remote", "https://api.browser-use.com/mcp"],
63
+ env: { MCP_SERVER: "yes" }
64
+ }
65
+ ]
66
+ }
67
+ }
68
+ )
69
+
70
+ await Promise.resolve()
71
+ mockChild.stdout.emit("data", '{"ok":true}')
72
+ mockChild.emit("close", 0)
73
+
74
+ await expect(promise).resolves.toEqual({ ok: true })
75
+ expect(spawn).toHaveBeenCalledWith(
76
+ "npx",
77
+ ["mcp-remote", "https://api.browser-use.com/mcp", "--header", "X-Test: 1"],
78
+ expect.objectContaining({
79
+ env: expect.objectContaining({ MCP_SERVER: "yes", MCP_CLIENT: "yes" })
80
+ })
81
+ )
82
+ })
83
+
45
84
  test("handles stdio tool error exit", async () => {
46
85
  const promise = execute({
47
86
  adapterConfig: { tool: "calc", command: "node" }
@@ -116,6 +155,46 @@ describe("mcp adapter", () => {
116
155
  expect(result).toEqual({ data: "ok" })
117
156
  })
118
157
 
158
+ test("merges server and command headers in http mode", async () => {
159
+ global.fetch.mockResolvedValue({
160
+ ok: true,
161
+ json: () => Promise.resolve({ ok: true })
162
+ })
163
+
164
+ await execute(
165
+ {
166
+ adapterConfig: {
167
+ tool: "t1",
168
+ server: "s1",
169
+ headers: { "X-Cmd": "2" }
170
+ }
171
+ },
172
+ {},
173
+ {
174
+ config: {
175
+ mcp_servers: [
176
+ {
177
+ name: "s1",
178
+ url: "http://server1",
179
+ headers: { "X-Server": "1" }
180
+ }
181
+ ]
182
+ }
183
+ }
184
+ )
185
+
186
+ expect(global.fetch).toHaveBeenCalledWith(
187
+ "http://server1/tool",
188
+ expect.objectContaining({
189
+ headers: expect.objectContaining({
190
+ "Content-Type": "application/json",
191
+ "X-Server": "1",
192
+ "X-Cmd": "2"
193
+ })
194
+ })
195
+ )
196
+ })
197
+
119
198
  test("resolves server url from local context", async () => {
120
199
  global.fetch.mockResolvedValue({
121
200
  ok: true,
@@ -52,12 +52,54 @@ describe("mcp-local", () => {
52
52
  setMcpServer: mockSetMcpServer,
53
53
  });
54
54
 
55
- expect(mockSetMcpServer).toHaveBeenCalledWith("s1", "u1");
55
+ expect(mockSetMcpServer).toHaveBeenCalledWith("s1", expect.objectContaining({ url: "u1" }));
56
56
  expect(mockOutput).toHaveBeenCalledWith(
57
57
  expect.objectContaining({ ok: true }),
58
58
  );
59
59
  });
60
60
 
61
+ test("add subcommand supports command and JSON fields", async () => {
62
+ await handleMcpRegistryCommand({
63
+ positional: ["mcp", "add", "browser-use"],
64
+ flags: {
65
+ command: "npx",
66
+ "args-json": '["mcp-remote","https://api.browser-use.com/mcp"]',
67
+ "headers-json": '{"X-Browser-Use-API-Key":"k"}',
68
+ "env-json": '{"BROWSER_USE_API_KEY":"k"}',
69
+ "timeout-ms": "12000"
70
+ },
71
+ output: mockOutput,
72
+ setMcpServer: mockSetMcpServer,
73
+ outputError: mockOutputError
74
+ });
75
+
76
+ expect(mockSetMcpServer).toHaveBeenCalledWith(
77
+ "browser-use",
78
+ expect.objectContaining({
79
+ command: "npx",
80
+ args: ["mcp-remote", "https://api.browser-use.com/mcp"],
81
+ headers: { "X-Browser-Use-API-Key": "k" },
82
+ env: { BROWSER_USE_API_KEY: "k" },
83
+ timeout_ms: 12000
84
+ })
85
+ );
86
+ expect(mockOutputError).not.toHaveBeenCalled();
87
+ });
88
+
89
+ test("add subcommand reports invalid JSON flags", async () => {
90
+ await handleMcpRegistryCommand({
91
+ positional: ["mcp", "add", "s1"],
92
+ flags: { command: "npx", "args-json": "[" },
93
+ outputError: mockOutputError,
94
+ setMcpServer: mockSetMcpServer,
95
+ });
96
+
97
+ expect(mockOutputError).toHaveBeenCalledWith(
98
+ expect.objectContaining({ code: 85 }),
99
+ );
100
+ expect(mockSetMcpServer).not.toHaveBeenCalled();
101
+ });
102
+
61
103
  test("add subcommand validation error", async () => {
62
104
  await handleMcpRegistryCommand({
63
105
  positional: ["mcp", "add"],