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.
- package/README.md +7 -0
- 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 +1 -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/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
|
+
})
|
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,
|
|
@@ -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"],
|