@yanglaofish/fishtools 0.5.1 → 0.5.2

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 (66) hide show
  1. package/README.md +4 -2
  2. package/manual/README.md +4 -2
  3. package/manual/docs/deployment.md +2 -2
  4. package/manual/docs/modules.md +2 -0
  5. package/manual/docs/plugin-development.md +3 -1
  6. package/manual/docs/releases.md +32 -6
  7. package/manual/docs/superpowers/plans/2026-10-13-command-shortcuts.md +107 -0
  8. package/manual/docs/superpowers/reviews/2026-10-13-0.5.0-followup-review.md +4 -1
  9. package/manual/docs/superpowers/reviews/2026-10-13-command-shortcuts-review.md +63 -0
  10. package/manual/docs/superpowers/specs/2026-10-12-workflow-variation-design.md +1 -1
  11. package/manual/docs/superpowers/specs/2026-10-13-command-shortcuts-design.md +56 -0
  12. package/manual/docs/superpowers/specs/2026-10-13-issue-4-workflow-menu.md +28 -0
  13. package/manual/docs/terminal-commands.md +64 -0
  14. package/manual/docs/verification.md +26 -1
  15. package/manual/packages/core/README.md +3 -1
  16. package/manual/packages/core/package.json +1 -0
  17. package/manual/packages/core/src/application.js +114 -17
  18. package/manual/packages/core/src/export-snapshot.js +20 -1
  19. package/manual/packages/core/src/version.js +1 -1
  20. package/manual/packages/fst-cli/README.md +2 -0
  21. package/manual/packages/fst-cli/src/terminal/command-menu.js +186 -0
  22. package/manual/packages/fst-cli/src/terminal/navigation.js +4 -3
  23. package/manual/packages/fst-cli/src/terminal/session.js +35 -4
  24. package/manual/packages/fst-cli/src/terminal/view.js +43 -8
  25. package/manual/packages/fst-cli/src/terminal/workflow-menu.js +1 -6
  26. package/manual/packages/fst-devtools/src/documentation.js +2 -1
  27. package/manual/packages/fst-platform/README.md +11 -0
  28. package/manual/packages/fst-platform/package.json +1 -0
  29. package/manual/packages/fst-platform/src/process.js +11 -7
  30. package/manual/packages/fst-platform/src/shell.js +58 -0
  31. package/manual/packages/fst-plugin/src/contracts.js +6 -2
  32. package/manual/packages/fst-workspace/README.md +3 -0
  33. package/manual/packages/fst-workspace/package.json +1 -0
  34. package/manual/packages/fst-workspace/src/shortcuts.js +119 -0
  35. package/manual/packages/pack/index.js +1 -1
  36. package/manual/plugins/commands/LICENSE +21 -0
  37. package/manual/plugins/commands/README.md +41 -0
  38. package/manual/plugins/commands/index.js +94 -0
  39. package/manual/plugins/commands/package.json +17 -0
  40. package/node_modules/@mytools/commands/LICENSE +21 -0
  41. package/node_modules/@mytools/commands/README.md +41 -0
  42. package/node_modules/@mytools/commands/index.js +94 -0
  43. package/node_modules/@mytools/commands/package.json +17 -0
  44. package/node_modules/@mytools/core/README.md +3 -1
  45. package/node_modules/@mytools/core/package.json +1 -0
  46. package/node_modules/@mytools/core/src/application.js +114 -17
  47. package/node_modules/@mytools/core/src/export-snapshot.js +20 -1
  48. package/node_modules/@mytools/core/src/version.js +1 -1
  49. package/node_modules/@mytools/fst-cli/README.md +2 -0
  50. package/node_modules/@mytools/fst-cli/src/terminal/command-menu.js +186 -0
  51. package/node_modules/@mytools/fst-cli/src/terminal/navigation.js +4 -3
  52. package/node_modules/@mytools/fst-cli/src/terminal/session.js +35 -4
  53. package/node_modules/@mytools/fst-cli/src/terminal/view.js +43 -8
  54. package/node_modules/@mytools/fst-cli/src/terminal/workflow-menu.js +1 -6
  55. package/node_modules/@mytools/fst-devtools/src/documentation.js +2 -1
  56. package/node_modules/@mytools/fst-platform/README.md +11 -0
  57. package/node_modules/@mytools/fst-platform/package.json +1 -0
  58. package/node_modules/@mytools/fst-platform/src/process.js +11 -7
  59. package/node_modules/@mytools/fst-platform/src/shell.js +58 -0
  60. package/node_modules/@mytools/fst-plugin/src/contracts.js +6 -2
  61. package/node_modules/@mytools/fst-workspace/README.md +3 -0
  62. package/node_modules/@mytools/fst-workspace/package.json +1 -0
  63. package/node_modules/@mytools/fst-workspace/src/shortcuts.js +119 -0
  64. package/node_modules/@mytools/pack/index.js +1 -1
  65. package/node_modules/@mytools/web/public/THIRD_PARTY_NOTICES.txt +1447 -1423
  66. package/package.json +3 -1
@@ -22,6 +22,7 @@
22
22
  "node": ">=24.15.0"
23
23
  },
24
24
  "dependencies": {
25
+ "@mytools/commands": "0.1.0",
25
26
  "@mytools/sdk": "0.1.0",
26
27
  "@mytools/fst-platform": "0.1.0",
27
28
  "@mytools/fst-logs": "0.1.0",
@@ -1,4 +1,4 @@
1
- import { mkdir, readFile, writeFile, rename } from 'node:fs/promises';
1
+ import { mkdir, readFile, writeFile, rename, rm } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { randomUUID } from 'node:crypto';
4
4
  import { EventEmitter } from 'node:events';
@@ -7,6 +7,9 @@ import { createHost } from '@mytools/fst-plugin';
7
7
  import { WorkflowStore } from '@mytools/fst-workspace/workflows';
8
8
  import { runWorkflow } from '@mytools/fst-workflow';
9
9
  import { runProcess } from '@mytools/fst-platform';
10
+ import {runShell} from '@mytools/fst-platform/shell';
11
+ import {COMMANDS_PLUGIN_ID,commandsPluginRoot,createCommandsPlugin,validateCommandPresets} from '@mytools/commands';
12
+ import {validateShortcuts,describeShortcuts,bindShortcutCommands} from '@mytools/fst-workspace/shortcuts';
10
13
  import {createFileProbe} from '@mytools/fst-platform/fs';
11
14
  import {readJson} from '@mytools/fst-platform/json';
12
15
  import {loadPlugin} from '@mytools/fst-plugin/loader';
@@ -20,9 +23,22 @@ import {createExportSnapshot} from './export-snapshot.js';
20
23
  import {createRunLogs,validateKeepRuns} from '@mytools/fst-logs';
21
24
  import {openPluginState,withPluginMutation,assertPluginState,savePluginState,leaseInstallation} from '@mytools/fst-workspace/installations';
22
25
  import {planPluginUpdate,changedPluginSources} from './plugin-update.js';
23
- import {validateNetworkPolicy,inspectNetworkPolicy} from '@mytools/fst-platform/network';
26
+ import {validateNetworkPolicy,inspectNetworkPolicy,resolveNetworkPolicy,buildProcessEnv} from '@mytools/fst-platform/network';
24
27
  import {resolvePrivilegeRequirement,checkStaticPrivilege} from '@mytools/fst-plugin/privileges';
25
28
 
29
+ function declaredCommands(entries){
30
+ return entries.flatMap(entry=>{const plugin=entry.plugin??entry;return Object.keys(plugin.commands??{}).map(name=>({name,plugin:plugin.id}));});
31
+ }
32
+
33
+ function workflowActions(steps,found=new Set()){
34
+ for(const step of steps??[]){
35
+ if(step.action)found.add(step.action);
36
+ if(step.branch){workflowActions(step.branch.then,found);workflowActions(step.branch.else,found);}
37
+ if(step.parallel)workflowActions(step.parallel.steps,found);
38
+ }
39
+ return found;
40
+ }
41
+
26
42
  export async function createApplication({projectDir=process.cwd(),workspace,outputDir,plugins,config={},maintenance=false,omitInstalledPlugin,allowUnknownPrivileges=false}={}) {
27
43
  projectDir=await prepareBundleWorkspace(path.resolve(projectDir),workspace);
28
44
  const project=await readJson(path.join(projectDir,'flow.project.json'),{plugins:[]});
@@ -47,9 +63,14 @@ export async function createApplication({projectDir=process.cwd(),workspace,outp
47
63
  catalog:()=>host.catalog(),
48
64
  listPlugins:()=>[...host.definitions.values()].map(p=>({id:p.id,version:p.version,title:p.title,description:p.description,platforms:p.platforms,architectures:p.architectures,environments:p.environments,config:p.config,requirements:p.requirements,privileges:pluginPrivileges(p),runtime:{...host.runtimeStatus().find(r=>r.id===p.id),...(p.runtime?{updatePolicy:p.runtime.updatePolicy??'maintenance'}:{})},removable:!!(state.dependencies?.[p.id]||state.links?.[p.id]),development:!!state.links?.[p.id],warnings:host.warnings.filter(item=>item.plugin===p.id).map(item=>item.message)})),
49
65
  listCommands:()=>host.commands(),
66
+ listShortcuts:()=>describeShortcuts(project.shortcuts??{},{actions:host.catalog(),commands:host.commands()}),
67
+ async configureShortcuts(value){return updateUserCommands({shortcuts:value});},
68
+ async configureCommandPresets(value,options={}){
69
+ return updateUserCommands({commandPresets:value,...(options.shortcuts!==undefined?{shortcuts:options.shortcuts}:{})},options);
70
+ },
50
71
  listWorkflows:()=>store.list(),
51
- async saveWorkflow(w){return mutateWorkflow(()=>store.save(w));},
52
- async duplicateWorkflow(definition,options){return mutateWorkflow(()=>store.duplicate(definition,options));},
72
+ async saveWorkflow(w){return mutateWorkflow(()=>store.save(w),w);},
73
+ async duplicateWorkflow(definition,options){return mutateWorkflow(()=>store.duplicate(definition,options),definition);},
53
74
  async removeWorkflow(id){return mutateWorkflow(()=>store.remove(id));},
54
75
  async checkWorkflow(id){
55
76
  host.assertHealthy();
@@ -71,7 +92,29 @@ export async function createApplication({projectDir=process.cwd(),workspace,outp
71
92
  async startCommand(name,input={},context={}){
72
93
  if(busy)throw new FlowError('插件更新中,请稍后调用');
73
94
  const networkPolicy=structuredClone(project.network??{});
74
- return app.executeTracked(`/${name}`,(activeHost,runContext)=>activeHost.invokeCommand(name,input,{...context,...runContext,networkPolicy}));
95
+ return app.executeTracked(`/${name}`,(activeHost,runContext)=>{
96
+ if(context.expectedPlugin!==undefined&&!activeHost.commands().some(command=>command.name===name&&command.plugin===context.expectedPlugin)){
97
+ throw new FlowError('命令目标插件已变更,请重新选择目标后执行',{code:'COMMAND_TARGET_CHANGED',details:{command:name,plugin:context.expectedPlugin}});
98
+ }
99
+ return activeHost.invokeCommand(name,input,{...context,...runContext,networkPolicy});
100
+ });
101
+ },
102
+ async startAction(id,input={},context={}){
103
+ if(busy)throw new FlowError('插件更新中,请稍后调用');
104
+ const networkPolicy=structuredClone(project.network??{});
105
+ return app.executeTracked(id,(activeHost,runContext)=>activeHost.invoke(id,input,{...context,...runContext,networkPolicy}));
106
+ },
107
+ async startShell(script,{shell='auto',timeout=30000}={}){
108
+ if(busy)throw new FlowError('项目更新中,请稍后执行命令');
109
+ if(!Number.isSafeInteger(timeout)||timeout<0||timeout>2147483647)throw new FlowError('命令超时必须为 0 到 2147483647 毫秒,0 表示不限时',{code:'VALIDATION'});
110
+ const policy=resolveNetworkPolicy(structuredClone(project.network??{}));
111
+ const env=buildProcessEnv(policy);
112
+ return app.executeTracked('!命令',async(_host,context)=>{
113
+ const result=await runShell(script,{shell,temporaryDir:context.outputDir,cwd:projectDir,env,extendEnv:false,signal:context.signal,timeout,outputMode:'stream',summaryBytes:65536,
114
+ onOutput:({stream,message})=>context.log(message,undefined,{stream})});
115
+ if(result.exitCode!==0)throw new FlowError(`命令执行失败,退出码 ${result.exitCode}`,{code:'EXECUTION_FAILED',details:result});
116
+ return result;
117
+ });
75
118
  },
76
119
  async executeTracked(id,execute){
77
120
  const runId=randomUUID(),controller=new AbortController();
@@ -245,7 +288,11 @@ export async function createApplication({projectDir=process.cwd(),workspace,outp
245
288
  let initial;
246
289
  const services={application:app,runProcess,fs:fileProbe};
247
290
  const hostProject=()=>({directory:projectDir,readOnly:!!project.readOnly});
248
- const build=extra=>createHost({plugins:[...initial,...extra],config:{...project.config,...config},services,project:hostProject(),network:()=>project.network,environmentOverrides:project.environments,runtimeEnabled:!maintenance,allowUnknownPrivileges});
291
+ const build=extra=>{
292
+ const entries=entriesWithPresets([...initial,...extra],project.commandPresets??{});
293
+ validateShortcuts(project.shortcuts??{},{reservedNames:declaredCommands(entries).map(command=>command.name)});
294
+ return createHost({plugins:entries,config:{...project.config,...config},services,project:hostProject(),network:()=>project.network,environmentOverrides:project.environments,runtimeEnabled:!maintenance,allowUnknownPrivileges});
295
+ };
249
296
  try{
250
297
  initial=plugins??await Promise.all((project.plugins??[]).map(spec=>loadPlugin(spec,project.baseBundle?.root??projectDir)));
251
298
  const external=[];
@@ -253,8 +300,8 @@ export async function createApplication({projectDir=process.cwd(),workspace,outp
253
300
  for(const [name,source] of Object.entries(state.links??{}))if(!maintenance||name!==omitInstalledPlugin)external.push(await loadPlugin(source,projectDir));
254
301
  host=await build(external);
255
302
  }catch(error){await fileProbe.close();await opened.release();throw error;}
256
- for(const name of ['startRun','startCommand','startWeb','saveWorkflow','duplicateWorkflow','removeWorkflow','installPlugin','linkPlugin','removePlugin','configurePlugin','configureNetworkPolicy','configureLogs','setMaintenance','pack']){
257
- const original=app[name],isRun=['startRun','startCommand'].includes(name);
303
+ for(const name of ['startRun','startCommand','startAction','startShell','startWeb','saveWorkflow','duplicateWorkflow','removeWorkflow','installPlugin','linkPlugin','removePlugin','configurePlugin','configureNetworkPolicy','configureLogs','configureCommandPresets','configureShortcuts','setMaintenance','pack']){
304
+ const original=app[name],isRun=['startRun','startCommand','startAction','startShell'].includes(name);
258
305
  app[name]=async(...args)=>{
259
306
  if(closing)throw new FlowError('应用已关闭');
260
307
  if(isRun)launching++;
@@ -262,11 +309,16 @@ export async function createApplication({projectDir=process.cwd(),workspace,outp
262
309
  try{return await task;}finally{operations.delete(task);if(isRun)launching--;}
263
310
  };
264
311
  }
265
- async function mutateWorkflow(operation){
312
+ async function mutateWorkflow(operation,definition){
266
313
  if(project.readOnly)throw new FlowError('只读运行包不能修改工作流');
267
314
  if(busy)throw new FlowError('插件变更期间暂不能修改工作流',{code:'PLUGIN_BUSY'});
268
315
  return withPluginMutation(projectDir,async()=>{
269
316
  await assertPluginState(projectDir,state);
317
+ if(definition){
318
+ const workflow=store.validateShape(definition),latest=await readJson(path.join(projectDir,'flow.project.json'),project);
319
+ const missing=[...workflowActions(workflow.steps)].filter(id=>id.startsWith(`${COMMANDS_PLUGIN_ID}:`)&&!Object.hasOwn(latest.commandPresets??{},id.slice(COMMANDS_PLUGIN_ID.length+1)));
320
+ if(missing.length)throw new FlowError('引用的注册命令已删除,请重新选择动作后保存',{code:'COMMAND_NOT_FOUND',details:{actions:missing}});
321
+ }
270
322
  return operation();
271
323
  });
272
324
  }
@@ -280,6 +332,55 @@ export async function createApplication({projectDir=process.cwd(),workspace,outp
280
332
  await writeFile(backup,original,{flag:'wx',mode:0o600});
281
333
  return backup;
282
334
  }
335
+ function entriesWithPresets(entries,presets){
336
+ const definition=createCommandsPlugin(presets);
337
+ const result=entries.filter(entry=>(entry.plugin??entry).id!==COMMANDS_PLUGIN_ID);
338
+ if(Object.keys(definition.actions).length)result.push({plugin:definition,root:commandsPluginRoot});
339
+ return result;
340
+ }
341
+ async function updateUserCommands(changes,options={}){
342
+ if(project.readOnly)throw new FlowError('只读运行包不能修改注册命令或快捷名称');
343
+ if(busy||launching||app.listRuns().some(run=>run.status==='running'))throw new FlowError('请等待运行或安装结束');
344
+ const requested=structuredClone(changes);
345
+ if(Object.hasOwn(requested,'commandPresets'))requested.commandPresets=validateCommandPresets(requested.commandPresets);
346
+ busy=true;
347
+ try{return await withPluginMutation(projectDir,async()=>{
348
+ await assertPluginState(projectDir,state);
349
+ const file=path.join(projectDir,'flow.project.json'),latest=await readJson(file,project);
350
+ if(latest.readOnly)throw new FlowError('只读运行包不能修改注册命令或快捷名称');
351
+ for(const field of Object.keys(requested))if(!isDeepStrictEqual(latest[field]??{},project[field]??{})){
352
+ throw new FlowError('注册命令或快捷名称已被另一实例修改,请重新打开工作区再保存',{code:'USER_COMMANDS_CONFLICT',details:{field}});
353
+ }
354
+ const nextProject={...latest,...requested};
355
+ const presets=validateCommandPresets(nextProject.commandPresets??{});
356
+ const oldEntries=[...host.definitions.values()].map(plugin=>({plugin,root:plugin.root}));
357
+ const entries=entriesWithPresets(oldEntries,presets);
358
+ const reservedNames=declaredCommands(entries).map(command=>command.name);
359
+ const shortcuts=Object.hasOwn(requested,'shortcuts')?bindShortcutCommands(nextProject.shortcuts??{},{commands:declaredCommands(entries),previous:latest.shortcuts??{}}):nextProject.shortcuts??{};
360
+ nextProject.shortcuts=validateShortcuts(shortcuts,{reservedNames});
361
+ const removed=new Set(Object.keys(project.commandPresets??{}).filter(id=>!Object.hasOwn(presets,id)).map(id=>`${COMMANDS_PLUGIN_ID}:${id}`));
362
+ if(removed.size){
363
+ const workflows=(await findPluginWorkflowReferences(COMMANDS_PLUGIN_ID)).filter(workflow=>workflow.actions.some(action=>removed.has(action)));
364
+ if(workflows.length)throw new FlowError(`命令仍被工作流使用:${workflows.map(workflow=>workflow.title).join('、')}`,{code:'COMMAND_IN_USE',details:{workflows}});
365
+ }
366
+ const effectiveConfig={...latest.config,...config},oldConfig={...project.config,...config};
367
+ const nextDefinitions=new Map(entries.map(entry=>{const plugin=entry.plugin??entry;return [plugin.id,plugin];}));
368
+ const changed=new Set(isDeepStrictEqual(presets,project.commandPresets??{})?[]:[COMMANDS_PLUGIN_ID]);
369
+ const plan=planPluginUpdate(host.definitions,nextDefinitions,oldConfig,effectiveConfig,changed);
370
+ assertMaintenance(plan,options);
371
+ const backup=await backupProjectFile(file,latest,'commands');
372
+ if(plan.affected.length)await host.replace({plugins:entries,config:effectiveConfig,affected:plan.affected});
373
+ const temp=`${file}.${randomUUID()}.tmp`;
374
+ try{await writeFile(temp,JSON.stringify(nextProject,null,2));await rename(temp,file);}
375
+ catch(error){
376
+ if(plan.affected.length)try{await host.replace({plugins:oldEntries,config:oldConfig,affected:plan.affected});}
377
+ catch(recovery){host.invalidate();throw new AggregateError([error,recovery],`命令设置保存失败且恢复失败:${error.message};${recovery.message}`);}
378
+ throw error;
379
+ }finally{await rm(temp,{force:true});}
380
+ for(const key of Object.keys(project))delete project[key];Object.assign(project,nextProject);
381
+ return {backup,commandPresets:structuredClone(presets),shortcuts:structuredClone(nextProject.shortcuts)};
382
+ });}finally{busy=false;}
383
+ }
283
384
  function pluginPrivileges(plugin){
284
385
  const report=action=>{
285
386
  const requirement=resolvePrivilegeRequirement(plugin,action);
@@ -327,14 +428,9 @@ export async function createApplication({projectDir=process.cwd(),workspace,outp
327
428
  }
328
429
  async function findPluginWorkflowReferences(id){
329
430
  const refs=[];
330
- const collect=(steps,found)=>{for(const step of steps??[]){
331
- if(typeof step.action==='string'&&step.action.startsWith(`${id}:`))found.add(step.action);
332
- if(step.branch){collect(step.branch.then,found);collect(step.branch.else,found);}
333
- if(step.parallel)collect(step.parallel.steps,found);
334
- }};
335
431
  for(const workflow of await store.list()){
336
- const found=new Set();collect(workflow.steps,found);
337
- if(found.size)refs.push({id:workflow.id,title:workflow.title??workflow.id,actions:[...found]});
432
+ const actions=[...workflowActions(workflow.steps)].filter(action=>action.startsWith(`${id}:`));
433
+ if(actions.length)refs.push({id:workflow.id,title:workflow.title??workflow.id,actions});
338
434
  }
339
435
  return refs;
340
436
  }
@@ -371,8 +467,9 @@ export async function createApplication({projectDir=process.cwd(),workspace,outp
371
467
  if(workflows.length&&!options.force)throw new FlowError(`插件 ${name} 仍被工作流引用:${workflows.map(w=>w.id).join('、')}`,{code:'PLUGIN_IN_USE',details:{workflows}});
372
468
  }
373
469
  const next=await installGeneration(projectDir,{...operation,state});
374
- const nextEntries=[...initial,...next.loaded],activeConfig={...project.config,...config},effectiveConfig={...latest.config,...config};
470
+ const nextEntries=entriesWithPresets([...initial,...next.loaded],latest.commandPresets??{}),activeConfig={...project.config,...config},effectiveConfig={...latest.config,...config};
375
471
  candidate=await createHost({plugins:nextEntries,config:effectiveConfig,services,project:hostProject(),network:()=>project.network,environmentOverrides:project.environments,runtimeEnabled:false,allowUnknownPrivileges});
472
+ validateShortcuts(latest.shortcuts??{},{reservedNames:declaredCommands(nextEntries).map(command=>command.name)});
376
473
  const sourceChanges=await changedPluginSources(host.definitions,candidate.definitions,{oldInstallationRoot:state.root,newInstallationRoot:next.state.root});
377
474
  const plan=planPluginUpdate(host.definitions,candidate.definitions,activeConfig,effectiveConfig,sourceChanges);
378
475
  assertMaintenance(plan,options);
@@ -1,5 +1,6 @@
1
1
  import {fileURLToPath} from 'node:url';
2
2
  import {FlowError} from '@mytools/sdk';
3
+ import {COMMANDS_PLUGIN_ID} from '@mytools/commands';
3
4
 
4
5
  function selectedNetwork(network,ids){
5
6
  if(!network)return undefined;
@@ -28,6 +29,24 @@ export async function createExportSnapshot({store,host,project},{entries,portabl
28
29
  for(const id of closure.plugins)ids.add(id);
29
30
  if(includeWeb){if(!web)throw new FlowError('未安装提供 Web 服务的插件');ids.add(web.id);}
30
31
  for(const id of ids)for(const dependency of Object.keys(host.definitions.get(id)?.dependencies??{}))ids.add(dependency);
32
+ const usedActions=new Set();
33
+ const scan=steps=>{for(const step of steps??[]){
34
+ if(step.action)usedActions.add(step.action);
35
+ if(step.branch){scan(step.branch.then);scan(step.branch.else);}
36
+ if(step.parallel)scan(step.parallel.steps);
37
+ }};
38
+ for(const workflow of closure.workflows)scan(workflow.steps);
39
+ const commandPresets=Object.fromEntries(Object.entries(project.commandPresets??{}).filter(([id])=>ids.has(COMMANDS_PLUGIN_ID)&&(portable||usedActions.has(`${COMMANDS_PLUGIN_ID}:${id}`))));
40
+ const commandAllowed=id=>!id.startsWith(`${COMMANDS_PLUGIN_ID}:`)||Object.hasOwn(commandPresets,id.slice(COMMANDS_PLUGIN_ID.length+1));
41
+ const commands=new Map(host.commands().map(command=>[command.name,command]));
42
+ const shortcuts=Object.fromEntries(Object.entries(project.shortcuts??{}).filter(([,target])=>{
43
+ if(target.action){
44
+ const action=host.actions.get(target.action);
45
+ return action&&ids.has(action.plugin)&&commandAllowed(target.action);
46
+ }
47
+ const command=commands.get(target.command);
48
+ return command&&target.plugin===command.plugin&&ids.has(command.plugin)&&(!command.action||commandAllowed(`${command.plugin}:${command.action}`));
49
+ }));
31
50
  const plugins=[...ids].map(id=>{
32
51
  const p=host.definitions.get(id);
33
52
  if(!p?.root)throw new FlowError('内存中临时定义的插件不能打包,请先安装为标准包');
@@ -37,6 +56,6 @@ export async function createExportSnapshot({store,host,project},{entries,portabl
37
56
  ['actions',Object.fromEntries(Object.entries(p.actions??{}).map(([name,action])=>[name,{environments:action.environments,requirements:action.requirements}]))],
38
57
  ]);
39
58
  });
40
- return structuredClone({entries,closure,plugins,project:{environments:project.environments,network:selectedNetwork(project.network,ids),logs:project.logs,baseBundle:project.baseBundle},
59
+ return structuredClone({entries,closure,plugins,project:{environments:project.environments,network:selectedNetwork(project.network,ids),logs:project.logs,baseBundle:project.baseBundle,commandPresets,shortcuts},
41
60
  toolRoot:fileURLToPath(new URL('../../fst-cli',import.meta.url))});
42
61
  }
@@ -1,2 +1,2 @@
1
1
  // 由 npm version 的生命周期脚本同步,内部插件 API 版本独立维护。
2
- export const VERSION="0.5.1";
2
+ export const VERSION="0.5.2";
@@ -33,6 +33,8 @@ fst 命令入口、交互终端、输入呈现和运行观察。通过公开应
33
33
 
34
34
  ## 维护与验证
35
35
 
36
+ TUI 支持 `!` 系统命令、`/commands` 常用命令注册和 `/shortcuts` 任意插件能力快捷名。主输入框与命令编辑框使用 Shift+Enter / Alt+Enter 换行、Enter 提交;真实多行粘贴保持换行及缩进。使用说明可通过 `fst docs commands` 离线阅读。
37
+
36
38
  本模块负责上述职责的实现、文档与回归;跨模块接口调整需要同时检查生产者与消费者。具体维护人员尚未指定。
37
39
 
38
40
  在仓库根目录执行:
@@ -0,0 +1,186 @@
1
+ import {randomUUID} from 'node:crypto';
2
+ import {isCommandName} from '@mytools/fst-plugin/contracts';
3
+ import {editValue} from './schema.js';
4
+
5
+ export async function runShortcut(session,entry,args=[]){
6
+ if(!entry.available)throw new Error(`快捷名称 /${entry.name} 已失效:${entry.reason??'目标不存在'}`);
7
+ const {app}=session,target=entry.target;
8
+ if(target.command){
9
+ const command=app.listCommands().find(command=>command.name===target.command&&command.plugin===target.plugin);
10
+ if(!command)throw new Error(`快捷名称 /${entry.name} 的目标命令不存在`);
11
+ return session.pluginCommand({...command,name:entry.name,targetName:command.name,shortcutInput:entry.input??{}},args);
12
+ }
13
+ const action=app.catalog().find(action=>action.id===target.action);
14
+ if(!action)throw new Error(`快捷名称 /${entry.name} 的目标动作不存在`);
15
+ const plugin=action.plugin??action.id.slice(0,action.id.lastIndexOf(':'));
16
+ return session.pluginCommand({...action,name:entry.name,plugin,action:action.id.slice(plugin.length+1),actionId:action.id,shortcutInput:entry.input??{}},args);
17
+ }
18
+
19
+ async function parameters(session,value={}){
20
+ const result=structuredClone(value),{ui}=session;
21
+ for(;;){
22
+ const choice=await ui.select('命令参数(脚本通过 FST_INPUT_参数名 环境变量读取)',[
23
+ ...Object.entries(result).map(([name,field])=>({value:`field:${name}`,label:field.title??name,description:`${name} · ${field.type}`})),
24
+ {value:'new',label:'添加参数'},{value:'back',label:'完成参数设置'},
25
+ ]);
26
+ if(choice==='back')return result;
27
+ const existing=choice.startsWith('field:')?choice.slice(6):undefined;
28
+ if(existing){
29
+ const operation=await ui.select(`参数 ${existing}`,[{value:'edit',label:'编辑'},{value:'remove',label:'删除'},{value:'back',label:'返回'}]);
30
+ if(operation==='back')continue;
31
+ if(operation==='remove'){delete result[existing];continue;}
32
+ }
33
+ const name=existing??(await ui.input('参数名(英文字母、数字、下划线,不能以数字开头)',{files:false})).trim();
34
+ if(!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)||['__proto__','constructor','prototype'].includes(name)){ui.show('参数名无效,请重新填写。');continue;}
35
+ if(!existing&&Object.hasOwn(result,name)){ui.show('参数名已经存在。');continue;}
36
+ const title=await ui.input('参数显示名称',{value:result[name]?.title??name,files:false});
37
+ const type=await ui.select('参数类型',[{value:'string',label:'文本'},{value:'number',label:'数字'},{value:'boolean',label:'是 / 否'}]);
38
+ const field={title,type,required:await ui.confirm('这个参数必须填写吗?')};
39
+ if(await ui.confirm('设置默认值吗?'))field.default=await editValue(ui,{type},result[name]?.default,{baseDir:session.baseDir,required:true,label:'默认值'});
40
+ result[name]=field;
41
+ }
42
+ }
43
+
44
+ async function editPreset(session,id,current){
45
+ const {ui,app}=session,preset=structuredClone(current??{shell:'auto'});
46
+ preset.title=(await ui.input('常用命令名称',{value:preset.title??'',files:false})).trim();
47
+ preset.script=await ui.input('命令文本 · Shift+Enter 换行,Enter 提交(Alt+Enter 备用)',{value:preset.script??'',files:false,multiline:true});
48
+ const action=`@mytools/commands:${id}`;
49
+ const oldName=Object.keys(app.project.shortcuts??{}).find(name=>app.project.shortcuts[name].action===action);
50
+ let shortcut=oldName??(isCommandName(preset.title)?preset.title:'');
51
+ for(;;){
52
+ const choice=await ui.select('常用命令设置',[
53
+ {value:'save',label:'保存',description:shortcut?`快捷执行 /${shortcut}`:'只注册工作流动作'},
54
+ {value:'title',label:'修改名称'},{value:'script',label:'编辑多行命令'},
55
+ {value:'shortcut',label:'快捷名称',description:shortcut||'未设置'},
56
+ {value:'shell',label:'命令解释器',description:preset.shell??'auto'},
57
+ {value:'timeout',label:'超时时间',description:preset.timeout===0?'不限时':`${(preset.timeout??30000)/1000} 秒`},
58
+ {value:'parameters',label:'参数',description:`${Object.keys(preset.parameters??{}).length} 项,可不设置`},
59
+ {value:'root',label:'Linux root 权限要求',description:preset.requireRoot?'需要 root':'按当前用户权限执行'},
60
+ {value:'back',label:'取消,返回'},
61
+ ]);
62
+ if(choice==='back')return;
63
+ if(choice==='title')preset.title=(await ui.input('常用命令名称',{value:preset.title,files:false})).trim();
64
+ else if(choice==='script')preset.script=await ui.input('命令文本 · Shift+Enter 换行,Enter 提交(Alt+Enter 备用)',{value:preset.script,files:false,multiline:true});
65
+ else if(choice==='shortcut')shortcut=(await ui.input('快捷名称(支持中文,不含空格或 /;留空不设置)',{value:shortcut,files:false})).trim();
66
+ else if(choice==='shell')preset.shell=await ui.select('命令解释器',[
67
+ {value:'auto',label:'按系统选择(Windows PowerShell / Linux sh)'},
68
+ {value:'powershell',label:'Windows PowerShell'},{value:'pwsh',label:'PowerShell 7(pwsh)'},
69
+ {value:'sh',label:'sh'},{value:'bash',label:'bash'},
70
+ ]);
71
+ else if(choice==='timeout'){
72
+ const seconds=Number(await ui.input('超时时间(秒,0 表示不限时)',{value:String((preset.timeout??30000)/1000),files:false}));
73
+ if(!Number.isFinite(seconds)||!Number.isSafeInteger(seconds*1000)||seconds<0||seconds*1000>2147483647){ui.show('超时时间无效。');continue;}
74
+ preset.timeout=seconds*1000;
75
+ }else if(choice==='parameters')preset.parameters=await parameters(session,preset.parameters);
76
+ else if(choice==='root')preset.requireRoot=await ui.confirm('这个命令在 Linux 上必须使用 root 吗?执行前会检查,不会自动提权。');
77
+ else if(choice==='save'){
78
+ const next={...app.project.commandPresets,[id]:preset},shortcuts=structuredClone(app.project.shortcuts??{});
79
+ if(oldName)delete shortcuts[oldName];
80
+ if(shortcut){
81
+ if(Object.hasOwn(shortcuts,shortcut)){ui.show(`快捷名称 /${shortcut} 已存在,请选择其他名称。`);continue;}
82
+ shortcuts[shortcut]={...(oldName?app.project.shortcuts[oldName]:{}),action};
83
+ }
84
+ try{
85
+ const result=await session.withMaintenance(options=>app.configureCommandPresets(next,{...options,shortcuts}));
86
+ if(result===null){ui.show('已取消保存。');continue;}
87
+ ui.show(`常用命令已保存,修改前配置已备份。${shortcut?`输入 /${shortcut} 可直接运行。`:''}也可在工作流中添加此动作。`);
88
+ return;
89
+ }catch(error){ui.show(`保存失败:${error.message}`);}
90
+ }
91
+ }
92
+ }
93
+
94
+ export async function commandPresetsMenu(session){
95
+ const {app,ui}=session;
96
+ for(;;){
97
+ const choice=await ui.select('常用命令',[
98
+ ...(!app.project.readOnly?[{value:'new',label:'注册常用命令'}]:[]),
99
+ ...Object.entries(app.project.commandPresets??{}).map(([id,preset])=>({value:`preset:${id}`,label:preset.title,description:id})),
100
+ {value:'back',label:'返回主面板'},
101
+ ]);
102
+ if(choice==='back')return;
103
+ if(choice==='new'){session.writable();await editPreset(session,`command-${randomUUID().slice(0,8)}`);continue;}
104
+ const id=choice.slice(7),preset=app.project.commandPresets?.[id];if(!preset)continue;
105
+ const operation=await ui.select(preset.title,[{value:'run',label:'运行'},
106
+ ...(!app.project.readOnly?[{value:'edit',label:'编辑'},{value:'remove',label:'删除注册项'}]:[]),{value:'back',label:'返回'}]);
107
+ if(operation==='run')await runShortcut(session,{name:preset.title,target:{action:`@mytools/commands:${id}`},input:{},available:true});
108
+ else if(operation==='edit'){session.writable();await editPreset(session,id,preset);}
109
+ else if(operation==='remove'){
110
+ session.writable();if(!await ui.confirm(`删除 ${preset.title}?指向它的快捷名称会显示失效;引用中的工作流需先调整。`))continue;
111
+ const next=structuredClone(app.project.commandPresets);delete next[id];
112
+ try{const result=await session.withMaintenance(options=>app.configureCommandPresets(next,options));if(result!==null)ui.show('注册项已删除,修改前配置已备份。');}
113
+ catch(error){ui.show(`删除失败:${error.message}`);}
114
+ }
115
+ }
116
+ }
117
+
118
+ function targets(app){
119
+ return [...app.catalog().map(action=>({value:`action:${action.id}`,label:`动作:${action.title??action.id}`,description:action.id})),
120
+ ...app.listCommands().map(command=>({value:`command:${command.name}`,label:`命令:/${command.name}`,description:command.description}))];
121
+ }
122
+ function shortcutTarget(app,selected){
123
+ if(selected.startsWith('action:'))return {action:selected.slice(7)};
124
+ const name=selected.slice(8),command=app.listCommands().find(command=>command.name===name);
125
+ if(!command)throw new Error('目标命令已失效,请重新选择');
126
+ return {command:name,plugin:command.plugin};
127
+ }
128
+ async function editShortcut(session,oldName){
129
+ const {app,ui}=session,old=app.project.shortcuts?.[oldName];
130
+ let name=(await ui.input('快捷名称(支持中文,不含空格或 /)',{value:oldName??'',files:false})).trim();
131
+ let target=old?structuredClone(old):{};
132
+ if(!old){
133
+ const choices=targets(app);if(!choices.length){ui.show('当前没有可选择的插件动作或命令。');return;}
134
+ const selected=await ui.select('选择快捷名称指向的能力',choices);
135
+ target=shortcutTarget(app,selected);
136
+ }
137
+ for(;;){
138
+ const choice=await ui.select(`快捷名称 /${name}`,[
139
+ {value:'save',label:'保存'},{value:'name',label:'修改快捷名称'},
140
+ {value:'target',label:'选择目标',description:target.action??target.command},
141
+ {value:'input',label:'设置默认参数',description:'可留空,运行时按需要填写'},
142
+ {value:'back',label:'取消,返回'},
143
+ ]);
144
+ if(choice==='back')return;
145
+ if(choice==='name')name=(await ui.input('快捷名称',{value:name,files:false})).trim();
146
+ else if(choice==='target'){
147
+ const selected=await ui.select('选择目标',targets(app));
148
+ target=shortcutTarget(app,selected);
149
+ }else if(choice==='input'){
150
+ const metadata=target.action?app.catalog().find(action=>action.id===target.action):app.listCommands().find(command=>command.name===target.command&&command.plugin===target.plugin);
151
+ if(!metadata){ui.show('目标已失效,请先重新选择目标。');continue;}
152
+ target.input=await editValue(ui,metadata.input??{type:'object',properties:{},additionalProperties:false},target.input??{},{baseDir:session.baseDir,required:true,label:'快捷名称默认参数'});
153
+ }else if(choice==='save'){
154
+ const next=structuredClone(app.project.shortcuts??{});if(oldName)delete next[oldName];
155
+ if(Object.hasOwn(next,name)){ui.show('快捷名称已经存在,请选择其他名称。');continue;}
156
+ next[name]=target;
157
+ try{await app.configureShortcuts(next);ui.show('快捷名称已保存,修改前配置已备份。');return;}
158
+ catch(error){ui.show(`保存失败:${error.message}`);}
159
+ }
160
+ }
161
+ }
162
+
163
+ export async function shortcutsMenu(session){
164
+ const {app,ui}=session;
165
+ for(;;){
166
+ const entries=app.listShortcuts(),choice=await ui.select('自定义快捷名称',[
167
+ ...(!app.project.readOnly?[{value:'new',label:'添加快捷名称'}]:[]),
168
+ ...entries.map(entry=>({value:`shortcut:${entry.name}`,label:`/${entry.name}${entry.available?'':' [失效]'}`,description:entry.available?entry.description:entry.reason})),
169
+ {value:'back',label:'返回主面板'},
170
+ ]);
171
+ if(choice==='back')return;
172
+ if(choice==='new'){session.writable();await editShortcut(session);continue;}
173
+ const name=choice.slice(9),entry=entries.find(entry=>entry.name===name);if(!entry)continue;
174
+ const operation=await ui.select(`/${name}`,[
175
+ ...(entry.available?[{value:'run',label:'运行'}]:[]),
176
+ ...(!app.project.readOnly?[{value:'edit',label:'编辑'},{value:'remove',label:'删除快捷名称'}]:[]),{value:'back',label:'返回'},
177
+ ]);
178
+ if(operation==='run')await runShortcut(session,entry);
179
+ else if(operation==='edit'){session.writable();await editShortcut(session,name);}
180
+ else if(operation==='remove'){
181
+ session.writable();if(!await ui.confirm(`删除快捷名称 /${name}?`))continue;
182
+ const next=structuredClone(app.project.shortcuts??{});delete next[name];
183
+ await app.configureShortcuts(next);ui.show('快捷名称已删除,插件能力仍可使用。');
184
+ }
185
+ }
186
+ }
@@ -1,10 +1,11 @@
1
1
  import {builtinCommands} from '@mytools/fst-plugin/contracts';
2
2
 
3
- const visible=['workflows','run','pack','logs','plugin','config','tools','help','exit'];
3
+ const visible=['workflows','run','pack','logs','plugin','commands','shortcuts','config','tools','help','exit'];
4
4
  // 入口由插件 ID 推导;冲突时加前缀,不能覆盖内置或作者注册的命令。
5
- export function commandMenu(plugins,commands){
5
+ export function commandMenu(plugins,commands,shortcuts=[]){
6
6
  const entries=builtinCommands.filter(command=>visible.includes(command.name)).map(command=>({...command,description:`[内置] ${command.description}`}));
7
- const used=new Set([...builtinCommands.map(c=>c.name),...commands.map(c=>c.name)]);
7
+ const used=new Set([...builtinCommands.map(c=>c.name),...commands.map(c=>c.name),...shortcuts.map(entry=>entry.name)]);
8
+ entries.push(...shortcuts.map(entry=>({name:entry.name,shortcut:true,description:`[快捷] ${entry.available?entry.description:`已失效:${entry.reason}`}`})));
8
9
  const groups=plugins.filter(plugin=>commands.some(c=>c.plugin===plugin.id)).sort((a,b)=>a.id.localeCompare(b.id));
9
10
  for(const plugin of groups){
10
11
  const leaf=plugin.id.split('/').at(-1).replace(/[^\w.-]/g,'-');
@@ -9,6 +9,8 @@ import {gettingStarted,pluginStatus,pluginDetails} from './presentation.js';
9
9
  import {readableValue,readableError} from './run-presentation.js';
10
10
  import {commandMenu} from './navigation.js';
11
11
  import {workflowMenu} from './workflow-menu.js';
12
+ import {commandPresetsMenu,shortcutsMenu,runShortcut} from './command-menu.js';
13
+ import {validate} from '@mytools/sdk/schema';
12
14
  import {resolvePrivilegeRequirement,checkStaticPrivilege} from '@mytools/fst-plugin/privileges';
13
15
 
14
16
  export class TerminalSession{
@@ -24,7 +26,7 @@ export class TerminalSession{
24
26
  }
25
27
  }
26
28
  current(){if(!this.flow)throw new Error('先用 /workflows 打开或新建工作流');return this.flow;}
27
- commands(){return commandMenu(this.app.listPlugins?.()??[],this.app.listCommands());}
29
+ commands(){return commandMenu(this.app.listPlugins?.()??[],this.app.listCommands(),this.app.listShortcuts?.()??[]);}
28
30
  async discard(){return !this.dirty||await this.ui.confirm('当前修改未保存,是否放弃修改?');}
29
31
  async mayExit(){
30
32
  if(!await this.discard())return false;
@@ -41,7 +43,7 @@ export class TerminalSession{
41
43
  while(!this.exiting){
42
44
  this.ui.summary(`欢迎使用 Fishtools · 插件编排工作台\n当前工作区:${this.app.projectDir}\n${this.app.listPlugins().length} 个插件 · ${(await this.app.listWorkflows()).length} 个工作流${this.flow?` · ${this.flow.title??this.flow.id} · ${this.dirty?'未保存':'已保存'}`:''}`);
43
45
  let text;
44
- try{text=await this.ui.input('fst > 输入 / 选择操作,@ 引用文件',{commands:this.commands(),workflows:await this.app.listWorkflows()});}
46
+ try{text=await this.ui.input('fst > / 操作 · ! 系统命令 · Shift+Enter 换行 · Enter 提交',{commands:this.commands(),workflows:await this.app.listWorkflows(),multiline:true});}
45
47
  catch(e){if(e.code!=='PROMPT_CANCELLED')throw e;try{if(await this.mayExit())break;}catch{}continue;}
46
48
  if(!text.trim())continue;
47
49
  try{await this.execute(text);}catch(e){if(e.code!=='PROMPT_CANCELLED'||!this.ui.endCommand)this.ui.show(e.code==='PROMPT_CANCELLED'?'已返回主面板。':`${e.code==='COMMAND_INPUT'?'输入提示':'操作失败'}:${e.message}`);}
@@ -57,19 +59,25 @@ export class TerminalSession{
57
59
  finally{this.commandDepth--;if(outer&&!this.exiting)this.ui.endCommand?.();}
58
60
  }
59
61
  async executeCommand(text){
62
+ if(text.trimStart().startsWith('!'))return this.shellCommand(text.trimStart().slice(1));
60
63
  const [command,...args]=parseCommand(text);
64
+ const shortcut=this.app.listShortcuts?.().find(entry=>entry.name===command);
65
+ if(shortcut)return runShortcut(this,shortcut,args);
61
66
  const entry=this.commands().find(c=>c.name===command&&c.plugin);
62
67
  if(entry){if(args.length)throw new Error(`/${command} 请从二级菜单选择能力`);return this.pluginSkills(entry.plugin);}
63
68
  const pluginCommand=this.app.listCommands().find(c=>c.name===command);
64
69
  if(pluginCommand)return this.pluginCommand(pluginCommand,args);
65
70
  if(command!=='run'&&args.length)throw new Error(`/${command} 使用交互选择,请不要附加参数`);
66
71
  switch(command){
72
+ case 'commands':return commandPresetsMenu(this);
73
+ case 'shortcuts':return shortcutsMenu(this);
67
74
  case 'help':await this.ui.viewMarkdown('Fishtools 使用指南',[
68
75
  '# Fishtools',
69
76
  '将插件能力组合为工作流,在终端内编辑、运行并查看结果。',
70
77
  `当前工作区:${this.app.projectDir}`,
71
78
  `文件路径起点:${this.baseDir}`,
72
79
  '## 常用操作',gettingStarted(await this.app.listWorkflows(),this.app.project),
80
+ '## 系统命令与快捷名称','输入 ! 后接系统命令可直接执行,输出和状态保留在主面板;/commands 注册常用命令供快捷执行与工作流编排,/shortcuts 给任意插件动作或命令设置中文快捷名称。主输入框和命令编辑框使用 Shift+Enter 换行、Enter 提交,Alt+Enter 为备用换行;支持多行粘贴。系统命令按当前 Shell 语法执行,目标机器需要已安装相应外部程序。',
73
81
  '## 编排工作流','/workflows 浏览、新建、打开工作流,进入后添加步骤、编辑参数、设置输出、保存或删除。删除会保留备份,仍被其他流程引用时不能删除。/run 选择已保存的工作流,或补全工作流 ID/中文标题后执行已保存版本,不会要求保存编辑草稿。要执行未保存修改,在工作流编辑菜单选择运行,再选择保存并运行;返回编辑会保留草稿。',
74
82
  '## 主面板','运行期间可以继续输入命令,打开配置、管理插件或执行其他工作流。↑/↓ 回看本次启动已提交的命令,回到底部恢复草稿;补全菜单打开时仍用于选择候选。Ctrl+O 展开或收起执行详情,PgUp/PgDn 或滚轮浏览执行过程;/logs 回看各次执行的步骤、输入输出和日志。关闭菜单不会清空运行内容。',
75
83
  '## 命令分层','/config 配置 Fishtools,/plugin 管理插件及各插件配置。输入 /插件入口名 后进入该插件的能力菜单;补全列表标明 [内置] 或 [插件]。/tools 包含 Web 与离线文档。旧 /new、/add、/steps、/outputs、/save、/plugins 以及插件的原始命令仍可直接输入。',
@@ -118,14 +126,26 @@ export class TerminalSession{
118
126
  }
119
127
  async pluginCommand(command,args){
120
128
  if(args.length>1)throw new Error(`用法:/${command.name} [@参数.json]`);
121
- const input=args.length?await readRunInput(args[0],this.baseDir):command.input?await editValue(this.ui,command.input,{}, {baseDir:this.baseDir,required:true,label:command.description}):{};
129
+ let input;
130
+ if(args.length)input=await readRunInput(args[0],this.baseDir);
131
+ else{
132
+ const initial=structuredClone(command.shortcutInput??{});
133
+ const empty=command.input?.type==='object'&&command.input.properties&&Object.keys(command.input.properties).length===0;
134
+ if(command.shortcutInput!==undefined||empty){
135
+ try{input=validate(command.input,initial,command.description??command.name);}
136
+ catch{input=await editValue(this.ui,command.input,initial,{baseDir:this.baseDir,required:true,label:command.description});}
137
+ }else input=command.input?await editValue(this.ui,command.input,initial,{baseDir:this.baseDir,required:true,label:command.description}):initial;
138
+ }
122
139
  const ui=Object.fromEntries(['input','select','show','confirm'].map(name=>[name,this.ui[name].bind(this.ui)]));
123
140
  const definition=this.app.host.definitions.get(command.plugin);
141
+ if(!definition)throw new Error('目标插件已移除,请重新选择目标后执行');
124
142
  const requirement=resolvePrivilegeRequirement(definition,command.action);
125
143
  const unknown=requirement.declared&&checkStaticPrivilege(requirement).status==='unknown';
126
144
  const allowUnknownPrivileges=unknown?await this.ui.confirm(`/${command.name} 的权限需要运行时核查。若检查结果仍无法确认,是否允许本次继续?\n${requirement.reason??''}\n${requirement.remediation??''}`):false;
127
145
  if(unknown&&!allowUnknownPrivileges)return;
128
- const run=await this.app.startCommand(command.name,input,{ui,allowUnknownPrivileges});
146
+ const targetName=command.targetName??command.name;
147
+ if(!command.actionId&&!this.app.listCommands().some(current=>current.name===targetName&&current.plugin===command.plugin))throw new Error('命令目标插件已变更,请重新选择目标后执行');
148
+ const run=command.actionId?await this.app.startAction(command.actionId,input,{ui,allowUnknownPrivileges}):await this.app.startCommand(targetName,input,{ui,allowUnknownPrivileges,expectedPlugin:command.plugin});
129
149
  if(this.ui.trackRun){
130
150
  this.ui.clearMessages?.();this.ui.trackRun({app:this.app,runId:run.id,title:command.description??command.name,input});
131
151
  if(command.action){void run.done.catch(error=>{if(!this.ui.closed)this.ui.show(`运行记录保存失败:${error.message}`);});return;}
@@ -135,6 +155,17 @@ export class TerminalSession{
135
155
  try{const result=await run.done;if(!this.ui.trackRun)this.ui.show(`插件执行${runStatuses[result.status]??result.status}\n${result.error?readableError(result.error):readableValue(result.output)}\n/logs 查看执行详情与历史日志`);}
136
156
  finally{unsubscribe();done();}
137
157
  }
158
+ async shellCommand(script){
159
+ const run=await this.app.startShell(script);
160
+ if(this.ui.trackRun){
161
+ this.ui.clearMessages?.();this.ui.trackRun({app:this.app,runId:run.id,title:'系统命令'});
162
+ void run.done.catch(error=>{if(!this.ui.closed)this.ui.show(`运行记录保存失败:${error.message}`);});return;
163
+ }
164
+ const unsubscribe=this.app.subscribe(event=>{if(event.runId===run.id&&event.type==='log')this.ui.show(event.message);});
165
+ const done=this.ui.busy('正在执行系统命令 · Ctrl+C 取消',()=>this.app.cancelRun(run.id));
166
+ try{const result=await run.done;this.ui.show(`命令${runStatuses[result.status]??result.status}\n${result.error?readableError(result.error):readableValue(result.output)}\n/logs 查看完整日志`);}
167
+ finally{unsubscribe();done();}
168
+ }
138
169
  async configure(selectedId){
139
170
  this.writable();
140
171
  const plugins=this.app.listPlugins().filter(p=>p.config);