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.
- package/.env.example +14 -0
- package/README.md +173 -0
- package/cli/adapters/http.js +72 -0
- package/cli/adapters/mcp.js +193 -0
- package/cli/adapters/openapi.js +160 -0
- package/cli/ask.js +208 -0
- package/cli/config.js +133 -0
- package/cli/executor.js +117 -0
- package/cli/help-json.js +46 -0
- package/cli/mcp-local.js +72 -0
- package/cli/plan-runtime.js +32 -0
- package/cli/planner.js +67 -0
- package/cli/skills.js +240 -0
- package/cli/supercli.js +704 -0
- package/docs/features/adapters.md +25 -0
- package/docs/features/agent-friendly.md +28 -0
- package/docs/features/ask.md +32 -0
- package/docs/features/config-sync.md +22 -0
- package/docs/features/execution-plans.md +25 -0
- package/docs/features/observability.md +22 -0
- package/docs/features/skills.md +25 -0
- package/docs/features/storage.md +25 -0
- package/docs/features/workflows.md +33 -0
- package/docs/initial/AGENTS_FRIENDLY_TOOLS.md +553 -0
- package/docs/initial/agent-friendly.md +447 -0
- package/docs/initial/architecture.md +436 -0
- package/docs/initial/built-in-mcp-server.md +64 -0
- package/docs/initial/command-plan.md +532 -0
- package/docs/initial/core-features-2.md +428 -0
- package/docs/initial/core-features.md +366 -0
- package/docs/initial/dag.md +20 -0
- package/docs/initial/description.txt +9 -0
- package/docs/initial/idea.txt +564 -0
- package/docs/initial/initial-spec-details.md +726 -0
- package/docs/initial/initial-spec.md +731 -0
- package/docs/initial/mcp-local-mode.md +53 -0
- package/docs/initial/mcp-sse-mode.md +54 -0
- package/docs/initial/skills-support.md +246 -0
- package/docs/initial/storage-adapter-example.md +155 -0
- package/docs/initial/supercli-vs-gwc.md +109 -0
- package/examples/mcp-sse/install-demo.js +86 -0
- package/examples/mcp-sse/server.js +81 -0
- package/examples/mcp-stdio/install-demo.js +78 -0
- package/examples/mcp-stdio/server.js +50 -0
- package/package.json +21 -0
- package/server/app.js +59 -0
- package/server/public/app.js +18 -0
- package/server/routes/ask.js +92 -0
- package/server/routes/commands.js +126 -0
- package/server/routes/config.js +58 -0
- package/server/routes/jobs.js +122 -0
- package/server/routes/mcp.js +79 -0
- package/server/routes/plans.js +134 -0
- package/server/routes/specs.js +79 -0
- package/server/services/configService.js +88 -0
- package/server/storage/adapter.js +32 -0
- package/server/storage/file.js +64 -0
- package/server/storage/mongo.js +55 -0
- package/server/views/command-edit.ejs +110 -0
- package/server/views/commands.ejs +49 -0
- package/server/views/jobs.ejs +72 -0
- package/server/views/layout.ejs +42 -0
- package/server/views/mcp.ejs +80 -0
- package/server/views/partials/foot.ejs +5 -0
- package/server/views/partials/head.ejs +27 -0
- package/server/views/specs.ejs +91 -0
- package/tests/test-cli.js +367 -0
- package/tests/test-mcp.js +189 -0
- package/tests/test-openapi.js +101 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
const { Router } = require("express")
|
|
2
|
+
const { getStorage } = require("../storage/adapter")
|
|
3
|
+
|
|
4
|
+
const router = Router()
|
|
5
|
+
|
|
6
|
+
// POST /api/jobs — record a command execution job
|
|
7
|
+
router.post("/", async (req, res) => {
|
|
8
|
+
try {
|
|
9
|
+
const storage = getStorage()
|
|
10
|
+
const { command, args, status, duration_ms, timestamp, plan_id, error } = req.body
|
|
11
|
+
|
|
12
|
+
// Generate a unique sequential-ish ID
|
|
13
|
+
const jobId = `job:${Date.now()}_${Math.random().toString(36).substring(2, 9)}`
|
|
14
|
+
|
|
15
|
+
const doc = {
|
|
16
|
+
_id: jobId,
|
|
17
|
+
command,
|
|
18
|
+
args: args || {},
|
|
19
|
+
status: status || "unknown",
|
|
20
|
+
duration_ms: duration_ms || 0,
|
|
21
|
+
plan_id: plan_id || null,
|
|
22
|
+
error: error || null,
|
|
23
|
+
timestamp: timestamp || new Date().toISOString()
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
await storage.set(jobId, doc)
|
|
27
|
+
res.status(201).json(doc)
|
|
28
|
+
} catch (err) {
|
|
29
|
+
res.status(500).json({ error: err.message })
|
|
30
|
+
}
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
// GET /api/jobs — list recent jobs
|
|
34
|
+
router.get("/", async (req, res) => {
|
|
35
|
+
try {
|
|
36
|
+
const storage = getStorage()
|
|
37
|
+
const limit = parseInt(req.query.limit) || 50
|
|
38
|
+
const commandQuery = req.query.command
|
|
39
|
+
|
|
40
|
+
const keys = await storage.listKeys("job:")
|
|
41
|
+
let jobs = await Promise.all(keys.map(k => storage.get(k)))
|
|
42
|
+
|
|
43
|
+
jobs = jobs.filter(j => !!j)
|
|
44
|
+
if (commandQuery) {
|
|
45
|
+
jobs = jobs.filter(j => j.command === commandQuery)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Sort descending by timestamp
|
|
49
|
+
jobs.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp))
|
|
50
|
+
jobs = jobs.slice(0, limit)
|
|
51
|
+
|
|
52
|
+
// If HTML request, render
|
|
53
|
+
if (req.query.format !== "json" && req.accepts("html") && !req.xhr && !req.headers["x-requested-with"]) {
|
|
54
|
+
return res.render("jobs", { jobs })
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
res.json(jobs)
|
|
58
|
+
} catch (err) {
|
|
59
|
+
res.status(500).json({ error: err.message })
|
|
60
|
+
}
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
// GET /api/jobs/stats — aggregate stats
|
|
64
|
+
router.get("/stats", async (req, res) => {
|
|
65
|
+
try {
|
|
66
|
+
const storage = getStorage()
|
|
67
|
+
const keys = await storage.listKeys("job:")
|
|
68
|
+
const jobs = await Promise.all(keys.map(k => storage.get(k)))
|
|
69
|
+
|
|
70
|
+
const total = jobs.length
|
|
71
|
+
let success = 0
|
|
72
|
+
let failed = 0
|
|
73
|
+
const commandStats = {}
|
|
74
|
+
|
|
75
|
+
for (const job of jobs) {
|
|
76
|
+
if (!job) continue
|
|
77
|
+
if (job.status === "success") success++
|
|
78
|
+
if (job.status === "failed") failed++
|
|
79
|
+
|
|
80
|
+
const cmd = job.command
|
|
81
|
+
if (!commandStats[cmd]) {
|
|
82
|
+
commandStats[cmd] = { count: 0, sum_ms: 0 }
|
|
83
|
+
}
|
|
84
|
+
commandStats[cmd].count++
|
|
85
|
+
commandStats[cmd].sum_ms += job.duration_ms
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const topCommands = Object.entries(commandStats)
|
|
89
|
+
.map(([cmd, stats]) => ({
|
|
90
|
+
command: cmd,
|
|
91
|
+
count: stats.count,
|
|
92
|
+
avg_ms: Math.round(stats.sum_ms / stats.count) || 0
|
|
93
|
+
}))
|
|
94
|
+
.sort((a, b) => b.count - a.count)
|
|
95
|
+
.slice(0, 10)
|
|
96
|
+
|
|
97
|
+
res.json({
|
|
98
|
+
total,
|
|
99
|
+
success,
|
|
100
|
+
failed,
|
|
101
|
+
failure_rate: total > 0 ? (failed / total * 100).toFixed(1) + "%" : "0%",
|
|
102
|
+
top_commands: topCommands
|
|
103
|
+
})
|
|
104
|
+
} catch (err) {
|
|
105
|
+
res.status(500).json({ error: err.message })
|
|
106
|
+
}
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
// GET /api/jobs/:id — get job details
|
|
110
|
+
router.get("/:id", async (req, res) => {
|
|
111
|
+
try {
|
|
112
|
+
const storage = getStorage()
|
|
113
|
+
const id = decodeURIComponent(req.params.id)
|
|
114
|
+
const job = await storage.get(id.startsWith("job:") ? id : `job:${id}`)
|
|
115
|
+
if (!job) return res.status(404).json({ error: "Job not found" })
|
|
116
|
+
res.json(job)
|
|
117
|
+
} catch (err) {
|
|
118
|
+
res.status(500).json({ error: err.message })
|
|
119
|
+
}
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
module.exports = router
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
const { Router } = require("express")
|
|
2
|
+
const { getStorage } = require("../storage/adapter")
|
|
3
|
+
const { bumpVersion } = require("../services/configService")
|
|
4
|
+
|
|
5
|
+
const router = Router()
|
|
6
|
+
|
|
7
|
+
async function getAllMCPs() {
|
|
8
|
+
const storage = getStorage()
|
|
9
|
+
const keys = await storage.listKeys("mcp:")
|
|
10
|
+
const servers = await Promise.all(keys.map(k => storage.get(k)))
|
|
11
|
+
return servers.sort((a, b) => a.name.localeCompare(b.name))
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// GET /api/mcp
|
|
15
|
+
router.get("/", async (req, res) => {
|
|
16
|
+
try {
|
|
17
|
+
const servers = await getAllMCPs()
|
|
18
|
+
if (req.query.format !== "json" && req.accepts("html") && !req.xhr && !req.headers["x-requested-with"]) {
|
|
19
|
+
return res.render("mcp", { servers })
|
|
20
|
+
}
|
|
21
|
+
res.json(servers)
|
|
22
|
+
} catch (err) {
|
|
23
|
+
res.status(500).json({ error: err.message })
|
|
24
|
+
}
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
// POST /api/mcp
|
|
28
|
+
router.post("/", async (req, res) => {
|
|
29
|
+
try {
|
|
30
|
+
const storage = getStorage()
|
|
31
|
+
const { name, url } = req.body
|
|
32
|
+
const key = `mcp:${name}`
|
|
33
|
+
const doc = { _id: key, name, url, createdAt: new Date() }
|
|
34
|
+
await storage.set(key, doc)
|
|
35
|
+
await bumpVersion()
|
|
36
|
+
if (req.headers["content-type"]?.includes("urlencoded")) {
|
|
37
|
+
return res.redirect("/api/mcp")
|
|
38
|
+
}
|
|
39
|
+
res.status(201).json(doc)
|
|
40
|
+
} catch (err) {
|
|
41
|
+
res.status(500).json({ error: err.message })
|
|
42
|
+
}
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
// PUT /api/mcp/:id
|
|
46
|
+
router.put("/:id", async (req, res) => {
|
|
47
|
+
try {
|
|
48
|
+
const storage = getStorage()
|
|
49
|
+
const id = decodeURIComponent(req.params.id)
|
|
50
|
+
const { name, url } = req.body
|
|
51
|
+
|
|
52
|
+
const newKey = `mcp:${name}`
|
|
53
|
+
if (newKey !== id) {
|
|
54
|
+
await storage.delete(id)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const doc = { _id: newKey, name, url }
|
|
58
|
+
await storage.set(newKey, doc)
|
|
59
|
+
await bumpVersion()
|
|
60
|
+
res.json({ ok: true })
|
|
61
|
+
} catch (err) {
|
|
62
|
+
res.status(500).json({ error: err.message })
|
|
63
|
+
}
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
// DELETE /api/mcp/:id
|
|
67
|
+
router.delete("/:id", async (req, res) => {
|
|
68
|
+
try {
|
|
69
|
+
const storage = getStorage()
|
|
70
|
+
const id = decodeURIComponent(req.params.id)
|
|
71
|
+
await storage.delete(id)
|
|
72
|
+
await bumpVersion()
|
|
73
|
+
res.json({ ok: true })
|
|
74
|
+
} catch (err) {
|
|
75
|
+
res.status(500).json({ error: err.message })
|
|
76
|
+
}
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
module.exports = router
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
const { Router } = require("express")
|
|
2
|
+
const { getStorage } = require("../storage/adapter")
|
|
3
|
+
const { createPlan } = require("../../cli/planner")
|
|
4
|
+
|
|
5
|
+
const router = Router()
|
|
6
|
+
|
|
7
|
+
// POST /api/plans — create a plan
|
|
8
|
+
router.post("/", async (req, res) => {
|
|
9
|
+
try {
|
|
10
|
+
const storage = getStorage()
|
|
11
|
+
const { command, args, cmd } = req.body
|
|
12
|
+
|
|
13
|
+
let planCmd = cmd
|
|
14
|
+
if (!planCmd) {
|
|
15
|
+
// Resolve command from DB
|
|
16
|
+
const [namespace, resource, action] = command.split(".")
|
|
17
|
+
planCmd = await storage.get(`command:${namespace}.${resource}.${action}`)
|
|
18
|
+
if (!planCmd) return res.status(404).json({ error: "Command not found" })
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const plan = createPlan(planCmd, args || {})
|
|
22
|
+
|
|
23
|
+
// Auto-expiry for file storage logic could go here, but for now we just store it
|
|
24
|
+
await storage.set(`plan:${plan.plan_id}`, plan)
|
|
25
|
+
res.status(201).json(plan)
|
|
26
|
+
} catch (err) {
|
|
27
|
+
res.status(500).json({ error: err.message })
|
|
28
|
+
}
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
// GET /api/plans — list recent plans
|
|
32
|
+
router.get("/", async (req, res) => {
|
|
33
|
+
try {
|
|
34
|
+
const storage = getStorage()
|
|
35
|
+
const keys = await storage.listKeys("plan:")
|
|
36
|
+
const plans = await Promise.all(keys.map(k => storage.get(k)))
|
|
37
|
+
|
|
38
|
+
// Sort descending by created_at and limit to 50
|
|
39
|
+
const sorted = plans
|
|
40
|
+
.filter(p => !!p)
|
|
41
|
+
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at))
|
|
42
|
+
.slice(0, 50)
|
|
43
|
+
|
|
44
|
+
res.json(sorted)
|
|
45
|
+
} catch (err) {
|
|
46
|
+
res.status(500).json({ error: err.message })
|
|
47
|
+
}
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
// GET /api/plans/:id — inspect plan
|
|
51
|
+
router.get("/:id", async (req, res) => {
|
|
52
|
+
try {
|
|
53
|
+
const storage = getStorage()
|
|
54
|
+
const id = decodeURIComponent(req.params.id)
|
|
55
|
+
const plan = await storage.get(`plan:${id}`)
|
|
56
|
+
if (!plan) return res.status(404).json({ error: "Plan not found" })
|
|
57
|
+
res.json(plan)
|
|
58
|
+
} catch (err) {
|
|
59
|
+
res.status(500).json({ error: err.message })
|
|
60
|
+
}
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
// POST /api/plans/:id/execute — execute a stored plan
|
|
64
|
+
router.post("/:id/execute", async (req, res) => {
|
|
65
|
+
try {
|
|
66
|
+
const storage = getStorage()
|
|
67
|
+
const id = decodeURIComponent(req.params.id)
|
|
68
|
+
const plan = await storage.get(`plan:${id}`)
|
|
69
|
+
if (!plan) return res.status(404).json({ error: "Plan not found" })
|
|
70
|
+
if (plan.status !== "planned") {
|
|
71
|
+
return res.status(400).json({ error: `Plan status is '${plan.status}', expected 'planned'` })
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Resolve command
|
|
75
|
+
const [namespace, resource, action] = plan.command.split(".")
|
|
76
|
+
const cmd = await storage.get(`command:${namespace}.${resource}.${action}`)
|
|
77
|
+
if (!cmd) return res.status(404).json({ error: "Command no longer exists" })
|
|
78
|
+
|
|
79
|
+
// Execute via adapter
|
|
80
|
+
const { execute } = require("../../cli/executor")
|
|
81
|
+
const start = Date.now()
|
|
82
|
+
let result, status = "success"
|
|
83
|
+
try {
|
|
84
|
+
result = await execute(cmd, plan.args || {}, { server: `http://localhost:${process.env.PORT || 3000}` })
|
|
85
|
+
} catch (err) {
|
|
86
|
+
result = { error: err.message }
|
|
87
|
+
status = "failed"
|
|
88
|
+
}
|
|
89
|
+
const duration = Date.now() - start
|
|
90
|
+
|
|
91
|
+
// Update plan status
|
|
92
|
+
plan.status = status
|
|
93
|
+
plan.executed_at = new Date().toISOString()
|
|
94
|
+
plan.duration_ms = duration
|
|
95
|
+
await storage.set(`plan:${id}`, plan)
|
|
96
|
+
|
|
97
|
+
// Record job
|
|
98
|
+
const jobId = `job:${Date.now()}_${Math.random().toString(36).substring(2, 9)}`
|
|
99
|
+
await storage.set(jobId, {
|
|
100
|
+
_id: jobId,
|
|
101
|
+
command: plan.command,
|
|
102
|
+
plan_id: plan.plan_id,
|
|
103
|
+
args: plan.args,
|
|
104
|
+
status,
|
|
105
|
+
duration_ms: duration,
|
|
106
|
+
timestamp: new Date().toISOString()
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
res.json({
|
|
110
|
+
version: "1.0",
|
|
111
|
+
plan_id: plan.plan_id,
|
|
112
|
+
command: plan.command,
|
|
113
|
+
status,
|
|
114
|
+
duration_ms: duration,
|
|
115
|
+
data: result
|
|
116
|
+
})
|
|
117
|
+
} catch (err) {
|
|
118
|
+
res.status(500).json({ error: err.message })
|
|
119
|
+
}
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
// DELETE /api/plans/:id — cancel plan
|
|
123
|
+
router.delete("/:id", async (req, res) => {
|
|
124
|
+
try {
|
|
125
|
+
const storage = getStorage()
|
|
126
|
+
const id = decodeURIComponent(req.params.id)
|
|
127
|
+
await storage.delete(`plan:${id}`)
|
|
128
|
+
res.json({ ok: true })
|
|
129
|
+
} catch (err) {
|
|
130
|
+
res.status(500).json({ error: err.message })
|
|
131
|
+
}
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
module.exports = router
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
const { Router } = require("express")
|
|
2
|
+
const { getStorage } = require("../storage/adapter")
|
|
3
|
+
const { bumpVersion } = require("../services/configService")
|
|
4
|
+
|
|
5
|
+
const router = Router()
|
|
6
|
+
|
|
7
|
+
async function getAllSpecs() {
|
|
8
|
+
const storage = getStorage()
|
|
9
|
+
const keys = await storage.listKeys("spec:")
|
|
10
|
+
const specs = await Promise.all(keys.map(k => storage.get(k)))
|
|
11
|
+
return specs.sort((a, b) => a.name.localeCompare(b.name))
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// GET /api/specs
|
|
15
|
+
router.get("/", async (req, res) => {
|
|
16
|
+
try {
|
|
17
|
+
const specs = await getAllSpecs()
|
|
18
|
+
if (req.query.format !== "json" && req.accepts("html") && !req.xhr && !req.headers["x-requested-with"]) {
|
|
19
|
+
return res.render("specs", { specs })
|
|
20
|
+
}
|
|
21
|
+
res.json(specs)
|
|
22
|
+
} catch (err) {
|
|
23
|
+
res.status(500).json({ error: err.message })
|
|
24
|
+
}
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
// POST /api/specs
|
|
28
|
+
router.post("/", async (req, res) => {
|
|
29
|
+
try {
|
|
30
|
+
const storage = getStorage()
|
|
31
|
+
const { name, url, auth } = req.body
|
|
32
|
+
const key = `spec:${name}`
|
|
33
|
+
const doc = { _id: key, name, url, auth: auth || "none", createdAt: new Date() }
|
|
34
|
+
await storage.set(key, doc)
|
|
35
|
+
await bumpVersion()
|
|
36
|
+
if (req.headers["content-type"]?.includes("urlencoded")) {
|
|
37
|
+
return res.redirect("/api/specs")
|
|
38
|
+
}
|
|
39
|
+
res.status(201).json(doc)
|
|
40
|
+
} catch (err) {
|
|
41
|
+
res.status(500).json({ error: err.message })
|
|
42
|
+
}
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
// PUT /api/specs/:id
|
|
46
|
+
router.put("/:id", async (req, res) => {
|
|
47
|
+
try {
|
|
48
|
+
const storage = getStorage()
|
|
49
|
+
const id = decodeURIComponent(req.params.id)
|
|
50
|
+
const { name, url, auth } = req.body
|
|
51
|
+
|
|
52
|
+
const newKey = `spec:${name}`
|
|
53
|
+
if (newKey !== id) {
|
|
54
|
+
await storage.delete(id)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const doc = { _id: newKey, name, url, auth: auth || "none" }
|
|
58
|
+
await storage.set(newKey, doc)
|
|
59
|
+
await bumpVersion()
|
|
60
|
+
res.json({ ok: true })
|
|
61
|
+
} catch (err) {
|
|
62
|
+
res.status(500).json({ error: err.message })
|
|
63
|
+
}
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
// DELETE /api/specs/:id
|
|
67
|
+
router.delete("/:id", async (req, res) => {
|
|
68
|
+
try {
|
|
69
|
+
const storage = getStorage()
|
|
70
|
+
const id = decodeURIComponent(req.params.id)
|
|
71
|
+
await storage.delete(id)
|
|
72
|
+
await bumpVersion()
|
|
73
|
+
res.json({ ok: true })
|
|
74
|
+
} catch (err) {
|
|
75
|
+
res.status(500).json({ error: err.message })
|
|
76
|
+
}
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
module.exports = router
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
const { getStorage } = require("../storage/adapter")
|
|
2
|
+
|
|
3
|
+
async function getCLIConfig() {
|
|
4
|
+
const storage = getStorage()
|
|
5
|
+
const keys = await storage.listKeys("command:")
|
|
6
|
+
const commands = await Promise.all(keys.map(k => storage.get(k)))
|
|
7
|
+
const mcpKeys = await storage.listKeys("mcp:")
|
|
8
|
+
const mcpServers = await Promise.all(mcpKeys.map(k => storage.get(k)))
|
|
9
|
+
const specKeys = await storage.listKeys("spec:")
|
|
10
|
+
const specs = await Promise.all(specKeys.map(k => storage.get(k)))
|
|
11
|
+
const version = await storage.get("settings:config_version")
|
|
12
|
+
|
|
13
|
+
return {
|
|
14
|
+
version: version || "1",
|
|
15
|
+
ttl: 3600,
|
|
16
|
+
mcp_servers: mcpServers
|
|
17
|
+
.filter(Boolean)
|
|
18
|
+
.map(s => ({ name: s.name, url: s.url })),
|
|
19
|
+
specs: specs
|
|
20
|
+
.filter(Boolean)
|
|
21
|
+
.map(s => ({ name: s.name, url: s.url, auth: s.auth || "none" })),
|
|
22
|
+
commands: commands.map(c => ({
|
|
23
|
+
_id: c._id, // this will now be the natural key
|
|
24
|
+
namespace: c.namespace,
|
|
25
|
+
resource: c.resource,
|
|
26
|
+
action: c.action,
|
|
27
|
+
description: c.description || "",
|
|
28
|
+
adapter: c.adapter,
|
|
29
|
+
adapterConfig: c.adapterConfig || {},
|
|
30
|
+
args: c.args || []
|
|
31
|
+
}))
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function bumpVersion() {
|
|
36
|
+
const storage = getStorage()
|
|
37
|
+
const current = await storage.get("settings:config_version")
|
|
38
|
+
const next = String(parseInt(current || "0", 10) + 1)
|
|
39
|
+
await storage.set("settings:config_version", next)
|
|
40
|
+
return next
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function getNamespaces() {
|
|
44
|
+
const storage = getStorage()
|
|
45
|
+
const keys = await storage.listKeys("command:")
|
|
46
|
+
const namespaces = new Set()
|
|
47
|
+
for (const k of keys) {
|
|
48
|
+
const parts = k.replace("command:", "").split(".")
|
|
49
|
+
namespaces.add(parts[0])
|
|
50
|
+
}
|
|
51
|
+
return Array.from(namespaces)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function getResources(namespace) {
|
|
55
|
+
const storage = getStorage()
|
|
56
|
+
const keys = await storage.listKeys(`command:${namespace}.`)
|
|
57
|
+
const resources = new Set()
|
|
58
|
+
for (const k of keys) {
|
|
59
|
+
const parts = k.replace("command:", "").split(".")
|
|
60
|
+
resources.add(parts[1])
|
|
61
|
+
}
|
|
62
|
+
return Array.from(resources)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function getActions(namespace, resource) {
|
|
66
|
+
const storage = getStorage()
|
|
67
|
+
const keys = await storage.listKeys(`command:${namespace}.${resource}.`)
|
|
68
|
+
const actions = new Set()
|
|
69
|
+
for (const k of keys) {
|
|
70
|
+
const parts = k.replace("command:", "").split(".")
|
|
71
|
+
actions.add(parts[2])
|
|
72
|
+
}
|
|
73
|
+
return Array.from(actions)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function getCommand(namespace, resource, action) {
|
|
77
|
+
const storage = getStorage()
|
|
78
|
+
return storage.get(`command:${namespace}.${resource}.${action}`)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
module.exports = {
|
|
82
|
+
getCLIConfig,
|
|
83
|
+
bumpVersion,
|
|
84
|
+
getNamespaces,
|
|
85
|
+
getResources,
|
|
86
|
+
getActions,
|
|
87
|
+
getCommand
|
|
88
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Storage Adapter Factory
|
|
3
|
+
* Provides a unified KV store interface (get, set, delete, listKeys)
|
|
4
|
+
* driven by either MongoDB or local JSON files.
|
|
5
|
+
*/
|
|
6
|
+
const MongoAdapter = require("./mongo");
|
|
7
|
+
const FileAdapter = require("./file");
|
|
8
|
+
|
|
9
|
+
// Singleton storage instance
|
|
10
|
+
let currentAdapter = null;
|
|
11
|
+
|
|
12
|
+
function getStorage() {
|
|
13
|
+
if (currentAdapter) return currentAdapter;
|
|
14
|
+
|
|
15
|
+
const useMongo = process.env.SUPERCLI_USE_MONGO === "true";
|
|
16
|
+
if (useMongo) {
|
|
17
|
+
const mongoUrl = process.env.MONGO_URL || "mongodb://localhost:27017";
|
|
18
|
+
const dbName = process.env.SUPERCLI_DB || "supercli";
|
|
19
|
+
currentAdapter = new MongoAdapter(mongoUrl, dbName);
|
|
20
|
+
console.log(
|
|
21
|
+
`[Storage] Initialized MongoDB adapter (${mongoUrl}/${dbName})`,
|
|
22
|
+
);
|
|
23
|
+
} else {
|
|
24
|
+
const storageDir = process.env.SUPERCLI_STORAGE_DIR || "./supercli_storage";
|
|
25
|
+
currentAdapter = new FileAdapter(storageDir);
|
|
26
|
+
console.log(`[Storage] Initialized File adapter (${storageDir})`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return currentAdapter;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
module.exports = { getStorage };
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FileAdapter implementation for SUPERCLI Storage using JSON files
|
|
3
|
+
*/
|
|
4
|
+
const fs = require("fs").promises;
|
|
5
|
+
const path = require("path");
|
|
6
|
+
|
|
7
|
+
class FileAdapter {
|
|
8
|
+
constructor(baseDir = "./supercli_storage") {
|
|
9
|
+
this.baseDir = path.resolve(baseDir);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
filePath(key) {
|
|
13
|
+
// Basic sanitization to avoid directory traversal inside the storage scope
|
|
14
|
+
const safeKey = key.replace(/[^a-zA-Z0-9_.-]/g, "_");
|
|
15
|
+
return path.join(this.baseDir, `${safeKey}.json`);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async get(key) {
|
|
19
|
+
try {
|
|
20
|
+
const data = await fs.readFile(this.filePath(key), "utf-8");
|
|
21
|
+
return JSON.parse(data);
|
|
22
|
+
} catch (e) {
|
|
23
|
+
if (e.code === "ENOENT") return null;
|
|
24
|
+
throw e;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async set(key, value) {
|
|
29
|
+
await fs.mkdir(this.baseDir, { recursive: true });
|
|
30
|
+
await fs.writeFile(
|
|
31
|
+
this.filePath(key),
|
|
32
|
+
JSON.stringify(value, null, 2),
|
|
33
|
+
"utf-8",
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async delete(key) {
|
|
38
|
+
try {
|
|
39
|
+
await fs.unlink(this.filePath(key));
|
|
40
|
+
} catch (e) {
|
|
41
|
+
if (e.code !== "ENOENT") throw e;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async listKeys(prefix = "") {
|
|
46
|
+
try {
|
|
47
|
+
const files = await fs.readdir(this.baseDir);
|
|
48
|
+
// Reverse the sanitization mapping in listKeys if we can,
|
|
49
|
+
// but for prefix search, we just look at files matching the sanitized prefix.
|
|
50
|
+
const safePrefix = prefix.replace(/[^a-zA-Z0-9_.-]/g, "_");
|
|
51
|
+
return (
|
|
52
|
+
files
|
|
53
|
+
.filter((f) => f.endsWith(".json") && f.startsWith(safePrefix))
|
|
54
|
+
// Here we just strip .json. Actual key might be slightly different if it had chars we sanitized out
|
|
55
|
+
.map((f) => f.replace(/\.json$/, ""))
|
|
56
|
+
);
|
|
57
|
+
} catch (e) {
|
|
58
|
+
if (e.code === "ENOENT") return [];
|
|
59
|
+
throw e;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
module.exports = FileAdapter;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MongoAdapter implementation for SUPERCLI Storage
|
|
3
|
+
*/
|
|
4
|
+
const { MongoClient } = require("mongodb");
|
|
5
|
+
|
|
6
|
+
class MongoAdapter {
|
|
7
|
+
constructor(uri, dbName = "supercli", collectionName = "storage") {
|
|
8
|
+
this.client = new MongoClient(uri);
|
|
9
|
+
this.dbName = dbName;
|
|
10
|
+
this.collectionName = collectionName;
|
|
11
|
+
this.connected = false;
|
|
12
|
+
this.collection = null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async connect() {
|
|
16
|
+
if (!this.connected) {
|
|
17
|
+
await this.client.connect();
|
|
18
|
+
this.collection = this.client
|
|
19
|
+
.db(this.dbName)
|
|
20
|
+
.collection(this.collectionName);
|
|
21
|
+
this.connected = true;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async get(key) {
|
|
26
|
+
await this.connect();
|
|
27
|
+
const doc = await this.collection.findOne({ _id: key });
|
|
28
|
+
return doc ? doc.value : null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async set(key, value) {
|
|
32
|
+
await this.connect();
|
|
33
|
+
await this.collection.updateOne(
|
|
34
|
+
{ _id: key },
|
|
35
|
+
{ $set: { value } },
|
|
36
|
+
{ upsert: true },
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async delete(key) {
|
|
41
|
+
await this.connect();
|
|
42
|
+
await this.collection.deleteOne({ _id: key });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async listKeys(prefix = "") {
|
|
46
|
+
await this.connect();
|
|
47
|
+
const query = prefix ? { _id: { $regex: `^${prefix}` } } : {};
|
|
48
|
+
const docs = await this.collection
|
|
49
|
+
.find(query, { projection: { _id: 1 } })
|
|
50
|
+
.toArray();
|
|
51
|
+
return docs.map((doc) => doc._id);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
module.exports = MongoAdapter;
|