@petercjl/boardmixcli 0.0.0-stage → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONTRACT.md +83 -0
- package/INSTALL.md +11 -0
- package/LICENSE +21 -0
- package/README.md +39 -2
- package/UPDATE.md +11 -0
- package/bin/boardmixcli.mjs +48 -0
- package/capabilities.json +289 -0
- package/lib/assets.mjs +18 -0
- package/lib/batch.mjs +45 -0
- package/lib/bridge.mjs +61 -0
- package/lib/browser-process.mjs +61 -0
- package/lib/browser-session.mjs +32 -0
- package/lib/client.mjs +11 -0
- package/lib/commands.mjs +50 -0
- package/lib/desktop.mjs +59 -0
- package/lib/image-codecs.mjs +23 -0
- package/lib/maintenance.mjs +170 -0
- package/lib/page-adapter.js +155 -0
- package/lib/protocol.mjs +35 -0
- package/lib/video.mjs +33 -0
- package/package.json +43 -4
- package/plugin/main.js +51 -0
- package/scripts/install-notice.mjs +1 -0
- package/scripts/packed-smoke.mjs +32 -0
- package/scripts/sync-version.mjs +13 -0
- package/scripts/validate-package.mjs +11 -0
- package/skills/boardmixcli/SKILL.md +31 -0
- package/skills/boardmixcli/adapters/generic.json +44 -0
- package/skills/boardmixcli/agents/openai.yaml +3 -0
- package/skills/boardmixcli/capabilities.json +98 -0
- package/skills/boardmixcli/manifest.json +5 -0
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// Versioned adapter for the Boardmix web bundle observed in desktop 1.7.2.
|
|
2
|
+
// Called with explicit local endpoint/token; no business layout decisions here.
|
|
3
|
+
async function installPageAdapter(config,mainScript){
|
|
4
|
+
if(window.__boardmixcliPageBridge)window.__boardmixcliPageBridge.stop();
|
|
5
|
+
const src=Array.from(document.scripts).map(s=>s.src).find(s=>/\/assets\/index-[^/]+\.js/.test(s));
|
|
6
|
+
if(!src||!src.endsWith('/index-DjSD2oPg.js'))throw Error('UNVERIFIED_BOARDMIX_BUILD');
|
|
7
|
+
const module=await import(src), manager=module.z(),files=module.bm(),importer=module.eb;
|
|
8
|
+
if(typeof manager.renameFile!=='function'||typeof files.httpCreateFile!=='function'||typeof importer.importPureRefFiles!=='function')throw Error('ADAPTER_CONTRACT_MISMATCH');
|
|
9
|
+
const headers={Authorization:'Bearer '+config.token,'Content-Type':'application/json'};
|
|
10
|
+
let stopped=false,timer,pendingReply;
|
|
11
|
+
async function send(route,data){const r=await fetch(config.endpoint+route,{method:data?'POST':'GET',headers,body:data?JSON.stringify(data):undefined});if(!r.ok)throw Error('LOCAL_BRIDGE_'+r.status);return r.json();}
|
|
12
|
+
const runner=module.lr;
|
|
13
|
+
await runner.run({id:'boardmixcli-atomic',name:'boardmixcli',main:'main.js'},mainScript,'<!doctype html><meta charset="utf-8"><p>boardmixcli 已连接</p>');
|
|
14
|
+
function onReply(e){const r=e.data?.pluginMessage;if(r?.channel==='boardmixcli-v1'&&typeof r.ok==='boolean'&&pendingReply?.id===r.id){const done=pendingReply.resolve;pendingReply=null;done(r);}}
|
|
15
|
+
window.addEventListener('message',onReply);
|
|
16
|
+
async function plugin(op,params={}){
|
|
17
|
+
if(pendingReply)throw Error('PLUGIN_BUSY');
|
|
18
|
+
const id=crypto.randomUUID();
|
|
19
|
+
return new Promise((resolve,reject)=>{
|
|
20
|
+
const t=setTimeout(()=>{if(pendingReply?.id===id)pendingReply=null;reject(Error('PLUGIN_TIMEOUT'));},10000);
|
|
21
|
+
pendingReply={id,resolve:r=>{clearTimeout(t);r.ok?resolve(r.result):reject(Error(r.error));}};
|
|
22
|
+
window.postMessage({pluginMessage:{channel:'boardmixcli-v1',id,op,fileKey:manager.fileKey,params}},location.origin);
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
function boardInfo(){const f=manager.fileInfo;return {fileKey:manager.fileKey,name:f.name,teamId:f.team_id,projectId:f.project_id,folderId:f.folder_id,localSavePending:manager.isLocalSpaceActive&&manager.isEditorNeedSave,coopStatus:module.rl().coopStatus,uploading:importer.progressMap?.size||0};}
|
|
26
|
+
async function execute(job){
|
|
27
|
+
if(job.fileKey!==manager.fileKey)throw Error('FILE_MISMATCH');
|
|
28
|
+
const p=job.params;
|
|
29
|
+
if(job.op==='board.info')return boardInfo();
|
|
30
|
+
if(job.op==='board.create'){
|
|
31
|
+
const r=await files.httpCreateFile(p.teamId,p.projectId,p.folderId,undefined);
|
|
32
|
+
return {fileKey:r.file_key,name:r.name,teamId:r.team_id,projectId:r.project_id};
|
|
33
|
+
}
|
|
34
|
+
if(job.op==='board.rename'){await manager.renameFile(p.name);const read=await files.httpGetFile(job.fileKey);if(read.name!==p.name)throw Error('RENAME_READBACK_MISMATCH');return {fileKey:job.fileKey,name:read.name};}
|
|
35
|
+
if(job.op==='board.open'){if(!window.desktopManagerApi?.openFile)throw Error('DESKTOP_OPEN_UNAVAILABLE');window.desktopManagerApi.openFile(p.targetFileKey);return {requested:true,targetFileKey:p.targetFileKey};}
|
|
36
|
+
if(job.op==='board.close'){
|
|
37
|
+
if(importer.progressMap?.size||(manager.isLocalSpaceActive&&manager.isEditorNeedSave)||(!manager.isLocalSpaceActive&&module.rl().coopStatus!==1))throw Error('SAVE_OR_UPLOAD_PENDING');
|
|
38
|
+
if(!window.desktopManagerApi?.closeView)throw Error('DESKTOP_CLOSE_UNAVAILABLE');return {closeRequested:true,fileKey:job.fileKey};
|
|
39
|
+
}
|
|
40
|
+
if(job.op==='frame.create'){
|
|
41
|
+
const result=await plugin('frame.create',p),guid=result.readback.guid;
|
|
42
|
+
module.mK({nodes:[{guid,name:p.content||''}]});await new Promise(r=>setTimeout(r,150));
|
|
43
|
+
const readback=await plugin('object.get',guid);if(readback.name!==(p.content||''))throw Error('FRAME_NAME_READBACK_MISMATCH');return {created:result.created,readback};
|
|
44
|
+
}
|
|
45
|
+
if(job.op==='object.resize'){
|
|
46
|
+
const guid={sessionID:p.sessionID,localID:p.localID},original=await plugin('object.get',guid);
|
|
47
|
+
if(original.mediaType==='video/mp4')return plugin('object.resize',p);
|
|
48
|
+
const locked=original.aspectRatioLocked;
|
|
49
|
+
let matched=false;
|
|
50
|
+
try{
|
|
51
|
+
if(locked){module.mK({nodes:[{guid,privateInfo:{lockAspectRatio:false}}]});await new Promise(r=>setTimeout(r,150));}
|
|
52
|
+
module.mK({nodes:[{guid,width:p.width,height:p.height}]});
|
|
53
|
+
const deadline=Date.now()+3000;
|
|
54
|
+
while(Date.now()<deadline){await new Promise(r=>setTimeout(r,100));const shape=await plugin('object.get',guid);if(Math.abs(shape.width-p.width)<0.01&&Math.abs(shape.height-p.height)<0.01){matched=true;break;}}
|
|
55
|
+
if(!matched)throw Error('RESIZE_READBACK_MISMATCH');
|
|
56
|
+
}finally{if(locked){module.mK({nodes:[{guid,privateInfo:{lockAspectRatio:true}}]});await new Promise(r=>setTimeout(r,150));}}
|
|
57
|
+
return plugin('object.get',guid);
|
|
58
|
+
}
|
|
59
|
+
if(job.op==='object.place.batch'){
|
|
60
|
+
const guids=p.items.map(({sessionID,localID})=>({sessionID,localID}));
|
|
61
|
+
const before=(await plugin('object.get.batch',{guids})).objects;
|
|
62
|
+
const unlocked=before.filter(o=>o.aspectRatioLocked&&o.mediaType!=='video/mp4').map(o=>({guid:o.guid,privateInfo:{lockAspectRatio:false}}));
|
|
63
|
+
try{
|
|
64
|
+
if(unlocked.length)module.mK({nodes:unlocked});
|
|
65
|
+
for(let i=0;i<before.length;i++)if(before[i].mediaType==='video/mp4')await plugin('object.resize',p.items[i]);
|
|
66
|
+
module.mK({nodes:p.items.filter((_,i)=>before[i].mediaType!=='video/mp4').map((i)=>({guid:{sessionID:i.sessionID,localID:i.localID},width:i.width,height:i.height}))});
|
|
67
|
+
let ready=false;const deadline=Date.now()+5000;
|
|
68
|
+
while(Date.now()<deadline){const read=(await plugin('object.get.batch',{guids})).objects;if(read.every((o,i)=>Math.abs(o.width-p.items[i].width)<0.01&&Math.abs(o.height-p.items[i].height)<0.01)){ready=true;break;}await new Promise(r=>setTimeout(r,100));}
|
|
69
|
+
if(!ready)throw Error('RESIZE_READBACK_MISMATCH');
|
|
70
|
+
await plugin('object.move.batch',{items:p.items});
|
|
71
|
+
}finally{if(unlocked.length)module.mK({nodes:before.filter(o=>o.aspectRatioLocked).map(o=>({guid:o.guid,privateInfo:{lockAspectRatio:true}}))});}
|
|
72
|
+
const deadline=Date.now()+5000;
|
|
73
|
+
while(Date.now()<deadline){
|
|
74
|
+
const result=await plugin('object.get.batch',{guids});
|
|
75
|
+
if(result.objects.every((o,i)=>['x','y','width','height'].every(k=>Math.abs(o[k]-p.items[i][k])<0.02)&&o.aspectRatioLocked===before[i].aspectRatioLocked))return result;
|
|
76
|
+
await new Promise(r=>setTimeout(r,100));
|
|
77
|
+
}
|
|
78
|
+
throw Error('PLACE_READBACK_MISMATCH');
|
|
79
|
+
}
|
|
80
|
+
if(job.op==='image.import.batch'){
|
|
81
|
+
if(importer.progressMap?.size)throw Error('UPLOAD_ALREADY_PENDING');
|
|
82
|
+
const before=await plugin('object.selection'),known=new Set(before.objects.map(o=>o.guid.sessionID+':'+o.guid.localID));
|
|
83
|
+
const inputs=[];
|
|
84
|
+
// Bound local reads; provider upload scheduling belongs to the native importer.
|
|
85
|
+
for(let start=0;start<p.items.length;start+=4){inputs.push(...await Promise.all(p.items.slice(start,start+4).map(async i=>{
|
|
86
|
+
const a=await send('/asset-info/'+i.assetId),r=await fetch(config.endpoint+'/asset/'+i.assetId,{headers});if(!r.ok)throw Error('ASSET_FETCH_FAILED');
|
|
87
|
+
return {...i,asset:a,file:new File([await r.blob()],a.name,{type:a.mime})};
|
|
88
|
+
})));}
|
|
89
|
+
const submitted=await importer.importPureRefFiles(inputs.map(i=>({file:i.file})));
|
|
90
|
+
if(submitted!==inputs.length)throw Error('BATCH_IMPORT_OUTCOME_UNKNOWN');
|
|
91
|
+
const deadline=Date.now()+120000;
|
|
92
|
+
while(Date.now()<deadline){
|
|
93
|
+
await new Promise(r=>setTimeout(r,250));
|
|
94
|
+
const selected=(await plugin('object.selection')).objects.filter(o=>!known.has(o.guid.sessionID+':'+o.guid.localID));
|
|
95
|
+
if(selected.length>inputs.length)throw Error('CONCURRENT_CANVAS_MUTATION');
|
|
96
|
+
if(selected.length===inputs.length&&!importer.progressMap?.size&&selected.every(o=>o.width>0&&o.height>0)){
|
|
97
|
+
const unused=[...selected],items=[];
|
|
98
|
+
for(const i of inputs){const n=unused.findIndex(o=>o.resourceHash===i.asset.md5);if(n<0)throw Error('BATCH_RESOURCE_MISMATCH');const object=unused.splice(n,1)[0];items.push({key:i.key,asset:i.asset,object});}
|
|
99
|
+
return {items,uploadFinished:true};
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
throw Error('BATCH_IMPORT_OUTCOME_UNKNOWN');
|
|
103
|
+
}
|
|
104
|
+
if(job.op==='video.import'){
|
|
105
|
+
if(importer.progressMap?.size)throw Error('UPLOAD_ALREADY_PENDING');
|
|
106
|
+
const a=await send('/asset-info/'+p.assetId),before=await plugin('object.selection');
|
|
107
|
+
if(a.kind!=='video')throw Error('VIDEO_ASSET_REQUIRED');
|
|
108
|
+
const r=await fetch(config.endpoint+'/asset/'+p.assetId,{headers});if(!r.ok)throw Error('ASSET_FETCH_FAILED');
|
|
109
|
+
const file=new File([await r.blob()],a.name,{type:a.mime});
|
|
110
|
+
const prepared=await importer.formatPlaceHolderInfo({splitFiles:{file:[file]}});
|
|
111
|
+
if(prepared.uploadTask?.length!==1||prepared.uploadTask[0].fileType!=='video')throw Error('VIDEO_PREPARATION_FAILED');
|
|
112
|
+
const task=prepared.uploadTask[0];
|
|
113
|
+
await importer.filesTask(prepared.placeholderResult,[]);
|
|
114
|
+
await importer.uploadVideo(task.promiseId,file);
|
|
115
|
+
const known=new Set(before.objects.map(o=>o.guid.sessionID+':'+o.guid.localID));
|
|
116
|
+
const deadline=Date.now()+10000;
|
|
117
|
+
while(Date.now()<deadline){
|
|
118
|
+
const added=(await plugin('object.selection')).objects.filter(o=>!known.has(o.guid.sessionID+':'+o.guid.localID));
|
|
119
|
+
if(added.length>1)throw Error('CONCURRENT_CANVAS_MUTATION');
|
|
120
|
+
if(added.length===1&&added[0].width>0&&added[0].height>0&&added[0].resourceHash===a.md5&&added[0].mediaType==='video/mp4'&&!added[0].mediaLoading)return {asset:a,object:added[0],uploadFinished:true};
|
|
121
|
+
await new Promise(r=>setTimeout(r,250));
|
|
122
|
+
}
|
|
123
|
+
throw Error('VIDEO_IMPORT_OUTCOME_UNKNOWN');
|
|
124
|
+
}
|
|
125
|
+
if(job.op==='image.import'){
|
|
126
|
+
const a=await send('/asset-info/'+p.assetId),before=await plugin('object.selection');
|
|
127
|
+
const r=await fetch(config.endpoint+'/asset/'+p.assetId,{headers});if(!r.ok)throw Error('ASSET_FETCH_FAILED');
|
|
128
|
+
const blob=await r.blob(),file=new File([blob],a.name,{type:a.mime});
|
|
129
|
+
const submitted=await importer.importPureRefFiles([{file}]);if(submitted!==1)throw Error('IMPORT_REJECTED');
|
|
130
|
+
const known=new Set(before.objects.map(x=>x.guid.sessionID+':'+x.guid.localID));
|
|
131
|
+
const deadline=Date.now()+120000;
|
|
132
|
+
while(Date.now()<deadline){
|
|
133
|
+
await new Promise(r=>setTimeout(r,500));
|
|
134
|
+
const current=await plugin('object.selection'),added=current.objects.filter(x=>!known.has(x.guid.sessionID+':'+x.guid.localID));
|
|
135
|
+
if(added.length>1)throw Error('CONCURRENT_CANVAS_MUTATION');
|
|
136
|
+
if(added.length===1&&!(importer.progressMap?.size)&&added[0].width>0&&added[0].height>0)return {asset:{name:a.name,sha256:a.sha256,width:a.width,height:a.height},object:added[0],uploadFinished:true};
|
|
137
|
+
}
|
|
138
|
+
throw Error('IMPORT_OUTCOME_UNKNOWN');
|
|
139
|
+
}
|
|
140
|
+
return plugin(job.op,p);
|
|
141
|
+
}
|
|
142
|
+
async function poll(){
|
|
143
|
+
if(stopped)return;
|
|
144
|
+
try{
|
|
145
|
+
const next=await send('/next?fileKey='+encodeURIComponent(manager.fileKey));
|
|
146
|
+
if(next.request){const job=next.request;let response;try{response={id:job.id,ok:true,result:await execute(job)};}catch(e){response={id:job.id,ok:false,error:String(e.message||e)};}
|
|
147
|
+
await send('/result',response);
|
|
148
|
+
if(job.op==='board.close'&&response.ok){window.__boardmixcliPageBridge.stop();setTimeout(()=>window.desktopManagerApi.closeView(),100);return;}
|
|
149
|
+
}
|
|
150
|
+
}catch(e){console.warn('boardmixcli connection',e.message);}
|
|
151
|
+
timer=setTimeout(poll,350);
|
|
152
|
+
}
|
|
153
|
+
window.__boardmixcliPageBridge={endpoint:config.endpoint,active:true,stop(){this.active=false;stopped=true;clearTimeout(timer);window.removeEventListener('message',onReply);}};
|
|
154
|
+
poll();return {connected:true,...boardInfo()};
|
|
155
|
+
}
|
package/lib/protocol.mjs
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export const operations = ['video.import','image.import.batch','object.get.batch','object.place.batch','info','text.create','frame.create','object.get','object.selection','object.move','object.resize','view.fit','board.info','board.create','board.rename','board.open','board.close','image.import'];
|
|
2
|
+
export function validate(input) {
|
|
3
|
+
if (!input || !operations.includes(input.op)) throw new Error('UNSUPPORTED_OPERATION');
|
|
4
|
+
if (typeof input.fileKey !== 'string' || !/^[A-Za-z0-9_-]{8,100}$/.test(input.fileKey)) throw new Error('INVALID_FILE_KEY');
|
|
5
|
+
const p = input.params ?? {};
|
|
6
|
+
if (!p || typeof p !== 'object' || Array.isArray(p)) throw new Error('INVALID_PARAMS');
|
|
7
|
+
const fields = ({'video.import':['assetId','operationId'],'image.import.batch':['items','operationId'],'object.get.batch':['guids'],'object.place.batch':['items'],'text.create':['x','y','width','height','content','fontSize'],'frame.create':['x','y','width','height','content'],'object.get':['sessionID','localID'],'object.move':['sessionID','localID','x','y'],'object.resize':['sessionID','localID','width','height'],'view.fit':['guids'],'board.create':['teamId','projectId','folderId'],'board.rename':['name'],'board.open':['targetFileKey'],'image.import':['assetId']})[input.op]||[];
|
|
8
|
+
if (Object.keys(p).some(k => !fields.includes(k))) throw new Error('UNKNOWN_PARAMETER');
|
|
9
|
+
if (['frame.create','text.create'].includes(input.op)) {
|
|
10
|
+
for (const k of ['x','y','width','height']) if (!Number.isFinite(p[k]) || Math.abs(p[k]) > 1e6) throw new Error('INVALID_GEOMETRY');
|
|
11
|
+
if (p.width <= 0 || p.height <= 0) throw new Error('INVALID_GEOMETRY');
|
|
12
|
+
if (p.content !== undefined && (typeof p.content !== 'string' || p.content.length > 1000)) throw new Error('INVALID_CONTENT');
|
|
13
|
+
}
|
|
14
|
+
if(input.op==='text.create'&&p.fontSize!==undefined&&(!Number.isFinite(p.fontSize)||p.fontSize<=0||p.fontSize>200))throw Error('INVALID_FONT_SIZE');
|
|
15
|
+
if (['object.get','object.move','object.resize'].includes(input.op)) for (const k of ['sessionID','localID']) if (!Number.isSafeInteger(p[k]) || p[k] < 0) throw new Error('INVALID_GUID');
|
|
16
|
+
if(input.op==='object.move')for(const k of ['x','y'])if(!Number.isFinite(p[k])||Math.abs(p[k])>1e7)throw Error('INVALID_GEOMETRY');
|
|
17
|
+
if(input.op==='object.resize')for(const k of ['width','height'])if(!Number.isFinite(p[k])||p[k]<=0||p[k]>1e6)throw Error('INVALID_GEOMETRY');
|
|
18
|
+
if(input.op==='board.create')for(const k of ['teamId','projectId','folderId'])if(!Number.isSafeInteger(p[k])||p[k]<0)throw Error('INVALID_DESTINATION');
|
|
19
|
+
if(input.op==='board.rename'&&(typeof p.name!=='string'||!p.name.trim()||p.name.length>100))throw Error('INVALID_NAME');
|
|
20
|
+
if(input.op==='board.open'&&(typeof p.targetFileKey!=='string'||!/^[A-Za-z0-9_-]{8,100}$/.test(p.targetFileKey)))throw Error('INVALID_FILE_KEY');
|
|
21
|
+
if(['image.import','video.import'].includes(input.op)&&(typeof p.assetId!=='string'||!/^[a-f0-9]{64}$/.test(p.assetId)))throw Error('INVALID_ASSET_ID');
|
|
22
|
+
if(input.op==='view.fit'&&(!Array.isArray(p.guids)||!p.guids.length||p.guids.length>1000||p.guids.some(g=>!Number.isSafeInteger(g.sessionID)||g.sessionID<0||!Number.isSafeInteger(g.localID)||g.localID<0)))throw Error('INVALID_GUID_LIST');
|
|
23
|
+
if(input.op==='video.import'&&(typeof p.operationId!=='string'||!/^[a-f0-9-]{36}$/.test(p.operationId)))throw Error('INVALID_OPERATION_ID');
|
|
24
|
+
if(input.op==='image.import.batch'){
|
|
25
|
+
if(typeof p.operationId!=='string'||!/^[a-f0-9-]{36}$/.test(p.operationId))throw Error('INVALID_OPERATION_ID');
|
|
26
|
+
if(!Array.isArray(p.items)||!p.items.length||p.items.length>20)throw Error('INVALID_BATCH');
|
|
27
|
+
const keys=new Set();for(const i of p.items){if(!i||Object.keys(i).some(k=>!['key','assetId'].includes(k))||typeof i.key!=='string'||!i.key||i.key.length>200||keys.has(i.key))throw Error('INVALID_BATCH_KEY');keys.add(i.key);validate({op:'image.import',fileKey:input.fileKey,params:{assetId:i.assetId}});}
|
|
28
|
+
}
|
|
29
|
+
if(input.op==='object.get.batch')validate({op:'view.fit',fileKey:input.fileKey,params:{guids:p.guids}});
|
|
30
|
+
if(input.op==='object.place.batch'){
|
|
31
|
+
if(!Array.isArray(p.items)||!p.items.length||p.items.length>200)throw Error('INVALID_BATCH');
|
|
32
|
+
const seen=new Set();for(const i of p.items){if(!i||Object.keys(i).some(k=>!['sessionID','localID','x','y','width','height'].includes(k)))throw Error('UNKNOWN_PARAMETER');const {sessionID,localID,x,y,width,height}=i;validate({op:'object.move',fileKey:input.fileKey,params:{sessionID,localID,x,y}});validate({op:'object.resize',fileKey:input.fileKey,params:{sessionID,localID,width,height}});const key=sessionID+':'+localID;if(seen.has(key))throw Error('DUPLICATE_GUID');seen.add(key);}
|
|
33
|
+
}
|
|
34
|
+
return {op:input.op,fileKey:input.fileKey,params:p};
|
|
35
|
+
}
|
package/lib/video.mjs
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import {execFile} from 'node:child_process';
|
|
5
|
+
import {promisify} from 'node:util';
|
|
6
|
+
const exec=promisify(execFile);
|
|
7
|
+
export async function inspectVideo(file){
|
|
8
|
+
const absolute=path.resolve(file),s=await fs.stat(absolute);
|
|
9
|
+
if(!s.isFile()||s.size>100*1024*1024)throw Error('INVALID_VIDEO_SIZE');
|
|
10
|
+
const bytes=await fs.readFile(absolute);if(bytes.toString('ascii',4,8)!=='ftyp')throw Error('MP4_REQUIRED');
|
|
11
|
+
let result;try{result=await exec('ffprobe',['-v','error','-show_streams','-show_format','-of','json',absolute],{timeout:30000,maxBuffer:1024*1024});}catch(e){throw Error(e.code==='ENOENT'?'FFPROBE_REQUIRED':'VIDEO_PROBE_FAILED');}
|
|
12
|
+
const info=JSON.parse(result.stdout),stream=info.streams.find(s=>s.codec_type==='video'),duration=Number(info.format.duration);
|
|
13
|
+
if(!stream?.width||!stream?.height||!Number.isFinite(duration)||duration<=0)throw Error('INVALID_VIDEO');
|
|
14
|
+
return {file:absolute,name:path.basename(absolute),kind:'video',mime:'video/mp4',size:bytes.length,width:stream.width,height:stream.height,duration,sha256:crypto.createHash('sha256').update(bytes).digest('hex'),md5:crypto.createHash('md5').update(bytes).digest('hex')};
|
|
15
|
+
}
|
|
16
|
+
export async function downloadVideo(url,output){
|
|
17
|
+
if(new URL(url).protocol!=='https:')throw Error('HTTPS_REQUIRED');
|
|
18
|
+
try{await fs.stat(output);throw Error('TARGET_EXISTS');}catch(e){if(e.code!=='ENOENT')throw e;}
|
|
19
|
+
const r=await fetch(url,{signal:AbortSignal.timeout(120000)});if(!r.ok||new URL(r.url).protocol!=='https:')throw Error('VIDEO_DOWNLOAD_FAILED');
|
|
20
|
+
const chunks=[];let size=0;for await(const c of r.body){size+=c.length;if(size>100*1024*1024)throw Error('INVALID_VIDEO_SIZE');chunks.push(c);}
|
|
21
|
+
const bytes=Buffer.concat(chunks);if(bytes.toString('ascii',4,8)!=='ftyp')throw Error('MP4_REQUIRED');
|
|
22
|
+
await fs.writeFile(output,bytes,{flag:'wx',mode:0o600});return {...await inspectVideo(output),sourceUrl:url};
|
|
23
|
+
}
|
|
24
|
+
export async function importVideo(client,fileKey,file,receiptPath){
|
|
25
|
+
const asset=await inspectVideo(file),identity=crypto.createHash('sha256').update(JSON.stringify({fileKey,sha256:asset.sha256})).digest('hex');
|
|
26
|
+
let old;try{old=JSON.parse(await fs.readFile(receiptPath,'utf8'));}catch(e){if(e.code!=='ENOENT')throw e;}
|
|
27
|
+
async function complete(value){await fs.copyFile(receiptPath,receiptPath+'.'+crypto.randomUUID()+'.backup',fs.constants.COPYFILE_EXCL);await fs.writeFile(receiptPath,JSON.stringify(value,null,2),{mode:0o600});}
|
|
28
|
+
if(old){if(old.owner!=='@petercjl/boardmixcli'||old.identity!==identity)throw Error('RECEIPT_CONFLICT');if(old.result)return {ok:true,result:old.result,recovered:true};const out=await client.call('/outcome/'+old.operationId);if(!out.ok||!out.result)throw Error('VIDEO_OUTCOME_UNRESOLVED');await complete({...old,status:'completed',result:out.result});return out;}
|
|
29
|
+
await client.call('/register-asset',{file:asset.file,sha256:asset.sha256,kind:'video'});
|
|
30
|
+
const receipt={owner:'@petercjl/boardmixcli',identity,fileKey,operationId:crypto.randomUUID(),status:'submitted',sha256:asset.sha256};
|
|
31
|
+
await fs.writeFile(receiptPath,JSON.stringify(receipt,null,2),{flag:'wx',mode:0o600});
|
|
32
|
+
const out=await client.request('video.import',fileKey,{assetId:asset.sha256,operationId:receipt.operationId});await complete({...receipt,status:'completed',result:out.result});return out;
|
|
33
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,45 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@petercjl/boardmixcli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
3
|
+
"version": "0.1.6",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Atomic Boardmix desktop operations and portable Agent capability manual",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/petercjl/boardmixcli.git"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/petercjl/boardmixcli#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/petercjl/boardmixcli/issues"
|
|
14
|
+
},
|
|
15
|
+
"publishConfig": {
|
|
16
|
+
"access": "public"
|
|
17
|
+
},
|
|
18
|
+
"bin": {
|
|
19
|
+
"boardmixcli": "bin/boardmixcli.mjs"
|
|
20
|
+
},
|
|
21
|
+
"scripts": {
|
|
22
|
+
"test": "node --test test/*.test.mjs",
|
|
23
|
+
"check": "node scripts/validate-package.mjs",
|
|
24
|
+
"prepack": "node scripts/validate-package.mjs",
|
|
25
|
+
"postinstall": "node scripts/install-notice.mjs",
|
|
26
|
+
"smoke": "node scripts/packed-smoke.mjs"
|
|
27
|
+
},
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=22"
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"bin/*.mjs",
|
|
33
|
+
"lib/*.mjs",
|
|
34
|
+
"lib/page-adapter.js",
|
|
35
|
+
"plugin/main.js",
|
|
36
|
+
"scripts/*.mjs",
|
|
37
|
+
"skills/boardmixcli/**",
|
|
38
|
+
"README.md",
|
|
39
|
+
"INSTALL.md",
|
|
40
|
+
"UPDATE.md",
|
|
41
|
+
"LICENSE",
|
|
42
|
+
"CONTRACT.md",
|
|
43
|
+
"capabilities.json"
|
|
44
|
+
]
|
|
45
|
+
}
|
package/plugin/main.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Boardmix plugin sandbox. Only named operations are accepted.
|
|
2
|
+
function compact(r) {
|
|
3
|
+
return {guid:r.guid,name:r.name,content:r.content,fontSize:r.textInfo&&r.textInfo.fontSize,x:r.x,y:r.y,width:r.width,height:r.height,objectType:r.objectType,aspectRatioLocked:r.lockAspectRatio,resourceHash:(r.imageInfo&&r.imageInfo.hash)||(r.fileInfo&&r.fileInfo.hash),mediaType:r.fileInfo&&r.fileInfo.mimeType,mediaLoading:r.fileInfo&&r.fileInfo.isInLoading,cropped:r.imageInfo&&r.imageInfo.cropped,imageNum:r.imageNum};
|
|
4
|
+
}
|
|
5
|
+
boardmix.ui.on('message', function(message) {
|
|
6
|
+
if (!message || message.channel !== 'boardmixcli-v1' || typeof message.op !== 'string') return;
|
|
7
|
+
var response = {channel:'boardmixcli-v1', id:message.id};
|
|
8
|
+
try {
|
|
9
|
+
if (message.fileKey !== boardmix.fileKey) throw new Error('FILE_MISMATCH');
|
|
10
|
+
var p = message.params || {};
|
|
11
|
+
if (message.op === 'info') response.result = {fileKey:boardmix.fileKey, apiVersion:boardmix.apiVersion, view:boardmix.viewport.getView(), zoom:boardmix.viewport.getZoom(), operations:['info','frame.create','object.get']};
|
|
12
|
+
else if ((message.op === 'frame.create'||message.op==='text.create')) {
|
|
13
|
+
var created = message.op==='frame.create'?boardmix.createFrame(p):boardmix.createText({...p,style:{fontSize:p.fontSize||28}});
|
|
14
|
+
boardmix.moveTo(created.guid.sessionID,created.guid.localID,p.x+created.width/2,p.y+created.height/2);
|
|
15
|
+
var g = created.guid;
|
|
16
|
+
response.result = {created:compact(created), readback:compact(boardmix.getById(g.sessionID,g.localID))};
|
|
17
|
+
} else if (message.op === 'object.get') response.result = compact(boardmix.getById(p.sessionID,p.localID));
|
|
18
|
+
else if(message.op==='object.get.batch')response.result={objects:p.guids.map(function(g){return compact(boardmix.getById(g.sessionID,g.localID));})};
|
|
19
|
+
else if(message.op==='object.move.batch'){
|
|
20
|
+
p.items.forEach(function(i){var o=boardmix.getById(i.sessionID,i.localID);boardmix.moveTo(i.sessionID,i.localID,i.x+o.width/2,i.y+o.height/2);});
|
|
21
|
+
response.result={objects:p.items.map(function(i){return compact(boardmix.getById(i.sessionID,i.localID));})};
|
|
22
|
+
}
|
|
23
|
+
else if(message.op==='object.selection'){
|
|
24
|
+
var selection=boardmix.getSelect();response.result={objects:selection.guids.map(function(g){return compact(boardmix.getById(g.sessionID,g.localID));})};
|
|
25
|
+
}
|
|
26
|
+
else if(message.op==='view.fit'){
|
|
27
|
+
boardmix.viewport.zoomTo({guids:p.guids});response.result={guids:p.guids,zoom:boardmix.viewport.getZoom(),view:boardmix.viewport.getView()};
|
|
28
|
+
}
|
|
29
|
+
else if(message.op==='object.move'){
|
|
30
|
+
var original=boardmix.getById(p.sessionID,p.localID);
|
|
31
|
+
boardmix.moveTo(p.sessionID,p.localID,p.x+original.width/2,p.y+original.height/2);
|
|
32
|
+
var moved=compact(boardmix.getById(p.sessionID,p.localID));
|
|
33
|
+
if(Math.abs(moved.x-p.x)>0.01||Math.abs(moved.y-p.y)>0.01)throw new Error('MOVE_READBACK_MISMATCH:'+JSON.stringify(moved));
|
|
34
|
+
response.result=moved;
|
|
35
|
+
}
|
|
36
|
+
else if(message.op==='object.resize'){
|
|
37
|
+
var before=compact(boardmix.getById(p.sessionID,p.localID));
|
|
38
|
+
if(before.mediaType!=='video/mp4')throw new Error('HOST_ADAPTER_REQUIRED_FOR_EXACT_RESIZE');
|
|
39
|
+
boardmix.resize(p.sessionID,p.localID,p.width,p.height);
|
|
40
|
+
var after=compact(boardmix.getById(p.sessionID,p.localID));
|
|
41
|
+
if(Math.abs(after.width-p.width)>0.02||Math.abs(after.height-p.height)>0.02||after.aspectRatioLocked!==before.aspectRatioLocked)throw new Error('RESIZE_READBACK_MISMATCH');
|
|
42
|
+
response.result=after;
|
|
43
|
+
}
|
|
44
|
+
else throw new Error('UNSUPPORTED_OPERATION');
|
|
45
|
+
response.ok = true;
|
|
46
|
+
} catch (error) { response.ok = false; response.error = String(error.message || error); }
|
|
47
|
+
boardmix.ui.postMessage(response);
|
|
48
|
+
});
|
|
49
|
+
// Boardmix routes sandbox replies through its plugin window wrapper.
|
|
50
|
+
boardmix.showUI({title:'boardmixcli',width:280,height:90});
|
|
51
|
+
boardmix.ui.hide();
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
console.log('@petercjl/boardmixcli: CLI installation is one phase. Ask your current Agent to read INSTALL.md / UPDATE.md, resolve its own Skill destination, run skill install/update, verify hashes and refresh host discovery. No service access is granted by installation.');
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import {mkdtemp,readFile,rm,writeFile,stat} from 'node:fs/promises';
|
|
2
|
+
import {execFileSync} from 'node:child_process';
|
|
3
|
+
import {fileURLToPath} from 'node:url';
|
|
4
|
+
import {tmpdir} from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import assert from 'node:assert/strict';
|
|
7
|
+
const root=fileURLToPath(new URL('../',import.meta.url)),scratch=await mkdtemp(path.join(tmpdir(),'boardmix-packed-'));
|
|
8
|
+
const npmCli=process.env.npm_execpath||fileURLToPath(new URL('../../../npm/bin/npm-cli.js',import.meta.url));
|
|
9
|
+
function npm(args){return execFileSync(process.execPath,[npmCli,...args],{cwd:root,encoding:'utf8',env:{...process.env,BOARDMIXCLI_UPDATE_CHECK:'0'}});}
|
|
10
|
+
function cli(entry,args){return JSON.parse(execFileSync(process.execPath,[entry,...args],{encoding:'utf8',env:{...process.env,BOARDMIXCLI_UPDATE_CHECK:'0'}}));}
|
|
11
|
+
try{
|
|
12
|
+
const packed=JSON.parse(npm(['pack','--silent','--json','--pack-destination',scratch]))[0];
|
|
13
|
+
assert.ok(packed.files.every(f=>!/(evidence|\.bak-|\.session\.|bootstrap\.js)/.test(f.path)));
|
|
14
|
+
const bootstrap=path.join(scratch,'bootstrap');npm(['install','--prefix',bootstrap,'--ignore-scripts','--no-audit','--no-fund',path.join(scratch,packed.filename)]);
|
|
15
|
+
const entry=path.join(bootstrap,'node_modules/@petercjl/boardmixcli/bin/boardmixcli.mjs');
|
|
16
|
+
const version=cli(entry,['version']).version;
|
|
17
|
+
for(const name of ['host-a','host-b']){
|
|
18
|
+
const target=path.join(scratch,name),args=['--agent',name,'--destination',target,'--json'];
|
|
19
|
+
assert.equal(cli(entry,['skill','status',...args]).installed,false);
|
|
20
|
+
assert.equal(cli(entry,['skill','install',...args]).current,true);
|
|
21
|
+
assert.equal(cli(entry,['skill','update',...args]).changed,false);
|
|
22
|
+
}
|
|
23
|
+
const a=path.join(scratch,'host-a/.runtime/bin/boardmixcli.mjs'),b=path.join(scratch,'host-b/.runtime/bin/boardmixcli.mjs');
|
|
24
|
+
if(process.platform!=='win32')assert.notEqual((await stat(a)).ino,(await stat(b)).ino);
|
|
25
|
+
await rm(bootstrap,{recursive:true});await rm(path.join(scratch,'host-a'),{recursive:true});
|
|
26
|
+
assert.equal(cli(b,['version']).version,version);
|
|
27
|
+
const png=Buffer.alloc(24);Buffer.from('89504e470d0a1a0a','hex').copy(png);png.write('IHDR',12);png.writeUInt32BE(750,16);png.writeUInt32BE(1475,20);
|
|
28
|
+
const image=path.join(scratch,'图片.png');await writeFile(image,png);
|
|
29
|
+
const result=cli(b,['image.inspect','--file',image]);assert.equal(result.width,750);assert.equal(result.height,1475);
|
|
30
|
+
assert.equal(cli(b,['skill','status','--agent','host-b','--destination',path.join(scratch,'host-b'),'--json']).current,true);
|
|
31
|
+
console.log(JSON.stringify({ok:true,version,packedFiles:packed.files.length,isolation:'bootstrap and host-a removed; host-b version, image.inspect and receipt verified',historicalUpdater:'0.1.0 retains the same explicit Skill destination and target-owned runtime contract; this smoke tests candidate isolation'}));
|
|
32
|
+
}finally{await rm(scratch,{recursive:true,force:true});}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Release-only identity synchronization; prepack remains read-only.
|
|
2
|
+
import {readFile,writeFile,copyFile,stat,mkdir} from 'node:fs/promises';
|
|
3
|
+
import {fileURLToPath} from 'node:url';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import {randomUUID} from 'node:crypto';
|
|
6
|
+
const root=fileURLToPath(new URL('../',import.meta.url));
|
|
7
|
+
const pkg=JSON.parse(await readFile(path.join(root,'package.json'),'utf8'));
|
|
8
|
+
for(const name of ['capabilities.json','skills/boardmixcli/manifest.json']){
|
|
9
|
+
const p=path.join(root,name);await stat(p);const x=JSON.parse(await readFile(p,'utf8'));
|
|
10
|
+
if(x.version===pkg.version&&x.package===pkg.name)continue;
|
|
11
|
+
const backup=path.join(root,'evidence','version-backups',randomUUID(),name);await mkdir(path.dirname(backup),{recursive:true});await copyFile(p,backup);x.version=pkg.version;x.package=pkg.name;
|
|
12
|
+
await writeFile(p,JSON.stringify(x,null,2)+'\n');
|
|
13
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import {readFile} from 'node:fs/promises';
|
|
2
|
+
import {fileURLToPath} from 'node:url';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
const root=fileURLToPath(new URL('../',import.meta.url));
|
|
5
|
+
const p=JSON.parse(await readFile(path.join(root,'package.json'),'utf8'));
|
|
6
|
+
for(const f of ['capabilities.json','skills/boardmixcli/manifest.json']){
|
|
7
|
+
const x=JSON.parse(await readFile(path.join(root,f),'utf8'));
|
|
8
|
+
if(x.version!==p.version||x.package!==p.name)throw Error('VERSION_DRIFT: '+f);
|
|
9
|
+
}
|
|
10
|
+
for(const f of ['INSTALL.md','UPDATE.md','CONTRACT.md','skills/boardmixcli/SKILL.md'])if(!(await readFile(path.join(root,f),'utf8')).trim())throw Error('MISSING_RESOURCE: '+f);
|
|
11
|
+
console.error('Package and Skill identity verified: '+p.name+'@'+p.version);
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: boardmixcli
|
|
3
|
+
description: Operate Boardmix desktop boards through explicit atomic CLI commands for files, images, videos, object geometry and viewport. Use for authorized Boardmix automation; the caller owns content and layout decisions.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# BoardmixCLI capability manual
|
|
7
|
+
|
|
8
|
+
Input: explicit operations, Boardmix file keys, local PNG/JPEG/WebP/MP4 paths and object GUIDs, authorized by the user. Strategy: discover the installed contract, connect the selected editor, execute atomic operations and verify readback. Output: JSON receipts and verified Boardmix state. The calling Agent or application Skill owns business methods, material selection, ordering and layout.
|
|
9
|
+
|
|
10
|
+
## Main line
|
|
11
|
+
|
|
12
|
+
1. Resolve this Skill directory from the host's actual loaded resource path. Use Node.js with its target-owned `.runtime/bin/boardmixcli.mjs`; never infer a different Agent's installation path. Read this Skill’s `capabilities.json` and `adapters/generic.json` to resolve the logical execution and desktop features from the current host. Run `version`, `capabilities --json`, and `doctor --json`. In a source npm checkout, resolve the canonical runtime through the package CLI instead. Node.js >=22, local filesystem, loopback HTTP and an authorized browser CDP connection or desktop console access are required. Any Agent with these capabilities may use the generic adapter. Native desktop execution is tested on macOS Boardmix 1.7.2; other desktop platforms remain unverified.
|
|
13
|
+
2. For local inspection, `image.list --directory ABSOLUTE_DIRECTORY` returns supported image filenames in natural numeric order; `image.inspect --file ABSOLUTE_PNG` returns source dimensions and SHA256. Caller decides whether this order suits the task. For editor actions, read the runtime's `CONTRACT.md` before the first write to obtain exact flags and response/error contracts.
|
|
14
|
+
3. Start `serve --session NEW_ABSOLUTE_PRIVATE_FILE`, or reuse a confirmed live session. For the default browser route first run `browser.ensure --json`. It starts or reuses Chrome on 9223 with the persistent home-relative `.dianshang-chrome-profile`, opens Boardmix, and checks login. On AUTH_REQUIRED relay the returned prompt to the user; after user login run `auth.wait --wait-ms 60000 --json` or repeat browser.ensure. Continue only on state ready. AUTH_STATE_UNKNOWN requires page/load diagnosis, never assumed login. Automatic launch is macOS-only; an explicitly selected alternative endpoint remains caller-managed. Then use the returned endpoint: `browser.targets --endpoint URL` discovers Boardmix app tabs; `browser.open --endpoint URL --file-key KEY` opens a specified existing file; `browser.connect --endpoint URL --session FILE --file-key KEY` attaches it without visible DevTools. The endpoint must use loopback and must belong to the intended browser. Verify `status` and `board.info` identify the exact file. Read the browser connection contract in CONTRACT.md. Missing target, ambiguous tabs or a changed front-end build stops this branch; preserve the selected host. For explicitly selected desktop mode, use authorized native controls to execute the bootstrap once in that editor's Console, then minimize the specific DevTools window and verify the board is visible. Console connection is a fallback prerequisite, not a window that stays in front throughout the task.
|
|
15
|
+
4. Execute only caller-selected operations: file create/name/open/close; Frame/text creation; single or batch PNG/JPEG/WebP import; native MP4 import; object get/move/resize or batch placement; explicit viewport fit. Every editor command requires `--session` and `--file-key`. GUID sessionID/localID are separate numeric flags. Move uses absolute top-left board coordinates. Resize sets both dimensions and preserves aspect-ratio lock. Inspect each receipt, then read object/file state to verify the requested change before proceeding.
|
|
16
|
+
5. Prefer the batch operations in CONTRACT.md for multiple explicit inputs: inspect up to 1000 files, import up to 20 with a durable receipt, read up to 1000 GUIDs, or place up to 200 objects. Match returned caller keys and GUIDs; keep one write in flight per editor. Save returned GUIDs before layout verification. Repeat a batch import only with its original input and receipt; unresolved outcomes require inspection, not a new receipt.
|
|
17
|
+
6. Report actual file identity, operations, verified state and unresolved outcomes. For browser close, use browser.close with its save guard; for desktop close, use board.close and verify the file is absent from the desktop's open-file tabs after the close receipt. Fit animates asynchronously, so an immediate viewport value may precede the final visual state.
|
|
18
|
+
|
|
19
|
+
## Recovery and boundaries
|
|
20
|
+
|
|
21
|
+
- Missing Node, desktop controls, console access, login or supported front-end build: return CAPABILITY_UNAVAILABLE / AUTH_REQUIRED / FEATURE_UNSUPPORTED with the prerequisite. Resume at step 1 or 3 after resolution; do not invent a different adapter.
|
|
22
|
+
- File create or image import timeout: outcome may be unknown. Inspect the operation receipt/outcome and actual file/object state before any new mutation. Avoid duplicate creates/imports. Resume at step 4 only when the outcome is known.
|
|
23
|
+
- Protected local destination: preserve unmanaged targets and edits; use CLI-managed skill status/install/update with the current Agent's explicit destination. Installation does not authorize Boardmix writes or paid service actions.
|
|
24
|
+
- Package update: read `.runtime/UPDATE.md`, reconcile only this selected installation, verify CLI/Skill/receipt versions and hashes, then refresh host discovery. A current verified receipt takes a read-only completion branch. Host discovery requires host evidence.
|
|
25
|
+
- New recurring integration failures belong in a separately authorized capability-package repair with tests. Business quality and layout rules remain owned by the application layer.
|
|
26
|
+
|
|
27
|
+
The runtime CONTRACT.md and capabilities.json are authoritative versioned operation references. This mechanical manual directly internalizes tested command behavior; it has no nested Skill or private knowledge-base dependency. Clean-context regression is separate from ordinary operation.
|
|
28
|
+
|
|
29
|
+
For remote images, image.download receives one explicit URL and a new output file. Frame and text operations are documented under Added capability operations in CONTRACT.md; native text bounds auto-fit, and Frame containment verification is geometric.
|
|
30
|
+
|
|
31
|
+
For videos, require ffprobe on PATH. Use `video.download --url HTTPS_URL --output NEW_FILE`, then `video.inspect --file FILE`. `video.import --file FILE --receipt NEW_RECEIPT --session SESSION --file-key KEY` uploads a native video and verifies the source MD5, video MIME and completed loading state. MP4 limit: 100 MiB. Persist the returned GUID before moving/resizing. Reuse the same receipt after interruption; VIDEO_OUTCOME_UNRESOLVED requires inspection and must not trigger another import. A poster alone does not satisfy video import. The caller owns region placement and playback acceptance.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema": "portable-skill-adapter",
|
|
3
|
+
"schema_version": "1.0.0",
|
|
4
|
+
"platform": "generic",
|
|
5
|
+
"mappings": [
|
|
6
|
+
{
|
|
7
|
+
"capability_id": "command.execute",
|
|
8
|
+
"status": "implemented",
|
|
9
|
+
"implementation": {
|
|
10
|
+
"kind": "instruction",
|
|
11
|
+
"discovery": "Resolve command execution and native desktop controls from the current Agent advertised tools. CLI is target-owned .runtime/bin/boardmixcli.mjs; run with Node >=22. Console and tab inspection use host-authorized native desktop control. Return CAPABILITY_UNAVAILABLE when missing."
|
|
12
|
+
},
|
|
13
|
+
"features": [
|
|
14
|
+
"node22",
|
|
15
|
+
"json-results"
|
|
16
|
+
]
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"capability_id": "desktop.console",
|
|
20
|
+
"status": "implemented",
|
|
21
|
+
"implementation": {
|
|
22
|
+
"kind": "instruction",
|
|
23
|
+
"discovery": "Resolve command execution and native desktop controls from the current Agent advertised tools. CLI is target-owned .runtime/bin/boardmixcli.mjs; run with Node >=22. Console and tab inspection use host-authorized native desktop control. Return CAPABILITY_UNAVAILABLE when missing."
|
|
24
|
+
},
|
|
25
|
+
"features": [
|
|
26
|
+
"console-paste",
|
|
27
|
+
"inspect-open-tabs"
|
|
28
|
+
]
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"capability_id": "browser.cdp",
|
|
32
|
+
"status": "implemented",
|
|
33
|
+
"implementation": {
|
|
34
|
+
"kind": "cli",
|
|
35
|
+
"command": "boardmixcli browser.connect",
|
|
36
|
+
"discovery": "Current target-owned CLI; explicit authorized loopback CDP endpoint and exact Boardmix file key."
|
|
37
|
+
},
|
|
38
|
+
"features": [
|
|
39
|
+
"loopback-cdp",
|
|
40
|
+
"existing-login"
|
|
41
|
+
]
|
|
42
|
+
}
|
|
43
|
+
]
|
|
44
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema": "portable-skill-capabilities",
|
|
3
|
+
"schema_version": "1.0.0",
|
|
4
|
+
"skill": "boardmixcli",
|
|
5
|
+
"target_platforms": [
|
|
6
|
+
"generic"
|
|
7
|
+
],
|
|
8
|
+
"host_resolution": {
|
|
9
|
+
"fallback_adapter": "generic",
|
|
10
|
+
"destination": "host-supplied explicit Skill directory"
|
|
11
|
+
},
|
|
12
|
+
"capabilities": [
|
|
13
|
+
{
|
|
14
|
+
"id": "command.execute",
|
|
15
|
+
"purpose": "Execute target-owned Node CLI",
|
|
16
|
+
"required": true,
|
|
17
|
+
"required_features": [
|
|
18
|
+
"node22",
|
|
19
|
+
"json-results"
|
|
20
|
+
],
|
|
21
|
+
"input_fields": [
|
|
22
|
+
"argv"
|
|
23
|
+
],
|
|
24
|
+
"output_fields": [
|
|
25
|
+
"stdout",
|
|
26
|
+
"exitCode"
|
|
27
|
+
],
|
|
28
|
+
"side_effects": {
|
|
29
|
+
"local_process": true,
|
|
30
|
+
"authorization": "Explicit caller operation",
|
|
31
|
+
"external_mutation": false,
|
|
32
|
+
"may_cost_money": false
|
|
33
|
+
},
|
|
34
|
+
"errors": [
|
|
35
|
+
"CAPABILITY_UNAVAILABLE",
|
|
36
|
+
"FEATURE_UNSUPPORTED"
|
|
37
|
+
]
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
"id": "desktop.console",
|
|
41
|
+
"purpose": "Connect authorized Boardmix editor",
|
|
42
|
+
"required": false,
|
|
43
|
+
"required_features": [
|
|
44
|
+
"console-paste",
|
|
45
|
+
"inspect-open-tabs"
|
|
46
|
+
],
|
|
47
|
+
"input_fields": [
|
|
48
|
+
"bootstrap-file",
|
|
49
|
+
"editor-file-key"
|
|
50
|
+
],
|
|
51
|
+
"output_fields": [
|
|
52
|
+
"connected",
|
|
53
|
+
"visible-file-state"
|
|
54
|
+
],
|
|
55
|
+
"side_effects": {
|
|
56
|
+
"application_access": true,
|
|
57
|
+
"authorization": "User authorized Boardmix access",
|
|
58
|
+
"external_mutation": true,
|
|
59
|
+
"may_cost_money": false
|
|
60
|
+
},
|
|
61
|
+
"errors": [
|
|
62
|
+
"CAPABILITY_UNAVAILABLE",
|
|
63
|
+
"FEATURE_UNSUPPORTED"
|
|
64
|
+
]
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
"id": "browser.cdp",
|
|
68
|
+
"purpose": "Connect a caller-selected Boardmix editor without visible DevTools; alternative to desktop.console",
|
|
69
|
+
"required": false,
|
|
70
|
+
"required_features": [
|
|
71
|
+
"loopback-cdp",
|
|
72
|
+
"existing-login"
|
|
73
|
+
],
|
|
74
|
+
"input_fields": [
|
|
75
|
+
"endpoint",
|
|
76
|
+
"file-key",
|
|
77
|
+
"session"
|
|
78
|
+
],
|
|
79
|
+
"output_fields": [
|
|
80
|
+
"connected",
|
|
81
|
+
"fileKey",
|
|
82
|
+
"reused"
|
|
83
|
+
],
|
|
84
|
+
"side_effects": {
|
|
85
|
+
"application_access": true,
|
|
86
|
+
"authorization": "Caller-selected browser and file",
|
|
87
|
+
"external_mutation": false,
|
|
88
|
+
"may_cost_money": false
|
|
89
|
+
},
|
|
90
|
+
"errors": [
|
|
91
|
+
"BROWSER_ENDPOINT_REQUIRED",
|
|
92
|
+
"EDITOR_NOT_OPEN",
|
|
93
|
+
"AMBIGUOUS_EDITOR",
|
|
94
|
+
"DESKTOP_ADAPTER_FAILED"
|
|
95
|
+
]
|
|
96
|
+
}
|
|
97
|
+
]
|
|
98
|
+
}
|