superacli 1.0.0

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 (69) hide show
  1. package/.env.example +14 -0
  2. package/README.md +173 -0
  3. package/cli/adapters/http.js +72 -0
  4. package/cli/adapters/mcp.js +193 -0
  5. package/cli/adapters/openapi.js +160 -0
  6. package/cli/ask.js +208 -0
  7. package/cli/config.js +133 -0
  8. package/cli/executor.js +117 -0
  9. package/cli/help-json.js +46 -0
  10. package/cli/mcp-local.js +72 -0
  11. package/cli/plan-runtime.js +32 -0
  12. package/cli/planner.js +67 -0
  13. package/cli/skills.js +240 -0
  14. package/cli/supercli.js +704 -0
  15. package/docs/features/adapters.md +25 -0
  16. package/docs/features/agent-friendly.md +28 -0
  17. package/docs/features/ask.md +32 -0
  18. package/docs/features/config-sync.md +22 -0
  19. package/docs/features/execution-plans.md +25 -0
  20. package/docs/features/observability.md +22 -0
  21. package/docs/features/skills.md +25 -0
  22. package/docs/features/storage.md +25 -0
  23. package/docs/features/workflows.md +33 -0
  24. package/docs/initial/AGENTS_FRIENDLY_TOOLS.md +553 -0
  25. package/docs/initial/agent-friendly.md +447 -0
  26. package/docs/initial/architecture.md +436 -0
  27. package/docs/initial/built-in-mcp-server.md +64 -0
  28. package/docs/initial/command-plan.md +532 -0
  29. package/docs/initial/core-features-2.md +428 -0
  30. package/docs/initial/core-features.md +366 -0
  31. package/docs/initial/dag.md +20 -0
  32. package/docs/initial/description.txt +9 -0
  33. package/docs/initial/idea.txt +564 -0
  34. package/docs/initial/initial-spec-details.md +726 -0
  35. package/docs/initial/initial-spec.md +731 -0
  36. package/docs/initial/mcp-local-mode.md +53 -0
  37. package/docs/initial/mcp-sse-mode.md +54 -0
  38. package/docs/initial/skills-support.md +246 -0
  39. package/docs/initial/storage-adapter-example.md +155 -0
  40. package/docs/initial/supercli-vs-gwc.md +109 -0
  41. package/examples/mcp-sse/install-demo.js +86 -0
  42. package/examples/mcp-sse/server.js +81 -0
  43. package/examples/mcp-stdio/install-demo.js +78 -0
  44. package/examples/mcp-stdio/server.js +50 -0
  45. package/package.json +21 -0
  46. package/server/app.js +59 -0
  47. package/server/public/app.js +18 -0
  48. package/server/routes/ask.js +92 -0
  49. package/server/routes/commands.js +126 -0
  50. package/server/routes/config.js +58 -0
  51. package/server/routes/jobs.js +122 -0
  52. package/server/routes/mcp.js +79 -0
  53. package/server/routes/plans.js +134 -0
  54. package/server/routes/specs.js +79 -0
  55. package/server/services/configService.js +88 -0
  56. package/server/storage/adapter.js +32 -0
  57. package/server/storage/file.js +64 -0
  58. package/server/storage/mongo.js +55 -0
  59. package/server/views/command-edit.ejs +110 -0
  60. package/server/views/commands.ejs +49 -0
  61. package/server/views/jobs.ejs +72 -0
  62. package/server/views/layout.ejs +42 -0
  63. package/server/views/mcp.ejs +80 -0
  64. package/server/views/partials/foot.ejs +5 -0
  65. package/server/views/partials/head.ejs +27 -0
  66. package/server/views/specs.ejs +91 -0
  67. package/tests/test-cli.js +367 -0
  68. package/tests/test-mcp.js +189 -0
  69. package/tests/test-openapi.js +101 -0
@@ -0,0 +1,81 @@
1
+ #!/usr/bin/env node
2
+
3
+ const http = require("http")
4
+
5
+ const PORT = Number(process.env.MCP_SSE_PORT || 8787)
6
+ const clients = new Set()
7
+
8
+ function summarize(text) {
9
+ const normalized = String(text || "").trim().replace(/\s+/g, " ")
10
+ if (!normalized) return ""
11
+ const words = normalized.split(" ")
12
+ if (words.length <= 12) return normalized
13
+ return words.slice(0, 12).join(" ") + " ..."
14
+ }
15
+
16
+ function broadcast(event, data) {
17
+ const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`
18
+ for (const res of clients) {
19
+ res.write(payload)
20
+ }
21
+ }
22
+
23
+ const server = http.createServer((req, res) => {
24
+ if (req.method === "GET" && req.url === "/events") {
25
+ res.writeHead(200, {
26
+ "Content-Type": "text/event-stream",
27
+ "Cache-Control": "no-cache",
28
+ Connection: "keep-alive"
29
+ })
30
+ res.write("event: ready\ndata: {\"ok\":true}\n\n")
31
+ clients.add(res)
32
+ req.on("close", () => clients.delete(res))
33
+ return
34
+ }
35
+
36
+ if (req.method === "POST" && req.url === "/tool") {
37
+ let raw = ""
38
+ req.setEncoding("utf-8")
39
+ req.on("data", chunk => { raw += chunk })
40
+ req.on("end", () => {
41
+ let payload
42
+ try {
43
+ payload = JSON.parse(raw || "{}")
44
+ } catch {
45
+ res.writeHead(400, { "Content-Type": "application/json" })
46
+ res.end(JSON.stringify({ error: "Invalid JSON body" }))
47
+ return
48
+ }
49
+
50
+ const tool = payload.tool
51
+ const input = payload.input || {}
52
+ if (tool !== "summarize") {
53
+ res.writeHead(404, { "Content-Type": "application/json" })
54
+ res.end(JSON.stringify({ error: `Unknown tool: ${tool}` }))
55
+ return
56
+ }
57
+
58
+ broadcast("tool_called", { tool, input })
59
+ const result = summarize(input.text)
60
+ const out = {
61
+ tool,
62
+ mode: "sse-http",
63
+ result,
64
+ words_in: String(input.text || "").trim() ? String(input.text).trim().split(/\s+/).length : 0,
65
+ words_out: result ? result.split(/\s+/).length : 0
66
+ }
67
+ broadcast("tool_done", out)
68
+ res.writeHead(200, { "Content-Type": "application/json" })
69
+ res.end(JSON.stringify(out))
70
+ })
71
+ return
72
+ }
73
+
74
+ res.writeHead(404, { "Content-Type": "application/json" })
75
+ res.end(JSON.stringify({ error: "Not found" }))
76
+ })
77
+
78
+ server.listen(PORT, "127.0.0.1", () => {
79
+ console.log(`MCP SSE demo server listening on http://127.0.0.1:${PORT}`)
80
+ console.log("Endpoints: POST /tool, GET /events")
81
+ })
@@ -0,0 +1,78 @@
1
+ #!/usr/bin/env node
2
+
3
+ const fs = require("fs");
4
+ const os = require("os");
5
+ const path = require("path");
6
+
7
+ const CACHE_DIR = path.join(os.homedir(), ".supercli");
8
+ const CACHE_FILE = path.join(CACHE_DIR, "config.json");
9
+ const ROOT = path.resolve(__dirname, "..", "..");
10
+ const SERVER_SCRIPT = path.join(ROOT, "examples", "mcp-stdio", "server.js");
11
+
12
+ function ensureDir(dir) {
13
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
14
+ }
15
+
16
+ function readConfig() {
17
+ if (!fs.existsSync(CACHE_FILE)) {
18
+ return {
19
+ version: "1",
20
+ ttl: 3600,
21
+ mcp_servers: [],
22
+ specs: [],
23
+ commands: [],
24
+ };
25
+ }
26
+ try {
27
+ return JSON.parse(fs.readFileSync(CACHE_FILE, "utf-8"));
28
+ } catch {
29
+ return {
30
+ version: "1",
31
+ ttl: 3600,
32
+ mcp_servers: [],
33
+ specs: [],
34
+ commands: [],
35
+ };
36
+ }
37
+ }
38
+
39
+ function upsertCommand(config, command) {
40
+ const idx = config.commands.findIndex(
41
+ (c) =>
42
+ c.namespace === command.namespace &&
43
+ c.resource === command.resource &&
44
+ c.action === command.action,
45
+ );
46
+ if (idx >= 0) config.commands[idx] = command;
47
+ else config.commands.push(command);
48
+ }
49
+
50
+ function main() {
51
+ ensureDir(CACHE_DIR);
52
+ const config = readConfig();
53
+ if (!Array.isArray(config.commands)) config.commands = [];
54
+ if (!Array.isArray(config.mcp_servers)) config.mcp_servers = [];
55
+ if (!Array.isArray(config.specs)) config.specs = [];
56
+
57
+ upsertCommand(config, {
58
+ _id: "command:ai.text.summarize",
59
+ namespace: "ai",
60
+ resource: "text",
61
+ action: "summarize",
62
+ description: "Mock summarize using local stdio MCP server",
63
+ adapter: "mcp",
64
+ adapterConfig: {
65
+ tool: "summarize",
66
+ command: `node ${SERVER_SCRIPT}`,
67
+ },
68
+ args: [{ name: "text", type: "string", required: true }],
69
+ });
70
+
71
+ config.fetchedAt = Date.now();
72
+ fs.writeFileSync(CACHE_FILE, JSON.stringify(config, null, 2));
73
+ process.stdout.write(
74
+ `Installed demo command ai.text.summarize in ${CACHE_FILE}\n`,
75
+ );
76
+ }
77
+
78
+ main();
@@ -0,0 +1,50 @@
1
+ #!/usr/bin/env node
2
+
3
+ function summarize(text) {
4
+ const normalized = String(text || "").trim().replace(/\s+/g, " ")
5
+ if (!normalized) return ""
6
+ const words = normalized.split(" ")
7
+ if (words.length <= 12) return normalized
8
+ return words.slice(0, 12).join(" ") + " ..."
9
+ }
10
+
11
+ async function main() {
12
+ const chunks = []
13
+ for await (const chunk of process.stdin) chunks.push(chunk)
14
+
15
+ let payload
16
+ try {
17
+ payload = JSON.parse(Buffer.concat(chunks).toString("utf-8") || "{}")
18
+ } catch {
19
+ process.stderr.write("Invalid JSON input\n")
20
+ process.exit(1)
21
+ return
22
+ }
23
+
24
+ const tool = payload.tool
25
+ const input = payload.input || {}
26
+
27
+ if (tool !== "summarize") {
28
+ process.stderr.write(`Unknown tool: ${tool}\n`)
29
+ process.exit(1)
30
+ return
31
+ }
32
+
33
+ const source = input.text || ""
34
+ const summary = summarize(source)
35
+ const out = {
36
+ tool,
37
+ mode: "stdio",
38
+ result: summary,
39
+ words_in: String(source).trim() ? String(source).trim().split(/\s+/).length : 0,
40
+ words_out: summary ? summary.split(/\s+/).length : 0
41
+ }
42
+
43
+ process.stdout.write(JSON.stringify(out))
44
+ process.exit(0)
45
+ }
46
+
47
+ main().catch((err) => {
48
+ process.stderr.write(`${err.message}\n`)
49
+ process.exit(1)
50
+ })
package/package.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "superacli",
3
+ "version": "1.0.0",
4
+ "description": "Config-driven, AI-friendly dynamic CLI",
5
+ "main": "server/app.js",
6
+ "bin": {
7
+ "dcli": "./cli/supercli.js",
8
+ "scli": "./cli/supercli.js",
9
+ "supercli": "./cli/supercli.js"
10
+ },
11
+ "scripts": {
12
+ "start": "node server/app.js",
13
+ "dev": "node server/app.js"
14
+ },
15
+ "dependencies": {
16
+ "dotenv": "^17.3.1",
17
+ "ejs": "^3.1.9",
18
+ "express": "^4.18.2",
19
+ "mongodb": "^6.3.0"
20
+ }
21
+ }
package/server/app.js ADDED
@@ -0,0 +1,59 @@
1
+ const express = require("express");
2
+ const path = require("path");
3
+ const { getStorage } = require("./storage/adapter");
4
+
5
+ const commandsRouter = require("./routes/commands");
6
+ const configRouter = require("./routes/config");
7
+ const specsRouter = require("./routes/specs");
8
+ const mcpRouter = require("./routes/mcp");
9
+ const plansRouter = require("./routes/plans");
10
+ const jobsRouter = require("./routes/jobs");
11
+ const askRouter = require("./routes/ask");
12
+
13
+ const PORT = process.env.PORT || 3000;
14
+
15
+ const app = express();
16
+
17
+ app.use(express.json());
18
+ app.use(express.urlencoded({ extended: true }));
19
+
20
+ app.set("view engine", "ejs");
21
+ app.set("views", path.join(__dirname, "views"));
22
+
23
+ app.use("/static", express.static(path.join(__dirname, "public")));
24
+
25
+ // API routes
26
+ app.use("/api/config", configRouter);
27
+ app.use("/api/commands", commandsRouter);
28
+ app.use("/api/specs", specsRouter);
29
+ app.use("/api/mcp", mcpRouter);
30
+ app.use("/api/plans", plansRouter);
31
+ app.use("/api/jobs", jobsRouter);
32
+ app.use("/api/ask", askRouter);
33
+
34
+ // Tree/command endpoints under /api (config router handles them)
35
+ app.use("/api", configRouter);
36
+
37
+ // UI page redirects
38
+ app.get("/", (req, res) => res.redirect("/api/commands"));
39
+ app.get("/commands", (req, res) => res.redirect("/api/commands"));
40
+ app.get("/commands/new", (req, res) => res.redirect("/api/commands/new"));
41
+ app.get("/specs", (req, res) => res.redirect("/api/specs"));
42
+ app.get("/mcp", (req, res) => res.redirect("/api/mcp"));
43
+ app.get("/jobs", (req, res) => res.redirect("/api/jobs"));
44
+
45
+ async function start() {
46
+ try {
47
+ // Initialize storage singleton
48
+ getStorage();
49
+
50
+ app.listen(PORT, () => {
51
+ console.log(`SUPERCLI server running on http://localhost:${PORT}`);
52
+ });
53
+ } catch (err) {
54
+ console.error("Failed to start:", err.message);
55
+ process.exit(1);
56
+ }
57
+ }
58
+
59
+ start();
@@ -0,0 +1,18 @@
1
+ // SUPERCLI Client-side utilities
2
+ // Highlight active nav item based on current URL
3
+ (function () {
4
+ const path = window.location.pathname;
5
+ if (path.startsWith("/api/commands") || path.startsWith("/commands")) {
6
+ const el = document.getElementById("nav-commands");
7
+ if (el) el.classList.add("nav-active");
8
+ } else if (path.startsWith("/api/specs") || path.startsWith("/specs")) {
9
+ const el = document.getElementById("nav-specs");
10
+ if (el) el.classList.add("nav-active");
11
+ } else if (path.startsWith("/api/mcp") || path.startsWith("/mcp")) {
12
+ const el = document.getElementById("nav-mcp");
13
+ if (el) el.classList.add("nav-active");
14
+ } else if (path.startsWith("/api/jobs") || path.startsWith("/jobs")) {
15
+ const el = document.getElementById("nav-jobs");
16
+ if (el) el.classList.add("nav-active");
17
+ }
18
+ })();
@@ -0,0 +1,92 @@
1
+ const { Router } = require("express")
2
+ const configService = require("../services/configService")
3
+
4
+ const router = Router()
5
+
6
+ router.post("/", async (req, res) => {
7
+ const { query } = req.body
8
+ if (!query) return res.status(400).json({ error: "Missing query parameter" })
9
+
10
+ const baseUrl = process.env.OPENAI_BASE_URL
11
+ const model = process.env.OPENAI_MODEL || "gpt-3.5-turbo"
12
+ const apiKey = process.env.OPENAI_API_KEY || "dummy"
13
+
14
+ if (!baseUrl) {
15
+ return res.status(501).json({ error: "Server is not configured for LLM completions (OPENAI_BASE_URL is missing)" })
16
+ }
17
+
18
+ try {
19
+ const namespaces = await configService.getNamespaces()
20
+ const allDefs = []
21
+
22
+ // Build a compact representation of all CLI commands
23
+ for (const ns of namespaces) {
24
+ const resources = await configService.getResources(ns)
25
+ for (const res of resources) {
26
+ const actions = await configService.getActions(ns, res)
27
+ for (const act of actions) {
28
+ const cmd = await configService.getCommand(ns, res, act)
29
+ if (!cmd) continue
30
+ const argList = (cmd.args || []).map(a => `--${a.name}${a.required ? " (required)" : ""}`).join(" ")
31
+ allDefs.push(`- ${ns} ${res} ${act} ${argList} : ${cmd.description || "no desc"}`)
32
+ }
33
+ }
34
+ }
35
+
36
+ const systemPrompt = `You are an AI CLI assistant router.
37
+ Available commands:
38
+ ${allDefs.join("\n")}
39
+
40
+ The user wants to accomplish a goal. Map their goal into a sequence of CLI commands to run.
41
+ Respond STRICTLY with a valid JSON array of steps. For example:
42
+ [
43
+ { "command": "aws.instances.list", "args": { "region": "us-east-1" } },
44
+ { "command": "ai.summarize.text", "args": { "text": "{{step.0.data.summary}}" } }
45
+ ]
46
+ Do not wrap it in markdown. Do not include any other text.`
47
+
48
+ const r = await fetch(`${baseUrl.replace(/\/$/, "")}/chat/completions`, {
49
+ method: "POST",
50
+ headers: {
51
+ "Content-Type": "application/json",
52
+ "Authorization": `Bearer ${apiKey}`
53
+ },
54
+ body: JSON.stringify({
55
+ model,
56
+ messages: [
57
+ { role: "system", content: systemPrompt },
58
+ { role: "user", content: query }
59
+ ],
60
+ temperature: 0
61
+ })
62
+ })
63
+
64
+ if (!r.ok) {
65
+ const txt = await r.text()
66
+ throw new Error(`LLM Error ${r.status}: ${txt}`)
67
+ }
68
+
69
+ const data = await r.json()
70
+ const content = data.choices && data.choices[0] && data.choices[0].message && data.choices[0].message.content
71
+ if (!content) throw new Error("Invalid response format from LLM")
72
+
73
+ // Attempt to extract JSON if the model ignored instructions and wrapped in markdown
74
+ let jsonStr = content.trim()
75
+ if (jsonStr.startsWith("```json")) {
76
+ jsonStr = jsonStr.replace(/^```json/, "").replace(/```$/, "").trim()
77
+ } else if (jsonStr.startsWith("```")) {
78
+ jsonStr = jsonStr.replace(/^```/, "").replace(/```$/, "").trim()
79
+ }
80
+
81
+ const steps = JSON.parse(jsonStr)
82
+ if (!Array.isArray(steps)) {
83
+ throw new Error("LLM did not return a JSON array")
84
+ }
85
+
86
+ res.json({ steps })
87
+ } catch (err) {
88
+ res.status(500).json({ error: err.message })
89
+ }
90
+ })
91
+
92
+ module.exports = router
@@ -0,0 +1,126 @@
1
+ const { Router } = require("express")
2
+ const { getStorage } = require("../storage/adapter")
3
+ const { bumpVersion } = require("../services/configService")
4
+
5
+ const router = Router()
6
+
7
+ // Helper to list all commands
8
+ async function getAllCommands() {
9
+ const storage = getStorage()
10
+ const keys = await storage.listKeys("command:")
11
+ const commands = await Promise.all(keys.map(k => storage.get(k)))
12
+ return commands.sort((a, b) => a._id.localeCompare(b._id))
13
+ }
14
+
15
+ // GET /api/commands
16
+ router.get("/", async (req, res) => {
17
+ try {
18
+ const commands = await getAllCommands()
19
+ if (req.query.format !== "json" && req.accepts("html") && !req.xhr && !req.headers["x-requested-with"]) {
20
+ return res.render("commands", { commands })
21
+ }
22
+ res.json(commands)
23
+ } catch (err) {
24
+ res.status(500).json({ error: err.message })
25
+ }
26
+ })
27
+
28
+ // GET /api/commands/new
29
+ router.get("/new", async (req, res) => {
30
+ res.render("command-edit", { command: null })
31
+ })
32
+
33
+ // GET /api/commands/:id/edit
34
+ router.get("/:id/edit", async (req, res) => {
35
+ try {
36
+ const storage = getStorage()
37
+ // id could be URL encoded if it's a natural key
38
+ const id = decodeURIComponent(req.params.id)
39
+ const command = await storage.get(id)
40
+ if (!command) return res.status(404).send("Not found")
41
+ res.render("command-edit", { command })
42
+ } catch (err) {
43
+ res.status(500).send(err.message)
44
+ }
45
+ })
46
+
47
+ // POST /api/commands
48
+ router.post("/", async (req, res) => {
49
+ try {
50
+ const storage = getStorage()
51
+ const { namespace, resource, action, description, adapter, adapterConfig, args } = req.body
52
+ const key = `command:${namespace}.${resource}.${action}`
53
+
54
+ // Check if exists? Overwrite is allowed for now, acts as upsert.
55
+
56
+ const doc = {
57
+ _id: key, // Store the key inside the document as well
58
+ namespace,
59
+ resource,
60
+ action,
61
+ description: description || "",
62
+ adapter: adapter || "http",
63
+ adapterConfig: typeof adapterConfig === "string" ? JSON.parse(adapterConfig || "{}") : (adapterConfig || {}),
64
+ args: Array.isArray(args) ? args : JSON.parse(args || "[]"),
65
+ createdAt: new Date(),
66
+ updatedAt: new Date()
67
+ }
68
+
69
+ await storage.set(key, doc)
70
+ await bumpVersion()
71
+
72
+ if (req.headers["content-type"]?.includes("urlencoded")) {
73
+ return res.redirect("/api/commands")
74
+ }
75
+ res.status(201).json(doc)
76
+ } catch (err) {
77
+ res.status(500).json({ error: err.message })
78
+ }
79
+ })
80
+
81
+ // PUT /api/commands/:id
82
+ router.put("/:id", async (req, res) => {
83
+ try {
84
+ const storage = getStorage()
85
+ const id = decodeURIComponent(req.params.id)
86
+ const { namespace, resource, action, description, adapter, adapterConfig, args } = req.body
87
+
88
+ // If n/r/a changed, the ID changes, we should delete the old one.
89
+ const newKey = `command:${namespace}.${resource}.${action}`
90
+ if (newKey !== id) {
91
+ await storage.delete(id)
92
+ }
93
+
94
+ const update = {
95
+ _id: newKey,
96
+ namespace,
97
+ resource,
98
+ action,
99
+ description: description || "",
100
+ adapter: adapter || "http",
101
+ adapterConfig: typeof adapterConfig === "string" ? JSON.parse(adapterConfig || "{}") : (adapterConfig || {}),
102
+ args: Array.isArray(args) ? args : JSON.parse(args || "[]"),
103
+ updatedAt: new Date()
104
+ }
105
+ await storage.set(newKey, update)
106
+ await bumpVersion()
107
+ res.json({ ok: true })
108
+ } catch (err) {
109
+ res.status(500).json({ error: err.message })
110
+ }
111
+ })
112
+
113
+ // DELETE /api/commands/:id
114
+ router.delete("/:id", async (req, res) => {
115
+ try {
116
+ const storage = getStorage()
117
+ const id = decodeURIComponent(req.params.id)
118
+ await storage.delete(id)
119
+ await bumpVersion()
120
+ res.json({ ok: true })
121
+ } catch (err) {
122
+ res.status(500).json({ error: err.message })
123
+ }
124
+ })
125
+
126
+ module.exports = router
@@ -0,0 +1,58 @@
1
+ const { Router } = require("express")
2
+ const configService = require("../services/configService")
3
+
4
+ const router = Router()
5
+
6
+ // GET /api/config — full CLI config
7
+ router.get("/", async (req, res) => {
8
+ try {
9
+ const config = await configService.getCLIConfig()
10
+ config.features = { ask: !!process.env.OPENAI_BASE_URL }
11
+ res.json(config)
12
+ } catch (err) {
13
+ res.status(500).json({ error: err.message })
14
+ }
15
+ })
16
+
17
+ // GET /api/tree — list namespaces
18
+ router.get("/tree", async (req, res) => {
19
+ try {
20
+ const namespaces = await configService.getNamespaces()
21
+ res.json({ namespaces })
22
+ } catch (err) {
23
+ res.status(500).json({ error: err.message })
24
+ }
25
+ })
26
+
27
+ // GET /api/tree/:ns — list resources in namespace
28
+ router.get("/tree/:ns", async (req, res) => {
29
+ try {
30
+ const resources = await configService.getResources(req.params.ns)
31
+ res.json({ resources })
32
+ } catch (err) {
33
+ res.status(500).json({ error: err.message })
34
+ }
35
+ })
36
+
37
+ // GET /api/tree/:ns/:res — list actions
38
+ router.get("/tree/:ns/:res", async (req, res) => {
39
+ try {
40
+ const actions = await configService.getActions(req.params.ns, req.params.res)
41
+ res.json({ actions })
42
+ } catch (err) {
43
+ res.status(500).json({ error: err.message })
44
+ }
45
+ })
46
+
47
+ // GET /api/command/:ns/:res/:act — full command spec
48
+ router.get("/command/:ns/:res/:act", async (req, res) => {
49
+ try {
50
+ const cmd = await configService.getCommand(req.params.ns, req.params.res, req.params.act)
51
+ if (!cmd) return res.status(404).json({ error: "Command not found" })
52
+ res.json(cmd)
53
+ } catch (err) {
54
+ res.status(500).json({ error: err.message })
55
+ }
56
+ })
57
+
58
+ module.exports = router