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.
- package/LICENSE +22 -0
- package/README.md +10 -1
- package/__tests__/blogwatcher-plugin.test.js +157 -0
- package/__tests__/clix-plugin.test.js +143 -0
- package/__tests__/config.test.js +46 -1
- package/__tests__/himalaya-plugin.test.js +121 -0
- package/__tests__/mcp-adapter.test.js +79 -0
- package/__tests__/mcp-local.test.js +43 -1
- package/__tests__/mongosh-plugin.test.js +106 -0
- package/__tests__/mysql-plugin.test.js +94 -0
- package/__tests__/plugin-blogwatcher.test.js +55 -0
- package/__tests__/plugin-clix.test.js +51 -0
- package/__tests__/plugin-xurl.test.js +51 -0
- package/__tests__/server-config-service.test.js +8 -1
- package/__tests__/skills.test.js +26 -0
- package/__tests__/wacli-plugin.test.js +132 -0
- package/__tests__/xurl-plugin.test.js +176 -0
- package/cli/adapter-schema.js +7 -0
- package/cli/adapters/mcp.js +82 -20
- package/cli/config.js +65 -8
- package/cli/mcp-local.js +50 -4
- package/cli/plugin-install-guidance.js +100 -0
- package/cli/skills.js +55 -0
- package/cli/supercli.js +1 -1
- package/docs/features/adapters.md +6 -2
- package/docs/initial/mcp-local-mode.md +3 -0
- package/docs/skills-catalog.md +50 -0
- package/docs/supported-harnesses.md +20 -0
- package/package.json +2 -1
- package/plugins/blogwatcher/README.md +52 -0
- package/plugins/blogwatcher/plugin.json +195 -0
- package/plugins/blogwatcher/scripts/post-install.js +66 -0
- package/plugins/blogwatcher/scripts/post-uninstall.js +25 -0
- package/plugins/clix/README.md +44 -0
- package/plugins/clix/plugin.json +126 -0
- package/plugins/clix/scripts/post-install.js +66 -0
- package/plugins/clix/scripts/post-uninstall.js +25 -0
- package/plugins/himalaya/README.md +48 -0
- package/plugins/himalaya/plugin.json +157 -0
- package/plugins/mongosh/README.md +56 -0
- package/plugins/mongosh/plugin.json +88 -0
- package/plugins/mysql/README.md +48 -0
- package/plugins/mysql/plugin.json +64 -0
- package/plugins/plugins.json +63 -0
- package/plugins/wacli/README.md +52 -0
- package/plugins/wacli/plugin.json +260 -0
- package/plugins/xurl/README.md +52 -0
- package/plugins/xurl/plugin.json +239 -0
- package/plugins/xurl/scripts/post-install.js +66 -0
- package/plugins/xurl/scripts/post-uninstall.js +25 -0
- package/server/routes/mcp.js +30 -4
- package/server/services/configService.js +9 -1
- package/tests/test-blogwatcher-smoke.sh +48 -0
- package/tests/test-clix-smoke.sh +44 -0
- package/tests/test-himalaya-smoke.sh +47 -0
- package/tests/test-mcp-browser-use-smoke.sh +141 -0
- package/tests/test-mongosh-smoke.sh +40 -0
- package/tests/test-mysql-smoke.sh +37 -0
- package/tests/test-plugins-registry.js +35 -0
- package/tests/test-wacli-smoke.sh +46 -0
- 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
|
+
})
|
package/__tests__/config.test.js
CHANGED
|
@@ -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: [
|
|
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,
|