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
package/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Javier Leandro Arancibia
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
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...
@@ -360,4 +367,6 @@ Contributions are welcome! If you have ideas for improvements, new adapters, or
360
367
 
361
368
  ## License
362
369
 
363
- MIT
370
+ MIT License - Copyright (c) 2026 Javier Leandro Arancibia
371
+
372
+ See [LICENSE](LICENSE) file for details.
@@ -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,