@mokuyoaxis/dsh-iris 0.2.7 → 0.3.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (160) hide show
  1. package/CHANGELOG.md +81 -1
  2. package/README.en.md +103 -312
  3. package/README.md +100 -310
  4. package/bin/dsh-iris.js +333 -65
  5. package/docs/AGENT_CORE_QUICKSTART.md +64 -0
  6. package/docs/AGENT_QUICKSTART.md +2 -0
  7. package/docs/ARCHITECTURE.md +13 -3
  8. package/docs/AUDIO_FLOWS.md +71 -0
  9. package/docs/CONTACT_SHEET.md +62 -0
  10. package/docs/CORE_CONNECTIONS.md +99 -0
  11. package/docs/CORE_QUICKSTART.md +108 -0
  12. package/docs/CORE_RUNTIME_CONTRACT.md +7 -2
  13. package/docs/CORE_SERVICE.md +97 -0
  14. package/docs/CORE_SKILLS.md +35 -3
  15. package/docs/DSH_COMPATIBILITY.md +10 -0
  16. package/docs/DSH_RC2_ADAPTATION.md +1 -1
  17. package/docs/DSH_RUNTIME.md +6 -2
  18. package/docs/HEADLESS_CLI.md +2 -2
  19. package/docs/IMAGE_FLOW.md +4 -0
  20. package/docs/IMAGE_GENERATION.md +127 -0
  21. package/docs/LOCAL_API.md +4 -2
  22. package/docs/LOCAL_API_CONTRACT.md +39 -14
  23. package/docs/LOCAL_CREATION.md +7 -1
  24. package/docs/LOCAL_OPERATIONS.md +46 -0
  25. package/docs/LOCAL_SELECTION.md +57 -0
  26. package/docs/LOCAL_WEB.md +201 -0
  27. package/docs/MCP.md +191 -0
  28. package/docs/MUSIC.md +2 -0
  29. package/docs/PANEL_EXTENSIONS.md +64 -0
  30. package/docs/PLUGINS.md +189 -0
  31. package/docs/PLUGIN_STARTUP.md +100 -0
  32. package/docs/PROVIDER_ADAPTER_CONTRACT.md +1 -1
  33. package/docs/PROVIDER_EXTENSIONS.md +78 -0
  34. package/docs/PROVIDER_HEALTH.md +3 -1
  35. package/docs/PROVIDER_SETTINGS.md +85 -0
  36. package/docs/PROVIDER_SUBMISSION_CONTRACT.md +6 -0
  37. package/docs/ROADMAP.md +17 -7
  38. package/docs/VISION_CLI.md +1 -1
  39. package/docs/WEB_PROTOTYPE.md +21 -6
  40. package/docs/WEB_RECOVERY.md +46 -0
  41. package/docs/WORKBENCH_LOGIN.md +63 -0
  42. package/docs/assets/screenshots/core-connections-preview.webp +0 -0
  43. package/docs/assets/screenshots/core-model-pools-preview.webp +0 -0
  44. package/docs/assets/screenshots/core-overview-preview.webp +0 -0
  45. package/docs/assets/screenshots/core-spectrum-preview.webp +0 -0
  46. package/docs/assets/screenshots/core-studio-dark-preview.webp +0 -0
  47. package/docs/assets/screenshots/core-studio-mobile-preview.webp +0 -0
  48. package/docs/assets/screenshots/core-studio-preview.webp +0 -0
  49. package/docs/releases/0.3.0-alpha.1.md +51 -0
  50. package/docs/screenshots.md +41 -1
  51. package/lib/actions.js +53 -2
  52. package/lib/adapters.js +20 -11
  53. package/lib/application-runtime.js +34 -7
  54. package/lib/audio-flow-input.js +37 -0
  55. package/lib/client.js +346 -53
  56. package/lib/comfyui-adapter.js +94 -0
  57. package/lib/comfyui-client.js +51 -0
  58. package/lib/comfyui-inspection.js +73 -0
  59. package/lib/comfyui-workflow.js +94 -0
  60. package/lib/config.js +36 -1
  61. package/lib/configured-image-runtime.js +50 -0
  62. package/lib/connection-skill.js +89 -0
  63. package/lib/connection-template.js +23 -0
  64. package/lib/core-artifact-store.js +16 -2
  65. package/lib/core-bridge-selection.js +31 -0
  66. package/lib/core-bridge-tools.js +83 -0
  67. package/lib/core-capability-catalog.js +182 -0
  68. package/lib/core-connection-diagnostics.js +49 -0
  69. package/lib/core-connection-store.js +277 -0
  70. package/lib/core-connections.js +225 -0
  71. package/lib/core-maintenance.js +9 -3
  72. package/lib/core-runtime.js +2 -1
  73. package/lib/core-service-client.js +170 -0
  74. package/lib/core-tasks.js +29 -12
  75. package/lib/core-writer-lease.js +16 -0
  76. package/lib/creation-request-store.js +38 -64
  77. package/lib/dsh-host-adapter.js +4 -1
  78. package/lib/dsh-service-bridge.js +66 -0
  79. package/lib/execution-ownership.js +43 -0
  80. package/lib/flow-store.js +105 -10
  81. package/lib/generation-input.js +10 -1
  82. package/lib/headless-vision.js +7 -7
  83. package/lib/image-edit-input.js +1 -1
  84. package/lib/image-flow.js +73 -29
  85. package/lib/index.js +37 -17
  86. package/lib/local-api-projection.js +10 -5
  87. package/lib/local-api-token-directory.js +31 -0
  88. package/lib/local-api.js +769 -67
  89. package/lib/local-creation.js +89 -24
  90. package/lib/local-operations.js +36 -0
  91. package/lib/local-request-root.js +86 -0
  92. package/lib/local-web-assets.js +37 -0
  93. package/lib/mcp-bridge.js +58 -0
  94. package/lib/mcp-check.js +26 -0
  95. package/lib/mcp-config.js +42 -0
  96. package/lib/mcp-delivery.js +46 -0
  97. package/lib/mcp-http.js +101 -0
  98. package/lib/mcp-server.js +47 -0
  99. package/lib/model-directory-service.js +16 -6
  100. package/lib/model-directory.js +3 -3
  101. package/lib/models.js +2 -2
  102. package/lib/music-transport.js +2 -1
  103. package/lib/operation-runner.js +236 -0
  104. package/lib/operation-store.js +216 -0
  105. package/lib/panel-extension.js +36 -0
  106. package/lib/plugin-contract.js +194 -0
  107. package/lib/plugin-execution.js +51 -0
  108. package/lib/plugin-launch.js +86 -0
  109. package/lib/plugin-operation-store.js +101 -0
  110. package/lib/plugin-operations.js +28 -0
  111. package/lib/plugin-registry.js +109 -0
  112. package/lib/plugin-selection.js +15 -0
  113. package/lib/plugin-source-image.js +5 -0
  114. package/lib/plugin-web-assets.js +54 -0
  115. package/lib/plugins.d.ts +288 -0
  116. package/lib/plugins.js +58 -0
  117. package/lib/provider-adapter.js +5 -1
  118. package/lib/provider-adapters.js +56 -18
  119. package/lib/provider-catalog.js +40 -29
  120. package/lib/provider-config-service.js +105 -23
  121. package/lib/provider-diagnostics.js +56 -0
  122. package/lib/provider-extensions.js +29 -0
  123. package/lib/provider-health.js +8 -1
  124. package/lib/provider-monitor.js +79 -0
  125. package/lib/provider-protocol.js +70 -20
  126. package/lib/provider-registry.js +118 -0
  127. package/lib/provider-response.js +28 -0
  128. package/lib/provider-settings.js +134 -0
  129. package/lib/provider-task-runner.js +22 -9
  130. package/lib/skill-installation.js +23 -11
  131. package/lib/vision-model-routing.js +3 -0
  132. package/lib/web/app.js +642 -0
  133. package/lib/web/appearance.js +111 -0
  134. package/lib/web/comfyui.js +146 -0
  135. package/lib/web/connections.js +377 -0
  136. package/lib/web/creation.js +355 -0
  137. package/lib/web/flow-view.js +33 -0
  138. package/lib/web/gallery.js +132 -0
  139. package/lib/web/index.html +291 -0
  140. package/lib/web/media-client.js +204 -0
  141. package/lib/web/plugin-panel.js +221 -0
  142. package/lib/web/plugin-surface.css +17 -0
  143. package/lib/web/providers.js +492 -0
  144. package/lib/web/recovery.js +105 -0
  145. package/lib/web/startup.js +18 -0
  146. package/lib/web/styles.css +518 -0
  147. package/lib/web/transport.js +96 -0
  148. package/lib/web/workbench-login.js +165 -0
  149. package/lib/workbench-auth-cli.js +47 -0
  150. package/lib/workbench-auth.js +200 -0
  151. package/package.json +20 -5
  152. package/plugins/contact-sheet/index.d.mts +4 -0
  153. package/plugins/contact-sheet/index.mjs +33 -0
  154. package/plugins/contact-sheet/web/board.css +14 -0
  155. package/plugins/contact-sheet/web/board.mjs +93 -0
  156. package/skills/iris-core/SKILL.md +8 -4
  157. package/skills/iris-core/references/cli.md +2 -2
  158. package/skills/iris-core/references/comfyui.md +69 -0
  159. package/skills/iris-core/references/tasks.md +2 -0
  160. package/user_guide.md +5 -1
package/bin/dsh-iris.js CHANGED
@@ -8,7 +8,8 @@ import { recoverCoreWriterLease } from '../lib/core-lease-recovery.js';
8
8
  import { doctor, formatDoctorReport } from '../lib/doctor.js';
9
9
  import { loadProviderCatalog, providerCatalogSnapshot, catalogCapabilitySnapshot, imageCandidatesFromCatalog, musicCandidatesFromCatalog, providerForTaskFromCatalog, providerTaskBinding, transcribeCandidatesFromCatalog, ttsCandidatesFromCatalog, videoCandidatesFromCatalog } from '../lib/provider-catalog.js';
10
10
  import { createConfiguredProviderAdapter } from '../lib/provider-adapters.js';
11
- import { providerForImageModel, selectMediaProtocol, dashscopeTtsInputIssue } from '../lib/provider-protocol.js';
11
+ import { providerForImageModel, selectMediaProtocol, dashscopeTtsInputIssue, supportsImageEdit } from '../lib/provider-protocol.js';
12
+ import { loadProviderRegistry } from '../lib/provider-extensions.js';
12
13
  import { prepareProviderInput } from '../lib/provider-adapter.js';
13
14
  import { createProviderTaskRunner } from '../lib/provider-task-runner.js';
14
15
  import { generateMusicTask } from '../lib/music-generation.js';
@@ -23,19 +24,22 @@ import { parseModelRef, modelRef as qualifiedModelRef } from '../lib/models.js';
23
24
  import { coreTaskWaitFinished } from '../lib/task-wait.js';
24
25
  import { createLocalReadApi, writeLocalApiTokenFile } from '../lib/local-api.js';
25
26
  import { manageCoreSkill } from '../lib/skill-installation.js';
27
+ import { createTrackedProviderAdapter } from '../lib/configured-image-runtime.js';
28
+ import { inspectExecutionLease, recoverExecutionLease } from '../lib/operation-runner.js';
29
+ import { createConnectionPair } from '../lib/core-connections.js';
30
+ import { builtinLaunchPlugins, loadPluginLaunch } from '../lib/plugin-launch.js';
31
+ import { checkLocalApiTokenDirectory, writeLocalApiTokenDirectory } from '../lib/local-api-token-directory.js';
32
+ import { resolveWorkbenchAuthDirectory } from '../lib/workbench-auth.js';
33
+ import { manageWorkbenchPassword } from '../lib/workbench-auth-cli.js';
26
34
 
27
35
  function trackedProviderAdapter(provider, options, task) {
28
- return createConfiguredProviderAdapter(provider, {
29
- modelId: parseModelRef(task?.modelRef)?.modelId,
30
- onModelAvailability: event => recordCatalogAvailability(options['provider-config'], event),
31
- onRateLimit: event => recordCatalogRateLimit(options['provider-config'], event)
32
- });
36
+ return createTrackedProviderAdapter(provider, options['provider-config'], task, options.providerRegistry);
33
37
  }
34
38
 
35
39
  async function generationCatalog(options, capability, modelRef, signal = cliAbortController.signal) {
36
40
  const file = required(options['provider-config'], 'provider-config');
37
41
  const catalog = loadProviderCatalog(file);
38
- if (!modelRef) await refreshCatalogDirectories(file, catalog, capability, { signal });
42
+ if (!modelRef) await refreshCatalogDirectories(file, catalog, capability, { signal, registry: options.providerRegistry });
39
43
  return catalog;
40
44
  }
41
45
 
@@ -51,13 +55,50 @@ function usage() {
51
55
  return [
52
56
  'Usage:',
53
57
  ' JSON input: --input <json>, --input-file <path>, or --input - (stdin).',
58
+ ' --provider-extension <absolute-js-or-mjs-entry> explicitly loads trusted Provider code; repeat for multiple extensions. Experimental v0, no installation or directory discovery.',
59
+ ' dsh-iris providers protocols [--provider-extension <absolute-entry>] lists protocol/configuration descriptions without credentials or model calls.',
60
+ ' dsh-iris plugins list',
61
+ ' dsh-iris plugins inspect --plugin-config <absolute-startup.json>',
62
+ ' Explicit trusted plugin entries, versions, contributions and selections; inspect imports/activates trusted code but does not start Core or grant connections.',
63
+ ' dsh-iris web serve ... --plugin-config <absolute-startup.json> [--auth-store <absolute-private-directory>]',
64
+ ' entries load plugins; operations enables exact business IDs; panel selects pages/settings/renderers. MCP grants remain separate. Do not combine with --provider-extension.',
54
65
  ' dsh-iris skills <install|status|uninstall> --target kimi [--directory <absolute-skills-directory>]',
55
66
  ' dsh-iris skills <install|status|uninstall> --directory <absolute-skills-directory>',
67
+ ' dsh-iris skills <install|status|uninstall> --bundle <absolute-connection-skill.json> --directory <absolute-skills-directory> [--dry-run]',
56
68
  ' install/uninstall accept --dry-run. Copy only iris-core; preserve local changes and archive managed updates/removals outside the scan directory. status is read-only and does not verify host discovery.',
57
69
  ' dsh-iris doctor [--data-root <absolute-path>] [--json]',
58
70
  ' dsh-iris api init --data-root <empty-existing-flow-root>',
59
- ' dsh-iris api serve --data-root <existing-flow-root> --token-file <new-absolute-path-outside-root> [--provider-config <absolute-path>] [--port <0..65535>] [--allow-local-writes]',
60
- ' Explicit loopback-only API, read-only by default. Local writes only upload images/create Runs, without generation. The session token is saved privately; stdout contains no token.',
71
+ ' dsh-iris execution init --data-root <empty-existing-flow-root> [--image-generation|--audio]',
72
+ ' Initialize a NEW v1 request container, v2 with --image-generation, or v3 with --audio (images + speech/transcription). Existing roots are never migrated.',
73
+ ' dsh-iris execution inspect --data-root <v1-flow-root>',
74
+ ' dsh-iris execution recover-lease --data-root <v1-flow-root> --confirm-stale-pid <pid>',
75
+ ' dsh-iris connection create --data-root <execution-flow-root> --directory <new-private-directory> --origin <http://127.0.0.1:port> --label <name> [--harness <generic|kimi|dsh>] [--scopes <read,create,execute,stop>] [--capabilities <image-edit,image-generate,tts,transcribe|none>] [--tools <comma-separated-names|none>]',
76
+ ' dsh-iris api serve ... --allow-execution --provider-config <absolute-path> [--connections-file <private-grants-file>]',
77
+ ' dsh-iris mcp serve --connection-file <private-client-json> [--scopes <read,create,execute,stop>] [--capabilities <image-edit,image-generate,tts,transcribe|none>] [--tools <comma-separated-names|none>]',
78
+ ' Official SDK stdio bridge to an already running Core service. Read-only by default; stdout contains MCP messages only.',
79
+ ' dsh-iris mcp capabilities',
80
+ ' dsh-iris mcp presets',
81
+ ' dsh-iris mcp config --connection-file <private-client-json> [--preset <generic|claude|codex|kimi>-<stdio|http> | --transport <stdio|http> --format <mcpServers|entry|claude-json|codex-toml>] [--output <new-absolute-path>]',
82
+ ' dsh-iris mcp check --connection-file <private-client-json> [--scopes <read,create,execute,stop>]',
83
+ ' stdio config also accepts scopes/capabilities/tools. HTTP config includes credentials and requires a new file inside the connection private directory.',
84
+ ' dsh-iris api serve ... --connections-file <private-grants-file> --allow-mcp',
85
+ ' dsh-iris web serve ... --connection-store <private-directory-outside-core> --allow-connection-management [--allow-mcp] [--allow-execution --provider-config <absolute-path>]',
86
+ ' dsh-iris web serve ... --provider-config <private-config> --allow-provider-management [--panel <trusted-panel-manifest.json>]',
87
+ ' Explicit standard Streamable HTTP endpoint at /mcp; default remains disabled. Uses the existing Core service and user-selected connection tools.',
88
+ ' dsh-iris operation list --data-root <execution-root> [--run-id <run-id>] [--limit <1..30>] [--cursor <operation-id>]',
89
+ ' dsh-iris operation inspect <operation-id> --data-root <execution-root>',
90
+ ' dsh-iris api serve --data-root <existing-flow-root> --token-file <new-absolute-path-outside-root> [--provider-config <absolute-path>] [--port <0..65535>] [--allow-local-writes] [--allow-selection]',
91
+ ' Explicit loopback-only API, read-only by default. Local writes import images/audio and create Runs within the root capabilities, without generation. The session token is saved privately; stdout contains no token.',
92
+ ' dsh-iris web serve --data-root <existing-flow-root> [--auth-store <absolute-private-directory>] [--provider-config <absolute-path>] [--port <0..65535>] [--allow-local-writes] [--allow-execution] [--allow-selection]',
93
+ ' Default: set one access password, then sign in at origin. Remembered browser sessions survive normal restarts. No Token file is required.',
94
+ ' Auth state defaults to $IRIS_HOME/web-auth/<root-key> ($IRIS_HOME defaults to ~/.iris). Open the initial loginUrl once to set your password.',
95
+ ' dsh-iris web login --data-root <existing-flow-root> [--auth-store <absolute-private-directory>]',
96
+ ' dsh-iris web password reset --data-root <existing-flow-root> [--auth-store <absolute-private-directory>]',
97
+ ' Reset only the workbench password/browser sessions; Core work and MCP grants remain intact. Prints a setup link while the service is running.',
98
+ ' Explicit --token-file or --token-dir keeps the legacy Token/link login mode. Do not combine them with --auth-store. api serve still requires one of these Token options.',
99
+ ' dsh-iris web login --origin <http://127.0.0.1:port> --token-file <current-session-token-file>',
100
+ ' Get a fresh one-time login link without restarting the workbench.',
101
+ ' Workbench media APIs are read-only by default; explicit write flags do not require connection management. --allow-selection permits owner-only, revision-checked image selection. Execution enables supported image/speech/transcription flows; Provider management uses its own owner-only flag.',
61
102
  ' dsh-iris runtime recover --data-root <absolute-path> --confirm-stale-pid <pid>',
62
103
  ' dsh-iris providers list --provider-config <absolute-path>',
63
104
  ' dsh-iris capabilities list --provider-config <absolute-path>',
@@ -97,8 +138,9 @@ function usage() {
97
138
  ' dsh-iris artifact export <id> --data-root <absolute-path> --output <absolute-path>',
98
139
  ' dsh-iris artifact rebuild --data-root <absolute-path>',
99
140
  ' dsh-iris flow init --data-root <new-absolute-path>',
100
- ' dsh-iris flow create --data-root <absolute-path> --provider-config <absolute-path> --input <image-edit-json>',
101
- ' Import the source image after flow init; use explicit model_ref and n=1. create saves private inputs but never submits.',
141
+ ' dsh-iris flow create --data-root <absolute-path> --provider-config <absolute-path> --input <image-json>',
142
+ ' dsh-iris models workflow-check <provider::workflow> --provider-config <absolute-path> [--input <workflow_parameters-json>] (read-only)',
143
+ ' Use explicit model_ref and n=1. Edits require an imported source; text-to-image requires a new execution init --image-generation root. create saves private inputs but never submits.',
102
144
  ' dsh-iris flow <list|inspect> [run-id] --data-root <absolute-path>',
103
145
  ' dsh-iris flow continue <run-id> --data-root <absolute-path> [--provider-config <absolute-path>]',
104
146
  ' dsh-iris flow select <run-id> --data-root <absolute-path> --input <json with artifact_id>',
@@ -112,7 +154,7 @@ function usage() {
112
154
  ].join('\n');
113
155
  }
114
156
 
115
- function flags(args, allowed) {
157
+ function parseFlags(args, allowed) {
116
158
  const parsed = {};
117
159
  for (let index = 0; index < args.length; index += 2) {
118
160
  const flag = args[index];
@@ -170,6 +212,7 @@ function generationInput(capability, input, options) {
170
212
  /** C-8: catchable signals dispose active runtimes before the CLI exits. */
171
213
  const activeRuntimes = new Set();
172
214
  const cliAbortController = new AbortController();
215
+ let cliProviderRegistry;
173
216
  let shuttingDown = false;
174
217
 
175
218
  function registerRuntime(runtime) {
@@ -180,6 +223,7 @@ function registerRuntime(runtime) {
180
223
  async function disposeActiveRuntimes() {
181
224
  await Promise.allSettled([...activeRuntimes].map((runtime) => runtime.dispose().catch(() => {})));
182
225
  activeRuntimes.clear();
226
+ await cliProviderRegistry?.dispose();
183
227
  }
184
228
 
185
229
  for (const signal of ['SIGINT', 'SIGTERM']) {
@@ -216,19 +260,71 @@ async function withApplication(dataRoot, callback, resolveImageCandidates, resol
216
260
  finally { try { await application.dispose(); } finally { unregister(); } }
217
261
  }
218
262
 
219
- async function main(args) {
263
+ /** Own the service and its plugin registry in order, including cancellation during import/start. */
264
+ async function withCliLaunch(pluginConfig, launchOptions, callback) {
265
+ let loading, launch, api, disposal, startupFailure;
266
+ const detail = error => ({ code: error.code || 'IRIS_CLI_FAILED', message: error.message || String(error),
267
+ ...(error instanceof AggregateError ? { errors: error.errors.map(detail) } : {}) });
268
+ const lifetime = { dispose() {
269
+ if (!disposal) disposal = (async () => {
270
+ if (loading) await Promise.allSettled([loading]); // main retains the original startup error.
271
+ const failures = [];
272
+ try { await api?.dispose(); } catch (error) { failures.push({ stage: 'core-stop', error }); }
273
+ try { await launch?.dispose(); } catch (error) { failures.push({ stage: 'plugin-stop', error }); }
274
+ if (failures.length) {
275
+ const error = Object.assign(new AggregateError(failures.map(item => item.error), 'Core 服务或插件释放失败;已尝试两阶段收尾'), { code: 'IRIS_CLI_SERVICE_CLEANUP_FAILED' });
276
+ error.diagnostic = { code: error.code, stage: 'shutdown', message: error.message, ...(pluginConfig ? { file: pluginConfig } : {}),
277
+ errors: failures.map(item => ({ stage: item.stage, ...detail(item.error) })) };
278
+ if (cliAbortController.signal.aborted) console.error(JSON.stringify(error.diagnostic));
279
+ throw error;
280
+ }
281
+ })();
282
+ return disposal;
283
+ } };
284
+ const unregister = registerRuntime(lifetime);
285
+ try {
286
+ loading = pluginConfig ? loadPluginLaunch(pluginConfig, launchOptions).then(value => (launch = value)) : Promise.resolve();
287
+ await loading; cliAbortController.signal.throwIfAborted();
288
+ return await callback(launch, value => { api = value; });
289
+ } catch (error) { startupFailure = error; throw error; }
290
+ finally {
291
+ try { await lifetime.dispose(); }
292
+ catch (error) { if (startupFailure) error.diagnostic.startup = detail(startupFailure); throw error; }
293
+ finally { unregister(); }
294
+ }
295
+ }
296
+
297
+ async function main(args, providerRegistry) {
298
+ const flags = (values, allowed) => Object.defineProperty(parseFlags(values, allowed), 'providerRegistry', { value: providerRegistry });
220
299
  if (!args.length || args.includes('--help') || args.includes('-h')) {
221
300
  console.log(usage());
222
301
  return args.length ? 0 : 2;
223
302
  }
224
303
 
304
+ if (args[0] === 'providers' && args[1] === 'protocols') {
305
+ flags(args.slice(2), []);
306
+ console.log(JSON.stringify(providerRegistry.snapshot(), null, 2)); return 0;
307
+ }
308
+
309
+ if (args[0] === 'plugins') {
310
+ if (args[1] === 'list') {
311
+ flags(args.slice(2), []);
312
+ console.log(JSON.stringify({ contractVersion: 0, builtins: builtinLaunchPlugins(), automaticDiscovery: false })); return 0;
313
+ }
314
+ if (args[1] !== 'inspect') throw new CliUsageError('plugins 仅支持 list/inspect');
315
+ const options = flags(args.slice(2), ['plugin-config']);
316
+ return withCliLaunch(required(options['plugin-config'], 'plugin-config'), {}, async launch => {
317
+ console.log(JSON.stringify(launch.summary, null, 2)); return 0;
318
+ });
319
+ }
320
+
225
321
  if (args[0] === 'skills') {
226
322
  const rest = args.slice(2), dryRunFlags = rest.filter(value => value === '--dry-run');
227
323
  if (dryRunFlags.length > 1) throw new CliUsageError('参数重复:--dry-run');
228
- const options = flags(rest.filter(value => value !== '--dry-run'), ['target', 'directory']);
324
+ const options = flags(rest.filter(value => value !== '--dry-run'), ['target', 'directory', 'bundle']);
229
325
  let result;
230
326
  try {
231
- result = manageCoreSkill({ action: args[1], target: options.target, directory: options.directory, dryRun: dryRunFlags.length === 1 });
327
+ result = manageCoreSkill({ action: args[1], target: options.target, directory: options.directory, bundle: options.bundle, dryRun: dryRunFlags.length === 1 });
232
328
  } catch (error) {
233
329
  if (error.code === 'IRIS_SKILL_OPTIONS_INVALID') throw new CliUsageError(error.message);
234
330
  throw error;
@@ -237,8 +333,133 @@ async function main(args) {
237
333
  return args[1] === 'status' && result.status !== 'current' ? 1 : 0;
238
334
  }
239
335
 
240
- if (args[0] === 'api') {
241
- if (args[1] === 'init') {
336
+ if (args[0] === 'mcp') {
337
+ if (args[1] === 'presets') {
338
+ flags(args.slice(2), []);
339
+ const { MCP_CONNECTION_PRESETS } = await import('../lib/mcp-delivery.js');
340
+ console.log(JSON.stringify({ contractVersion: 0, presets: MCP_CONNECTION_PRESETS, installationDetected: false }, null, 2)); return 0;
341
+ }
342
+ if (args[1] === 'capabilities') {
343
+ flags(args.slice(2), []);
344
+ const { BRIDGE_CAPABILITIES, BRIDGE_CONTROL_TOOLS } = await import('../lib/core-bridge-selection.js');
345
+ console.log(JSON.stringify({ contractVersion: 0, capabilities: BRIDGE_CAPABILITIES, controlTools: BRIDGE_CONTROL_TOOLS,
346
+ implementedMedia: BRIDGE_CAPABILITIES.map(group => group.recipe), selectionDoesNotGenerate: true }, null, 2)); return 0;
347
+ }
348
+ if (!['serve', 'config', 'check'].includes(args[1])) throw new CliUsageError('mcp 支持 serve/config/check/capabilities/presets');
349
+ const options = flags(args.slice(2), ['connection-file', 'scopes', 'capabilities', 'tools', ...(args[1] === 'config' ? ['transport', 'format', 'preset', 'output'] : [])]);
350
+ const list = value => value === 'none' ? [] : value.split(',');
351
+ const selected = { ...(options.capabilities !== undefined ? { capabilities: list(options.capabilities) } : {}),
352
+ ...(options.tools !== undefined ? { tools: list(options.tools) } : {}) };
353
+ if (args[1] === 'check') {
354
+ const { checkMcpConnection } = await import('../lib/mcp-check.js');
355
+ const result = await checkMcpConnection({ connectionFile: required(options['connection-file'], 'connection-file'),
356
+ ...(options.scopes !== undefined ? { scopes: options.scopes.split(',') } : {}), ...selected });
357
+ console.log(JSON.stringify(result, null, 2)); return result.ok ? 0 : 1;
358
+ }
359
+ if (args[1] === 'config') {
360
+ const { exportMcpConfiguration } = await import('../lib/mcp-config.js');
361
+ const result = exportMcpConfiguration({ connectionFile: required(options['connection-file'], 'connection-file'), ...selected,
362
+ ...(options.scopes !== undefined ? { scopes: options.scopes.split(',') } : {}), transport: options.transport, format: options.format, presetId: options.preset, output: options.output });
363
+ process.stdout.write(typeof result === 'string' ? result : JSON.stringify(result, null, 2) + '\n');
364
+ return 0;
365
+ }
366
+ const { createMcpBridge } = await import('../lib/mcp-bridge.js');
367
+ const manifest = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
368
+ const bridge = createMcpBridge({ connectionFile: required(options['connection-file'], 'connection-file'),
369
+ requestedScopes: (options.scopes === undefined ? 'read' : options.scopes).split(','), ...selected, version: manifest.version });
370
+ const unregister = registerRuntime(bridge);
371
+ try { await bridge.start(); await bridge.closed; return 0; }
372
+ finally { try { await bridge.dispose(); } finally { unregister(); } }
373
+ }
374
+ if (args[0] === 'connection') {
375
+ if (args[1] !== 'create') throw new CliUsageError('connection 只支持显式 create');
376
+ const options = flags(args.slice(2), ['data-root', 'directory', 'origin', 'harness', 'label', 'scopes', 'capabilities', 'tools']);
377
+ const result = createConnectionPair({ dataRoot: required(options['data-root'], 'data-root'), directory: required(options.directory, 'directory'), origin: required(options.origin, 'origin'),
378
+ harnessKind: options.harness === undefined ? 'generic' : options.harness, label: required(options.label, 'label'),
379
+ ...(options.scopes !== undefined ? { scopes: options.scopes.split(',') } : {}),
380
+ ...(options.capabilities !== undefined ? { capabilities: options.capabilities === 'none' ? [] : options.capabilities.split(',') } : {}),
381
+ ...(options.tools !== undefined ? { tools: options.tools === 'none' ? [] : options.tools.split(',') } : {}) });
382
+ console.log(JSON.stringify(result, null, 2)); return 0;
383
+ }
384
+ if (args[0] === 'execution' || args[0] === 'operation') {
385
+ if (args[0] === 'execution') {
386
+ if (['inspect', 'recover-lease'].includes(args[1])) {
387
+ const options = flags(args.slice(2), ['data-root', ...(args[1] === 'recover-lease' ? ['confirm-stale-pid'] : [])]);
388
+ const dataRoot = required(options['data-root'], 'data-root');
389
+ if (args[1] === 'recover-lease' && !/^[1-9][0-9]*$/.test(options['confirm-stale-pid'] || '')) throw new CliUsageError('需要明确回显 stale owner PID');
390
+ const result = args[1] === 'inspect' ? inspectExecutionLease(dataRoot)
391
+ : recoverExecutionLease(dataRoot, { confirmStalePid: Number(options['confirm-stale-pid']) });
392
+ console.log(JSON.stringify(result, null, 2)); return 0;
393
+ }
394
+ if (args[1] !== 'init') throw new CliUsageError('不支持的 execution 子命令');
395
+ const initArgs = args.slice(2), generationFlags = initArgs.filter(value => value === '--image-generation'), audioFlags = initArgs.filter(value => value === '--audio');
396
+ if (generationFlags.length > 1 || audioFlags.length > 1 || generationFlags.length && audioFlags.length) throw new CliUsageError('初始化开关重复或冲突;audio已包含文生图');
397
+ const options = flags(initArgs.filter(value => !['--image-generation', '--audio'].includes(value)), ['data-root']);
398
+ const suppliedRoot = required(options['data-root'], 'data-root');
399
+ if (!path.isAbsolute(suppliedRoot)) throw new CliUsageError('data-root 必须是已初始化的空 Flow 根绝对路径');
400
+ let dataRoot;
401
+ try { dataRoot = fs.realpathSync(suppliedRoot); }
402
+ catch (_) { throw new CliUsageError('请先在新的专用数据根执行 flow init'); }
403
+ const result = await withApplication(dataRoot, application =>
404
+ application.execute('execution.init', audioFlags.length ? { audio: true } : generationFlags.length ? { imageGeneration: true } : {}, { signal: cliAbortController.signal }));
405
+ console.log(JSON.stringify(result, null, 2)); return 0;
406
+ }
407
+ if (!['list', 'inspect'].includes(args[1])) throw new CliUsageError('operation 只支持只读 list/inspect');
408
+ const inspecting = args[1] === 'inspect';
409
+ if (inspecting && (!args[2] || args[2].startsWith('--'))) throw new CliUsageError('缺少 Operation ID');
410
+ const options = flags(args.slice(inspecting ? 3 : 2), ['data-root', ...(inspecting ? [] : ['run-id', 'limit', 'cursor'])]);
411
+ if (options.limit !== undefined && !/^(?:[1-9]|[12][0-9]|30)$/.test(options.limit)) throw new CliUsageError('limit 必须是 1–30 的整数');
412
+ const input = inspecting ? { id: args[2] } : { ...(options['run-id'] ? { runId: options['run-id'] } : {}),
413
+ ...(options.limit !== undefined ? { limit: Number(options.limit) } : {}), ...(options.cursor ? { cursor: options.cursor } : {}) };
414
+ const result = await withApplication(required(options['data-root'], 'data-root'), application =>
415
+ application.execute('operation.' + args[1], input, { signal: cliAbortController.signal }));
416
+ console.log(JSON.stringify(result, null, 2)); return 0;
417
+ }
418
+
419
+ if (args[0] === 'api' || args[0] === 'web') {
420
+ const serveWorkbench = args[0] === 'web';
421
+ if (serveWorkbench && args[1] === 'password') {
422
+ if (args[2] !== 'reset') throw new CliUsageError('web password 仅支持 reset');
423
+ const options = flags(args.slice(3), ['data-root', 'auth-store']);
424
+ const dataRoot = required(options['data-root'], 'data-root');
425
+ if (!path.isAbsolute(dataRoot)) throw new CliUsageError('data-root 必须是工作台数据根的绝对路径');
426
+ console.log(JSON.stringify(await manageWorkbenchPassword({ dataRoot, directory: options['auth-store'], reset: true, signal: cliAbortController.signal })));
427
+ return 0;
428
+ }
429
+ if (serveWorkbench && args[1] === 'login') {
430
+ const options = flags(args.slice(2), ['origin', 'token-file', 'data-root', 'auth-store']);
431
+ if (options['data-root'] !== undefined) {
432
+ if (options.origin !== undefined || options['token-file'] !== undefined) throw new CliUsageError('口令登录用 data-root,旧 Token 登录用 origin/token-file,不能混用');
433
+ if (!path.isAbsolute(options['data-root'])) throw new CliUsageError('data-root 必须是工作台数据根的绝对路径');
434
+ console.log(JSON.stringify(await manageWorkbenchPassword({ dataRoot: options['data-root'], directory: options['auth-store'], signal: cliAbortController.signal })));
435
+ return 0;
436
+ }
437
+ if (options['auth-store'] !== undefined) throw new CliUsageError('auth-store 需要同时明确 data-root');
438
+ const origin = required(options.origin, 'origin'), tokenFile = required(options['token-file'], 'token-file');
439
+ if (!/^http:\/\/127\.0\.0\.1:[1-9]\d{0,4}$/.test(origin) || Number(origin.split(':').at(-1)) > 65535) throw new CliUsageError('origin 必须是启动时输出的 http://127.0.0.1:端口');
440
+ if (!path.isAbsolute(tokenFile)) throw new CliUsageError('token-file 必须是当前启动文件的绝对路径');
441
+ let token;
442
+ const fd = fs.openSync(tokenFile, 'r');
443
+ try {
444
+ const info = fs.fstatSync(fd);
445
+ if (!info.isFile() || info.size > 256) throw new CliUsageError('请选择当前启动生成的小型 Token 文本文件');
446
+ token = fs.readFileSync(fd, 'utf8').trim();
447
+ } finally { fs.closeSync(fd); }
448
+ if (!/^[A-Za-z0-9_-]{43}$/.test(token)) throw new CliUsageError('Token 文件内容无效,请使用当前启动生成的文件');
449
+ const response = await fetch(origin + '/api/v0/workbench/login-link', { method: 'POST',
450
+ headers: { Authorization: 'Bearer ' + token, 'Content-Type': 'application/json' }, body: '{}',
451
+ redirect: 'error', signal: AbortSignal.any([cliAbortController.signal, AbortSignal.timeout(15000)]) });
452
+ if (!response.ok) throw new CliUsageError(response.status === 401 ? 'Token 已失效,请使用当前服务启动时生成的 Token 文件'
453
+ : response.status === 404 || response.status === 405 ? '此服务不支持登录链接,请确认地址属于新版 web serve 工作台' : '无法获取登录链接,请检查本机工作台状态');
454
+ const result = await response.json();
455
+ if (result.contractVersion !== 0 || typeof result.loginUrl !== 'string' || !result.loginUrl.startsWith(origin + '/#login=')
456
+ || !/^[A-Za-z0-9_-]{43}$/.test(result.loginUrl.slice((origin + '/#login=').length)) || !Number.isFinite(Date.parse(result.loginExpiresAt))) {
457
+ throw new CliUsageError('工作台返回的登录链接无效');
458
+ }
459
+ console.log(JSON.stringify({ ...result, message: '打开 loginUrl 即可进入;链接 10 分钟内有效、仅用一次,刷新无需重新登录。' }));
460
+ return 0;
461
+ }
462
+ if (!serveWorkbench && args[1] === 'init') {
242
463
  const options = flags(args.slice(2), ['data-root']);
243
464
  const suppliedRoot = required(options['data-root'], 'data-root');
244
465
  if (!path.isAbsolute(suppliedRoot)) throw new CliUsageError('data-root 必须是已初始化的空 Flow 根绝对路径');
@@ -249,30 +470,61 @@ async function main(args) {
249
470
  console.log(JSON.stringify(initialized));
250
471
  return 0;
251
472
  }
252
- if (args[1] !== 'serve') throw new CliUsageError('不支持的 api 子命令');
253
- const rest = args.slice(2), localWriteFlags = rest.filter(value => value === '--allow-local-writes');
254
- if (localWriteFlags.length > 1) throw new CliUsageError('参数重复:--allow-local-writes');
255
- const allowLocalWrites = localWriteFlags.length === 1;
256
- const options = flags(rest.filter(value => value !== '--allow-local-writes'), ['data-root', 'token-file', 'provider-config', 'port']);
473
+ if (args[1] !== 'serve') throw new CliUsageError(`不支持的 ${args[0]} 子命令`);
474
+ const rest = args.slice(2), localWriteFlags = rest.filter(value => value === '--allow-local-writes'), executionFlags = rest.filter(value => value === '--allow-execution'), selectionFlags = rest.filter(value => value === '--allow-selection'), mcpFlags = rest.filter(value => value === '--allow-mcp'), managementFlags = rest.filter(value => value === '--allow-connection-management'), providerFlags = rest.filter(value => value === '--allow-provider-management');
475
+ if (localWriteFlags.length > 1 || executionFlags.length > 1 || selectionFlags.length > 1 || mcpFlags.length > 1 || managementFlags.length > 1 || providerFlags.length > 1) throw new CliUsageError('写入/执行/选择/MCP/管理参数重复');
476
+ const allowExecution = executionFlags.length === 1, allowLocalWrites = localWriteFlags.length === 1 || allowExecution;
477
+ const allowSelection = selectionFlags.length === 1;
478
+ const allowConnectionManagement = managementFlags.length === 1;
479
+ const options = flags(rest.filter(value => !['--allow-local-writes', '--allow-execution', '--allow-selection', '--allow-mcp', '--allow-connection-management', '--allow-provider-management'].includes(value)), ['data-root', 'token-file', 'token-dir', 'auth-store', 'provider-config', 'port', 'connections-file', 'connection-store', 'panel', 'plugin-config']);
257
480
  const suppliedRoot = required(options['data-root'], 'data-root');
258
- const tokenFile = required(options['token-file'], 'token-file');
259
- if (!path.isAbsolute(suppliedRoot) || !path.isAbsolute(tokenFile)) throw new CliUsageError('data-root 与 token-file 必须是绝对路径');
481
+ const passwordMode = serveWorkbench && options['token-file'] === undefined && options['token-dir'] === undefined;
482
+ if (!passwordMode && Boolean(options['token-file']) === Boolean(options['token-dir'])) throw new CliUsageError('请选择且只选择 --token-file 或 --token-dir');
483
+ if (options['auth-store'] !== undefined && !passwordMode) throw new CliUsageError('auth-store 仅用于默认口令工作台;不能与 Token 参数或 api serve 混用');
484
+ if (!path.isAbsolute(suppliedRoot) || !passwordMode && !path.isAbsolute(options['token-file'] || options['token-dir'])) throw new CliUsageError('data-root 与 token-file/token-dir 必须是绝对路径');
485
+ let tokenFile = options['token-file'];
260
486
  if (options.port !== undefined && (!/^\d+$/.test(options.port) || Number(options.port) > 65535)) throw new CliUsageError('port 必须是 0–65535 的整数');
261
487
  // Pin the physical root for both the server and the outside-root credential check.
262
488
  let dataRoot;
263
489
  try { dataRoot = fs.realpathSync(suppliedRoot); }
264
490
  catch (_) { throw new CliUsageError('data-root 必须是已初始化且可读取的 Flow 根'); }
265
- const api = createLocalReadApi({ dataRoot, providerConfigPath: options['provider-config'], allowLocalWrites });
266
- const unregister = registerRuntime(api);
267
- try {
491
+ const authDirectory = passwordMode ? resolveWorkbenchAuthDirectory(dataRoot, options['auth-store']) : undefined;
492
+ if (options['token-dir']) checkLocalApiTokenDirectory(options['token-dir'], dataRoot);
493
+ return withCliLaunch(options['plugin-config'], { serveWorkbench, panelManifestPath: options.panel }, async (launch, ownApi) => {
494
+ const api = createLocalReadApi({ dataRoot, providerConfigPath: options['provider-config'], allowLocalWrites, serveWorkbench,
495
+ ...(passwordMode ? { workbenchAuth: { directory: authDirectory } } : {}),
496
+ allowExecution, allowSelection, allowMcp: mcpFlags.length === 1, connectionsFile: options['connections-file'], allowConnectionManagement, connectionStore: options['connection-store'], providerRegistry,
497
+ ...(launch ? { pluginRegistry: launch.registry, providerRegistry: launch.registry.providers, allowedOperations: launch.allowedOperations,
498
+ panelContributions: launch.panelContributions } : {}),
499
+ allowProviderManagement: providerFlags.length === 1, panelManifestPath: options.panel });
500
+ ownApi(api);
268
501
  const started = await api.start({ port: options.port === undefined ? 0 : Number(options.port) });
269
- writeLocalApiTokenFile(tokenFile, started.accessToken, dataRoot);
270
- console.log(JSON.stringify({ contractVersion: 0, readOnly: !allowLocalWrites, origin: started.origin, tokenFile,
271
- message: allowLocalWrites ? '本地创建 API 已启动;只上传/创建,不执行生成。会话 Token 在指定私有文件中,退出后失效。'
502
+ if (!passwordMode) {
503
+ if (options['token-dir']) tokenFile = writeLocalApiTokenDirectory(options['token-dir'], started.accessToken, dataRoot);
504
+ else writeLocalApiTokenFile(tokenFile, started.accessToken, dataRoot);
505
+ }
506
+ console.log(JSON.stringify({ contractVersion: 0, readOnly: !allowLocalWrites && !allowSelection, origin: started.origin,
507
+ ...(passwordMode ? { authentication: started.authentication, authStore: authDirectory } : { tokenFile }),
508
+ ...(launch ? { pluginLaunch: launch.summary } : {}),
509
+ ...(serveWorkbench ? { loginUrl: started.loginUrl, loginExpiresAt: started.loginExpiresAt } : {}),
510
+ ...(allowConnectionManagement ? { connectionManagement: true } : {}),
511
+ ...(providerFlags.length ? { providerManagement: true } : {}),
512
+ ...(started.mcpEndpoint ? { mcpEndpoint: started.mcpEndpoint } : {}),
513
+ message: passwordMode ? (started.authentication.configured ? '工作台已启动,打开 origin 用访问口令登录;记住设备后可跨正常重启继续使用。'
514
+ : '首次使用请打开 loginUrl 设置访问口令;之后打开普通地址即可登录,无需 Token 文件。')
515
+ : serveWorkbench ? (providerFlags.length ? 'Core 工作台与供应商管理已启动;打开 loginUrl 即可进入。配置保存不生成媒体。'
516
+ : allowConnectionManagement ? 'Core 工作台与连接管理已启动;打开 loginUrl 即可进入。配置连接不执行媒体。'
517
+ : allowLocalWrites || allowSelection ? 'Core 工作台已启动;打开 loginUrl 即可进入。媒体 API 按显式开关开放。'
518
+ : launch?.allowedOperations !== undefined ? 'Core 工作台已启动;打开 loginUrl 即可进入。插件业务按启动选择开放,媒体 API 保持只读。'
519
+ : '只读工作台已启动;打开 loginUrl 即可进入。')
520
+ : allowExecution ? '有限执行 API 已启动;显式继续才执行,停止只中止本地等待,重启不自动重提。'
521
+ : allowLocalWrites ? '本地创建 API 已启动;只上传/创建,不执行生成。会话 Token 在指定私有文件中,退出后失效。'
522
+ : allowSelection ? '本地选择 API 已启动;只更新已完成流程的选择,不执行生成。会话 Token 在指定私有文件中,退出后失效。'
523
+ : launch?.allowedOperations !== undefined ? '插件业务 API 已启动;按启动选择执行,媒体 API 保持只读。会话 Token 在指定私有文件中,退出后失效。'
272
524
  : '只读本地 API 已启动;会话 Token 在指定私有文件中,退出后失效。' }));
273
525
  await api.closed;
274
526
  return 0;
275
- } finally { try { await api.dispose(); } finally { unregister(); } }
527
+ });
276
528
  }
277
529
 
278
530
  if (args[0] === 'flow') {
@@ -289,12 +541,13 @@ async function main(args) {
289
541
  if (verb === 'export') input.output_path = required(options.output, 'output');
290
542
  const resolveImageCandidates = options['provider-config'] ? async ({ modelRef, signal }) => {
291
543
  const catalog = await generationCatalog(options, 'image-gen', modelRef, signal);
292
- return imageCandidatesFromCatalog(catalog, modelRef).map(route => ({ adapter: trackedProviderAdapter(route.provider, options),
293
- model: route.modelRef, selectionReason: route.selectionReason, providerBinding: providerTaskBinding(route.provider) }));
544
+ return imageCandidatesFromCatalog(catalog, modelRef, providerRegistry).map(route => ({ adapter: trackedProviderAdapter(route.provider, options),
545
+ model: route.modelRef, supportsImageEdit: supportsImageEdit(route.provider.mediaProtocol, route.model, providerRegistry),
546
+ selectionReason: route.selectionReason, providerBinding: providerTaskBinding(route.provider, providerRegistry) }));
294
547
  } : undefined;
295
548
  const resolveTaskAdapter = options['provider-config'] ? task => {
296
549
  const catalog = loadProviderCatalog(options['provider-config']);
297
- return trackedProviderAdapter(providerForTaskFromCatalog(catalog, task), options, task);
550
+ return trackedProviderAdapter(providerForTaskFromCatalog(catalog, task, providerRegistry), options, task);
298
551
  } : undefined;
299
552
  let result;
300
553
  try {
@@ -344,14 +597,14 @@ async function main(args) {
344
597
  if (['providers', 'capabilities'].includes(args[0]) && args[1] === 'list') {
345
598
  const options = flags(args.slice(2), ['provider-config']);
346
599
  const catalog = loadProviderCatalog(required(options['provider-config'], 'provider-config'));
347
- const report = args[0] === 'providers' ? providerCatalogSnapshot(catalog) : catalogCapabilitySnapshot(catalog);
600
+ const report = args[0] === 'providers' ? providerCatalogSnapshot(catalog) : catalogCapabilitySnapshot(catalog, providerRegistry);
348
601
  console.log(JSON.stringify(report, null, 2));
349
602
  return 0;
350
603
  }
351
604
 
352
605
  if (['config', 'providers', 'models', 'assignments'].includes(args[0])) {
353
606
  const [group, verb] = args;
354
- const verbs = { config: ['init', 'show', 'check'], providers: ['add', 'set', 'remove'], models: ['list', 'add', 'remove', 'caps', 'protocol', 'music-protocol', 'vision-input', 'discover', 'test'], assignments: ['list', 'set', 'clear'] };
607
+ const verbs = { config: ['init', 'show', 'check'], providers: ['add', 'set', 'remove'], models: ['list', 'add', 'remove', 'caps', 'protocol', 'music-protocol', 'vision-input', 'discover', 'test', 'workflow-check'], assignments: ['list', 'set', 'clear'] };
355
608
  if (!verbs[group].includes(verb)) throw new CliUsageError('未知配置命令');
356
609
  const positional = (group === 'providers' && verb === 'remove') || (group === 'models' && verb !== 'list');
357
610
  const id = positional ? required(args[2], group === 'providers' || verb === 'discover' ? 'provider-id' : 'model-ref') : undefined;
@@ -368,7 +621,7 @@ async function main(args) {
368
621
  }
369
622
  }
370
623
  const result = await executeConfigCommand(required(options['provider-config'], 'provider-config'), group === 'config' ? verb : group + '.' + verb, input, {
371
- signal: cliAbortController.signal,
624
+ signal: cliAbortController.signal, registry: providerRegistry,
372
625
  probe: async ({ provider, modelId, capability, signal }) => {
373
626
  if (capability === 'music-gen') return { skipped: true, ok: false, reason: 'Music 不发起小样本探测;请使用 run music 提交创作要求' };
374
627
  if (capability === 'vision') return probeVisionModel(provider, modelId, { bytes: Buffer.from(RED_TEST_IMAGE.split(',')[1], 'base64'), mediaType: 'image/png' }, { signal });
@@ -381,14 +634,14 @@ async function main(args) {
381
634
  }
382
635
  return withRawRuntime('writer', options['data-root'], async runtime => {
383
636
  const mediaCapability = capability === 'image-gen' ? 'image' : capability === 'video-gen' ? 'video' : capability;
384
- if (mediaCapability === 'image') provider = providerForImageModel(provider, modelId);
385
- const adapter = createConfiguredProviderAdapter(provider, { allowRateLimited: true, modelId });
637
+ if (mediaCapability === 'image') provider = providerForImageModel(provider, modelId, providerRegistry);
638
+ const adapter = createConfiguredProviderAdapter(provider, { allowRateLimited: true, modelId, registry: providerRegistry });
386
639
  let providerInput = mediaCapability === 'image'
387
- ? { prompt: 'red circle', n: 1, ...(['openai-chat-images', 'openai-responses-images'].includes(adapter.protocol) ? {} : {
640
+ ? { prompt: 'red circle', n: 1, ...(['openai-chat-images', 'openai-responses-images', 'comfyui'].includes(adapter.protocol) ? {} : {
388
641
  size: adapter.protocol === 'openai-images' ? '256x256' : '512*512'
389
642
  }) }
390
643
  : { text: '你好', voice: probeInput.voice || provider.ttsVoice || 'Cherry' };
391
- let candidates = [{ adapter, model: qualifiedModelRef(provider.id, modelId), selectionReason: 'explicit', providerBinding: providerTaskBinding(provider) }];
644
+ let candidates = [{ adapter, model: qualifiedModelRef(provider.id, modelId), selectionReason: 'explicit', providerBinding: providerTaskBinding(provider, providerRegistry) }];
392
645
  if (['video', 'transcribe'].includes(mediaCapability)) {
393
646
  const normalized = generationInput(mediaCapability, { ...probeInput, model_ref: qualifiedModelRef(provider.id, modelId) },
394
647
  { allowVideoPaths: true, allowAudioPath: true });
@@ -429,8 +682,8 @@ async function main(args) {
429
682
  } catch (_) { throw new CliUsageError('输出文件已存在或父目录不可写'); }
430
683
  }
431
684
  const catalog = await generationCatalog(options, 'vision', options['model-ref'] || input.model_ref);
432
- if (input.transcribe && !input.transcribe_model_ref) await refreshCatalogDirectories(options['provider-config'], catalog, 'transcribe', { signal: cliAbortController.signal });
433
- const execute = runtime => executeVisionCommand({ command, input, catalog, runtime,
685
+ if (input.transcribe && !input.transcribe_model_ref) await refreshCatalogDirectories(options['provider-config'], catalog, 'transcribe', { signal: cliAbortController.signal, registry: providerRegistry });
686
+ const execute = runtime => executeVisionCommand({ command, input, catalog, runtime, registry: providerRegistry,
434
687
  onRateLimit: event => recordCatalogRateLimit(options['provider-config'], event),
435
688
  onModelAvailability: event => recordCatalogAvailability(options['provider-config'], event),
436
689
  modelRef: options['model-ref'] || '', signal: cliAbortController.signal,
@@ -488,10 +741,11 @@ async function main(args) {
488
741
  const input = jsonInput(options.input);
489
742
  const resolveImageCandidates = async ({ modelRef, signal }) => {
490
743
  const catalog = await generationCatalog(options, 'image-gen', modelRef, signal);
491
- return imageCandidatesFromCatalog(catalog, modelRef).map(route => ({
744
+ return imageCandidatesFromCatalog(catalog, modelRef, providerRegistry).map(route => ({
492
745
  adapter: trackedProviderAdapter(route.provider, options), model: route.modelRef,
746
+ supportsImageEdit: supportsImageEdit(route.provider.mediaProtocol, route.model, providerRegistry),
493
747
  selectionReason: route.selectionReason,
494
- providerBinding: providerTaskBinding(route.provider)
748
+ providerBinding: providerTaskBinding(route.provider, providerRegistry)
495
749
  }));
496
750
  };
497
751
  let result;
@@ -512,12 +766,12 @@ async function main(args) {
512
766
  const normalized = generationInput('video', jsonInput(options.input), { allowVideoPaths: true });
513
767
  const { providerInput, modelRef } = normalized;
514
768
  const catalog = await generationCatalog(options, 'video-gen', modelRef);
515
- const routes = videoCandidatesFromCatalog(catalog, modelRef);
769
+ const routes = videoCandidatesFromCatalog(catalog, modelRef, providerRegistry);
516
770
  await withRawRuntime('writer', options['data-root'], async (runtime) => {
517
771
  const candidates = videoTaskCandidates(routes.map((route) => ({
518
772
  adapter: trackedProviderAdapter(route.provider, options), model: route.modelRef,
519
773
  selectionReason: route.selectionReason,
520
- providerBinding: providerTaskBinding(route.provider)
774
+ providerBinding: providerTaskBinding(route.provider, providerRegistry)
521
775
  })), normalized);
522
776
  const result = await createProviderTaskRunner(runtime).submit({
523
777
  capability: 'video', candidates,
@@ -533,12 +787,12 @@ async function main(args) {
533
787
  const options = flags(args.slice(2), ['data-root', 'provider-config', 'input']);
534
788
  const normalized = generationInput('music', jsonInput(options.input));
535
789
  const catalog = await generationCatalog(options, 'music-gen', normalized.modelRef);
536
- const routes = musicCandidatesFromCatalog(catalog, normalized.modelRef);
790
+ const routes = musicCandidatesFromCatalog(catalog, normalized.modelRef, providerRegistry);
537
791
  let result;
538
792
  await withRawRuntime('writer', options['data-root'], async runtime => {
539
793
  result = await generateMusicTask({ runner: createProviderTaskRunner(runtime),
540
794
  candidates: routes.map(route => ({ adapter: trackedProviderAdapter(route.provider, options), model: route.modelRef,
541
- selectionReason: route.selectionReason, providerBinding: providerTaskBinding(route.provider) })),
795
+ selectionReason: route.selectionReason, providerBinding: providerTaskBinding(route.provider, providerRegistry) })),
542
796
  providerInput: normalized.providerInput, options: normalized.musicOptions, signal: cliAbortController.signal });
543
797
  console.log(JSON.stringify(result, null, 2));
544
798
  });
@@ -549,12 +803,12 @@ async function main(args) {
549
803
  const options = flags(args.slice(2), ['data-root', 'provider-config', 'input']);
550
804
  const { providerInput, modelRef } = generationInput('tts', jsonInput(options.input));
551
805
  const catalog = await generationCatalog(options, 'tts', modelRef);
552
- const routes = ttsCandidatesFromCatalog(catalog, modelRef);
806
+ const routes = ttsCandidatesFromCatalog(catalog, modelRef, providerRegistry);
553
807
  await withRawRuntime('writer', options['data-root'], async (runtime) => {
554
808
  const candidates = routes.map((route) => ({
555
809
  adapter: trackedProviderAdapter(route.provider, options), model: route.modelRef,
556
810
  selectionReason: route.selectionReason,
557
- providerBinding: providerTaskBinding(route.provider)
811
+ providerBinding: providerTaskBinding(route.provider, providerRegistry)
558
812
  }));
559
813
  const result = await createProviderTaskRunner(runtime).submit({
560
814
  capability: 'tts', candidates,
@@ -571,7 +825,7 @@ async function main(args) {
571
825
  const options = flags(args.slice(2), ['data-root', 'provider-config', 'input']);
572
826
  const normalized = generationInput('transcribe', jsonInput(options.input), { allowAudioPath: true });
573
827
  const catalog = await generationCatalog(options, 'transcribe', normalized.modelRef);
574
- const routes = transcribeCandidatesFromCatalog(catalog, normalized.modelRef);
828
+ const routes = transcribeCandidatesFromCatalog(catalog, normalized.modelRef, providerRegistry);
575
829
  let providerInput = normalized.providerInput;
576
830
  if (normalized.audioPath) {
577
831
  if (!fs.existsSync(normalized.audioPath)) throw new CliUsageError('音频文件不存在');
@@ -584,7 +838,7 @@ async function main(args) {
584
838
  const candidates = routes.map((route) => ({
585
839
  adapter: trackedProviderAdapter(route.provider, options), model: route.modelRef,
586
840
  selectionReason: route.selectionReason,
587
- providerBinding: providerTaskBinding(route.provider)
841
+ providerBinding: providerTaskBinding(route.provider, providerRegistry)
588
842
  }));
589
843
  const result = await createProviderTaskRunner(runtime).submit({
590
844
  capability: 'transcribe', candidates,
@@ -602,7 +856,7 @@ async function main(args) {
602
856
  const inspected = await withRuntime('reader', options['data-root'], commands => commands.execute('task.inspect', { task_id: args[2] }));
603
857
  const catalog = coreTaskWaitFinished(inspected.task) ? null : loadProviderCatalog(required(options['provider-config'], 'provider-config'));
604
858
  const result = await withRuntime(catalog ? 'writer' : 'reader', options['data-root'], commands => commands.execute('task.wait', input), {
605
- ...(catalog ? { resolveTaskAdapter: task => trackedProviderAdapter(providerForTaskFromCatalog(catalog, task), options, task) } : {}) });
859
+ ...(catalog ? { resolveTaskAdapter: task => trackedProviderAdapter(providerForTaskFromCatalog(catalog, task, providerRegistry), options, task) } : {}) });
606
860
  console.log(JSON.stringify(result, null, 2));
607
861
  return result.timedOut ? 3 : result.ready ? 0 : 1;
608
862
  }
@@ -612,7 +866,7 @@ async function main(args) {
612
866
  const catalog = loadProviderCatalog(required(options['provider-config'], 'provider-config'));
613
867
  await withRuntime('reader', options['data-root'], (commands) => commands.execute('task.inspect', { task_id: args[2] }));
614
868
  const result = await withRuntime('writer', options['data-root'], (commands) => commands.execute('task.observe', { task_id: args[2] }), {
615
- resolveTaskAdapter: (task) => trackedProviderAdapter(providerForTaskFromCatalog(catalog, task), options, task)
869
+ resolveTaskAdapter: (task) => trackedProviderAdapter(providerForTaskFromCatalog(catalog, task, providerRegistry), options, task)
616
870
  });
617
871
  console.log(JSON.stringify(result, null, 2));
618
872
  return 0;
@@ -623,7 +877,7 @@ async function main(args) {
623
877
  const catalog = options['provider-config'] ? loadProviderCatalog(options['provider-config']) : null;
624
878
  await withRuntime('reader', options['data-root'], (commands) => commands.execute('task.inspect', { task_id: args[2] }));
625
879
  const result = await withRuntime('writer', options['data-root'], (commands) => commands.execute('task.redeliver', { task_id: args[2] }), {
626
- ...(catalog ? { resolveTaskAdapter: (task) => trackedProviderAdapter(providerForTaskFromCatalog(catalog, task), options, task) } : {})
880
+ ...(catalog ? { resolveTaskAdapter: (task) => trackedProviderAdapter(providerForTaskFromCatalog(catalog, task, providerRegistry), options, task) } : {})
627
881
  });
628
882
  console.log(JSON.stringify(result, null, 2));
629
883
  return 0;
@@ -634,7 +888,7 @@ async function main(args) {
634
888
  const catalog = loadProviderCatalog(required(options['provider-config'], 'provider-config'));
635
889
  await withRuntime('reader', options['data-root'], (commands) => commands.execute('task.inspect', { task_id: args[2] }));
636
890
  const result = await withRuntime('writer', options['data-root'], (commands) => commands.execute('task.cancel', { task_id: args[2] }), {
637
- resolveTaskAdapter: (task) => trackedProviderAdapter(providerForTaskFromCatalog(catalog, task), options, task)
891
+ resolveTaskAdapter: (task) => trackedProviderAdapter(providerForTaskFromCatalog(catalog, task, providerRegistry), options, task)
638
892
  });
639
893
  console.log(JSON.stringify(result, null, 2));
640
894
  return 0;
@@ -658,19 +912,19 @@ async function main(args) {
658
912
  resolveTaskCandidates: async ({ capability, modelRef }) => {
659
913
  const catalog = await generationCatalog(options, { image: 'image-gen', video: 'video-gen', music: 'music-gen' }[capability] || capability, modelRef);
660
914
  return (capability === 'video'
661
- ? videoCandidatesFromCatalog(catalog, modelRef || undefined)
915
+ ? videoCandidatesFromCatalog(catalog, modelRef || undefined, providerRegistry)
662
916
  : capability === 'tts'
663
- ? ttsCandidatesFromCatalog(catalog, modelRef || undefined)
917
+ ? ttsCandidatesFromCatalog(catalog, modelRef || undefined, providerRegistry)
664
918
  : capability === 'music'
665
- ? musicCandidatesFromCatalog(catalog, modelRef || undefined)
919
+ ? musicCandidatesFromCatalog(catalog, modelRef || undefined, providerRegistry)
666
920
  : capability === 'transcribe'
667
- ? transcribeCandidatesFromCatalog(catalog, modelRef || undefined)
668
- : imageCandidatesFromCatalog(catalog, modelRef || undefined)
921
+ ? transcribeCandidatesFromCatalog(catalog, modelRef || undefined, providerRegistry)
922
+ : imageCandidatesFromCatalog(catalog, modelRef || undefined, providerRegistry)
669
923
  ).map((route) => ({
670
924
  adapter: trackedProviderAdapter(route.provider, options),
671
925
  model: route.modelRef,
672
926
  selectionReason: route.selectionReason,
673
- providerBinding: providerTaskBinding(route.provider)
927
+ providerBinding: providerTaskBinding(route.provider, providerRegistry)
674
928
  }));
675
929
  }
676
930
  });
@@ -753,11 +1007,25 @@ async function main(args) {
753
1007
  }
754
1008
 
755
1009
  try {
756
- process.exitCode = await main(process.argv.slice(2));
1010
+ const args = [], entries = [], raw = process.argv.slice(2);
1011
+ for (let index = 0; index < raw.length; index++) {
1012
+ if (raw[index] !== '--provider-extension') args.push(raw[index]);
1013
+ else {
1014
+ const entry = raw[++index];
1015
+ if (!entry || entry.startsWith('--')) throw new CliUsageError('--provider-extension 缺少入口路径');
1016
+ entries.push(entry);
1017
+ }
1018
+ }
1019
+ try {
1020
+ if (entries.length && args.includes('--plugin-config')) throw new CliUsageError('--plugin-config 与 --provider-extension 不能混用;Provider 插件应通过同一插件装配实例加载');
1021
+ if (args.length && !args.includes('--help') && !args.includes('-h')) cliProviderRegistry = await loadProviderRegistry(entries);
1022
+ process.exitCode = await main(args, cliProviderRegistry);
1023
+ } finally { await cliProviderRegistry?.dispose(); }
757
1024
  } catch (error) {
758
1025
  const code = error && error.code || 'IRIS_CLI_FAILED';
759
1026
  const message = error && error.message || '命令执行失败';
760
- console.error(code + ': ' + message);
1027
+ if (error.diagnostic) console.error(JSON.stringify(error.diagnostic));
1028
+ else console.error(code + ': ' + message);
761
1029
  if (error instanceof CliUsageError) console.error('\n' + usage());
762
1030
  process.exitCode = error instanceof CliUsageError ? 2 : 1;
763
1031
  }