superacli 1.1.2 → 1.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +10 -1
  3. package/__tests__/blogwatcher-plugin.test.js +157 -0
  4. package/__tests__/clix-plugin.test.js +143 -0
  5. package/__tests__/config.test.js +46 -1
  6. package/__tests__/himalaya-plugin.test.js +121 -0
  7. package/__tests__/mcp-adapter.test.js +79 -0
  8. package/__tests__/mcp-local.test.js +43 -1
  9. package/__tests__/mongosh-plugin.test.js +106 -0
  10. package/__tests__/mysql-plugin.test.js +94 -0
  11. package/__tests__/plugin-blogwatcher.test.js +55 -0
  12. package/__tests__/plugin-clix.test.js +51 -0
  13. package/__tests__/plugin-xurl.test.js +51 -0
  14. package/__tests__/server-config-service.test.js +8 -1
  15. package/__tests__/skills.test.js +26 -0
  16. package/__tests__/wacli-plugin.test.js +132 -0
  17. package/__tests__/xurl-plugin.test.js +176 -0
  18. package/cli/adapter-schema.js +7 -0
  19. package/cli/adapters/mcp.js +82 -20
  20. package/cli/config.js +65 -8
  21. package/cli/mcp-local.js +50 -4
  22. package/cli/plugin-install-guidance.js +100 -0
  23. package/cli/skills.js +55 -0
  24. package/cli/supercli.js +1 -1
  25. package/docs/features/adapters.md +6 -2
  26. package/docs/initial/mcp-local-mode.md +3 -0
  27. package/docs/skills-catalog.md +50 -0
  28. package/docs/supported-harnesses.md +20 -0
  29. package/package.json +2 -1
  30. package/plugins/blogwatcher/README.md +52 -0
  31. package/plugins/blogwatcher/plugin.json +195 -0
  32. package/plugins/blogwatcher/scripts/post-install.js +66 -0
  33. package/plugins/blogwatcher/scripts/post-uninstall.js +25 -0
  34. package/plugins/clix/README.md +44 -0
  35. package/plugins/clix/plugin.json +126 -0
  36. package/plugins/clix/scripts/post-install.js +66 -0
  37. package/plugins/clix/scripts/post-uninstall.js +25 -0
  38. package/plugins/himalaya/README.md +48 -0
  39. package/plugins/himalaya/plugin.json +157 -0
  40. package/plugins/mongosh/README.md +56 -0
  41. package/plugins/mongosh/plugin.json +88 -0
  42. package/plugins/mysql/README.md +48 -0
  43. package/plugins/mysql/plugin.json +64 -0
  44. package/plugins/plugins.json +63 -0
  45. package/plugins/wacli/README.md +52 -0
  46. package/plugins/wacli/plugin.json +260 -0
  47. package/plugins/xurl/README.md +52 -0
  48. package/plugins/xurl/plugin.json +239 -0
  49. package/plugins/xurl/scripts/post-install.js +66 -0
  50. package/plugins/xurl/scripts/post-uninstall.js +25 -0
  51. package/server/routes/mcp.js +30 -4
  52. package/server/services/configService.js +9 -1
  53. package/tests/test-blogwatcher-smoke.sh +48 -0
  54. package/tests/test-clix-smoke.sh +44 -0
  55. package/tests/test-himalaya-smoke.sh +47 -0
  56. package/tests/test-mcp-browser-use-smoke.sh +141 -0
  57. package/tests/test-mongosh-smoke.sh +40 -0
  58. package/tests/test-mysql-smoke.sh +37 -0
  59. package/tests/test-plugins-registry.js +35 -0
  60. package/tests/test-wacli-smoke.sh +46 -0
  61. package/tests/test-xurl-smoke.sh +46 -0
@@ -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", url: "u1" }
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
 
@@ -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
+ })