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
|
@@ -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"],
|
|
@@ -0,0 +1,106 @@
|
|
|
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 writeFakeMongoshBinary(dir) {
|
|
29
|
+
const bin = path.join(dir, "mongosh")
|
|
30
|
+
fs.writeFileSync(bin, [
|
|
31
|
+
"#!/usr/bin/env node",
|
|
32
|
+
"const args = process.argv.slice(2);",
|
|
33
|
+
"if (args[0] === '--version') { console.log('2.3.9-test'); process.exit(0); }",
|
|
34
|
+
"const evalIndex = args.indexOf('--eval');",
|
|
35
|
+
"if (evalIndex >= 0) {",
|
|
36
|
+
" const script = args[evalIndex + 1];",
|
|
37
|
+
" if (script === 'db.adminCommand({ ping: 1 })') {",
|
|
38
|
+
" console.log(JSON.stringify({ ok: 1 }));",
|
|
39
|
+
" process.exit(0);",
|
|
40
|
+
" }",
|
|
41
|
+
" console.log(JSON.stringify({ ok: 1, script }));",
|
|
42
|
+
" process.exit(0);",
|
|
43
|
+
"}",
|
|
44
|
+
"console.log(JSON.stringify({ ok: true, args }));"
|
|
45
|
+
].join("\n"), "utf-8")
|
|
46
|
+
fs.chmodSync(bin, 0o755)
|
|
47
|
+
return bin
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe("mongosh plugin", () => {
|
|
51
|
+
const fakeDir = fs.mkdtempSync(path.join(os.tmpdir(), "dcli-mongosh-"))
|
|
52
|
+
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "dcli-home-mongosh-"))
|
|
53
|
+
writeFakeMongoshBinary(fakeDir)
|
|
54
|
+
const env = { ...process.env, PATH: `${fakeDir}:${process.env.PATH || ""}`, SUPERCLI_HOME: tempHome }
|
|
55
|
+
|
|
56
|
+
beforeAll(() => {
|
|
57
|
+
runNoServer("plugins install ./plugins/mongosh --on-conflict replace --json", { env })
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
afterAll(() => {
|
|
61
|
+
runNoServer("plugins remove mongosh --json", { env })
|
|
62
|
+
fs.rmSync(fakeDir, { recursive: true, force: true })
|
|
63
|
+
fs.rmSync(tempHome, { recursive: true, force: true })
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
test("routes cli version wrapped command", () => {
|
|
67
|
+
const r = runNoServer("mongosh cli version --json", { env })
|
|
68
|
+
expect(r.ok).toBe(true)
|
|
69
|
+
const data = JSON.parse(r.output)
|
|
70
|
+
expect(data.command).toBe("mongosh.cli.version")
|
|
71
|
+
expect(data.data.raw).toBe("2.3.9-test")
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
test("routes server ping wrapped command", () => {
|
|
75
|
+
const r = runNoServer("mongosh server ping --host 127.0.0.1 --port 27017 --json", { env })
|
|
76
|
+
expect(r.ok).toBe(true)
|
|
77
|
+
const data = JSON.parse(r.output)
|
|
78
|
+
expect(data.command).toBe("mongosh.server.ping")
|
|
79
|
+
expect(data.data.ok).toBe(1)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
test("routes eval run wrapped command", () => {
|
|
83
|
+
const r = runNoServer("mongosh eval run --javascript \"db.runCommand({ buildInfo: 1 })\" --json", { env })
|
|
84
|
+
expect(r.ok).toBe(true)
|
|
85
|
+
const data = JSON.parse(r.output)
|
|
86
|
+
expect(data.command).toBe("mongosh.eval.run")
|
|
87
|
+
expect(data.data.script).toBe("db.runCommand({ buildInfo: 1 })")
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
test("supports namespace passthrough", () => {
|
|
91
|
+
const r = runNoServer("mongosh --help --json", { env })
|
|
92
|
+
expect(r.ok).toBe(true)
|
|
93
|
+
const data = JSON.parse(r.output)
|
|
94
|
+
expect(data.command).toBe("mongosh.passthrough")
|
|
95
|
+
expect(data.data.args).toContain("--help")
|
|
96
|
+
expect(data.data.args).toContain("--json")
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
test("doctor reports mongosh dependency as healthy", () => {
|
|
100
|
+
const r = runNoServer("plugins doctor mongosh --json", { env })
|
|
101
|
+
expect(r.ok).toBe(true)
|
|
102
|
+
const data = JSON.parse(r.output)
|
|
103
|
+
expect(data.ok).toBe(true)
|
|
104
|
+
expect(data.checks.some(c => c.type === "binary" && c.binary === "mongosh" && c.ok === true)).toBe(true)
|
|
105
|
+
})
|
|
106
|
+
})
|
|
@@ -0,0 +1,94 @@
|
|
|
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 writeFakeMysqlBinary(dir) {
|
|
29
|
+
const bin = path.join(dir, "mysql")
|
|
30
|
+
fs.writeFileSync(bin, [
|
|
31
|
+
"#!/usr/bin/env node",
|
|
32
|
+
"const args = process.argv.slice(2);",
|
|
33
|
+
"if (args[0] === '--version') { console.log('mysql Ver 8.4.0-test for Linux on x86_64'); process.exit(0); }",
|
|
34
|
+
"const executeIndex = args.indexOf('--execute');",
|
|
35
|
+
"if (executeIndex >= 0) {",
|
|
36
|
+
" const sql = args[executeIndex + 1];",
|
|
37
|
+
" console.log('RESULT\\t' + sql);",
|
|
38
|
+
" process.exit(0);",
|
|
39
|
+
"}",
|
|
40
|
+
"console.log(JSON.stringify({ ok: true, args }));"
|
|
41
|
+
].join("\n"), "utf-8")
|
|
42
|
+
fs.chmodSync(bin, 0o755)
|
|
43
|
+
return bin
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe("mysql plugin", () => {
|
|
47
|
+
const fakeDir = fs.mkdtempSync(path.join(os.tmpdir(), "dcli-mysql-"))
|
|
48
|
+
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "dcli-home-mysql-"))
|
|
49
|
+
writeFakeMysqlBinary(fakeDir)
|
|
50
|
+
const env = { ...process.env, PATH: `${fakeDir}:${process.env.PATH || ""}`, SUPERCLI_HOME: tempHome }
|
|
51
|
+
|
|
52
|
+
beforeAll(() => {
|
|
53
|
+
runNoServer("plugins install ./plugins/mysql --on-conflict replace --json", { env })
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
afterAll(() => {
|
|
57
|
+
runNoServer("plugins remove mysql --json", { env })
|
|
58
|
+
fs.rmSync(fakeDir, { recursive: true, force: true })
|
|
59
|
+
fs.rmSync(tempHome, { recursive: true, force: true })
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test("routes cli version wrapped command", () => {
|
|
63
|
+
const r = runNoServer("mysql cli version --json", { env })
|
|
64
|
+
expect(r.ok).toBe(true)
|
|
65
|
+
const data = JSON.parse(r.output)
|
|
66
|
+
expect(data.command).toBe("mysql.cli.version")
|
|
67
|
+
expect(data.data.raw).toContain("mysql Ver 8.4.0-test")
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
test("routes query execute wrapped command", () => {
|
|
71
|
+
const r = runNoServer("mysql query execute --execute \"select 1\" --host 127.0.0.1 --user root --database mysql --json", { env })
|
|
72
|
+
expect(r.ok).toBe(true)
|
|
73
|
+
const data = JSON.parse(r.output)
|
|
74
|
+
expect(data.command).toBe("mysql.query.execute")
|
|
75
|
+
expect(data.data.raw).toBe("RESULT\tselect 1")
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
test("supports namespace passthrough", () => {
|
|
79
|
+
const r = runNoServer("mysql --help --json", { env })
|
|
80
|
+
expect(r.ok).toBe(true)
|
|
81
|
+
const data = JSON.parse(r.output)
|
|
82
|
+
expect(data.command).toBe("mysql.passthrough")
|
|
83
|
+
expect(data.data.args).toContain("--help")
|
|
84
|
+
expect(data.data.args).toContain("--json")
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
test("doctor reports mysql dependency as healthy", () => {
|
|
88
|
+
const r = runNoServer("plugins doctor mysql --json", { env })
|
|
89
|
+
expect(r.ok).toBe(true)
|
|
90
|
+
const data = JSON.parse(r.output)
|
|
91
|
+
expect(data.ok).toBe(true)
|
|
92
|
+
expect(data.checks.some(c => c.type === "binary" && c.binary === "mysql" && c.ok === true)).toBe(true)
|
|
93
|
+
})
|
|
94
|
+
})
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
const { addProvider, removeProvider, syncCatalog } = require("../cli/skills-catalog")
|
|
2
|
+
const {
|
|
3
|
+
CATALOG_FILES,
|
|
4
|
+
buildRemoteEntries,
|
|
5
|
+
run
|
|
6
|
+
} = require("../plugins/blogwatcher/scripts/post-install")
|
|
7
|
+
const { run: runUninstall } = require("../plugins/blogwatcher/scripts/post-uninstall")
|
|
8
|
+
|
|
9
|
+
jest.mock("../cli/skills-catalog")
|
|
10
|
+
|
|
11
|
+
describe("plugin-blogwatcher", () => {
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
jest.clearAllMocks()
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
test("buildRemoteEntries maps curated blogwatcher docs", () => {
|
|
17
|
+
const entries = buildRemoteEntries()
|
|
18
|
+
|
|
19
|
+
expect(entries).toHaveLength(CATALOG_FILES.length)
|
|
20
|
+
expect(entries.map(entry => entry.id)).toEqual(["root.skill", "root.readme"])
|
|
21
|
+
expect(entries.every(entry => entry.source_url.includes("raw.githubusercontent.com/Hyaxia/blogwatcher/main/"))).toBe(true)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
test("run stores provider and syncs catalog", () => {
|
|
25
|
+
syncCatalog.mockReturnValue({ skills: [1, 2, 3] })
|
|
26
|
+
|
|
27
|
+
const result = run()
|
|
28
|
+
|
|
29
|
+
expect(addProvider).toHaveBeenCalledWith(expect.objectContaining({
|
|
30
|
+
name: "blogwatcher",
|
|
31
|
+
type: "remote_static",
|
|
32
|
+
entries: expect.arrayContaining([
|
|
33
|
+
expect.objectContaining({ id: "root.skill" }),
|
|
34
|
+
expect.objectContaining({ id: "root.readme" })
|
|
35
|
+
])
|
|
36
|
+
}))
|
|
37
|
+
expect(syncCatalog).toHaveBeenCalled()
|
|
38
|
+
expect(result).toEqual({
|
|
39
|
+
provider: "blogwatcher",
|
|
40
|
+
entries: CATALOG_FILES.length,
|
|
41
|
+
synced_skills: 3
|
|
42
|
+
})
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test("post-uninstall removes provider and syncs catalog", () => {
|
|
46
|
+
removeProvider.mockReturnValue(true)
|
|
47
|
+
syncCatalog.mockReturnValue({ skills: [1] })
|
|
48
|
+
|
|
49
|
+
const result = runUninstall()
|
|
50
|
+
|
|
51
|
+
expect(removeProvider).toHaveBeenCalledWith("blogwatcher")
|
|
52
|
+
expect(syncCatalog).toHaveBeenCalled()
|
|
53
|
+
expect(result).toEqual({ provider: "blogwatcher", removed: true, synced_skills: 1 })
|
|
54
|
+
})
|
|
55
|
+
})
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
const { addProvider, removeProvider, syncCatalog } = require("../cli/skills-catalog")
|
|
2
|
+
const {
|
|
3
|
+
CATALOG_FILES,
|
|
4
|
+
buildRemoteEntries,
|
|
5
|
+
run
|
|
6
|
+
} = require("../plugins/clix/scripts/post-install")
|
|
7
|
+
const { run: runUninstall } = require("../plugins/clix/scripts/post-uninstall")
|
|
8
|
+
|
|
9
|
+
jest.mock("../cli/skills-catalog")
|
|
10
|
+
|
|
11
|
+
describe("plugin-clix", () => {
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
jest.clearAllMocks()
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
test("buildRemoteEntries maps curated clix docs", () => {
|
|
17
|
+
const entries = buildRemoteEntries()
|
|
18
|
+
|
|
19
|
+
expect(entries).toHaveLength(CATALOG_FILES.length)
|
|
20
|
+
expect(entries.map(entry => entry.id)).toEqual(["root.skill", "root.readme"])
|
|
21
|
+
expect(entries.every(entry => entry.source_url.includes("raw.githubusercontent.com/spideystreet/clix/main/"))).toBe(true)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
test("run stores provider and syncs catalog", () => {
|
|
25
|
+
syncCatalog.mockReturnValue({ skills: [1, 2] })
|
|
26
|
+
|
|
27
|
+
const result = run()
|
|
28
|
+
|
|
29
|
+
expect(addProvider).toHaveBeenCalledWith(expect.objectContaining({
|
|
30
|
+
name: "clix",
|
|
31
|
+
type: "remote_static",
|
|
32
|
+
entries: expect.arrayContaining([
|
|
33
|
+
expect.objectContaining({ id: "root.skill" }),
|
|
34
|
+
expect.objectContaining({ id: "root.readme" })
|
|
35
|
+
])
|
|
36
|
+
}))
|
|
37
|
+
expect(syncCatalog).toHaveBeenCalled()
|
|
38
|
+
expect(result).toEqual({ provider: "clix", entries: CATALOG_FILES.length, synced_skills: 2 })
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test("post-uninstall removes provider and syncs catalog", () => {
|
|
42
|
+
removeProvider.mockReturnValue(true)
|
|
43
|
+
syncCatalog.mockReturnValue({ skills: [1] })
|
|
44
|
+
|
|
45
|
+
const result = runUninstall()
|
|
46
|
+
|
|
47
|
+
expect(removeProvider).toHaveBeenCalledWith("clix")
|
|
48
|
+
expect(syncCatalog).toHaveBeenCalled()
|
|
49
|
+
expect(result).toEqual({ provider: "clix", removed: true, synced_skills: 1 })
|
|
50
|
+
})
|
|
51
|
+
})
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
const { addProvider, removeProvider, syncCatalog } = require("../cli/skills-catalog")
|
|
2
|
+
const {
|
|
3
|
+
CATALOG_FILES,
|
|
4
|
+
buildRemoteEntries,
|
|
5
|
+
run
|
|
6
|
+
} = require("../plugins/xurl/scripts/post-install")
|
|
7
|
+
const { run: runUninstall } = require("../plugins/xurl/scripts/post-uninstall")
|
|
8
|
+
|
|
9
|
+
jest.mock("../cli/skills-catalog")
|
|
10
|
+
|
|
11
|
+
describe("plugin-xurl", () => {
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
jest.clearAllMocks()
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
test("buildRemoteEntries maps curated xurl docs", () => {
|
|
17
|
+
const entries = buildRemoteEntries()
|
|
18
|
+
|
|
19
|
+
expect(entries).toHaveLength(CATALOG_FILES.length)
|
|
20
|
+
expect(entries.map(entry => entry.id)).toEqual(["root.skill", "root.readme"])
|
|
21
|
+
expect(entries.every(entry => entry.source_url.includes("raw.githubusercontent.com/xdevplatform/xurl/main/"))).toBe(true)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
test("run stores provider and syncs catalog", () => {
|
|
25
|
+
syncCatalog.mockReturnValue({ skills: [1, 2] })
|
|
26
|
+
|
|
27
|
+
const result = run()
|
|
28
|
+
|
|
29
|
+
expect(addProvider).toHaveBeenCalledWith(expect.objectContaining({
|
|
30
|
+
name: "xurl",
|
|
31
|
+
type: "remote_static",
|
|
32
|
+
entries: expect.arrayContaining([
|
|
33
|
+
expect.objectContaining({ id: "root.skill" }),
|
|
34
|
+
expect.objectContaining({ id: "root.readme" })
|
|
35
|
+
])
|
|
36
|
+
}))
|
|
37
|
+
expect(syncCatalog).toHaveBeenCalled()
|
|
38
|
+
expect(result).toEqual({ provider: "xurl", entries: CATALOG_FILES.length, synced_skills: 2 })
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test("post-uninstall removes provider and syncs catalog", () => {
|
|
42
|
+
removeProvider.mockReturnValue(true)
|
|
43
|
+
syncCatalog.mockReturnValue({ skills: [1] })
|
|
44
|
+
|
|
45
|
+
const result = runUninstall()
|
|
46
|
+
|
|
47
|
+
expect(removeProvider).toHaveBeenCalledWith("xurl")
|
|
48
|
+
expect(syncCatalog).toHaveBeenCalled()
|
|
49
|
+
expect(result).toEqual({ provider: "xurl", removed: true, synced_skills: 1 })
|
|
50
|
+
})
|
|
51
|
+
})
|
|
@@ -32,7 +32,7 @@ describe("configService", () => {
|
|
|
32
32
|
})
|
|
33
33
|
mockStorage.get.mockImplementation((key) => {
|
|
34
34
|
if (key === "command:n.r.a") return { namespace: "n", resource: "r", action: "a", adapter: "builtin" }
|
|
35
|
-
if (key === "mcp:s1") return { name: "s1",
|
|
35
|
+
if (key === "mcp:s1") return { name: "s1", command: "npx", args: ["mcp-remote", "u1"], headers: { H: "v" }, env: { E: "1" } }
|
|
36
36
|
if (key === "spec:sp1") return { name: "sp1", url: "u2", auth: "none" }
|
|
37
37
|
if (key === "settings:config_version") return "10"
|
|
38
38
|
return null
|
|
@@ -42,6 +42,13 @@ describe("configService", () => {
|
|
|
42
42
|
expect(config.version).toBe("10")
|
|
43
43
|
expect(config.commands).toHaveLength(1)
|
|
44
44
|
expect(config.mcp_servers).toHaveLength(1)
|
|
45
|
+
expect(config.mcp_servers[0]).toEqual(expect.objectContaining({
|
|
46
|
+
name: "s1",
|
|
47
|
+
command: "npx",
|
|
48
|
+
args: ["mcp-remote", "u1"],
|
|
49
|
+
headers: { H: "v" },
|
|
50
|
+
env: { E: "1" }
|
|
51
|
+
}))
|
|
45
52
|
expect(config.specs).toHaveLength(1)
|
|
46
53
|
})
|
|
47
54
|
|
package/__tests__/skills.test.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
const {
|
|
2
|
+
MCP_SERVERS_USAGE_SKILL_ID,
|
|
2
3
|
normalizeSkillId,
|
|
3
4
|
buildCommandSkillMarkdown,
|
|
4
5
|
buildTeachSkillMarkdown,
|
|
5
6
|
buildPluginsUsageSkillMarkdown,
|
|
7
|
+
buildMcpServersUsageSkillMarkdown,
|
|
6
8
|
listSkillsMetadata,
|
|
7
9
|
handleSkillsCommand,
|
|
8
10
|
renderYamlObject
|
|
@@ -97,6 +99,14 @@ describe("skills", () => {
|
|
|
97
99
|
expect(md).toContain("# Instruction")
|
|
98
100
|
})
|
|
99
101
|
|
|
102
|
+
test("buildMcpServersUsageSkillMarkdown returns markdown", () => {
|
|
103
|
+
const md = buildMcpServersUsageSkillMarkdown({ showDag: true })
|
|
104
|
+
expect(md).toContain("skill_name: \"mcp_servers_usage\"")
|
|
105
|
+
expect(md).toContain("supercli mcp add browser-use --command npx")
|
|
106
|
+
expect(md).toContain("X-Browser-Use-API-Key")
|
|
107
|
+
expect(md).toContain("dag:")
|
|
108
|
+
})
|
|
109
|
+
|
|
100
110
|
test("listSkillsMetadata keeps name and description only", () => {
|
|
101
111
|
const skills = listSkillsMetadata({
|
|
102
112
|
commands: [{ namespace: "x", resource: "y", action: "z", description: "desc" }]
|
|
@@ -104,6 +114,8 @@ describe("skills", () => {
|
|
|
104
114
|
expect(skills).toEqual(expect.arrayContaining([{ name: "x.y.z", description: "desc" }]))
|
|
105
115
|
const item = skills.find(s => s.name === "x.y.z")
|
|
106
116
|
expect(item.description).toBe("desc")
|
|
117
|
+
const mcpSkill = skills.find(s => s.name === MCP_SERVERS_USAGE_SKILL_ID)
|
|
118
|
+
expect(mcpSkill).toBeTruthy()
|
|
107
119
|
})
|
|
108
120
|
|
|
109
121
|
describe("handleSkillsCommand", () => {
|
|
@@ -220,6 +232,20 @@ describe("skills", () => {
|
|
|
220
232
|
consoleSpy.mockRestore()
|
|
221
233
|
})
|
|
222
234
|
|
|
235
|
+
test("get subcommand (mcp servers usage)", () => {
|
|
236
|
+
const consoleSpy = jest.spyOn(console, "log").mockImplementation()
|
|
237
|
+
const result = handleSkillsCommand({
|
|
238
|
+
positional: ["skills", "get", "mcp.servers.usage"],
|
|
239
|
+
flags: { format: "skill.md" },
|
|
240
|
+
config: {},
|
|
241
|
+
output: mockOutput
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
expect(result).toBe(true)
|
|
245
|
+
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("skill_name: \"mcp_servers_usage\""))
|
|
246
|
+
consoleSpy.mockRestore()
|
|
247
|
+
})
|
|
248
|
+
|
|
223
249
|
test("get subcommand (catalog)", () => {
|
|
224
250
|
const consoleSpy = jest.spyOn(console, "log").mockImplementation()
|
|
225
251
|
catalog.getCatalogSkill.mockReturnValue({ markdown: "catalog-md" })
|
|
@@ -0,0 +1,132 @@
|
|
|
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 writeFakeWacliBinary(dir) {
|
|
29
|
+
const bin = path.join(dir, "wacli")
|
|
30
|
+
fs.writeFileSync(bin, [
|
|
31
|
+
"#!/usr/bin/env node",
|
|
32
|
+
"const args = process.argv.slice(2);",
|
|
33
|
+
"if (args[0] === '--version') { console.log('wacli 0.2.0-test'); process.exit(0); }",
|
|
34
|
+
"const hasJson = args.includes('--json');",
|
|
35
|
+
"const storeIndex = args.indexOf('--store');",
|
|
36
|
+
"const store = storeIndex >= 0 ? args[storeIndex + 1] : null;",
|
|
37
|
+
"const clean = args.filter((arg, i) => arg !== '--json' && arg !== '--store' && i !== storeIndex + 1);",
|
|
38
|
+
"if (clean[0] === 'doctor') { console.log(JSON.stringify({ ok: true, store })); process.exit(0); }",
|
|
39
|
+
"if (clean[0] === 'auth' && clean[1] === 'status') { console.log(JSON.stringify({ authenticated: true, store })); process.exit(0); }",
|
|
40
|
+
"if (clean[0] === 'chats' && clean[1] === 'list') { console.log(JSON.stringify([{ JID: '123@s.whatsapp.net', Name: 'Alice', store }])); process.exit(0); }",
|
|
41
|
+
"if (clean[0] === 'chats' && clean[1] === 'show') { console.log(JSON.stringify({ JID: clean[3], Name: 'Alice Chat', store })); process.exit(0); }",
|
|
42
|
+
"if (clean[0] === 'messages' && clean[1] === 'list') { console.log(JSON.stringify([{ ChatJID: '123@s.whatsapp.net', ID: 'm1', Text: 'hello', store }])); process.exit(0); }",
|
|
43
|
+
"if (clean[0] === 'messages' && clean[1] === 'search') { console.log(JSON.stringify([{ ID: 'm2', Text: clean[2], store }])); process.exit(0); }",
|
|
44
|
+
"if (clean[0] === 'messages' && clean[1] === 'show') { console.log(JSON.stringify({ ChatJID: clean[3], ID: clean[5], Text: 'full message', store })); process.exit(0); }",
|
|
45
|
+
"if (clean[0] === 'messages' && clean[1] === 'context') { console.log(JSON.stringify({ center: clean[5], messages: [{ ID: 'm0' }, { ID: clean[5] }, { ID: 'm3' }], store })); process.exit(0); }",
|
|
46
|
+
"if (clean[0] === 'contacts' && clean[1] === 'search') { console.log(JSON.stringify([{ JID: '123@s.whatsapp.net', Name: clean[2], store }])); process.exit(0); }",
|
|
47
|
+
"if (clean[0] === 'contacts' && clean[1] === 'show') { console.log(JSON.stringify({ JID: clean[3], Name: 'Alice', store })); process.exit(0); }",
|
|
48
|
+
"if (clean[0] === 'groups' && clean[1] === 'list') { console.log(JSON.stringify([{ JID: '456@g.us', Name: 'Test Group', store }])); process.exit(0); }",
|
|
49
|
+
"if (clean[0] === 'groups' && clean[1] === 'info') { console.log(JSON.stringify({ JID: clean[3], Name: 'Test Group', store })); process.exit(0); process.exit(0); }",
|
|
50
|
+
"if (hasJson) { console.log(JSON.stringify({ ok: true, args, store })); process.exit(0); }",
|
|
51
|
+
"console.log('human output');"
|
|
52
|
+
].join("\n"), "utf-8")
|
|
53
|
+
fs.chmodSync(bin, 0o755)
|
|
54
|
+
return bin
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
describe("wacli plugin", () => {
|
|
58
|
+
const fakeDir = fs.mkdtempSync(path.join(os.tmpdir(), "dcli-wacli-"))
|
|
59
|
+
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "dcli-home-wacli-"))
|
|
60
|
+
const fakeStore = path.join(tempHome, "store")
|
|
61
|
+
fs.mkdirSync(fakeStore, { recursive: true })
|
|
62
|
+
writeFakeWacliBinary(fakeDir)
|
|
63
|
+
const env = { ...process.env, PATH: `${fakeDir}:${process.env.PATH || ""}`, SUPERCLI_HOME: tempHome }
|
|
64
|
+
|
|
65
|
+
beforeAll(() => {
|
|
66
|
+
runNoServer("plugins install ./plugins/wacli --on-conflict replace --json", { env })
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
afterAll(() => {
|
|
70
|
+
runNoServer("plugins remove wacli --json", { env })
|
|
71
|
+
fs.rmSync(fakeDir, { recursive: true, force: true })
|
|
72
|
+
fs.rmSync(tempHome, { recursive: true, force: true })
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
test("routes version and doctor commands", () => {
|
|
76
|
+
const version = runNoServer("wacli cli version --json", { env })
|
|
77
|
+
expect(version.ok).toBe(true)
|
|
78
|
+
expect(JSON.parse(version.output).data.raw).toBe("wacli 0.2.0-test")
|
|
79
|
+
|
|
80
|
+
const doctor = runNoServer(`wacli doctor run --store ${fakeStore} --json`, { env })
|
|
81
|
+
expect(doctor.ok).toBe(true)
|
|
82
|
+
const doctorData = JSON.parse(doctor.output)
|
|
83
|
+
expect(doctorData.command).toBe("wacli.doctor.run")
|
|
84
|
+
expect(doctorData.data.ok).toBe(true)
|
|
85
|
+
expect(doctorData.data.store).toBe(fakeStore)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
test("routes auth chats and messages wrappers", () => {
|
|
89
|
+
const auth = runNoServer(`wacli auth status --store ${fakeStore} --json`, { env })
|
|
90
|
+
expect(auth.ok).toBe(true)
|
|
91
|
+
expect(JSON.parse(auth.output).data.authenticated).toBe(true)
|
|
92
|
+
|
|
93
|
+
const chats = runNoServer(`wacli chats list --store ${fakeStore} --limit 5 --json`, { env })
|
|
94
|
+
expect(chats.ok).toBe(true)
|
|
95
|
+
expect(JSON.parse(chats.output).data[0].JID).toBe("123@s.whatsapp.net")
|
|
96
|
+
|
|
97
|
+
const show = runNoServer(`wacli chats show --jid 123@s.whatsapp.net --store ${fakeStore} --json`, { env })
|
|
98
|
+
expect(show.ok).toBe(true)
|
|
99
|
+
expect(JSON.parse(show.output).data.JID).toBe("123@s.whatsapp.net")
|
|
100
|
+
|
|
101
|
+
const search = runNoServer(`wacli messages search --query meeting --store ${fakeStore} --limit 10 --json`, { env })
|
|
102
|
+
expect(search.ok).toBe(true)
|
|
103
|
+
expect(JSON.parse(search.output).data[0].Text).toBe("meeting")
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
test("routes message context contacts and groups wrappers", () => {
|
|
107
|
+
const show = runNoServer(`wacli messages show --chat 123@s.whatsapp.net --id m1 --store ${fakeStore} --json`, { env })
|
|
108
|
+
expect(show.ok).toBe(true)
|
|
109
|
+
expect(JSON.parse(show.output).data.ID).toBe("m1")
|
|
110
|
+
|
|
111
|
+
const context = runNoServer(`wacli messages context --chat 123@s.whatsapp.net --id m1 --before 1 --after 1 --store ${fakeStore} --json`, { env })
|
|
112
|
+
expect(context.ok).toBe(true)
|
|
113
|
+
expect(JSON.parse(context.output).data.messages).toHaveLength(3)
|
|
114
|
+
|
|
115
|
+
const contact = runNoServer(`wacli contacts show --jid 123@s.whatsapp.net --store ${fakeStore} --json`, { env })
|
|
116
|
+
expect(contact.ok).toBe(true)
|
|
117
|
+
expect(JSON.parse(contact.output).data.Name).toBe("Alice")
|
|
118
|
+
|
|
119
|
+
const group = runNoServer(`wacli groups info --jid 456@g.us --store ${fakeStore} --json`, { env })
|
|
120
|
+
expect(group.ok).toBe(true)
|
|
121
|
+
expect(JSON.parse(group.output).data.JID).toBe("456@g.us")
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
test("does not expose passthrough and reports dependency health", () => {
|
|
125
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(__dirname, "..", "plugins", "wacli", "plugin.json"), "utf-8"))
|
|
126
|
+
expect(manifest.commands.some(command => command.resource === "_" && command.action === "_")).toBe(false)
|
|
127
|
+
|
|
128
|
+
const doctor = runNoServer("plugins doctor wacli --json", { env })
|
|
129
|
+
expect(doctor.ok).toBe(true)
|
|
130
|
+
expect(JSON.parse(doctor.output).checks.some(c => c.type === "binary" && c.binary === "wacli" && c.ok === true)).toBe(true)
|
|
131
|
+
})
|
|
132
|
+
})
|