@josephyan/qingflow-app-builder-mcp 1.1.1 → 1.1.3
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/README.md +2 -2
- package/npm/bin/qingflow-app-builder-mcp.mjs +3 -1
- package/npm/lib/runtime.mjs +343 -13
- package/npm/scripts/postinstall.mjs +7 -2
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/skills/qingflow-app-builder/SKILL.md +1 -0
- package/skills/qingflow-app-builder/references/tool-selection.md +1 -1
- package/src/qingflow_mcp/builder_facade/service.py +24 -0
- package/src/qingflow_mcp/cli/commands/builder.py +32 -1
- package/src/qingflow_mcp/cli/main.py +41 -0
- package/src/qingflow_mcp/tools/ai_builder_tools.py +37 -1
- package/src/qingflow_mcp/version.py +110 -0
package/README.md
CHANGED
|
@@ -3,13 +3,13 @@
|
|
|
3
3
|
Install:
|
|
4
4
|
|
|
5
5
|
```bash
|
|
6
|
-
npm install @josephyan/qingflow-app-builder-mcp@1.1.
|
|
6
|
+
npm install @josephyan/qingflow-app-builder-mcp@1.1.3
|
|
7
7
|
```
|
|
8
8
|
|
|
9
9
|
Run:
|
|
10
10
|
|
|
11
11
|
```bash
|
|
12
|
-
npx -y -p @josephyan/qingflow-app-builder-mcp@1.1.
|
|
12
|
+
npx -y -p @josephyan/qingflow-app-builder-mcp@1.1.3 qingflow-app-builder-mcp
|
|
13
13
|
```
|
|
14
14
|
|
|
15
15
|
Environment:
|
|
@@ -12,7 +12,9 @@ const PKG_BY_BIN = {
|
|
|
12
12
|
function resolvePackageModule(metaUrl, ...segments) {
|
|
13
13
|
const scriptPath = fileURLToPath(metaUrl);
|
|
14
14
|
const scriptDir = path.dirname(scriptPath);
|
|
15
|
-
|
|
15
|
+
// bin files live in <pkg>/npm/bin/, runtime.mjs lives in the sibling <pkg>/npm/lib/,
|
|
16
|
+
// so step up one level before joining the requested segments.
|
|
17
|
+
const direct = path.join(scriptDir, "..", ...segments);
|
|
16
18
|
if (fs.existsSync(direct)) {
|
|
17
19
|
return pathToFileURL(direct).href;
|
|
18
20
|
}
|
package/npm/lib/runtime.mjs
CHANGED
|
@@ -60,29 +60,359 @@ export function getCodexHome() {
|
|
|
60
60
|
return path.join(home, ".codex");
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
-
|
|
63
|
+
function getHomeDir() {
|
|
64
|
+
const home = process.env.HOME || process.env.USERPROFILE;
|
|
65
|
+
if (!home) {
|
|
66
|
+
throw new Error("Cannot resolve user home because HOME is not set.");
|
|
67
|
+
}
|
|
68
|
+
return home;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function getAgentSkillDest(agent, scope, cwd = process.cwd()) {
|
|
72
|
+
const normalizedAgent = agent.trim().toLowerCase();
|
|
73
|
+
const normalizedScope = scope.trim().toLowerCase();
|
|
74
|
+
const home = getHomeDir();
|
|
75
|
+
if (!["user", "project"].includes(normalizedScope)) {
|
|
76
|
+
throw new Error(`Unsupported skills scope '${scope}'. Expected 'user' or 'project'.`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const projectRoots = {
|
|
80
|
+
codex: [cwd, ".codex", "skills"],
|
|
81
|
+
claude: [cwd, ".claude", "skills"],
|
|
82
|
+
"claude-code": [cwd, ".claude", "skills"],
|
|
83
|
+
cursor: [cwd, ".cursor", "skills"],
|
|
84
|
+
generic: [cwd, ".agents", "skills"],
|
|
85
|
+
};
|
|
86
|
+
const userRoots = {
|
|
87
|
+
codex: [process.env.CODEX_HOME?.trim() || path.join(home, ".codex"), "skills"],
|
|
88
|
+
claude: [home, ".claude", "skills"],
|
|
89
|
+
"claude-code": [home, ".claude", "skills"],
|
|
90
|
+
cursor: [home, ".cursor", "skills"],
|
|
91
|
+
generic: [home, ".agents", "skills"],
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const roots = normalizedScope === "project" ? projectRoots : userRoots;
|
|
95
|
+
const parts = roots[normalizedAgent];
|
|
96
|
+
if (!parts) {
|
|
97
|
+
throw new Error(`Unsupported skills agent '${agent}'. Expected one of: codex, claude, claude-code, cursor, generic.`);
|
|
98
|
+
}
|
|
99
|
+
return path.resolve(...parts);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function listBundledSkills(packageRoot) {
|
|
64
103
|
const skillsSrc = path.join(packageRoot, "skills");
|
|
65
104
|
if (!fs.existsSync(skillsSrc)) {
|
|
66
|
-
return
|
|
105
|
+
return [];
|
|
67
106
|
}
|
|
68
107
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
108
|
+
return fs
|
|
109
|
+
.readdirSync(skillsSrc, { withFileTypes: true })
|
|
110
|
+
.filter((entry) => entry.isDirectory() && fs.existsSync(path.join(skillsSrc, entry.name, "SKILL.md")))
|
|
111
|
+
.map((entry) => entry.name)
|
|
112
|
+
.sort();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function sameRealPath(a, b) {
|
|
116
|
+
try {
|
|
117
|
+
return fs.realpathSync(a) === fs.realpathSync(b);
|
|
118
|
+
} catch {
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
72
122
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
123
|
+
function installOneSkill(src, dest, { force, mode }) {
|
|
124
|
+
let existingStat = null;
|
|
125
|
+
try {
|
|
126
|
+
existingStat = fs.lstatSync(dest);
|
|
127
|
+
} catch (error) {
|
|
128
|
+
if (error.code !== "ENOENT") {
|
|
129
|
+
throw error;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (existingStat) {
|
|
133
|
+
if (existingStat.isSymbolicLink() && sameRealPath(dest, src)) {
|
|
134
|
+
return { status: "unchanged" };
|
|
135
|
+
}
|
|
136
|
+
if (!force) {
|
|
137
|
+
return { status: "conflict" };
|
|
77
138
|
}
|
|
78
|
-
const src = path.join(skillsSrc, entry.name);
|
|
79
|
-
const dest = path.join(skillsDestRoot, entry.name);
|
|
80
139
|
fs.rmSync(dest, { recursive: true, force: true });
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (mode === "copy") {
|
|
81
143
|
fs.cpSync(src, dest, { recursive: true });
|
|
82
|
-
installed
|
|
144
|
+
return { status: "installed" };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
fs.symlinkSync(src, dest, "dir");
|
|
148
|
+
return { status: "installed" };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function writeSkillProvenance(skillsDestRoot, skillName, payload) {
|
|
152
|
+
const provenanceRoot = path.join(skillsDestRoot, ".qingflow-skill-sources");
|
|
153
|
+
fs.mkdirSync(provenanceRoot, { recursive: true });
|
|
154
|
+
fs.writeFileSync(path.join(provenanceRoot, `${skillName}.json`), `${JSON.stringify(payload, null, 2)}\n`);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function sleepMs(ms) {
|
|
158
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function withSkillInstallLock(skillsDestRoot, callback) {
|
|
162
|
+
const lockDir = path.join(skillsDestRoot, ".qingflow-skill-install.lock");
|
|
163
|
+
const startedAt = Date.now();
|
|
164
|
+
while (true) {
|
|
165
|
+
try {
|
|
166
|
+
fs.mkdirSync(lockDir);
|
|
167
|
+
break;
|
|
168
|
+
} catch (error) {
|
|
169
|
+
if (error.code !== "EEXIST") {
|
|
170
|
+
throw error;
|
|
171
|
+
}
|
|
172
|
+
try {
|
|
173
|
+
const stat = fs.statSync(lockDir);
|
|
174
|
+
if (Date.now() - stat.mtimeMs > 300_000) {
|
|
175
|
+
fs.rmSync(lockDir, { recursive: true, force: true });
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
} catch {
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
if (Date.now() - startedAt > 30_000) {
|
|
182
|
+
throw new Error(`Timed out waiting for skill install lock: ${lockDir}`);
|
|
183
|
+
}
|
|
184
|
+
sleepMs(100);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
try {
|
|
188
|
+
return callback();
|
|
189
|
+
} finally {
|
|
190
|
+
fs.rmSync(lockDir, { recursive: true, force: true });
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function installBundledSkills(
|
|
195
|
+
packageRoot,
|
|
196
|
+
{
|
|
197
|
+
agent = "codex",
|
|
198
|
+
scope = "user",
|
|
199
|
+
mode = "symlink",
|
|
200
|
+
force = false,
|
|
201
|
+
skills = [],
|
|
202
|
+
cwd = process.cwd(),
|
|
203
|
+
} = {},
|
|
204
|
+
) {
|
|
205
|
+
const skillsSrc = path.join(packageRoot, "skills");
|
|
206
|
+
if (!fs.existsSync(skillsSrc)) {
|
|
207
|
+
return { installed: [], unchanged: [], conflicts: [], skipped: true, destination: null };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const validModes = new Set(["symlink", "copy"]);
|
|
211
|
+
if (!validModes.has(mode)) {
|
|
212
|
+
throw new Error(`Unsupported skills install mode '${mode}'. Expected 'symlink' or 'copy'.`);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const available = listBundledSkills(packageRoot);
|
|
216
|
+
const wanted = skills.length ? skills : available;
|
|
217
|
+
const unknown = wanted.filter((skillName) => !available.includes(skillName));
|
|
218
|
+
if (unknown.length) {
|
|
219
|
+
throw new Error(`Unknown bundled skill(s): ${unknown.join(", ")}. Available: ${available.join(", ")}`);
|
|
83
220
|
}
|
|
84
221
|
|
|
85
|
-
|
|
222
|
+
const skillsDestRoot = getAgentSkillDest(agent, scope, cwd);
|
|
223
|
+
fs.mkdirSync(skillsDestRoot, { recursive: true });
|
|
224
|
+
|
|
225
|
+
const packageJson = JSON.parse(fs.readFileSync(path.join(packageRoot, "package.json"), "utf8"));
|
|
226
|
+
const result = {
|
|
227
|
+
installed: [],
|
|
228
|
+
unchanged: [],
|
|
229
|
+
conflicts: [],
|
|
230
|
+
skipped: false,
|
|
231
|
+
destination: skillsDestRoot,
|
|
232
|
+
agent,
|
|
233
|
+
scope,
|
|
234
|
+
mode,
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
return withSkillInstallLock(skillsDestRoot, () => {
|
|
238
|
+
for (const skillName of wanted) {
|
|
239
|
+
const src = path.join(skillsSrc, skillName);
|
|
240
|
+
const dest = path.join(skillsDestRoot, skillName);
|
|
241
|
+
const installResult = installOneSkill(src, dest, { force, mode });
|
|
242
|
+
result[installResult.status === "conflict" ? "conflicts" : installResult.status].push(skillName);
|
|
243
|
+
if (installResult.status !== "conflict") {
|
|
244
|
+
writeSkillProvenance(skillsDestRoot, skillName, {
|
|
245
|
+
package: packageJson.name ?? null,
|
|
246
|
+
version: packageJson.version ?? null,
|
|
247
|
+
skill: skillName,
|
|
248
|
+
source: src,
|
|
249
|
+
destination: dest,
|
|
250
|
+
agent,
|
|
251
|
+
scope,
|
|
252
|
+
mode,
|
|
253
|
+
installed_at: new Date().toISOString(),
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return result;
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function parseSkillsCliArgs(args) {
|
|
263
|
+
const parsed = {
|
|
264
|
+
command: "help",
|
|
265
|
+
agent: "codex",
|
|
266
|
+
scope: "user",
|
|
267
|
+
mode: "symlink",
|
|
268
|
+
force: false,
|
|
269
|
+
json: false,
|
|
270
|
+
skills: [],
|
|
271
|
+
};
|
|
272
|
+
const rest = [...args];
|
|
273
|
+
if (rest.length && !rest[0].startsWith("-")) {
|
|
274
|
+
parsed.command = rest.shift();
|
|
275
|
+
}
|
|
276
|
+
for (let index = 0; index < rest.length; index += 1) {
|
|
277
|
+
const arg = rest[index];
|
|
278
|
+
const next = () => {
|
|
279
|
+
index += 1;
|
|
280
|
+
if (index >= rest.length) {
|
|
281
|
+
throw new Error(`Missing value for ${arg}`);
|
|
282
|
+
}
|
|
283
|
+
return rest[index];
|
|
284
|
+
};
|
|
285
|
+
switch (arg) {
|
|
286
|
+
case "--agent":
|
|
287
|
+
case "-a":
|
|
288
|
+
parsed.agent = next();
|
|
289
|
+
break;
|
|
290
|
+
case "--scope":
|
|
291
|
+
case "-s":
|
|
292
|
+
parsed.scope = next();
|
|
293
|
+
break;
|
|
294
|
+
case "--mode":
|
|
295
|
+
case "-m":
|
|
296
|
+
parsed.mode = next();
|
|
297
|
+
break;
|
|
298
|
+
case "--skill":
|
|
299
|
+
parsed.skills.push(next());
|
|
300
|
+
break;
|
|
301
|
+
case "--all":
|
|
302
|
+
parsed.agent = "all";
|
|
303
|
+
break;
|
|
304
|
+
case "--force":
|
|
305
|
+
case "-f":
|
|
306
|
+
parsed.force = true;
|
|
307
|
+
break;
|
|
308
|
+
case "--copy":
|
|
309
|
+
parsed.mode = "copy";
|
|
310
|
+
break;
|
|
311
|
+
case "--json":
|
|
312
|
+
parsed.json = true;
|
|
313
|
+
break;
|
|
314
|
+
case "--help":
|
|
315
|
+
case "-h":
|
|
316
|
+
parsed.command = "help";
|
|
317
|
+
break;
|
|
318
|
+
default:
|
|
319
|
+
if (arg.startsWith("-")) {
|
|
320
|
+
throw new Error(`Unknown option ${arg}`);
|
|
321
|
+
}
|
|
322
|
+
parsed.skills.push(arg);
|
|
323
|
+
break;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
return parsed;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function printSkillsHelp() {
|
|
330
|
+
console.log(`Qingflow bundled skills
|
|
331
|
+
|
|
332
|
+
Usage:
|
|
333
|
+
qingflow-skills list [--json]
|
|
334
|
+
qingflow-skills install [--agent codex|claude|claude-code|cursor|generic|all] [--scope user|project] [--mode symlink|copy] [--skill name] [--force] [--json]
|
|
335
|
+
|
|
336
|
+
Examples:
|
|
337
|
+
qingflow-skills list
|
|
338
|
+
qingflow-skills install --agent codex --scope user
|
|
339
|
+
qingflow-skills install --agent claude-code --scope project --copy
|
|
340
|
+
qingflow-skills install --agent all --scope project
|
|
341
|
+
|
|
342
|
+
Defaults:
|
|
343
|
+
--agent codex
|
|
344
|
+
--scope user
|
|
345
|
+
--mode symlink
|
|
346
|
+
|
|
347
|
+
Existing skills are not overwritten unless --force is provided.`);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function printInstallSummary(result) {
|
|
351
|
+
console.log(`[qingflow-skills] destination: ${result.destination}`);
|
|
352
|
+
if (result.installed.length) {
|
|
353
|
+
console.log(`[qingflow-skills] installed: ${result.installed.join(", ")}`);
|
|
354
|
+
}
|
|
355
|
+
if (result.unchanged.length) {
|
|
356
|
+
console.log(`[qingflow-skills] unchanged: ${result.unchanged.join(", ")}`);
|
|
357
|
+
}
|
|
358
|
+
if (result.conflicts.length) {
|
|
359
|
+
console.log(`[qingflow-skills] conflicts: ${result.conflicts.join(", ")}`);
|
|
360
|
+
console.log("[qingflow-skills] Re-run with --force to replace conflicting skills.");
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
export function runSkillsCli(packageRoot, args) {
|
|
365
|
+
let parsed;
|
|
366
|
+
try {
|
|
367
|
+
parsed = parseSkillsCliArgs(args);
|
|
368
|
+
if (parsed.command === "help") {
|
|
369
|
+
printSkillsHelp();
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
if (parsed.command === "list") {
|
|
373
|
+
const skills = listBundledSkills(packageRoot);
|
|
374
|
+
if (parsed.json) {
|
|
375
|
+
console.log(JSON.stringify({ skills }, null, 2));
|
|
376
|
+
} else {
|
|
377
|
+
for (const skill of skills) {
|
|
378
|
+
console.log(skill);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
if (parsed.command !== "install") {
|
|
384
|
+
throw new Error(`Unknown qingflow skills command '${parsed.command}'.`);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const agents = parsed.agent === "all" ? ["codex", "claude-code", "cursor", "generic"] : [parsed.agent];
|
|
388
|
+
const results = agents.map((agent) =>
|
|
389
|
+
installBundledSkills(packageRoot, {
|
|
390
|
+
agent,
|
|
391
|
+
scope: parsed.scope,
|
|
392
|
+
mode: parsed.mode,
|
|
393
|
+
force: parsed.force,
|
|
394
|
+
skills: parsed.skills,
|
|
395
|
+
}),
|
|
396
|
+
);
|
|
397
|
+
if (parsed.json) {
|
|
398
|
+
console.log(JSON.stringify({ results }, null, 2));
|
|
399
|
+
} else {
|
|
400
|
+
for (const result of results) {
|
|
401
|
+
printInstallSummary(result);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
if (results.some((result) => result.conflicts.length)) {
|
|
405
|
+
process.exit(2);
|
|
406
|
+
}
|
|
407
|
+
} catch (error) {
|
|
408
|
+
if (parsed?.json) {
|
|
409
|
+
console.error(JSON.stringify({ error: error.message }, null, 2));
|
|
410
|
+
} else {
|
|
411
|
+
console.error(`[qingflow-skills] ${error.message}`);
|
|
412
|
+
console.error("Run 'qingflow-skills --help' for usage.");
|
|
413
|
+
}
|
|
414
|
+
process.exit(1);
|
|
415
|
+
}
|
|
86
416
|
}
|
|
87
417
|
|
|
88
418
|
export function getVenvDir(packageRoot) {
|
|
@@ -6,9 +6,14 @@ try {
|
|
|
6
6
|
console.log("[qingflow-mcp] Bootstrapping Python runtime...");
|
|
7
7
|
ensurePythonEnv(packageRoot, { commandName: "qingflow-app-builder-mcp" });
|
|
8
8
|
console.log("[qingflow-mcp] Python runtime is ready.");
|
|
9
|
-
const skills = installBundledSkills(packageRoot);
|
|
9
|
+
const skills = installBundledSkills(packageRoot, { force: true });
|
|
10
10
|
if (!skills.skipped) {
|
|
11
|
-
|
|
11
|
+
const changed = [
|
|
12
|
+
skills.installed.length ? `installed=${skills.installed.join(",")}` : "",
|
|
13
|
+
skills.unchanged.length ? `unchanged=${skills.unchanged.join(",")}` : "",
|
|
14
|
+
skills.conflicts.length ? `conflicts=${skills.conflicts.join(",")}` : "",
|
|
15
|
+
].filter(Boolean).join(" ");
|
|
16
|
+
console.log(`[qingflow-mcp] Installed skills to ${skills.destination}: ${changed || "none"}`);
|
|
12
17
|
}
|
|
13
18
|
} catch (error) {
|
|
14
19
|
console.error(`[qingflow-mcp] postinstall failed: ${error.message}`);
|
package/package.json
CHANGED
package/pyproject.toml
CHANGED
|
@@ -87,6 +87,7 @@ Treat these as the official surface. Do not default to `package_create`, `packag
|
|
|
87
87
|
- advanced bindings may use `button_limit`, `button_formula`, `button_formula_type`, and `print_tpls` only when visibility or print-template behavior is required
|
|
88
88
|
- Associated resources:
|
|
89
89
|
- `app_associated_resources_apply` is the public write path for the Qingflow app-level associated report/view pool and per-view display config
|
|
90
|
+
- for multi-app systems, use `apps[]` to batch associated-resource writes; each item carries its own `app_key` plus `upsert_resources` / `patch_resources` / `view_configs`
|
|
90
91
|
- it attaches existing BI reports/views for in-app display; it does not create or edit QingBI report bodies/configs, including dataset reports
|
|
91
92
|
- use `patch_resources` for changing match rules or other existing associated-resource parameters; the tool preserves backend-required raw fields internally
|
|
92
93
|
- `associated_item_id` is the backend internal associated-resource id; for `view_configs`, `remove_associated_item_ids`, and `reorder_associated_item_ids`, you may pass `associated_item_id` or an existing resource's `chart_id`/`chart_key`/`view_key`, and the tool resolves it to the internal id
|
|
@@ -52,7 +52,7 @@ These execute normalized patches. Some app apply tools publish by default and st
|
|
|
52
52
|
- `app_flow_apply`: replace full workflow spec; use `patch_nodes[]` (`{id, set, unset}`) to update specific nodes without rewriting the full spec
|
|
53
53
|
- `app_views_apply`: use `patch_views` for existing-view parameter replacement; use `upsert_views` for creation or full target config; remove views by key; new views default associated report/view display to visible with `limit_type="all"`; use `apps[]` for multi-app batch
|
|
54
54
|
- `app_custom_buttons_apply`: use `patch_buttons` for existing-button parameter replacement; use `upsert_buttons` for creation or full target config; configure add-data field mappings/default values; bind buttons to header/detail/list view positions; `placement=list` maps to the backend `INSIDE` row/list button position; merge-mode view configs require `buttons`; use `view_configs[].mode="replace"` or `buttons=[]` to clear a view's custom button bindings. For child-record creation linked to the current source record, map `source_field: "数据ID"` to the target relation field. Use `apps[]` for multi-app batch.
|
|
55
|
-
- `app_associated_resources_apply`: attach existing BI reports/views to the Qingflow app associated-resource pool and per-view display area; it does not create or edit QingBI report bodies/configs. Use `patch_resources` for existing associated-resource parameter replacement; use `upsert_resources` for creation or full target config; `view_configs`, remove, and reorder may reference existing resources by internal `associated_item_id` or by `chart_id`/`chart_key`/`view_key`; use `match_mappings` for associated view/report filters; publishes after successful writes; omit raw `sourceType`, and use `report_source="dataset"` only to attach an existing BI dataset report. Before `upsert_resources`, read `app_get.associated_resources` and reuse an existing matching `target_app_key + view_key/chart_key`; repeated upsert can create duplicates because `client_key` is only valid inside one apply call.
|
|
55
|
+
- `app_associated_resources_apply`: attach existing BI reports/views to the Qingflow app associated-resource pool and per-view display area; it does not create or edit QingBI report bodies/configs. Use `patch_resources` for existing associated-resource parameter replacement; use `upsert_resources` for creation or full target config; `view_configs`, remove, and reorder may reference existing resources by internal `associated_item_id` or by `chart_id`/`chart_key`/`view_key`; use `match_mappings` for associated view/report filters; publishes after successful writes; omit raw `sourceType`, and use `report_source="dataset"` only to attach an existing BI dataset report. Before `upsert_resources`, read `app_get.associated_resources` and reuse an existing matching `target_app_key + view_key/chart_key`; repeated upsert can create duplicates because `client_key` is only valid inside one apply call. Use `apps[]` for multi-app batch.
|
|
56
56
|
- `app_charts_apply`: create/edit/remove/reorder app-source QingBI report bodies/configs with `dataSourceType=qingflow`; it does not create/edit dataset BI reports and does not attach reports to Qingflow app associated-resource display. Use `patch_charts` for existing-chart parameter replacement; use `upsert_charts` for creation or full target config; supports `target/table` aliases plus QingBI chart types such as `summary`, `columnar`, `area`, `funnel`, `radar`, `scatter`, `dualaxes`, and `map`; charts are immediate-live and do not publish; use `chart_id` when names are not unique; use `apps[]` for multi-app batch
|
|
57
57
|
- `portal_apply`: create or replace-update portal pages; use `dash_key` for update mode or `package_id + dash_name` for create mode; edit mode may omit `sections` for base-info-only updates; when sections are supplied they still use replace semantics; sections use a 24-column PC grid — all components in the same row (same `y`) must share the same `rows` value and their `cols` must sum to exactly 24; mismatched `rows` causes height misalignment, `cols` under 24 leaves trailing blank space; use `patch_sections[]` (`{chart_ref/view_ref/order, set, unset}`) to update individual sections without replacing all
|
|
58
58
|
|
|
@@ -5486,6 +5486,9 @@ class AiBuilderFacade:
|
|
|
5486
5486
|
result = []
|
|
5487
5487
|
for c in components:
|
|
5488
5488
|
item = {k: v for k, v in c.items() if k != "order"}
|
|
5489
|
+
position = item.get("position")
|
|
5490
|
+
if isinstance(position, dict):
|
|
5491
|
+
item["position"] = _portal_component_position_for_request(position)
|
|
5489
5492
|
result.append(item)
|
|
5490
5493
|
return result
|
|
5491
5494
|
|
|
@@ -14180,6 +14183,27 @@ def _portal_position_payload(position: Any, *, inferred_mobile_y: int = 0) -> di
|
|
|
14180
14183
|
}
|
|
14181
14184
|
|
|
14182
14185
|
|
|
14186
|
+
def _portal_component_position_for_request(position: dict[str, Any]) -> dict[str, Any]:
|
|
14187
|
+
"""Normalize portal_get component positions into PortalSectionPatch input shape."""
|
|
14188
|
+
payload = deepcopy(position)
|
|
14189
|
+
if isinstance(payload.get("pc"), dict) or isinstance(payload.get("mobile"), dict):
|
|
14190
|
+
return payload
|
|
14191
|
+
pc_keys = {"x", "y", "cols", "rows"}
|
|
14192
|
+
if any(key in payload for key in pc_keys):
|
|
14193
|
+
pc = {
|
|
14194
|
+
"x": int(payload.get("x") or 0),
|
|
14195
|
+
"y": int(payload.get("y") or 0),
|
|
14196
|
+
"cols": int(payload.get("cols") or payload.get("w") or 12),
|
|
14197
|
+
"rows": int(payload.get("rows") or payload.get("h") or 8),
|
|
14198
|
+
}
|
|
14199
|
+
normalized: dict[str, Any] = {"pc": pc}
|
|
14200
|
+
mobile = payload.get("mobile")
|
|
14201
|
+
if isinstance(mobile, dict):
|
|
14202
|
+
normalized["mobile"] = mobile
|
|
14203
|
+
return normalized
|
|
14204
|
+
return payload
|
|
14205
|
+
|
|
14206
|
+
|
|
14183
14207
|
def _portal_component_position_public(
|
|
14184
14208
|
source_type: Any,
|
|
14185
14209
|
*,
|
|
@@ -166,12 +166,13 @@ def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) ->
|
|
|
166
166
|
associated_resource_get.set_defaults(handler=_handle_associated_resource_get, format_hint="builder_summary")
|
|
167
167
|
|
|
168
168
|
associated_resource_apply = associated_resource_subparsers.add_parser("apply", help="声明式管理应用关联资源池和视图展示配置")
|
|
169
|
-
associated_resource_apply.add_argument("--app-key",
|
|
169
|
+
associated_resource_apply.add_argument("--app-key", default="")
|
|
170
170
|
associated_resource_apply.add_argument("--upsert-resources-file")
|
|
171
171
|
associated_resource_apply.add_argument("--patch-resources-file")
|
|
172
172
|
associated_resource_apply.add_argument("--remove-associated-item-ids-file")
|
|
173
173
|
associated_resource_apply.add_argument("--reorder-associated-item-ids-file")
|
|
174
174
|
associated_resource_apply.add_argument("--view-configs-file")
|
|
175
|
+
associated_resource_apply.add_argument("--apps-file", help="多应用批量关联资源 JSON 数组,每项含 app_key + upsert_resources/patch_resources/remove_associated_item_ids/reorder_associated_item_ids/view_configs")
|
|
175
176
|
associated_resource_apply.set_defaults(handler=_handle_associated_resource_apply, format_hint="builder_summary", force_json_output=True)
|
|
176
177
|
|
|
177
178
|
portal = builder_subparsers.add_parser("portal", help="门户")
|
|
@@ -456,6 +457,36 @@ def _handle_button_apply(args: argparse.Namespace, context: CliContext) -> dict:
|
|
|
456
457
|
|
|
457
458
|
|
|
458
459
|
def _handle_associated_resource_apply(args: argparse.Namespace, context: CliContext) -> dict:
|
|
460
|
+
apps = load_list_arg(getattr(args, "apps_file", None), option_name="--apps-file")
|
|
461
|
+
if apps:
|
|
462
|
+
single_app_args = [
|
|
463
|
+
a for a in [
|
|
464
|
+
"--app-key" if args.app_key else None,
|
|
465
|
+
"--upsert-resources-file" if getattr(args, "upsert_resources_file", None) else None,
|
|
466
|
+
"--patch-resources-file" if getattr(args, "patch_resources_file", None) else None,
|
|
467
|
+
"--remove-associated-item-ids-file" if getattr(args, "remove_associated_item_ids_file", None) else None,
|
|
468
|
+
"--reorder-associated-item-ids-file" if getattr(args, "reorder_associated_item_ids_file", None) else None,
|
|
469
|
+
"--view-configs-file" if getattr(args, "view_configs_file", None) else None,
|
|
470
|
+
] if a
|
|
471
|
+
]
|
|
472
|
+
if single_app_args:
|
|
473
|
+
raise_config_error(
|
|
474
|
+
f"associated-resource apply --apps-file cannot be combined with {', '.join(single_app_args)}.",
|
|
475
|
+
fix_hint="Use --apps-file for batch mode (each item contains app_key + per-app params), or remove --apps-file for single-app mode.",
|
|
476
|
+
)
|
|
477
|
+
if apps:
|
|
478
|
+
return context.builder.app_associated_resources_apply(
|
|
479
|
+
profile=args.profile,
|
|
480
|
+
app_key="",
|
|
481
|
+
upsert_resources=[],
|
|
482
|
+
patch_resources=[],
|
|
483
|
+
remove_associated_item_ids=[],
|
|
484
|
+
reorder_associated_item_ids=[],
|
|
485
|
+
view_configs=[],
|
|
486
|
+
apps=apps,
|
|
487
|
+
)
|
|
488
|
+
if not args.app_key:
|
|
489
|
+
raise_config_error("associated-resource apply requires --app-key or --apps-file", fix_hint="Pass --app-key APP_KEY for single-app mode, or --apps-file for batch mode.")
|
|
459
490
|
return context.builder.app_associated_resources_apply(
|
|
460
491
|
profile=args.profile,
|
|
461
492
|
app_key=args.app_key,
|
|
@@ -9,6 +9,7 @@ from ..errors import QingflowApiError
|
|
|
9
9
|
from ..public_surface import cli_public_tool_spec_from_namespace
|
|
10
10
|
from ..response_trim import resolve_cli_tool_name, trim_error_response, trim_public_response
|
|
11
11
|
from ..tools.ai_builder_tools import _attach_builder_apply_envelope
|
|
12
|
+
from ..version import get_cli_version, get_cli_version_info
|
|
12
13
|
from .context import CliContext, build_cli_context
|
|
13
14
|
from .formatters import emit_json_result, emit_text_result
|
|
14
15
|
from .commands import register_all_commands
|
|
@@ -34,7 +35,10 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
34
35
|
parser = _QingflowArgumentParser(prog="qingflow", description="Qingflow CLI")
|
|
35
36
|
parser.add_argument("--profile", default="default", help="会话 profile,默认 default")
|
|
36
37
|
parser.add_argument("--json", action="store_true", help="输出 JSON")
|
|
38
|
+
parser.add_argument("--version", action="store_true", help="输出 Qingflow CLI 版本")
|
|
37
39
|
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
40
|
+
version_parser = subparsers.add_parser("version", help="输出 Qingflow CLI 版本")
|
|
41
|
+
version_parser.set_defaults(handler=_handle_version, format_hint="version")
|
|
38
42
|
register_all_commands(subparsers)
|
|
39
43
|
return parser
|
|
40
44
|
|
|
@@ -54,6 +58,8 @@ def run(
|
|
|
54
58
|
err = stderr or sys.stderr
|
|
55
59
|
parser = build_parser()
|
|
56
60
|
normalized_argv = _normalize_global_args(list(argv) if argv is not None else sys.argv[1:])
|
|
61
|
+
if "--version" in normalized_argv:
|
|
62
|
+
return _emit_version(json_mode=_should_force_json_output_argv_for_version(normalized_argv), stdout=out)
|
|
57
63
|
try:
|
|
58
64
|
args = parser.parse_args(normalized_argv)
|
|
59
65
|
except _CliArgumentError as exc:
|
|
@@ -76,6 +82,8 @@ def run(
|
|
|
76
82
|
setattr(args, "_stdin", sys.stdin)
|
|
77
83
|
setattr(args, "_stdout_stream", out)
|
|
78
84
|
setattr(args, "_stderr_stream", err)
|
|
85
|
+
if getattr(args, "command", "") == "version":
|
|
86
|
+
return _emit_version(json_mode=bool(args.json), stdout=out)
|
|
79
87
|
handler = getattr(args, "handler", None)
|
|
80
88
|
if handler is None:
|
|
81
89
|
parser.print_help(out)
|
|
@@ -121,6 +129,10 @@ def _normalize_global_args(argv: list[str]) -> list[str]:
|
|
|
121
129
|
global_args.append(token)
|
|
122
130
|
index += 1
|
|
123
131
|
continue
|
|
132
|
+
if token == "--version":
|
|
133
|
+
global_args.append(token)
|
|
134
|
+
index += 1
|
|
135
|
+
continue
|
|
124
136
|
if token == "--profile":
|
|
125
137
|
global_args.append(token)
|
|
126
138
|
if index + 1 >= len(argv):
|
|
@@ -138,6 +150,35 @@ def _normalize_global_args(argv: list[str]) -> list[str]:
|
|
|
138
150
|
return global_args + remaining
|
|
139
151
|
|
|
140
152
|
|
|
153
|
+
def _handle_version(_args: argparse.Namespace, _context: CliContext) -> dict[str, Any]:
|
|
154
|
+
info = get_cli_version_info()
|
|
155
|
+
return {
|
|
156
|
+
"ok": True,
|
|
157
|
+
"status": "success",
|
|
158
|
+
**info,
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _emit_version(*, json_mode: bool, stdout: TextIO) -> int:
|
|
163
|
+
version = get_cli_version()
|
|
164
|
+
if json_mode:
|
|
165
|
+
emit_json_result(
|
|
166
|
+
{
|
|
167
|
+
"ok": True,
|
|
168
|
+
"status": "success",
|
|
169
|
+
**get_cli_version_info(),
|
|
170
|
+
},
|
|
171
|
+
stream=stdout,
|
|
172
|
+
)
|
|
173
|
+
else:
|
|
174
|
+
stdout.write(f"{version}\n")
|
|
175
|
+
return 0
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _should_force_json_output_argv_for_version(argv: list[str]) -> bool:
|
|
179
|
+
return "--json" in argv
|
|
180
|
+
|
|
181
|
+
|
|
141
182
|
def _should_force_json_output(args: argparse.Namespace) -> bool:
|
|
142
183
|
if bool(getattr(args, "force_json_output", False)):
|
|
143
184
|
return True
|
|
@@ -284,6 +284,7 @@ class AiBuilderTools(ToolBase):
|
|
|
284
284
|
remove_associated_item_ids: list[int] | None = None,
|
|
285
285
|
reorder_associated_item_ids: list[int] | None = None,
|
|
286
286
|
view_configs: list[JSONObject] | None = None,
|
|
287
|
+
apps: list[JSONObject] | None = None,
|
|
287
288
|
) -> JSONObject:
|
|
288
289
|
return self.app_associated_resources_apply(
|
|
289
290
|
profile=profile,
|
|
@@ -293,6 +294,7 @@ class AiBuilderTools(ToolBase):
|
|
|
293
294
|
remove_associated_item_ids=remove_associated_item_ids or [],
|
|
294
295
|
reorder_associated_item_ids=reorder_associated_item_ids or [],
|
|
295
296
|
view_configs=view_configs or [],
|
|
297
|
+
apps=apps,
|
|
296
298
|
)
|
|
297
299
|
|
|
298
300
|
@mcp.tool()
|
|
@@ -1120,8 +1122,24 @@ class AiBuilderTools(ToolBase):
|
|
|
1120
1122
|
reorder_associated_item_ids: list[int],
|
|
1121
1123
|
view_configs: list[JSONObject],
|
|
1122
1124
|
patch_resources: list[JSONObject] | None = None,
|
|
1125
|
+
apps: list[JSONObject] | None = None,
|
|
1123
1126
|
) -> JSONObject:
|
|
1124
1127
|
"""执行应用关联资源 apply 逻辑。"""
|
|
1128
|
+
if apps:
|
|
1129
|
+
return self._facade._batch_write_apps(
|
|
1130
|
+
profile=profile,
|
|
1131
|
+
apps=apps,
|
|
1132
|
+
single_writer=lambda profile, app_key, **kw: self.app_associated_resources_apply(
|
|
1133
|
+
profile=profile,
|
|
1134
|
+
app_key=app_key,
|
|
1135
|
+
upsert_resources=kw.get("upsert_resources", []),
|
|
1136
|
+
patch_resources=kw.get("patch_resources", []),
|
|
1137
|
+
remove_associated_item_ids=kw.get("remove_associated_item_ids", []),
|
|
1138
|
+
reorder_associated_item_ids=kw.get("reorder_associated_item_ids", []),
|
|
1139
|
+
view_configs=kw.get("view_configs", []),
|
|
1140
|
+
),
|
|
1141
|
+
tool_name="app_associated_resources_apply",
|
|
1142
|
+
)
|
|
1125
1143
|
raw_request = {
|
|
1126
1144
|
"app_key": app_key,
|
|
1127
1145
|
"upsert_resources": upsert_resources,
|
|
@@ -4458,6 +4476,7 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
|
|
|
4458
4476
|
"app_associated_resources_apply": {
|
|
4459
4477
|
"allowed_keys": [
|
|
4460
4478
|
"app_key",
|
|
4479
|
+
"apps",
|
|
4461
4480
|
"upsert_resources",
|
|
4462
4481
|
"patch_resources",
|
|
4463
4482
|
"remove_associated_item_ids",
|
|
@@ -4527,6 +4546,7 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
|
|
|
4527
4546
|
"if an associated resource delete returns readback_status=unavailable or still_exists, treat the result as readback pending and do not blindly repeat the delete",
|
|
4528
4547
|
"this tool publishes after at least one write succeeds; there is no draft-only mode",
|
|
4529
4548
|
"visible=false hides the associated-resource area without clearing previous selected ids; visible=true with limit_type=all shows the whole app-level pool",
|
|
4549
|
+
"accepts apps[] for multi-app batch; each item is {app_key, upsert_resources?, patch_resources?, remove_associated_item_ids?, reorder_associated_item_ids?, view_configs?}",
|
|
4530
4550
|
],
|
|
4531
4551
|
"minimal_example": {
|
|
4532
4552
|
"profile": "default",
|
|
@@ -4557,6 +4577,22 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
|
|
|
4557
4577
|
}
|
|
4558
4578
|
],
|
|
4559
4579
|
},
|
|
4580
|
+
"batch_example": {
|
|
4581
|
+
"profile": "default",
|
|
4582
|
+
"apps": [
|
|
4583
|
+
{
|
|
4584
|
+
"app_key": "APP_1",
|
|
4585
|
+
"upsert_resources": [
|
|
4586
|
+
{"client_key": "orders_view", "graph_type": "view", "target_app_key": "ORDER_APP", "view_key": "ORDER_VIEW"}
|
|
4587
|
+
],
|
|
4588
|
+
"view_configs": [{"view_key": "MAIN_VIEW", "limit_type": "select", "associated_item_refs": ["orders_view"]}],
|
|
4589
|
+
},
|
|
4590
|
+
{
|
|
4591
|
+
"app_key": "APP_2",
|
|
4592
|
+
"view_configs": [{"view_key": "MAIN_VIEW", "visible": True, "limit_type": "all"}],
|
|
4593
|
+
},
|
|
4594
|
+
],
|
|
4595
|
+
},
|
|
4560
4596
|
},
|
|
4561
4597
|
"app_schema_plan": {
|
|
4562
4598
|
"allowed_keys": ["app_key", "package_id", "app_name", "icon", "color", "visibility", "create_if_missing", "add_fields", "update_fields", "remove_fields"],
|
|
@@ -5552,7 +5588,7 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
|
|
|
5552
5588
|
"call workspace_icon_catalog_get or `qingflow --json builder icon catalog` for supported icon/color candidates",
|
|
5553
5589
|
"portal_apply uses replace semantics for sections",
|
|
5554
5590
|
"when editing an existing portal, sections may be omitted to update only base info such as visibility, icon, or package",
|
|
5555
|
-
"use patch_sections[] for
|
|
5591
|
+
"use patch_sections[] for section-level patch updates without replacing all sections; each item needs one selector (chart_ref with chart_id/chart_key/chart_name, view_ref with view_key/view_name, or order as 0-based index) plus set/unset",
|
|
5556
5592
|
"when sections[] is supplied without patch_sections[], it uses replace semantics for all sections",
|
|
5557
5593
|
"remove a section by omitting it from the sections list (replace mode) or by unset in patch_sections (patch mode)",
|
|
5558
5594
|
"package_id is required when creating a new portal",
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
import shutil
|
|
6
|
+
import sys
|
|
7
|
+
from importlib import metadata
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def get_cli_version() -> str:
|
|
12
|
+
package_json_version = _find_package_json_version()
|
|
13
|
+
if package_json_version:
|
|
14
|
+
return package_json_version
|
|
15
|
+
try:
|
|
16
|
+
return metadata.version("qingflow-mcp")
|
|
17
|
+
except metadata.PackageNotFoundError:
|
|
18
|
+
return "0+local"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def get_cli_version_info() -> dict[str, str | None]:
|
|
22
|
+
package_root = _find_package_root()
|
|
23
|
+
return {
|
|
24
|
+
"version": get_cli_version(),
|
|
25
|
+
"package": _find_package_name(package_root) or "@qingflow-tech/qingflow-cli",
|
|
26
|
+
"executable_path": _resolve_executable_path(),
|
|
27
|
+
"command_path": shutil.which("qingflow"),
|
|
28
|
+
"package_root": str(package_root) if package_root is not None else None,
|
|
29
|
+
"skill_version": _find_skill_version(package_root),
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _find_package_json_version() -> str | None:
|
|
34
|
+
package_root = _find_package_root()
|
|
35
|
+
if package_root is None:
|
|
36
|
+
return None
|
|
37
|
+
try:
|
|
38
|
+
payload = json.loads((package_root / "package.json").read_text(encoding="utf-8"))
|
|
39
|
+
except (OSError, json.JSONDecodeError):
|
|
40
|
+
return None
|
|
41
|
+
version = str(payload.get("version") or "")
|
|
42
|
+
return version or None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _find_package_root() -> Path | None:
|
|
46
|
+
current = Path(__file__).resolve()
|
|
47
|
+
for parent in current.parents:
|
|
48
|
+
package_json = parent / "package.json"
|
|
49
|
+
if not package_json.exists():
|
|
50
|
+
continue
|
|
51
|
+
try:
|
|
52
|
+
payload = json.loads(package_json.read_text(encoding="utf-8"))
|
|
53
|
+
except (OSError, json.JSONDecodeError):
|
|
54
|
+
continue
|
|
55
|
+
name = str(payload.get("name") or "")
|
|
56
|
+
version = str(payload.get("version") or "")
|
|
57
|
+
if version and name in {
|
|
58
|
+
"qingflow-mcp-workspace",
|
|
59
|
+
"@qingflow-tech/qingflow-cli",
|
|
60
|
+
"@qingflow-tech/qingflow-app-user-mcp",
|
|
61
|
+
"@qingflow-tech/qingflow-app-builder-mcp",
|
|
62
|
+
"@josephyan/qingflow-cli",
|
|
63
|
+
"@josephyan/qingflow-app-user-mcp",
|
|
64
|
+
"@josephyan/qingflow-app-builder-mcp",
|
|
65
|
+
}:
|
|
66
|
+
return parent
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _find_package_name(package_root: Path | None) -> str | None:
|
|
71
|
+
if package_root is None:
|
|
72
|
+
return None
|
|
73
|
+
try:
|
|
74
|
+
payload = json.loads((package_root / "package.json").read_text(encoding="utf-8"))
|
|
75
|
+
except (OSError, json.JSONDecodeError):
|
|
76
|
+
return None
|
|
77
|
+
name = str(payload.get("name") or "")
|
|
78
|
+
return name or None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _resolve_executable_path() -> str | None:
|
|
82
|
+
if not sys.argv:
|
|
83
|
+
return None
|
|
84
|
+
raw = str(sys.argv[0] or "").strip()
|
|
85
|
+
if not raw:
|
|
86
|
+
return None
|
|
87
|
+
try:
|
|
88
|
+
return str(Path(raw).resolve())
|
|
89
|
+
except OSError:
|
|
90
|
+
return raw
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _find_skill_version(package_root: Path | None) -> str | None:
|
|
94
|
+
if package_root is None:
|
|
95
|
+
return None
|
|
96
|
+
candidates = [
|
|
97
|
+
package_root / "skills" / "qingflow-cli" / "SKILL.md",
|
|
98
|
+
package_root / "skill" / "qingflow-cli" / "SKILL.md",
|
|
99
|
+
]
|
|
100
|
+
for skill_file in candidates:
|
|
101
|
+
if not skill_file.exists():
|
|
102
|
+
continue
|
|
103
|
+
try:
|
|
104
|
+
text = skill_file.read_text(encoding="utf-8")
|
|
105
|
+
except OSError:
|
|
106
|
+
continue
|
|
107
|
+
match = re.search(r"Skill\s*版本\**[::]\s*`?([^`\s))]+)", text)
|
|
108
|
+
if match:
|
|
109
|
+
return match.group(1).strip()
|
|
110
|
+
return None
|