@josephyan/qingflow-app-user-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 CHANGED
@@ -3,13 +3,13 @@
3
3
  Install:
4
4
 
5
5
  ```bash
6
- npm install @josephyan/qingflow-app-user-mcp@1.1.1
6
+ npm install @josephyan/qingflow-app-user-mcp@1.1.3
7
7
  ```
8
8
 
9
9
  Run:
10
10
 
11
11
  ```bash
12
- npx -y -p @josephyan/qingflow-app-user-mcp@1.1.1 qingflow-app-user-mcp
12
+ npx -y -p @josephyan/qingflow-app-user-mcp@1.1.3 qingflow-app-user-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
- const direct = path.join(scriptDir, ...segments);
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
  }
@@ -60,29 +60,359 @@ export function getCodexHome() {
60
60
  return path.join(home, ".codex");
61
61
  }
62
62
 
63
- export function installBundledSkills(packageRoot) {
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 { installed: [], skipped: true, destination: null };
105
+ return [];
67
106
  }
68
107
 
69
- const codexHome = getCodexHome();
70
- const skillsDestRoot = path.join(codexHome, "skills");
71
- fs.mkdirSync(skillsDestRoot, { recursive: true });
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
- const installed = [];
74
- for (const entry of fs.readdirSync(skillsSrc, { withFileTypes: true })) {
75
- if (!entry.isDirectory()) {
76
- continue;
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.push(entry.name);
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
- return { installed, skipped: false, destination: skillsDestRoot };
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-user-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
- console.log(`[qingflow-mcp] Installed skills to ${skills.destination}: ${skills.installed.join(", ")}`);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@josephyan/qingflow-app-user-mcp",
3
- "version": "1.1.1",
3
+ "version": "1.1.3",
4
4
  "description": "Operational end-user MCP for Qingflow records, tasks, comments, and directory workflows.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "qingflow-mcp"
7
- version = "1.1.1"
7
+ version = "1.1.3"
8
8
  description = "User-authenticated MCP server for Qingflow"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -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", required=True)
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 targeted section 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",
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