@yanglaofish/fishtools 0.2.0 → 0.4.0

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 (83) hide show
  1. package/README.md +43 -17
  2. package/manual/README.md +43 -17
  3. package/manual/docs/examples/pipeline-reader/LICENSE +21 -0
  4. package/manual/docs/examples/pipeline-reader/index.js +14 -0
  5. package/manual/docs/examples/pipeline-reader/package.json +7 -0
  6. package/manual/docs/examples/pipeline-writer/LICENSE +21 -0
  7. package/manual/docs/examples/pipeline-writer/index.js +17 -0
  8. package/manual/docs/examples/pipeline-writer/package.json +7 -0
  9. package/manual/docs/examples/script-plugin/LICENSE +21 -0
  10. package/manual/docs/examples/script-plugin/README.md +64 -0
  11. package/manual/docs/examples/script-plugin/index.js +39 -0
  12. package/manual/docs/examples/script-plugin/package.json +9 -0
  13. package/manual/docs/examples/script-plugin/scripts/benchmark.py +23 -0
  14. package/manual/docs/plugin-development.md +75 -14
  15. package/manual/docs/releases.md +30 -1
  16. package/manual/docs/research/2026-10-11-plugin-kernel-capabilities.md +167 -0
  17. package/manual/docs/roadmap.md +8 -1
  18. package/manual/docs/superpowers/plans/2026-10-11-control-observation.md +1 -1
  19. package/manual/docs/superpowers/plans/2026-10-11-public-plugin-fixes.md +40 -0
  20. package/manual/docs/superpowers/plans/2026-10-11-terminal-hierarchy.md +40 -0
  21. package/manual/docs/superpowers/plans/2026-10-12-plugin-developer-experience.md +50 -0
  22. package/manual/docs/superpowers/reviews/2026-10-12-plugin-release-review.md +20 -0
  23. package/manual/docs/superpowers/specs/2026-10-11-collapsed-plugin-output.md +23 -0
  24. package/manual/docs/superpowers/specs/2026-10-12-plugin-developer-experience.md +22 -0
  25. package/manual/docs/verification.md +100 -0
  26. package/manual/packages/core/src/application.js +235 -0
  27. package/manual/packages/core/src/engine.js +93 -0
  28. package/manual/packages/core/src/host.js +22 -5
  29. package/manual/packages/core/src/runtime.js +135 -0
  30. package/manual/packages/core/src/terminal/run-feed.js +78 -0
  31. package/manual/packages/core/src/terminal/session.js +438 -0
  32. package/manual/packages/core/src/terminal/view.js +283 -0
  33. package/manual/tests/application.test.js +30 -0
  34. package/node_modules/@mytools/core/bin/fst.js +27 -5
  35. package/node_modules/@mytools/core/package.json +2 -0
  36. package/node_modules/@mytools/core/src/application.js +46 -21
  37. package/node_modules/@mytools/core/src/contracts.js +27 -9
  38. package/node_modules/@mytools/core/src/documentation.js +1 -1
  39. package/node_modules/@mytools/core/src/engine.js +1 -1
  40. package/node_modules/@mytools/core/src/environments.js +39 -1
  41. package/node_modules/@mytools/core/src/host.js +22 -5
  42. package/node_modules/@mytools/core/src/markdown-log.js +1 -1
  43. package/node_modules/@mytools/core/src/plugin-installations.js +84 -0
  44. package/node_modules/@mytools/core/src/plugin-tools.js +133 -0
  45. package/node_modules/@mytools/core/src/plugins.js +16 -6
  46. package/node_modules/@mytools/core/src/process.js +30 -8
  47. package/node_modules/@mytools/core/src/schema-path.js +31 -0
  48. package/node_modules/@mytools/core/src/terminal/model.js +1 -1
  49. package/node_modules/@mytools/core/src/terminal/navigation.js +17 -0
  50. package/node_modules/@mytools/core/src/terminal/presentation.js +3 -3
  51. package/node_modules/@mytools/core/src/terminal/run-feed.js +78 -0
  52. package/node_modules/@mytools/core/src/terminal/run-presentation.js +60 -0
  53. package/node_modules/@mytools/core/src/terminal/run-viewer.js +30 -12
  54. package/node_modules/@mytools/core/src/terminal/session.js +118 -50
  55. package/node_modules/@mytools/core/src/terminal/theme.js +1 -0
  56. package/node_modules/@mytools/core/src/terminal/view.js +125 -28
  57. package/node_modules/@mytools/core/src/terminal/workflow-menu.js +50 -0
  58. package/node_modules/@mytools/core/src/validation.js +24 -9
  59. package/node_modules/@mytools/core/src/version.js +1 -1
  60. package/node_modules/@mytools/core/src/workflows.js +13 -15
  61. package/node_modules/@mytools/pack/dependencies.js +8 -1
  62. package/node_modules/@mytools/pack/index.js +4 -2
  63. package/node_modules/@mytools/web/public/THIRD_PARTY_NOTICES.txt +871 -870
  64. package/node_modules/{tar → minipass-flush/node_modules/minipass}/node_modules/yallist/README.md +7 -8
  65. package/node_modules/minipass-flush/node_modules/minipass/node_modules/yallist/package.json +29 -0
  66. package/node_modules/yallist/README.md +8 -7
  67. package/node_modules/yallist/package.json +51 -12
  68. package/package.json +15 -15
  69. package/node_modules/tar/node_modules/yallist/package.json +0 -68
  70. /package/node_modules/{yallist → minipass-flush/node_modules/minipass/node_modules/yallist}/LICENSE +0 -0
  71. /package/node_modules/{yallist → minipass-flush/node_modules/minipass/node_modules/yallist}/iterator.js +0 -0
  72. /package/node_modules/{yallist → minipass-flush/node_modules/minipass/node_modules/yallist}/yallist.js +0 -0
  73. /package/node_modules/{tar/node_modules/yallist → yallist}/LICENSE.md +0 -0
  74. /package/node_modules/{tar/node_modules/yallist → yallist}/dist/commonjs/index.d.ts +0 -0
  75. /package/node_modules/{tar/node_modules/yallist → yallist}/dist/commonjs/index.d.ts.map +0 -0
  76. /package/node_modules/{tar/node_modules/yallist → yallist}/dist/commonjs/index.js +0 -0
  77. /package/node_modules/{tar/node_modules/yallist → yallist}/dist/commonjs/index.js.map +0 -0
  78. /package/node_modules/{tar/node_modules/yallist → yallist}/dist/commonjs/package.json +0 -0
  79. /package/node_modules/{tar/node_modules/yallist → yallist}/dist/esm/index.d.ts +0 -0
  80. /package/node_modules/{tar/node_modules/yallist → yallist}/dist/esm/index.d.ts.map +0 -0
  81. /package/node_modules/{tar/node_modules/yallist → yallist}/dist/esm/index.js +0 -0
  82. /package/node_modules/{tar/node_modules/yallist → yallist}/dist/esm/index.js.map +0 -0
  83. /package/node_modules/{tar/node_modules/yallist → yallist}/dist/esm/package.json +0 -0
@@ -0,0 +1,438 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { isDeepStrictEqual } from 'node:util';
3
+ import path from 'node:path';
4
+ import { parseCommand, fileReference, readRunInput, newWorkflow, setBinding, stepMetadata, outputSources, stepAt, sequenceAt, pruneRuntimeInputs } from './model.js';
5
+ import { editCondition } from './conditions.js';
6
+ import { runStatuses } from './run-viewer.js';
7
+ import { editValue, needsJsonEditor } from './schema.js';
8
+ import {gettingStarted,pluginStatus,pluginDetails} from './presentation.js';
9
+ import {readableValue,readableError} from './run-presentation.js';
10
+ import {commandMenu} from './navigation.js';
11
+ import {workflowMenu} from './workflow-menu.js';
12
+
13
+ export class TerminalSession{
14
+ constructor(app,ui,{baseDir=process.cwd(),webOptions={}}={}){this.app=app;this.ui=ui;this.baseDir=baseDir;this.webOptions=webOptions;this.flow=null;this.dirty=false;this.exiting=false;this.web=null;ui.activeRuns=()=>app.listRuns().filter(run=>run.status==='running').length;}
15
+ writable(){if(this.app.project.readOnly)throw new Error('这是只读运行包,请在完整工作区中编排、安装或打包');}
16
+ current(){if(!this.flow)throw new Error('先用 /workflows 打开或新建工作流');return this.flow;}
17
+ commands(){return commandMenu(this.app.listPlugins?.()??[],this.app.listCommands());}
18
+ async discard(){return !this.dirty||await this.ui.confirm('当前修改未保存,是否放弃修改?');}
19
+ async mayExit(){
20
+ if(!await this.discard())return false;
21
+ const active=this.app.listRuns().filter(run=>run.status==='running');
22
+ if(active.length){
23
+ if(!await this.ui.confirm(`仍有 ${active.length} 个后台任务。退出工作台将取消这些任务,是否退出?`))return false;
24
+ for(const run of active)this.app.cancelRun(run.id);
25
+ }
26
+ return true;
27
+ }
28
+ async run(){
29
+ this.ui.show(gettingStarted(await this.app.listWorkflows(),this.app.project),{kind:'welcome'});
30
+ try{
31
+ while(!this.exiting){
32
+ 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?'未保存':'已保存'}`:''}`);
33
+ let text;
34
+ try{text=await this.ui.input('fst > 输入 / 选择操作,@ 引用文件',{commands:this.commands(),workflows:await this.app.listWorkflows()});}
35
+ catch(e){if(e.code!=='PROMPT_CANCELLED')throw e;try{if(await this.mayExit())break;}catch{}continue;}
36
+ if(!text.trim())continue;
37
+ 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}`);}
38
+ }
39
+ }finally{if(this.web)await this.web.close();}
40
+ }
41
+ async execute(text){
42
+ // 只记录用户提交的入口;菜单内部调用 /save 等动作不制造伪命令。
43
+ const outer=!this.commandDepth;
44
+ if(outer)this.ui.beginCommand?.(text);
45
+ this.commandDepth=(this.commandDepth??0)+1;
46
+ try{return await this.executeCommand(text);}
47
+ finally{this.commandDepth--;if(outer&&!this.exiting)this.ui.endCommand?.();}
48
+ }
49
+ async executeCommand(text){
50
+ const [command,...args]=parseCommand(text);
51
+ const entry=this.commands().find(c=>c.name===command&&c.plugin);
52
+ if(entry){if(args.length)throw new Error(`/${command} 请从二级菜单选择能力`);return this.pluginSkills(entry.plugin);}
53
+ const pluginCommand=this.app.listCommands().find(c=>c.name===command);
54
+ if(pluginCommand)return this.pluginCommand(pluginCommand,args);
55
+ if(command!=='run'&&args.length)throw new Error(`/${command} 使用交互选择,请不要附加参数`);
56
+ switch(command){
57
+ case 'help':await this.ui.viewMarkdown('Fishtools 使用指南',[
58
+ '# Fishtools',
59
+ '将插件能力组合为工作流,在终端内编辑、运行并查看结果。',
60
+ `当前工作区:${this.app.projectDir}`,
61
+ `文件路径起点:${this.baseDir}`,
62
+ '## 常用操作',gettingStarted(await this.app.listWorkflows(),this.app.project),
63
+ '## 编排工作流','/workflows 浏览、新建、打开工作流,进入后添加步骤、编辑参数、设置输出、保存或删除。删除会保留备份,仍被其他流程引用时不能删除。/run 选择已保存的工作流,或补全工作流 ID/中文标题后执行已保存版本,不会要求保存编辑草稿。要执行未保存修改,在工作流编辑菜单选择运行,再选择保存并运行;返回编辑会保留草稿。',
64
+ '## 主面板','运行期间可以继续输入命令,打开配置、管理插件或执行其他工作流。↑/↓ 回看本次启动已提交的命令,回到底部恢复草稿;补全菜单打开时仍用于选择候选。Ctrl+O 展开或收起执行详情,PgUp/PgDn 或滚轮浏览执行过程;/logs 回看各次执行的步骤、输入输出和日志。关闭菜单不会清空运行内容。',
65
+ '## 命令分层','/config 配置 Fishtools,/plugin 管理插件及各插件配置。输入 /插件入口名 后进入该插件的能力菜单;补全列表标明 [内置] 或 [插件]。/tools 包含 Web 与离线文档。旧 /new、/add、/steps、/outputs、/save、/plugins 以及插件的原始命令仍可直接输入。',
66
+ '## 观察运行','输入 /logs 打开执行记录。列表包含多个工作流的每次执行,运行中任务排在前面;选择后查看进度、结果和日志。从详情返回后可选择另一条记录,本次启动期间每条记录分别保留阅读位置。重启后仍可查看保留下来的执行记录。',
67
+ '1 / 2 / 3 切换总览、输入输出和日志,Enter 查看步骤详情,[ / ] 切换步骤,j 查看原始诊断数据。↑↓、PgUp/PgDn 或滚轮浏览;End 恢复尾部跟随。q / Esc 返回,正在执行的任务在后台继续,x 后 y 明确取消。',
68
+ '## 文件与运行参数','输入 @ 后按 Tab 选择文件;@@ 表示字面量 @。运行命令的 @参数.json 会读取 JSON 对象作为输入。',
69
+ '/run 工作流ID @"参数 文件.json"',
70
+ '## 离线文档','输入 /docs 阅读插件开发、部署、控制流与服务文档。独立命令 fst docs --path 查看资料位置,fst docs --export 目标目录 导出资料。',
71
+ '## 全部操作',this.commands().map(c=>`- /${c.name}:${c.description}`).join('\n'),
72
+ ].join('\n\n'));return;
73
+ case 'docs':{
74
+ const {listDocumentation,readDocumentation}=await import('../documentation.js');
75
+ const topics=await listDocumentation();
76
+ const topic=await this.ui.select('离线文档', [...topics.map(item=>({value:item.id,label:item.title})),{value:'back',label:'返回工作台'}]);
77
+ if(topic!=='back'){const document=await readDocumentation(topic);await this.ui.viewMarkdown(document.title,document.text);}
78
+ return;
79
+ }
80
+ case 'exit':if(await this.mayExit())this.exiting=true;return;
81
+ case 'workflows':return workflowMenu(this);
82
+ case 'new':{
83
+ this.writable();if(!await this.discard())return false;
84
+ const title=(await this.ui.input('工作流名称',{files:false})).trim();if(!title)throw new Error('名称不能为空');
85
+ const description=await this.ui.input('用途说明(可留空)',{files:false});
86
+ this.flow=newWorkflow(title,description);this.dirty=true;this.ui.show('工作流草稿已创建,可以在编辑菜单添加步骤。');return true;
87
+ }
88
+ case 'add':return this.add();
89
+ case 'steps':return this.steps();
90
+ case 'outputs':return this.outputs();
91
+ case 'save':this.writable();await this.app.saveWorkflow(this.current());this.dirty=false;this.ui.show(`已保存:${this.flow.title}`);return;
92
+ case 'run':return this.startRun(args);
93
+ case 'runs':return this.logs();
94
+ case 'logs':return this.logs();
95
+ case 'plugin':case 'plugins':return this.plugins();
96
+ case 'config':return this.toolConfig();
97
+ case 'tools':{
98
+ const choice=await this.ui.select('工具',[{value:'web',label:'启动 Web 工作台'},{value:'docs',label:'阅读离线文档'},{value:'back',label:'返回主面板'}]);
99
+ if(choice!=='back')await this.execute('/'+choice);return;
100
+ }
101
+ case 'pack':return this.pack();
102
+ case 'web':{
103
+ if(!this.web)this.web=await this.app.startWeb(this.webOptions);
104
+ this.ui.show(`Web 已启动,退出 fst 时关闭:\n${this.web.url}`);return;
105
+ }
106
+ default:throw new Error(`未知操作 /${command},输入 /help 查看可用操作`);
107
+ }
108
+ }
109
+ async pluginCommand(command,args){
110
+ if(args.length>1)throw new Error(`用法:/${command.name} [@参数.json]`);
111
+ 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}):{};
112
+ const ui=Object.fromEntries(['input','select','show','confirm'].map(name=>[name,this.ui[name].bind(this.ui)]));
113
+ const run=await this.app.startCommand(command.name,input,{ui});
114
+ if(this.ui.trackRun){
115
+ this.ui.clearMessages?.();this.ui.trackRun({app:this.app,runId:run.id,title:command.description??command.name,input});
116
+ if(command.action){void run.done.catch(error=>{if(!this.ui.closed)this.ui.show(`运行记录保存失败:${error.message}`);});return;}
117
+ }
118
+ const unsubscribe=this.ui.trackRun?()=>{}:this.app.subscribe(e=>{if(e.runId===run.id&&e.type==='log')this.ui.show(e.message);});
119
+ const done=this.ui.busy(`正在执行 /${command.name} · Ctrl+C 取消`,()=>this.app.cancelRun(run.id));
120
+ 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 查看执行详情与历史日志`);}
121
+ finally{unsubscribe();done();}
122
+ }
123
+ async configure(selectedId){
124
+ this.writable();
125
+ const plugins=this.app.listPlugins().filter(p=>p.config);
126
+ if(!plugins.length){this.ui.show('当前插件没有声明可配置字段。');return;}
127
+ const id=selectedId??await this.ui.select('选择要配置的插件',plugins.map(p=>({value:p.id,label:p.title??p.id})));
128
+ const plugin=plugins.find(p=>p.id===id);
129
+ const value=await editValue(this.ui,plugin.config,this.app.project.config?.[id]??{}, {baseDir:this.baseDir,required:true,label:plugin.title??id});
130
+ await this.app.configurePlugin(id,value);this.ui.show('插件配置已保存到当前工作区。');
131
+ }
132
+ async logs(){
133
+ const statuses=runStatuses;
134
+ while(true){
135
+ const history=(await this.app.listRunHistory()).sort((a,b)=>(b.status==='running')-(a.status==='running'));
136
+ if(!history.length){this.ui.show('还没有执行记录。运行工作流后,可在 /logs 回看。');return;}
137
+ const titles=new Map((await this.app.listWorkflows()).map(flow=>[flow.id,flow.title??flow.id]));
138
+ const choice=await this.ui.select('执行记录 · 选择一次执行,查看步骤、输入输出与日志',[
139
+ ...history.map(r=>({value:`run:${r.id}`,label:`${statuses[r.status]??r.status} · ${titles.get(r.workflowId)??r.workflowId}`,description:`${new Date(r.startedAt).toLocaleString()} · ${r.id.slice(0,8)}`})),
140
+ {value:'back',label:'返回'},
141
+ ]);
142
+ if(choice==='back')return;
143
+ const id=choice.slice(4),record=history.find(r=>r.id===id);
144
+ try{
145
+ if(this.ui.observeRun)await this.openRun(record,{initialView:2});
146
+ else await this.ui.viewMarkdown(`${record.workflowId} · ${statuses[record.status]??record.status}`,await this.app.readLog(id));
147
+ }
148
+ catch(error){if(error.code!=='PROMPT_CANCELLED')throw error;}
149
+ }
150
+ }
151
+ async add(){
152
+ this.writable();const flow=structuredClone(this.current()),catalog=this.app.catalog(),workflows=await this.app.listWorkflows();
153
+ await this.addNode(flow,[],catalog,workflows);
154
+ this.flow=flow;this.dirty=true;this.ui.show('步骤已添加,可在工作流菜单继续调整或保存。');
155
+ }
156
+ async addNode(flow,parent,catalog,workflows){
157
+ this.writable();
158
+ const choice=await this.ui.select('添加能力、子工作流或控制节点',[
159
+ ...catalog.map(a=>({value:`action:${a.id}`,label:a.title??a.id,description:a.description})),
160
+ ...workflows.filter(w=>w.id!==flow.id).map(w=>({value:`workflow:${w.id}`,label:`工作流:${w.title}`,description:w.description})),
161
+ {value:'control:branch',label:'条件分支'},{value:'control:parallel',label:'并行组'},{value:'control:stop',label:'提前结束'},
162
+ ]);
163
+ const sequence=sequenceAt(flow,parent),step={id:`step-${randomUUID().slice(0,8)}`},index=[...parent,sequence.length];
164
+ sequence.push(step);
165
+ if(choice==='control:branch')step.branch={condition:await editCondition(this.ui,flow,index,catalog,workflows,{baseDir:this.baseDir}),then:[],else:[]};
166
+ else if(choice==='control:parallel')step.parallel={concurrency:4,steps:[]};
167
+ else if(choice==='control:stop')step.stop={reason:''};
168
+ else{step.with={};if(choice.startsWith('action:'))step.action=choice.slice(7);else step.workflow=`./${choice.slice(9)}.yaml`;}
169
+ await this.editNode(flow,index,catalog,workflows);
170
+ }
171
+ async editNode(flow,index,catalog,workflows){
172
+ this.writable();const step=stepAt(flow,index);
173
+ if(step.stop){step.stop.reason=await this.ui.input('提前结束原因',{value:step.stop.reason,files:false});return;}
174
+ if(step.parallel){
175
+ const value=Number(await this.ui.input('最大并发数(1–32)',{value:String(step.parallel.concurrency??4),files:false}));
176
+ if(!Number.isInteger(value)||value<1||value>32)throw new Error('并发数必须为 1–32 的整数');
177
+ step.parallel.concurrency=value;await this.editSequence(flow,[...index,'parallel','steps'],catalog,workflows);return;
178
+ }
179
+ if(step.branch){
180
+ step.branch.else??=[];
181
+ while(true){
182
+ const choice=await this.ui.select('条件分支', [{value:'condition',label:'修改判断条件'},{value:'then',label:`条件成立 · ${step.branch.then.length} 步`},{value:'else',label:`条件不成立 · ${step.branch.else.length} 步`},{value:'done',label:'完成分支编辑'}]);
183
+ if(choice==='done')return;
184
+ if(choice==='condition')step.branch.condition=await editCondition(this.ui,flow,index,catalog,workflows,{baseDir:this.baseDir,current:step.branch.condition});
185
+ else await this.editSequence(flow,[...index,'branch',choice],catalog,workflows);
186
+ }
187
+ }
188
+ return this.editBindings(flow,index,catalog,workflows);
189
+ }
190
+ async editSequence(flow,parent,catalog,workflows){
191
+ const sequence=sequenceAt(flow,parent);
192
+ while(true){
193
+ const choice=await this.ui.select('嵌套步骤', [...sequence.map((s,i)=>({value:`step:${i}`,label:`${i+1}. ${stepMetadata(s,catalog,workflows)?.title??s.id}`})),{value:'add',label:'添加步骤'},{value:'done',label:'完成此组'}]);
194
+ if(choice==='done')return;
195
+ if(choice==='add')await this.addNode(flow,parent,catalog,workflows);
196
+ else await this.stepOperation(flow,[...parent,Number(choice.slice(5))],catalog,workflows);
197
+ }
198
+ }
199
+ async stepOperation(flow,index,catalog,workflows){
200
+ this.writable();const step=stepAt(flow,index),parent=index.slice(0,-1),position=index.at(-1),sequence=sequenceAt(flow,parent);
201
+ const action=await this.ui.select('步骤操作',[{value:'edit',label:step.branch||step.parallel||step.stop?'编辑控制节点与嵌套步骤':'设置参数'},{value:'when',label:'设置执行条件'},...(step.when?[{value:'clear-when',label:'移除执行条件'}]:[]),...(position>0?[{value:'up',label:'上移一步'}]:[]),...(position<sequence.length-1?[{value:'down',label:'下移一步'}]:[]),{value:'remove',label:'删除此步骤'}]);
202
+ if(action==='edit')await this.editNode(flow,index,catalog,workflows);
203
+ else if(action==='when')step.when=await editCondition(this.ui,flow,index,catalog,workflows,{baseDir:this.baseDir,current:step.when});
204
+ else if(action==='clear-when')delete step.when;
205
+ else if(action==='remove'){
206
+ if(!await this.ui.confirm('删除此步骤?引用它的步骤或输出需要重新设置。'))return;
207
+ for(const key of Object.keys(step.with??{}))setBinding(flow,index,key,'fixed',{},undefined);
208
+ sequence.splice(position,1);
209
+ pruneRuntimeInputs(flow);
210
+ }else{const to=position+(action==='up'?-1:1);[sequence[position],sequence[to]]=[sequence[to],sequence[position]];}
211
+ }
212
+ async editBindings(flow,index,catalog,workflows){
213
+ const step=stepAt(flow,index),meta=stepMetadata(step,catalog,workflows);
214
+ if(!meta)throw new Error('该步骤所需插件或子工作流不可用');
215
+ if(needsJsonEditor(meta.input)||!meta.input?.properties){
216
+ step.with=await editValue(this.ui,meta.input??{type:'object'},step.with,{baseDir:this.baseDir,required:true,label:`${meta.title} · 完整参数 JSON`});return;
217
+ }
218
+ const props=Object.entries(meta.input?.properties??{});if(!props.length)return;
219
+ while(true){
220
+ const selection=await this.ui.select(`${meta.title} · 选择要设置的参数`,[
221
+ ...props.map(([key,s])=>({value:`field:${key}`,label:`${s.title??key}${meta.input.required?.includes(key)?' *':''}`,description:JSON.stringify(step.with?.[key]??s.default)??'未填写'})),
222
+ {value:'done',label:'完成参数设置'},
223
+ ]);
224
+ if(selection==='done')return;
225
+ const key=selection.slice(6);
226
+ const schema=meta.input.properties[key],required=meta.input.required?.includes(key);
227
+ const choices=outputSources(flow,index,catalog,workflows).filter(s=>!schema.type||!s.schema?.type||schema.type===s.schema.type);
228
+ const mode=await this.ui.select(`${schema.title??key} · 输入来源`,[
229
+ {value:'fixed',label:'填写固定值 / @本地文件路径'},{value:'runtime',label:'每次运行时填写'},
230
+ ...choices.map(({value,label})=>({value,label})),...(!required?[{value:'unset',label:'不传此参数,使用插件默认值'}]:[]),
231
+ ]);
232
+ if(mode==='unset')setBinding(flow,index,key,'fixed',schema,undefined);
233
+ else if(mode==='fixed'){
234
+ const previous=step.with?.[key];
235
+ const value=await editValue(this.ui,schema,previous?.$ref?undefined:previous,{baseDir:this.baseDir,required,label:schema.title??key});
236
+ setBinding(flow,index,key,mode,schema,value);
237
+ }else setBinding(flow,index,key,mode,schema);
238
+ }
239
+ }
240
+ async steps(){
241
+ const flow=structuredClone(this.current()),catalog=this.app.catalog(),workflows=await this.app.listWorkflows();
242
+ if(!flow.steps.length){this.ui.show('当前没有步骤,用 /add 添加。');return;}
243
+ const id=await this.ui.select('选择步骤',flow.steps.map((s,i)=>({value:s.id,label:`${i+1}. ${stepMetadata(s,catalog,workflows)?.title??s.id}`,description:JSON.stringify(s.with)})));
244
+ const index=flow.steps.findIndex(s=>s.id===id);
245
+ if(this.app.project.readOnly){this.ui.show(JSON.stringify(flow.steps[index],null,2));return;}
246
+ await this.stepOperation(flow,[index],catalog,workflows);
247
+ if(isDeepStrictEqual(flow,this.flow))return;
248
+ this.flow=flow;this.dirty=true;this.ui.show('步骤已更新,保存前可继续调整。');
249
+ }
250
+ async outputs(){
251
+ this.writable();const flow=structuredClone(this.current());flow.outputs??={};
252
+ const choices=outputSources(flow,flow.steps.length,this.app.catalog(),await this.app.listWorkflows());
253
+ const action=await this.ui.select('工作流输出',[{value:'add',label:'添加汇总输出'},...Object.keys(flow.outputs).map(k=>({value:`remove:${k}`,label:`移除输出:${k}`}))]);
254
+ if(action==='add'){
255
+ const source=await this.ui.select('选择步骤输出',choices);
256
+ const name=(await this.ui.input('输出名称,例如 result',{files:false})).trim();
257
+ if(!/^[a-zA-Z][\w-]*$/.test(name)||['constructor','prototype','__proto__'].includes(name))throw new Error('输出名称请使用字母开头的英文、数字、下划线或短横线');
258
+ if(Object.hasOwn(flow.outputs,name))throw new Error('输出名称已存在,请使用另一个名称');
259
+ flow.outputs[name]={$ref:source};
260
+ }else delete flow.outputs[action.slice(7)];
261
+ this.flow=flow;this.dirty=true;this.ui.show('汇总输出已更新。');
262
+ }
263
+ async startRun(args,{draft=false}={}){
264
+ if(args.length>2)throw new Error('用法:/run [工作流ID] [@参数.json]');
265
+ const [first,second]=args;let reference=first?.startsWith('@')?first:second;
266
+ if(first?.startsWith('@')&&second)throw new Error('参数文件后不能再附加参数');
267
+ const workflows=await this.app.listWorkflows();
268
+ let flow=draft?this.current():null;
269
+ if(first&&!first.startsWith('@')){
270
+ flow=workflows.find(w=>w.id===first);if(!flow)throw new Error(`工作流不存在:${first}`);
271
+ }
272
+ if(!flow){
273
+ if(!workflows.length){this.ui.show('还没有已保存的工作流,请在 /workflows 中新建并保存。');return false;}
274
+ const id=await this.ui.select('运行 · 选择已保存的工作流',[...workflows.map(w=>({value:`workflow:${w.id}`,label:w.title??w.id,description:w.id})),{value:'back',label:'返回主面板'}]);
275
+ if(id==='back')return false;flow=workflows.find(w=>`workflow:${w.id}`===id);
276
+ }
277
+ if(flow===this.flow&&this.dirty){
278
+ const saved=workflows.find(w=>w.id===flow.id);
279
+ const choice=await this.ui.select(`${flow.title??flow.id} · 有未保存的修改`,[
280
+ {value:'cancel',label:'返回编辑',description:'保留草稿,本次不运行'},
281
+ {value:'save',label:'保存并运行',description:'将当前修改保存到工作流文件'},
282
+ ...(saved?[{value:'saved',label:'运行已保存版本',description:'保留当前草稿,不执行未保存修改'}]:[]),
283
+ ]);
284
+ if(choice==='cancel'){this.ui.show('已取消本次运行,草稿保留,返回工作流编辑。');return false;}
285
+ if(choice==='saved')flow=saved;
286
+ else await this.execute('/save');
287
+ }
288
+ await this.app.checkWorkflow(flow.id);
289
+ const input=reference?await readRunInput(reference,this.baseDir):(needsJsonEditor(flow.inputs)||Object.keys(flow.inputs?.properties??{}).length)?await editValue(this.ui,flow.inputs,{}, {baseDir:this.baseDir,label:'本次运行参数',required:true}):{};
290
+ const run=await this.app.startRun(flow.id,input);
291
+ if(this.ui.trackRun){
292
+ this.ui.clearMessages?.();this.ui.trackRun({app:this.app,runId:run.id,title:flow.title??flow.id,input});
293
+ void run.done.catch(error=>{if(!this.ui.closed)this.ui.show(`运行记录保存失败:${error.message}`);});return true;
294
+ }
295
+ if(this.ui.observeRun){
296
+ await this.ui.observeRun({app:this.app,runId:run.id,flow,workflows:await this.app.listWorkflows()});
297
+ const status=this.app.getRun(run.id)?.status;
298
+ this.ui.clearMessages?.();this.ui.show(`${status==='running'?'已返回工作台,任务在后台继续执行。':`本次运行${runStatuses[status]??'已结束'}。`}\n输入 /logs 查看任意一次执行的进度、结果和日志。`);return true;
299
+ }
300
+ const unsubscribe=this.app.subscribe(e=>{if(e.runId===run.id&&e.type.startsWith('step.'))this.ui.show(`${e.type} · ${e.callPath}${e.message?' · '+e.message:''}`);});
301
+ const done=this.ui.busy('运行中 · Ctrl+C 取消当前运行',()=>this.app.cancelRun(run.id));
302
+ try{
303
+ const result=await run.done;
304
+ this.ui.show(`运行${runStatuses[result.status]??result.status}\n${JSON.stringify(result.reason??(result.status==='succeeded'?result.output:result.error),null,2)}`);
305
+ if(result.status==='succeeded'&&!Object.keys(result.output??{}).length)this.ui.show(JSON.stringify(result.events.filter(e=>e.type==='step.succeeded').map(e=>({step:e.callPath,output:e.output})),null,2));
306
+ this.ui.show(`输入 /logs 在终端内查看日志\n可读日志:${path.join(this.app.outputDir,run.id,'run.md')}\n结果目录:${path.join(this.app.outputDir,run.id)}`);
307
+ }finally{unsubscribe();done();}
308
+ return true;
309
+ }
310
+ async pluginSkills(id){
311
+ const plugin=this.app.listPlugins().find(p=>p.id===id),commands=this.app.listCommands().filter(c=>c.plugin===id);
312
+ const choice=await this.ui.select(`[插件] ${plugin?.title??id} · 选择能力`,[
313
+ ...commands.map(c=>({value:`command:${c.name}`,label:c.description??c.name,description:`/${c.name}`})),{value:'back',label:'返回主面板'},
314
+ ]);
315
+ if(choice!=='back')await this.pluginCommand(commands.find(c=>`command:${c.name}`===choice),[]);
316
+ }
317
+ async toolConfig(){
318
+ while(true){
319
+ const keep=this.app.logSettings().keepRuns;
320
+ const choice=await this.ui.select('Fishtools 配置',[
321
+ {value:'info',label:'查看工作区信息'},
322
+ ...(!this.app.project.readOnly?[{value:'logs',label:'日志保留次数',description:keep===null?'不自动清理':`最近 ${keep} 次`}]:[]),
323
+ {value:'back',label:'返回主面板'},
324
+ ]);
325
+ if(choice==='back')return;
326
+ if(choice==='info')this.ui.show(`工作区:${this.app.projectDir}\n日志目录:${this.app.outputDir}\n模式:${this.app.project.readOnly?'只读运行包':'可编辑工作区'}`);
327
+ else await this.logSettings();
328
+ }
329
+ }
330
+ async logSettings(){
331
+ const keep=this.app.logSettings().keepRuns;
332
+ const mode=await this.ui.select('保留策略(整个项目合计,仅清理日志,保留业务文件)',[{value:'count',label:'仅保留最近 N 次已结束运行'},{value:'all',label:'不自动清理'},{value:'back',label:'返回'}]);
333
+ if(mode==='back')return;
334
+ const count=mode==='all'?null:Number(await this.ui.input('保留次数(例如 10)',{value:String(keep??10),files:false}));
335
+ await this.app.configureLogs(count);this.ui.show('日志保留设置已保存。');
336
+ }
337
+ async openRun(entry,{initialView}={}){
338
+ const record=this.app.getRun(entry.id)??await this.app.readRun(entry.id),workflows=await this.app.listWorkflows();
339
+ await this.ui.observeRun({app:this.app,runId:record.id,record,flow:workflows.find(flow=>flow.id===record.workflowId),workflows,initialView});
340
+ }
341
+ async plugins(){
342
+ this.ui.clearMessages?.();
343
+ while(true){
344
+ const plugins=this.app.listPlugins();
345
+ const action=await this.ui.select(`已安装插件(${plugins.length})· ↑↓ 选择插件查看能力与配置`,[
346
+ ...plugins.map(p=>({value:`plugin:${p.id}`,label:`${p.title??p.id} · v${p.version} · ${pluginStatus(p)}`,description:p.description??p.id})),
347
+ ...(!this.app.project.readOnly?[{value:'local',label:'+ 安装本地插件 / @路径'},{value:'npm',label:'+ 安装 npm 插件包'},{value:'maintenance',label:this.app.maintenance?'恢复插件服务':'进入维护模式(停止有状态服务)'},...(this.web?[{value:'stop-web',label:'停止 Web 工作台'}]:[])]:[]),{value:'back',label:'返回工作台'},
348
+ ]);
349
+ if(action.startsWith('plugin:')){
350
+ try{await this.pluginPage(action.slice(7));}catch(error){if(error.code!=='PROMPT_CANCELLED')throw error;}
351
+ continue;
352
+ }
353
+ if(action==='maintenance'){await this.app.setMaintenance(!this.app.maintenance);this.ui.show(this.app.maintenance?'已进入维护模式,可修改插件与配置。':'插件服务已恢复。');return;}
354
+ if(action==='stop-web'){await this.web.close();this.web=null;this.ui.show('Web 已停止。');return;}
355
+ if(action==='back')return;
356
+ this.writable();
357
+ if(action==='local'||action==='npm'){
358
+ const text=await this.ui.input(action==='npm'?'npm 包名(支持 @scope/name)':'输入 @ 并选择插件目录或 .tgz',{files:action==='local'});
359
+ const source=action==='local'?fileReference(text,this.baseDir):text.trim();if(!source)throw new Error('插件来源不能为空');
360
+ if(!await this.ui.confirm(`安装并加载可信插件:${source}?`))return;
361
+ const done=this.ui.busy('正在安装插件…');try{await this.app.installPlugin(source);}finally{done();}
362
+ }
363
+ this.ui.show('插件列表已更新。');
364
+ }
365
+ }
366
+ async pluginPage(id){
367
+ while(true){
368
+ const plugin=this.app.listPlugins().find(p=>p.id===id);if(!plugin)return;
369
+ const definition=this.app.host.definitions.get(id);
370
+ // 详情显示作者声明;维护模式只停止执行,不能让能力说明消失。
371
+ const actions=Object.entries(definition.actions??{}).map(([name,a])=>({...a,id:`${id}:${name}`}));
372
+ const declaredCommands=Object.entries(definition.commands??{}).map(([name,c])=>({...c,name}));
373
+ const commands=this.app.listCommands().filter(c=>c.plugin===id);
374
+ const choice=await this.ui.select(`${plugin.title??id} · ${pluginStatus(plugin)} · ${actions.length} 项能力`,[
375
+ {value:'details',label:'查看用途、能力、输入输出和环境要求'},
376
+ ...(!this.app.project.readOnly&&plugin.config?[{value:'configure',label:'设置插件配置'}]:[]),
377
+ ...commands.map(c=>({value:`command:${c.name}`,label:`运行 /${c.name}`,description:c.description})),
378
+ ...(!this.app.project.readOnly&&plugin.removable?[{value:'remove',label:'移除此插件'}]:[]),
379
+ {value:'back',label:'返回插件列表'},
380
+ ]);
381
+ if(choice==='back')return;
382
+ if(choice==='details')await this.ui.viewMarkdown(plugin.title??id,pluginDetails(plugin,actions,declaredCommands));
383
+ else if(choice==='configure')await this.configure(id);
384
+ else if(choice.startsWith('command:'))await this.pluginCommand(commands.find(c=>c.name===choice.slice(8)),[]);
385
+ else if(choice==='remove'){
386
+ this.writable();if(await this.ui.confirm(`移除 ${id}?已有工作流可能依赖它。`)){await this.app.removePlugin(id);this.ui.show('插件已移除。');return;}
387
+ }
388
+ }
389
+ }
390
+ async pack(){
391
+ this.writable();if(this.dirty){if(!await this.ui.confirm('先保存当前工作流再选择打包?'))return;await this.execute('/save');}
392
+ const flows=await this.app.listWorkflows(),entries=new Set();if(!flows.length)throw new Error('没有可打包的工作流');
393
+ while(true){
394
+ const selection=await this.ui.select('选择入口工作流(Enter 切换勾选)',[...flows.map(w=>({value:`workflow:${w.id}`,label:`${entries.has(w.id)?'[x]':'[ ]'} ${w.title??w.id}`})),{value:'done',label:'完成选择'}]);
395
+ if(selection==='done'){if(entries.size)break;this.ui.show('请至少选择一个工作流');continue;}
396
+ const id=selection.slice(9);
397
+ if(entries.has(id))entries.delete(id);else entries.add(id);
398
+ }
399
+ const target=await this.ui.select('目标运行平台',[{value:'win32-x64',label:'Windows x64 ZIP'},{value:'linux-x64-glibc',label:'Linux x64 / Linux 容器 tar.gz'}]);
400
+ const includeWeb=await this.ui.confirm('包含可选 Web 运行界面?(终端界面始终可用)');
401
+ const readOnly=await this.ui.select('导出用途',[{value:'editable',label:'完整工具,可编辑工作区(含内部 npm)'},{value:'readonly',label:'只读精简运行包'}])==='readonly';
402
+ const closure=await this.app.checkWorkflow([...entries][0]);
403
+ const ids=new Set(closure.plugins);
404
+ for(const id of [...entries].slice(1))for(const plugin of (await this.app.checkWorkflow(id)).plugins)ids.add(plugin);
405
+ if(includeWeb){
406
+ const provider=[...this.app.host.definitions.values()].find(plugin=>typeof plugin.services?.web==='function');
407
+ if(!provider)throw new Error('未安装提供 Web 服务的插件');
408
+ ids.add(provider.id);
409
+ }
410
+ for(const id of ids)for(const dependency of Object.keys(this.app.host.definitions.get(id)?.dependencies??{}))ids.add(dependency);
411
+ const environments={};
412
+ for(const id of ids){
413
+ const plugin=this.app.host.definitions.get(id),declarations=new Map();
414
+ for(const group of [plugin.environments,...Object.values(plugin.actions??{}).map(a=>a.environments)])for(const [key,e] of Object.entries(group??{})){
415
+ const sources=e.allowedSources??[e.source],previous=declarations.get(key);
416
+ declarations.set(key,{...e,allowedSources:previous?previous.allowedSources.filter(s=>sources.includes(s)):sources});
417
+ }
418
+ for(const [key,e] of declarations)if(e.allowedSources.length>1){
419
+ const source=await this.ui.select(`${id} / ${key} · 环境来源`,e.allowedSources.map(value=>({value,label:{host:'使用目标环境,不入包',bundled:'携带作者提供的目标环境资源',tool:'使用 Fishtools 自带环境'}[value]})));
420
+ environments[id]??={};environments[id][key]={source};
421
+ }
422
+ }
423
+ const options={entries:[...entries],target,includeWeb,readOnly,environments,outDir:path.join(this.baseDir,'dist')};
424
+ const plan=await this.app.packPlan(options);
425
+ this.ui.show([...plan.warnings,...plan.environments.map(e=>`${e.plugin} / ${e.id}: ${e.source} · ${e.bytes} 字节`),`声明环境资源合计 ${plan.environmentBytes} 字节(另含工具及 Node)`].join('\n'));
426
+ if(!await this.ui.confirm('按此清单导出?'))return;
427
+ const done=this.ui.busy('正在收集依赖并打包…');
428
+ try{const result=await this.app.pack(options);this.ui.show(`已生成:${result.archive}`);}finally{done();}
429
+ }
430
+ }
431
+
432
+ export async function startTerminal(app,options={}){
433
+ const {TerminalView}=await import('./view.js');
434
+ const ui=new TerminalView({baseDir:options.baseDir??process.cwd(),previewLabel:options.previewLabel}),session=new TerminalSession(app,ui,options);
435
+ const terminate=()=>{session.exiting=true;ui.pending?.reject(Object.assign(new Error('退出'),{code:'PROMPT_CANCELLED'}));for(const r of app.listRuns())if(r.status==='running')app.cancelRun(r.id);};
436
+ process.once('SIGTERM',terminate);
437
+ try{ui.start();await session.run();}finally{process.removeListener('SIGTERM',terminate);await ui.close();}
438
+ }