@josephyan/qingflow-cli 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.mjs +10 -3
- 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-cli/reference/QINGFLOW_CLI_BUILDER_APP_DELIVERY_WORKFLOW.md +6 -0
- 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-cli@1.1.
|
|
6
|
+
npm install @josephyan/qingflow-cli@1.1.3
|
|
7
7
|
```
|
|
8
8
|
|
|
9
9
|
Run:
|
|
10
10
|
|
|
11
11
|
```bash
|
|
12
|
-
npx -y -p @josephyan/qingflow-cli@1.1.
|
|
12
|
+
npx -y -p @josephyan/qingflow-cli@1.1.3 qingflow
|
|
13
13
|
```
|
|
14
14
|
|
|
15
15
|
Environment:
|
package/npm/bin/qingflow.mjs
CHANGED
|
@@ -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
|
}
|
|
@@ -31,6 +33,11 @@ function resolvePackageModule(metaUrl, ...segments) {
|
|
|
31
33
|
throw new Error(`Cannot locate ${segments.join("/")} from ${scriptPath}`);
|
|
32
34
|
}
|
|
33
35
|
|
|
34
|
-
const { getPackageRoot, spawnServer } = await import(resolvePackageModule(import.meta.url, "lib", "runtime.mjs"));
|
|
36
|
+
const { getPackageRoot, spawnServer, runSkillsCli } = await import(resolvePackageModule(import.meta.url, "lib", "runtime.mjs"));
|
|
35
37
|
const packageRoot = getPackageRoot(import.meta.url);
|
|
36
|
-
|
|
38
|
+
const args = process.argv.slice(2);
|
|
39
|
+
if (args[0] === "skills") {
|
|
40
|
+
runSkillsCli(packageRoot, args.slice(1));
|
|
41
|
+
} else {
|
|
42
|
+
spawnServer(packageRoot, args, "qingflow", { allowRuntimeBootstrap: true, stdio: "inherit" });
|
|
43
|
+
}
|
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" });
|
|
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
|
@@ -428,6 +428,7 @@ qingflow builder button apply \
|
|
|
428
428
|
默认使用 `qingflow builder associated-resource apply` 管理应用级关联资源池与视图展示配置。
|
|
429
429
|
|
|
430
430
|
- 权限分层:`upsert_resources` / `patch_resources` / 删除 / 排序应用级关联资源池走 **EditAppAuth**;`view_configs` 修改某个视图里的关联资源展示配置,还需要视图配置侧的 **ViewManagementAuth**(未开启高级应用权限时回落到 **DataManageAuth**)。
|
|
431
|
+
- 多应用批量配置时可用 `--apps-file`,文件为 JSON 数组,每项写 `{ "app_key": "...", "upsert_resources": [...], "patch_resources": [...], "remove_associated_item_ids": [...], "reorder_associated_item_ids": [...], "view_configs": [...] }`;不要和单应用的 `--app-key` / `--*-file` 混用。
|
|
431
432
|
|
|
432
433
|
```bash
|
|
433
434
|
qingflow builder associated-resource apply \
|
|
@@ -436,6 +437,11 @@ qingflow builder associated-resource apply \
|
|
|
436
437
|
--view-configs-file tmp/associated_view_configs.json
|
|
437
438
|
```
|
|
438
439
|
|
|
440
|
+
```bash
|
|
441
|
+
qingflow builder associated-resource apply \
|
|
442
|
+
--apps-file tmp/associated_resource_apps.json
|
|
443
|
+
```
|
|
444
|
+
|
|
439
445
|
- Shell 退出码为 0、重定向成功或 `echo OK` 只表示命令执行完,不表示业务成功。必须读取输出 JSON,检查顶层 `status`、`error_code`、`warnings`、`blocking_issues`;`status: failed`、`partial_success`、`ASSOCIATED_RESOURCES_APPLY_BLOCKED` 都不能当作完成。
|
|
440
446
|
- 关联资源要生效必须同时处理两层:应用级资源池(`upsert_resources` / `patch_resources`)和视图详情展示绑定(`view_configs`)。只传 `--upsert-resources-file` 往往只是在资源池准备资源,不会让按钮或视图详情里看到关联资源。
|
|
441
447
|
- `builder views apply` 新建视图会默认打开详情页关联查看(展示全部应用级关联资源);若只需要默认展示全部资源,创建视图时不用额外写 `view_configs`。需要指定部分资源、关闭展示或配置匹配筛选时,仍必须使用本工具的 `view_configs` / `match_mappings`。
|
|
@@ -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
|