@cloudgrid-io/mcp 0.4.7 → 0.5.1
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/package.json +5 -2
- package/src/corpus/cli-reference.md +148 -0
- package/src/corpus/cookbook.md +89 -0
- package/src/corpus/install-for-agents.md +63 -0
- package/src/corpus/install.md +117 -0
- package/src/corpus/skill-brain.md +49 -0
- package/src/corpus/skill-claim.md +53 -0
- package/src/corpus/skill-drop.md +111 -0
- package/src/corpus/skill-feedback.md +49 -0
- package/src/corpus/skill-init.md +72 -0
- package/src/corpus/skill-login.md +86 -0
- package/src/corpus/skill-logs.md +52 -0
- package/src/corpus/skill-plug.md +57 -0
- package/src/corpus/skill-share.md +55 -0
- package/src/corpus/skills-readme.md +92 -0
- package/src/corpus/usage.md +138 -0
- package/src/docs-search.js +176 -0
- package/src/docs.js +135 -0
- package/src/tools.js +60 -19
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// BM25 keyword search over the CloudGrid documentation corpus.
|
|
2
|
+
//
|
|
3
|
+
// Chunks markdown files by heading, builds an inverted index, and ranks by
|
|
4
|
+
// BM25. The DocsSearch class exposes a clean .search(query) → results interface
|
|
5
|
+
// so the backend can be swapped to semantic/embedding search later without
|
|
6
|
+
// changing the tool contract.
|
|
7
|
+
|
|
8
|
+
import { readFileSync, readdirSync } from "node:fs";
|
|
9
|
+
|
|
10
|
+
const STOPWORDS = new Set([
|
|
11
|
+
"a", "an", "the", "and", "or", "but", "in", "on", "at", "to", "for",
|
|
12
|
+
"of", "with", "by", "from", "is", "it", "as", "be", "was", "are",
|
|
13
|
+
"this", "that", "not", "if", "do", "so", "no", "can", "will", "all",
|
|
14
|
+
"has", "had", "have", "each", "which", "their", "you", "your", "its",
|
|
15
|
+
"any", "more", "also", "than", "then", "what", "when", "how", "who",
|
|
16
|
+
"up", "out", "about", "into", "over", "just", "some", "only",
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
function tokenize(text) {
|
|
20
|
+
return text
|
|
21
|
+
.toLowerCase()
|
|
22
|
+
.replace(/[^a-z0-9]+/g, " ")
|
|
23
|
+
.split(/\s+/)
|
|
24
|
+
.filter((t) => t.length > 1 && !STOPWORDS.has(t));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function termFrequency(tokens) {
|
|
28
|
+
const tf = Object.create(null);
|
|
29
|
+
for (const t of tokens) tf[t] = (tf[t] || 0) + 1;
|
|
30
|
+
return tf;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Trim a markdown body to a readable snippet. */
|
|
34
|
+
function makeSnippet(body, maxLen = 300) {
|
|
35
|
+
const clean = body
|
|
36
|
+
.replace(/```[\s\S]*?```/g, " ")
|
|
37
|
+
.replace(/\n+/g, " ")
|
|
38
|
+
.replace(/\s+/g, " ")
|
|
39
|
+
.trim();
|
|
40
|
+
return clean.length <= maxLen ? clean : clean.slice(0, maxLen - 1) + "\u2026";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Split a markdown document into chunks by headings (# or ##). */
|
|
44
|
+
function chunkMarkdown(source, content) {
|
|
45
|
+
const lines = content.split("\n");
|
|
46
|
+
const chunks = [];
|
|
47
|
+
let title = source;
|
|
48
|
+
let body = [];
|
|
49
|
+
let inFrontmatter = false;
|
|
50
|
+
let seenFirstLine = false;
|
|
51
|
+
|
|
52
|
+
for (const line of lines) {
|
|
53
|
+
// Skip YAML frontmatter (--- delimited block at the top).
|
|
54
|
+
if (line.trim() === "---" && !seenFirstLine) {
|
|
55
|
+
inFrontmatter = true;
|
|
56
|
+
seenFirstLine = true;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (inFrontmatter) {
|
|
60
|
+
if (line.trim() === "---") inFrontmatter = false;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
seenFirstLine = true;
|
|
64
|
+
|
|
65
|
+
const heading = line.match(/^(#{1,3})\s+(.+)/);
|
|
66
|
+
if (heading) {
|
|
67
|
+
if (body.length > 0) {
|
|
68
|
+
const text = body.join("\n").trim();
|
|
69
|
+
if (text) chunks.push({ title, body: text, source });
|
|
70
|
+
}
|
|
71
|
+
body = [];
|
|
72
|
+
title = heading[2].trim();
|
|
73
|
+
} else {
|
|
74
|
+
body.push(line);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const text = body.join("\n").trim();
|
|
78
|
+
if (text) chunks.push({ title, body: text, source });
|
|
79
|
+
|
|
80
|
+
return chunks;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export class DocsSearch {
|
|
84
|
+
constructor() {
|
|
85
|
+
/** @type {Array<{title: string, body: string, source: string, tokens: string[], tf: Record<string,number>}>} */
|
|
86
|
+
this.docs = [];
|
|
87
|
+
this.df = Object.create(null);
|
|
88
|
+
this.avgdl = 0;
|
|
89
|
+
this._built = false;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
get size() {
|
|
93
|
+
return this.docs.length;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Add a single document chunk to the index. */
|
|
97
|
+
addDocument(title, body, source) {
|
|
98
|
+
// Weight the title 2x by repeating it in the token stream.
|
|
99
|
+
const tokens = tokenize(title + " " + title + " " + body);
|
|
100
|
+
this.docs.push({ title, body, source, tokens, tf: termFrequency(tokens) });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Compute document frequencies and average document length. Call after all adds. */
|
|
104
|
+
build() {
|
|
105
|
+
const N = this.docs.length;
|
|
106
|
+
if (N === 0) return;
|
|
107
|
+
this.avgdl = this.docs.reduce((sum, d) => sum + d.tokens.length, 0) / N;
|
|
108
|
+
this.df = Object.create(null);
|
|
109
|
+
for (const doc of this.docs) {
|
|
110
|
+
const seen = new Set(doc.tokens);
|
|
111
|
+
for (const t of seen) this.df[t] = (this.df[t] || 0) + 1;
|
|
112
|
+
}
|
|
113
|
+
this._built = true;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Search the corpus. Returns the top-K results ranked by BM25.
|
|
118
|
+
* @param {string} query
|
|
119
|
+
* @param {number} topK
|
|
120
|
+
* @returns {Array<{title: string, snippet: string, source: string, score: number}>}
|
|
121
|
+
*/
|
|
122
|
+
search(query, topK = 5) {
|
|
123
|
+
if (!this._built) this.build();
|
|
124
|
+
const qTokens = tokenize(query);
|
|
125
|
+
if (qTokens.length === 0) return [];
|
|
126
|
+
|
|
127
|
+
const N = this.docs.length;
|
|
128
|
+
const k1 = 1.5;
|
|
129
|
+
const b = 0.75;
|
|
130
|
+
|
|
131
|
+
const scored = this.docs.map((doc) => {
|
|
132
|
+
let score = 0;
|
|
133
|
+
for (const qt of qTokens) {
|
|
134
|
+
const tf = doc.tf[qt] || 0;
|
|
135
|
+
const df = this.df[qt] || 0;
|
|
136
|
+
if (df === 0 || tf === 0) continue;
|
|
137
|
+
const idf = Math.log((N - df + 0.5) / (df + 0.5) + 1);
|
|
138
|
+
score +=
|
|
139
|
+
idf * ((tf * (k1 + 1)) / (tf + k1 * (1 - b + (b * doc.tokens.length) / this.avgdl)));
|
|
140
|
+
}
|
|
141
|
+
return { doc, score };
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
return scored
|
|
145
|
+
.filter((s) => s.score > 0)
|
|
146
|
+
.sort((a, b) => b.score - a.score)
|
|
147
|
+
.slice(0, topK)
|
|
148
|
+
.map(({ doc, score }) => ({
|
|
149
|
+
title: doc.title,
|
|
150
|
+
snippet: makeSnippet(doc.body),
|
|
151
|
+
source: doc.source,
|
|
152
|
+
score: Math.round(score * 100) / 100,
|
|
153
|
+
}));
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Load all .md files from src/corpus/, chunk by heading, and build the index.
|
|
159
|
+
* @param {DocsSearch} searchIndex
|
|
160
|
+
* @returns {DocsSearch}
|
|
161
|
+
*/
|
|
162
|
+
export function loadCorpus(searchIndex) {
|
|
163
|
+
const corpusDir = new URL("./corpus/", import.meta.url);
|
|
164
|
+
const files = readdirSync(corpusDir).filter((f) => f.endsWith(".md")).sort();
|
|
165
|
+
|
|
166
|
+
for (const file of files) {
|
|
167
|
+
const content = readFileSync(new URL(file, corpusDir), "utf-8");
|
|
168
|
+
const source = file.replace(/\.md$/, "");
|
|
169
|
+
for (const chunk of chunkMarkdown(source, content)) {
|
|
170
|
+
searchIndex.addDocument(chunk.title, chunk.body, chunk.source);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
searchIndex.build();
|
|
175
|
+
return searchIndex;
|
|
176
|
+
}
|
package/src/docs.js
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// CloudGrid MCP server — docs edition (public, read-only, no auth).
|
|
2
|
+
//
|
|
3
|
+
// A public documentation search server exposing search_cloudgrid_documentation
|
|
4
|
+
// (and an optional cloudgrid_quickstart_guide) over MCP Streamable HTTP. The
|
|
5
|
+
// same Express + StreamableHTTP pattern as the web edition (src/web.js),
|
|
6
|
+
// stripped to the minimum: no auth, no OAuth, no session identity, no secrets.
|
|
7
|
+
// Safe to expose anonymously.
|
|
8
|
+
//
|
|
9
|
+
// The corpus (src/corpus/*.md) is indexed at startup with BM25 keyword search.
|
|
10
|
+
// The search backend is behind a clean interface so it can be swapped to
|
|
11
|
+
// semantic/embedding search later without changing the tool contract.
|
|
12
|
+
//
|
|
13
|
+
// Run: PORT=8080 node src/docs.js Health: GET /healthz
|
|
14
|
+
|
|
15
|
+
import { readFileSync } from "node:fs";
|
|
16
|
+
import { randomUUID } from "node:crypto";
|
|
17
|
+
import express from "express";
|
|
18
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
19
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
20
|
+
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
|
21
|
+
import { z } from "zod";
|
|
22
|
+
import { DocsSearch, loadCorpus } from "./docs-search.js";
|
|
23
|
+
|
|
24
|
+
const { version } = JSON.parse(readFileSync(new URL("../package.json", import.meta.url)));
|
|
25
|
+
|
|
26
|
+
const PORT = Number(process.env.PORT || 8080);
|
|
27
|
+
|
|
28
|
+
// Build the search index once at startup.
|
|
29
|
+
const search = loadCorpus(new DocsSearch());
|
|
30
|
+
|
|
31
|
+
// Load the quickstart guide verbatim.
|
|
32
|
+
const QUICKSTART = readFileSync(new URL("./corpus/cookbook.md", import.meta.url), "utf-8");
|
|
33
|
+
|
|
34
|
+
// ── Tool helpers (same convention as tools.js) ────────────────────────────────
|
|
35
|
+
function ok(text) {
|
|
36
|
+
return { content: [{ type: "text", text }] };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function createDocsServer() {
|
|
40
|
+
const server = new McpServer({ name: "cloudgrid-docs", version });
|
|
41
|
+
|
|
42
|
+
server.tool(
|
|
43
|
+
"search_cloudgrid_documentation",
|
|
44
|
+
"Search CloudGrid documentation — guides, tutorials, CLI reference, MCP setup, and skill descriptions. Returns the most relevant doc chunks with title, snippet, and source path.",
|
|
45
|
+
{
|
|
46
|
+
query: z
|
|
47
|
+
.string()
|
|
48
|
+
.describe(
|
|
49
|
+
"The search query, e.g. 'deploy a database app' or 'add the MCP to Cursor'",
|
|
50
|
+
),
|
|
51
|
+
},
|
|
52
|
+
async ({ query }) => {
|
|
53
|
+
const results = search.search(query, 5);
|
|
54
|
+
if (results.length === 0) {
|
|
55
|
+
return ok("No documentation matched your query. Try different keywords.");
|
|
56
|
+
}
|
|
57
|
+
const text = results
|
|
58
|
+
.map(
|
|
59
|
+
(r, i) => `### ${i + 1}. ${r.title}\n${r.snippet}\n_Source: ${r.source}_`,
|
|
60
|
+
)
|
|
61
|
+
.join("\n\n---\n\n");
|
|
62
|
+
return ok(text);
|
|
63
|
+
},
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
server.tool(
|
|
67
|
+
"cloudgrid_quickstart_guide",
|
|
68
|
+
"Get the CloudGrid quickstart guide — the canonical build-and-ship loop from scaffold to deploy to feedback.",
|
|
69
|
+
{},
|
|
70
|
+
async () => ok(QUICKSTART),
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
return server;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ── HTTP server (mirrors src/web.js, no auth) ─────────────────────────────────
|
|
77
|
+
const app = express();
|
|
78
|
+
app.use(express.json());
|
|
79
|
+
|
|
80
|
+
app.get("/healthz", (_req, res) => res.json({ ok: true, edition: "docs" }));
|
|
81
|
+
|
|
82
|
+
const transports = Object.create(null);
|
|
83
|
+
|
|
84
|
+
app.post("/mcp", async (req, res) => {
|
|
85
|
+
const sessionId = req.headers["mcp-session-id"];
|
|
86
|
+
let transport = sessionId ? transports[sessionId] : undefined;
|
|
87
|
+
|
|
88
|
+
if (transport) {
|
|
89
|
+
await transport.handleRequest(req, res, req.body);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (sessionId || !isInitializeRequest(req.body)) {
|
|
94
|
+
res.status(400).json({
|
|
95
|
+
jsonrpc: "2.0",
|
|
96
|
+
error: { code: -32000, message: "No valid session. Send an initialize request first." },
|
|
97
|
+
id: null,
|
|
98
|
+
});
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const newSessionId = randomUUID();
|
|
103
|
+
transport = new StreamableHTTPServerTransport({
|
|
104
|
+
sessionIdGenerator: () => newSessionId,
|
|
105
|
+
onsessioninitialized: (sid) => {
|
|
106
|
+
transports[sid] = transport;
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
transport.onclose = () => {
|
|
110
|
+
if (transport.sessionId) delete transports[transport.sessionId];
|
|
111
|
+
};
|
|
112
|
+
const server = createDocsServer();
|
|
113
|
+
await server.connect(transport);
|
|
114
|
+
await transport.handleRequest(req, res, req.body);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
async function handleSessionRequest(req, res) {
|
|
118
|
+
const sessionId = req.headers["mcp-session-id"];
|
|
119
|
+
const transport = sessionId ? transports[sessionId] : undefined;
|
|
120
|
+
if (!transport) {
|
|
121
|
+
res.status(400).send("Invalid or missing session id");
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
await transport.handleRequest(req, res);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
app.get("/mcp", handleSessionRequest);
|
|
128
|
+
app.delete("/mcp", handleSessionRequest);
|
|
129
|
+
|
|
130
|
+
app.listen(PORT, () => {
|
|
131
|
+
// eslint-disable-next-line no-console
|
|
132
|
+
console.error(
|
|
133
|
+
`cloudgrid-docs edition listening on :${PORT} (POST /mcp, GET /healthz) — ${search.size} chunks indexed`,
|
|
134
|
+
);
|
|
135
|
+
});
|
package/src/tools.js
CHANGED
|
@@ -9,7 +9,6 @@
|
|
|
9
9
|
// The difference is injected as a `ctx` object, so the tool logic is written once.
|
|
10
10
|
|
|
11
11
|
import { execFile } from "node:child_process";
|
|
12
|
-
import { createRequire } from "node:module";
|
|
13
12
|
import { promisify } from "node:util";
|
|
14
13
|
import { readFile } from "node:fs/promises";
|
|
15
14
|
import { readFileSync, existsSync, statSync } from "node:fs";
|
|
@@ -75,24 +74,42 @@ function resolveCwd(cwd) {
|
|
|
75
74
|
return abs;
|
|
76
75
|
}
|
|
77
76
|
|
|
78
|
-
//
|
|
79
|
-
|
|
77
|
+
// Pin the CLI version for the lazy npx fallback so behaviour is reproducible.
|
|
78
|
+
const CLI_NPX_PKG = "@cloudgrid-io/cli@^0.9.20";
|
|
79
|
+
|
|
80
|
+
// Minimum CLI version that supports the flags the MCP passes (--auto etc.).
|
|
81
|
+
const MIN_CLI_VERSION = "0.9.20";
|
|
82
|
+
|
|
83
|
+
// Simple semver comparison: true when `version` >= MIN_CLI_VERSION.
|
|
84
|
+
function meetsMinVersion(version) {
|
|
85
|
+
if (!version) return false;
|
|
86
|
+
const parse = (s) => s.replace(/^v/, "").split(".").map((p) => parseInt(p, 10) || 0);
|
|
87
|
+
const v = parse(version);
|
|
88
|
+
const m = parse(MIN_CLI_VERSION);
|
|
89
|
+
for (let i = 0; i < 3; i++) {
|
|
90
|
+
if ((v[i] || 0) > (m[i] || 0)) return true;
|
|
91
|
+
if ((v[i] || 0) < (m[i] || 0)) return false;
|
|
92
|
+
}
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Resolve the bundled CLI from this package's OWN node_modules only — never
|
|
97
|
+
// walk up to parent directories (which may contain stale CLI versions).
|
|
98
|
+
// Returns { entry, version } or null.
|
|
80
99
|
function resolveBundledCli() {
|
|
81
100
|
try {
|
|
82
|
-
const
|
|
83
|
-
const pkgPath =
|
|
101
|
+
const srcDir = dirname(new URL(import.meta.url).pathname);
|
|
102
|
+
const pkgPath = join(srcDir, "..", "node_modules", "@cloudgrid-io", "cli", "package.json");
|
|
103
|
+
if (!existsSync(pkgPath)) return null;
|
|
84
104
|
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
|
85
105
|
const bin = pkg.bin && pkg.bin.cloudgrid;
|
|
86
106
|
if (!bin) return null;
|
|
87
|
-
return join(dirname(pkgPath), bin);
|
|
107
|
+
return { entry: join(dirname(pkgPath), bin), version: pkg.version || null };
|
|
88
108
|
} catch {
|
|
89
109
|
return null;
|
|
90
110
|
}
|
|
91
111
|
}
|
|
92
112
|
|
|
93
|
-
// Pin the CLI version for the lazy npx fallback so behaviour is reproducible.
|
|
94
|
-
const CLI_NPX_PKG = "@cloudgrid-io/cli@^0.9.20";
|
|
95
|
-
|
|
96
113
|
async function runCloudgrid(args, opts = {}) {
|
|
97
114
|
const cwd = resolveCwd(opts.cwd);
|
|
98
115
|
const execOpts = {
|
|
@@ -110,28 +127,52 @@ async function runCloudgrid(args, opts = {}) {
|
|
|
110
127
|
return new Error(detail || "cloudgrid command failed");
|
|
111
128
|
};
|
|
112
129
|
|
|
113
|
-
// 1. Bundled CLI
|
|
130
|
+
// 1. Bundled CLI — own node_modules only, version-gated
|
|
114
131
|
const bundled = resolveBundledCli();
|
|
115
|
-
if (bundled) {
|
|
132
|
+
if (bundled && meetsMinVersion(bundled.version)) {
|
|
116
133
|
try {
|
|
117
134
|
const { stdout, stderr } = await execFileAsync(
|
|
118
135
|
process.execPath,
|
|
119
|
-
[bundled, ...args],
|
|
136
|
+
[bundled.entry, ...args],
|
|
120
137
|
execOpts,
|
|
121
138
|
);
|
|
122
139
|
return (stdout || stderr || "").trim() || "Done.";
|
|
123
140
|
} catch (err) {
|
|
124
141
|
throw extract(err);
|
|
125
142
|
}
|
|
143
|
+
} else if (bundled) {
|
|
144
|
+
console.error(
|
|
145
|
+
`cloudgrid-mcp: bundled CLI ${bundled.version} < ${MIN_CLI_VERSION}, skipping`,
|
|
146
|
+
);
|
|
126
147
|
}
|
|
127
148
|
|
|
128
|
-
// 2. Global `cloudgrid` on PATH
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
149
|
+
// 2. Global `cloudgrid` on PATH — version-gated
|
|
150
|
+
{
|
|
151
|
+
let useGlobal = false;
|
|
152
|
+
try {
|
|
153
|
+
const { stdout: vOut } = await execFileAsync("cloudgrid", ["--version"], {
|
|
154
|
+
timeout: 5_000,
|
|
155
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
156
|
+
});
|
|
157
|
+
const ver = vOut.trim().match(/(\d+\.\d+\.\d+)/)?.[1];
|
|
158
|
+
if (meetsMinVersion(ver)) {
|
|
159
|
+
useGlobal = true;
|
|
160
|
+
} else {
|
|
161
|
+
console.error(
|
|
162
|
+
`cloudgrid-mcp: global CLI ${ver || vOut.trim()} < ${MIN_CLI_VERSION}, skipping`,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
} catch {
|
|
166
|
+
// Not on PATH or --version failed — skip
|
|
167
|
+
}
|
|
168
|
+
if (useGlobal) {
|
|
169
|
+
try {
|
|
170
|
+
const { stdout, stderr } = await execFileAsync("cloudgrid", args, execOpts);
|
|
171
|
+
return (stdout || stderr || "").trim() || "Done.";
|
|
172
|
+
} catch (err) {
|
|
173
|
+
throw extract(err);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
135
176
|
}
|
|
136
177
|
|
|
137
178
|
// 3. Lazy fetch: npx -y @cloudgrid-io/cli@<pinned> <args>
|