@petercjl/competitor-visual-canvas 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,55 @@
1
- # Temporary Holding Version
1
+ # Competitor visual canvas
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Application package for collecting Taobao/Tmall visual references into SealSeek Infinite Canvas. The application owns the collection workflow and layout. `@petercjl/tbcli` owns product collection; `@petercjl/sealseek-media-cli` owns service authentication and generic canvas operations.
4
+
5
+ Requires Node.js 22+, `tbcli item media`, and SealSeek `canvas batch` (including text-batch) / `media inspect` capabilities. Service credentials stay with the dependency packages. The public npm package supports Codex, SealSeek and WorkBuddy adapters. macOS Codex business runs are verified; other host runtime evidence is separate from portable package tests.
6
+
7
+ ```sh
8
+ competitor-visual-canvas doctor --json
9
+ competitor-visual-canvas collect --input PRODUCT_ID --input OTHER_PRODUCT_ID --run NEW_DIR --json
10
+ competitor-visual-canvas plan --run NEW_DIR --json
11
+ competitor-visual-canvas canvas --run NEW_DIR --json
12
+ competitor-visual-canvas canvas --run NEW_DIR --submit --json
13
+ ```
14
+
15
+ Bare IDs default to Taobao. Each product has four Frames named after its shop. Main images and videos run horizontally; detail images run vertically with zero inter-image gap; SKU images form rows of five. Regions run left to right: main images, main videos, SKU, detail images. Between products, main-image/video/SKU Frames run vertically and detail Frames run horizontally. Each region has a text heading. Fixed column origins preserve existing positions during append. Empty video lists retain an empty Frame and explicit coverage evidence.
16
+
17
+ The private run directory holds raw collection results, `assets.manifest.json`, `layout.plan.json`, `canvas.batch.json`, `canvas.state.json` and `collection-report.json`. Existing outputs are preserved. Replay reuses the recorded canvas and stable IDs. Unknown creation outcomes require checking existing canvases and explicit recovery; no automatic duplicate creation.
18
+
19
+ Use `skill source/status/install/update --agent codex|sealseek --json` for the canonical bundled Skill and managed links. Unmanaged targets are preserved. Windows adapters remain unresolved. Source installs do not self-update. After future publication the explicit update path is `npm install -g @petercjl/competitor-visual-canvas@latest`, followed by `competitor-visual-canvas skill update --agent AGENT --json`; linked Skills follow the owning package. The host suite may manage that lifecycle after separate registration.
20
+
21
+ Dependency entry points can be configured outside the package with `configure --tool tbcli|sealseek-media --entry ABSOLUTE_CLI_PATH --submit --json`. Inspect them with `doctor`, and delegate via `tool run TOOL ARGS...`; source discovery and execution use the same selected entry. No credentials are stored by the application.
22
+
23
+
24
+ Append products using the newest completed run:
25
+
26
+ ```sh
27
+ competitor-visual-canvas append --base-run BASE_DIR --run NEW_DIR --input NEW_PRODUCT_ID --json
28
+ competitor-visual-canvas canvas --run NEW_DIR --json
29
+ competitor-visual-canvas canvas --run NEW_DIR --submit --json
30
+ ```
31
+
32
+ The new run records cumulative assets and uses the same canvas. Duplicate IDs are skipped. Old elements retain their positions; a stale base, manual changes or excessive column width stops before saving. Main-image/video/SKU columns reserve at least 2400/1600/1840 canvas units initially. Legacy vertical-region canvases require an explicitly requested `reflow --run DIR --submit`; normal append does not move existing elements.
33
+
34
+ Find an existing application canvas by its exact name and append without a previous local run:
35
+
36
+ ```sh
37
+ competitor-visual-canvas find --title "My canvas" --json
38
+ competitor-visual-canvas append --title "My canvas" --run NEW_DIR --input NEW_PRODUCT_ID --json
39
+ competitor-visual-canvas canvas --run NEW_DIR --json
40
+ competitor-visual-canvas canvas --run NEW_DIR --submit --json
41
+ ```
42
+
43
+ Name lookup scans all result pages and matches the full title. Multiple matches return `AMBIGUOUS_CANVAS` with candidates; select with `append --canvas ID`. A missing name returns `CANVAS_NOT_FOUND`. Lookup never creates a canvas. ID and title may be combined to verify the chosen name; `--base-run` is an alternative selector.
44
+
45
+ Cloud recovery restores this application's four-region layout, product IDs and existing links from element source metadata, including empty Frames. Old coordinates stay intact and submission rechecks the saved snapshot. Incomplete or unrelated canvases return `CANVAS_STATE_UNSUPPORTED`. Recovery snapshots live in private application state, outside npm. Original product titles, SKU attributes and collection evidence unavailable in cloud metadata are marked unavailable; a completed local base run retains that richer evidence. The application controls these rules; the media CLI continues to provide generic canvas listing, reading and writing.
46
+
47
+ Agent prompt: “用竞品视觉素材画板工具,找到画板‘我的竞品素材’,追加商品 ID:123456789012。”
48
+
49
+ ## Installation and updates
50
+
51
+ Install `@petercjl/competitor-visual-canvas` globally, then use `competitor-visual-canvas skill install --agent codex|sealseek|workbuddy --json` for the current Agent. Its npm dependencies provide compatible tbcli and sealseek-media CLIs; service authorization remains separate. SealSeek resolves the active default workspace or a configured SEALSEEK_SKILLS_DIR; ambiguous roots require an explicit --path.
52
+
53
+ Global installations check stable latest on business invocation once per day, download/install a newer version, refresh already managed Skills with backups and run the original command once. Local edits stop automatic synchronization. Offline checks keep the current version; interrupted updates retain package backups in private state. `update auto status|on|off` controls the policy, and `update install --yes` forces a check. Suite-local installations stay managed by taobao-ai-ops; Git development stays Git-owned. Custom Skill paths need explicit skill synchronization.
54
+
55
+ Application run directories and credential profiles stay outside npm. Continue a canvas from its newest completed run; backup and unknown-write recovery use that existing canvas rather than creating another.
@@ -0,0 +1,63 @@
1
+ #!/usr/bin/env node
2
+ import path from 'node:path';
3
+ import {spawnSync} from 'node:child_process';
4
+ import {performUpdate,latestVersion,updateStatus,setAutomatic} from '../src/update.mjs';
5
+ import {collect,planRun,publishCanvas,invoke,read,fail,configureTool,appendRun} from '../src/workflow.mjs';
6
+ import {reflowRun} from '../src/reflow.mjs';
7
+ import {findCanvases,appendToCanvas} from '../src/canvas-target.mjs';
8
+ import {ROOT,META,skill} from '../src/lifecycle.mjs';
9
+ const argv=process.argv.slice(2),command=argv.shift(),options={},args=[];
10
+ function parse(){for(let i=0;i<argv.length;i++){const k=argv[i];if(k==='--json')continue;if(['--submit','--yes'].includes(k)){options[k.slice(2)]=true;continue;}if(k.startsWith('--')){const key=k.slice(2),value=argv[++i];if(!value||value.startsWith('--'))fail('INVALID_INPUT','Missing '+k);if(!['input','raw','run','title','agent','canvas','tool','entry','base-run','path'].includes(key))fail('INVALID_INPUT','Unknown '+k);(options[key]??=[]).push(value);}else args.push(k);}}
11
+ const one=k=>options[k]?.at(-1);
12
+ const help=`competitor-visual-canvas ${META.version}
13
+ collect --input ID_OR_URL [--input MORE] --run NEW_DIR [--raw SAVED_COLLECTION.json ...] --json
14
+ plan --run DIR [--title TEXT] [--base-run COMPLETED_DIR] --json
15
+ find --title EXACT_TITLE --json (read-only exact-name lookup)
16
+ append (--base-run COMPLETED_DIR | --title EXACT_TITLE | --canvas ID) --run NEW_DIR --input ID_OR_URL [--input MORE] [--raw FILE ...] --json
17
+ reflow --run DIR [--submit] --json (migrate a legacy canvas to horizontal regions)
18
+ canvas --run DIR [--submit] --json
19
+ recover --run DIR --canvas ID --submit --json (bind verified existing canvas after uncertain creation)
20
+ tool run tbcli|sealseek-media ARGS...
21
+ configure --tool tbcli|sealseek-media --entry CLI_PATH --submit --json
22
+ version | capabilities --json | doctor --json
23
+ skill source|status|install|update [--agent codex|sealseek|workbuddy] [--path DIR] --json
24
+ update check|install --yes --json
25
+ update auto status|on|off --json
26
+ Requires tbcli item media and sealseek-media canvas batch/media inspect.
27
+ Global npm installations check stable updates daily during use and synchronize managed Skills.
28
+ Source development and suite-managed installations use their owner update path.`;
29
+ async function main(){
30
+ if(!command||['--help','help'].includes(command))return {help};
31
+ if(command==='tool'&&argv[0]==='run'){const tool=argv[1];if(!['tbcli','sealseek-media'].includes(tool))fail('INVALID_INPUT','Unknown dependency.');return invoke(tool,argv.slice(2));}
32
+ parse();
33
+ if(command==='configure'){if(!options.submit)fail('SUBMIT_REQUIRED','Configuring dependency entry requires --submit.');return configureTool(one('tool'),one('entry'));}
34
+ if(command==='version')return {version:META.version};
35
+ if(command==='skill')return skill(args[0],one('agent'),one('path'));
36
+ if(command==='capabilities')return {...await read(path.join(ROOT,'skills','competitor-visual-canvas','capabilities.json')),execution:{frame_grouping:'four-per-product',inputs:'Taobao IDs or Taobao/Tmall detail URLs',archive:'canvas',supported_platform:process.platform==='darwin'?'macos':'unverified'}};
37
+ if(command==='doctor'){const checks=[];for(const tool of ['tbcli','sealseek-media'])try{const source=await invoke(tool,['skill','source','--json']),capabilities=await invoke(tool,['capabilities','--json']);const s=source.source??source.data?.source;const valid=tool==='tbcli'?JSON.stringify(capabilities).includes('item media'):['frame-batch','text-batch','media-inspect','source-metadata'].every(f=>capabilities.manifest?.canvas_contract?.features?.includes(f));checks.push({tool,source:s,ok:!!s&&valid});}catch(e){checks.push({tool,ok:false,code:e.code??'CLI_UNAVAILABLE'});}return {ok:checks.every(c=>c.ok),checks,node:process.version,service_access:'not-checked'};}
38
+ if(command==='update'){
39
+ if(args[0]==='check'){const latest=await latestVersion();return {current:META.version,latest,updateAvailable:latest!==META.version};}
40
+ if(args[0]==='auto'){if(args[1]==='status')return updateStatus();if(['on','off'].includes(args[1]))return setAutomatic(args[1]==='on');}
41
+ if(args[0]==='install'){if(!options.yes)fail('AUTHORIZATION_REQUIRED','Use --yes for package update.');return performUpdate({automatic:false});}
42
+ fail('INVALID_INPUT','Use update check/install or auto status/on/off.');
43
+ }
44
+ if(command==='find')return findCanvases(one('title'));
45
+ const run=one('run');if(!run)fail('INVALID_INPUT','Provide --run DIR.');
46
+ if(command==='collect'){const result=await collect(path.resolve(run),options.input||[],options.raw||[]);return {run_id:result.run_id,run:path.resolve(run),products:result.products.map(p=>({id:p.id,title:p.title,counts:Object.fromEntries(Object.entries(p.assets).map(([k,v])=>[k,v.length]))}))};}
47
+ if(command==='plan')return planRun(path.resolve(run),{title:one('title'),baseRun:one('base-run')?path.resolve(one('base-run')):undefined});
48
+ if(command==='append'){
49
+ if(one('base-run')){if(one('title')||one('canvas'))fail('INVALID_INPUT','Use a base run or a canvas selector.');return appendRun(path.resolve(run),path.resolve(one('base-run')),options.input||[],options.raw||[]);}
50
+ return appendToCanvas(path.resolve(run),{title:one('title'),canvas:one('canvas')},options.input||[],options.raw||[]);
51
+ }
52
+ if(command==='reflow')return reflowRun(path.resolve(run),!!options.submit);
53
+ if(command==='canvas')return publishCanvas(path.resolve(run),!!options.submit);
54
+ if(command==='recover'){if(!one('canvas')||!options.submit)fail('SUBMIT_REQUIRED','Recovery requires --canvas ID --submit.');const {checkpoint}=await import('../src/workflow.mjs');const plan=await read(path.join(run,'layout.plan.json')),board=await invoke('sealseek-media',['canvas','get',one('canvas'),'--json']);if(board.title!==plan.title)fail('STATE_CONFLICT','Recovered canvas title differs.');await checkpoint(path.join(run,'canvas.state.json'),{run_id:plan.run_id,canvas_id:board.canvas_id,canvas_url:board.canvas_url,status:'recovered'});return {recovered:true,...board};}
55
+ fail('INVALID_INPUT','Unknown command.');
56
+ }
57
+ async function execute(){
58
+ if(!['update','version','skill','capabilities','doctor','tool','help','--help'].includes(command)){
59
+ const u=await performUpdate();if(!u.ok)process.stderr.write('Automatic update deferred: '+(u.error?.code||'UPDATE_FAILED')+'\n');if(u.updated){const r=spawnSync(process.execPath,[process.argv[1],...process.argv.slice(2)],{stdio:'inherit',env:{...process.env,COMPETITOR_CANVAS_AUTO_UPDATE:'0'}});process.exit(r.status??1);}
60
+ }
61
+ return main();
62
+ }
63
+ execute().then(value=>console.log(JSON.stringify(value,null,2))).catch(e=>{console.error(JSON.stringify({ok:false,code:e.code??'FAILED',message:e.message,...(e.details?{details:e.details}:{})}));process.exitCode=1;});
package/package.json CHANGED
@@ -1,6 +1,35 @@
1
1
  {
2
2
  "name": "@petercjl/competitor-visual-canvas",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "type": "module",
5
+ "description": "Collect product visual assets and organize them into SealSeek canvas regions",
6
+ "bin": {
7
+ "competitor-visual-canvas": "bin/competitor-visual-canvas.mjs"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "src",
12
+ "skills",
13
+ "README.md"
14
+ ],
15
+ "engines": {
16
+ "node": ">=22"
17
+ },
18
+ "license": "UNLICENSED",
19
+ "publishConfig": {
20
+ "access": "public"
21
+ },
22
+ "scripts": {
23
+ "test": "node --test",
24
+ "check": "node --check bin/competitor-visual-canvas.mjs && node --test"
25
+ },
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "git+https://github.com/petercjl/competitor-visual-canvas.git"
29
+ },
30
+ "homepage": "https://github.com/petercjl/competitor-visual-canvas#readme",
31
+ "dependencies": {
32
+ "@petercjl/tbcli": "^0.10.5",
33
+ "@petercjl/sealseek-media-cli": "^0.5.1"
34
+ }
35
+ }
@@ -0,0 +1,68 @@
1
+ ---
2
+ name: competitor-visual-canvas
3
+ description: Collect Taobao or Tmall product main images, main videos, detail images and SKU images from IDs or links, then organize them into four regional Frames per product in SealSeek Infinite Canvas. Use for collecting competitor visual references into a canvas.
4
+ ---
5
+
6
+ # 竞品视觉素材画板
7
+
8
+ ## 契约与边界
9
+
10
+ 输入:一个或多个商品 ID 或淘宝/天猫详情链接,可选画板名称。纯数字 ID 默认为淘宝,按输入顺序去重。输出:原始采集结果、可复用素材清单、排版计划、运行状态、核验报告和画板链接。
11
+
12
+ 应用负责素材组织、命名、Frame 分组、排版和业务验收;tbcli 负责商品媒体采集;sealseek-media-cli 负责认证、媒体元信息和画板写入。采用 direct 方法:业务规则来自用户确认,服务限制来自运行时能力契约。无需私有知识库。此流程导入已有素材,无需生图或生视频。
13
+
14
+ 每个商品建立四个 Frame,分别位于主图、主图视频、SKU、详情页四个大区;Frame 名称为商品所在店铺名称。四个大区从左到右依次为主图、主图视频、SKU、详情页,横向起点固定。前三个区域预留宽度,新增商品向下追加;最右侧详情页区向右追加。Frame 位置由实际尺寸和已保存区域边界确定,追加保持已有 Frame 和素材的坐标不变。单商品画板使用商品名称;多商品默认“首个商品名称 等N款”,用户指定名称优先。
15
+
16
+ | 区域 | Frame 内部 | 同类 Frame 之间 |
17
+ | --- | --- | --- |
18
+ | 主图 | 原顺序横排 | 商品输入顺序上下排 |
19
+ | 主图视频 | 原顺序横排 | 商品输入顺序上下排 |
20
+ | SKU | 原顺序网格,每行最多5张 | 商品输入顺序上下排 |
21
+ | 详情页 | 原顺序无间隙竖排,统一宽度、等比缩放 | 商品输入顺序左右排 |
22
+
23
+ 每个大区顶部写入独立文字标题:主图区、主图视频区、SKU区、详情页区。标题属于画板文字元素,追加商品时复用。详情页图片首尾相接,图片间距为0,保留 Frame 外边距。图片按真实宽高等比展示;长详情图保持完整。SKU 同一区域相同链接归并,属性和 SKU 对应关系保留。空视频列表仍保留对应 Frame,并在报告中注明;未读到视频列表标 unavailable,不视为没有视频。
24
+
25
+ ## 运行主线
26
+
27
+ 1. 解析当前平台,加载对应 `adapters/codex.json` 、`adapters/sealseek.json` 或 `adapters/workbuddy.json`。发现 `competitor-visual-canvas version` 和 `doctor --json`。doctor 成功只是本地依赖检查。依赖不满足时停止受影响节点。
28
+ 2. 解析精确依赖 Skill:分别执行 `competitor-visual-canvas tool run tbcli skill source --json` 和 `competitor-visual-canvas tool run sealseek-media skill source --json`,读取返回路径下完整当前 SKILL.md、当前主机适配与任务相关参考。通过同一 tool run 入口发现当前版本及 capabilities;要求 tbcli 支持 item media、SealSeek 支持 frame-batch、text-batch 和 media-inspect。认证和采集/写入安全规则遵循各自插件。依赖缺失返回 CAPABILITY_UNAVAILABLE,不能自行编写 API 替代。
29
+ 3. 选择新的运行目录,执行 `competitor-visual-canvas collect --input ID_OR_URL [--input MORE] --run NEW_DIR --json`。应用逐商品调用 tbcli,先保存 raw 与 assets.manifest.json。已有 item media 原始结果可用重复 `--raw FILE` 导入;身份必须匹配。每个成功商品立即落盘。
30
+ 4. 检查覆盖状态;登录、验证和访问异常按 tbcli 分支处理。采集错误不能据此继续请求下一个商品。返回主线第3步时使用新采集输出,保留既有结果。媒体缺失、unknown 和 absent 明确区分,部分交付须说明范围。
31
+ 5. 执行 `competitor-visual-canvas plan --run DIR [--title TEXT] --json`。通过 SealSeek 通用媒体检查取得真实尺寸,保存 layout.plan.json 与 canvas.batch.json。确定性检查元素顺序、Frame 边界和无重叠。元信息失败停止规划,不用猜测尺寸冒充验证。
32
+ 6. 执行 `competitor-visual-canvas canvas --run DIR --json` 预览数量与名称。在用户已授权收集到画板的范围内执行同命令加 `--submit`。首次创建一个画板;运行状态记录其 ID,恢复沿用该画板。新建和追加的 Frame 与媒体通过能力层 batch 保存,保存前保留服务内容备份。
33
+ 7. 读回校验每个 Frame 的名称/位置/尺寸、素材数量/归属/位置/链接。检查 collection-report.json 的商品覆盖和缺口。向用户交付可点击画板链接、素材分类数量及中间文件位置。实际浏览器未查看时仅称读回验证通过。
34
+
35
+ 依赖调用完成后分别返回采集节点3、规划节点5或写入节点6,最后回到第7步交付。依赖 Skill 的说明不复制到应用包,当前 CLI 改进通过运行时契约发现。
36
+
37
+ ## 向已有画板追加商品
38
+
39
+ 用户给出已有画板名称时,按完整名称精确查找:`competitor-visual-canvas find --title EXACT_TITLE --json`。唯一匹配时执行 `competitor-visual-canvas append --title EXACT_TITLE --run NEW_DIR --input ID_OR_URL [--input MORE] --json`,再按主线第6、7步预览、提交与核验。名称可包含空格与中文,作为完整参数传入。用户提供画板 ID 或链接时可使用 `append --canvas ID`。同名候选由用户选择,随后传 `--canvas ID`;也可同时传 `--title` 校验名称。CANVAS_NOT_FOUND 时报告未找到,不自动新建。
40
+
41
+ 名称或 ID 追加从服务端读取当前内容,恢复本应用四区域 Frame、商品来源、已有素材及布局边界;无需旧对话或本地运行目录。恢复快照保存在本应用私有状态目录,输出 recovered_base_run。恢复依据元素的来源元数据及稳定 Frame 标识,支持空视频 Frame;保留已有元素的坐标、链接、归属和区域标题尺寸。原始采集证据、商品完整标题、SKU 属性等未保存在云端的信息标为 unavailable,不凭空补全。该方式适用于本应用当前四区域结构;缺少元数据、Frame 不完整或混入无法识别元素时返回 CANVAS_STATE_UNSUPPORTED,保留画板,使用可用的完整运行目录或交由用户决定如何整理。恢复和选择均只读,canvas --submit 才写入画板。
42
+
43
+ 用户提出继续添加商品时,使用最新已完成运行目录 BASE,不新建画板。执行 `competitor-visual-canvas append --base-run BASE --run NEW_DIR --input ID_OR_URL [--input MORE] --json`,先采集并规划;预览 `canvas --run NEW_DIR --json`,再在用户授权范围内执行加 `--submit` 的同命令。可重复 `--raw FILE` 复用 item media 原始结果。
44
+
45
+ 追加计划和素材清单为累计结果,沿用原画板 ID、标题、区域横向起点及旧元素 ID/位置;新商品主图、视频、SKU Frame 分别从对应区域底部继续向下,详情页 Frame 从最右侧继续向右。重复商品 ID 跳过;全部已存在时返回 changed:false。后续追加以 NEW_DIR 为最新基准,保留 BASE 作为恢复点。
46
+
47
+ 默认主图/视频/SKU 区域宽度至少为2400/1600/1840画板单位,并以首批实际内容宽度扩展。既有区域的宽度固定;超宽新商品返回 REGION_CAPACITY_EXCEEDED。将限制交给用户或上层布局策略处理,不挪动旧内容,也不改变横排规则。扩展布局属于另一次明确调整。
48
+
49
+ 写入前读回基准画板,保留区域标题当前的文字尺寸与字号,吸收不超过1e-6画板单位的浮点差异;标题内容和起点、素材归属与链接、实质坐标变化仍严格校验。检测手动移动和过期运行。CANVAS_CHANGED 时停止,读取最新完成的运行或由用户决定如何调整。允许未知结果恢复为同一累计计划;禁止从过期基准直接覆盖。
50
+
51
+ 既有画板需要应用当前排版与标题时,先用 `competitor-visual-canvas reflow --run BASE --json` 预览迁移,再在用户明确要求调整布局时加 `--submit`。迁移通过插件通用 canvas place 执行,保持元素 ID、素材链接与归属,写前备份,读回后更新本地计划。普通追加保留既有坐标和区域标题。
52
+
53
+ ## 分支与恢复
54
+
55
+ - 未知平台/短链接:通过 tbcli 支持的解析能力处理;缺失时报告 FEATURE_UNSUPPORTED,保留输入,返回第2步。
56
+ - 素材 URL 失效:用 tbcli 重新采集受影响商品到新文件,再建立新的规划;保留旧清单及来源。不能以视频封面替代视频。
57
+ - 规划失败:保留清单,修复元信息后返回第5步。已存在计划不覆盖,选择新的运行目录或保存可验证备份后开展授权修订。
58
+ - 创建结果未知:状态为 creating 时禁止再次创建。先用插件 canvas list/get 核对既有画板;确认匹配后 `competitor-visual-canvas recover --run DIR --canvas ID --submit --json` 绑定,再回到第6步。
59
+ - 保存结果未知:查询同一画板,复用同一批次和稳定 ID;再次执行 canvas 只恢复写入,不能新建另一个画板。ID 内容或布局冲突返回 IDEMPOTENCY_CONFLICT,交由应用决策。
60
+ - 目标文件已存在:保留现有文件,选新路径。应用运行文件为私有数据,不随 npm 包发布。
61
+
62
+ ## 安装、维护与 QA
63
+
64
+ 通过本包 CLI `skill source/status/install/update --agent codex|sealseek|workbuddy --json` 管理当前 Skill,source 是唯一来源。安装不授予淘宝或 SealSeek 服务访问权限。全局 npm 安装在业务命令启动时每日检查 latest 并自动下载安装、同步已管理 Skill 后重新执行原命令;离线沿用当前版本,更新失败保留备份,已有运行状态与服务凭证保持在包外。套件内部安装由 taobao-ai-ops 统一更新,源码由 Git 管理。手动更新使用 update install --yes;检查或开关自动更新使用 update check 和 update auto status/on/off。依赖 CLI 优先使用显式配置,其次解析本 npm 包安装的依赖,最后从 PATH 发现;开发或托管环境可用 `configure --tool TOOL --entry ABSOLUTE_CLI_PATH --submit --json` 配置外部私有工具入口,所有依赖发现和执行使用同一路径。WorkBuddy 目标支持显式安装;维护本机其他 Agent 的安装需遵循用户授权。
65
+
66
+ 需要 Node.js22+、已安装的两个依赖 CLI、淘宝已登录浏览器和 SealSeek 已授权账户;视频元信息检查需要 ffprobe。平台支持以适配器证据为准。静态兼容不能代替真实 Agent 测试。
67
+
68
+ 验收:每商品四个 Frame,店铺命名正确,四类覆盖明确,主图/详情顺序正确,SKU每行最多5张,元素无重叠、均在所属 Frame 内;恢复不重复创建或导入,追加前后旧元素坐标逐项保持一致。收藏清单与画板来源可追溯。生成任务质量、创意策略及竞品商业判断不在本应用范围内。
@@ -0,0 +1,62 @@
1
+ {
2
+ "schema": "portable-skill-adapter",
3
+ "schema_version": "1.0.0",
4
+ "platform": "codex",
5
+ "mappings": [
6
+ {
7
+ "capability_id": "canvas.product.append",
8
+ "implementation": {
9
+ "type": "cli",
10
+ "command": "competitor-visual-canvas",
11
+ "discovery": "Current package CLI; find --title and append --title/--canvas/--base-run"
12
+ },
13
+ "features": [
14
+ "canvas-list",
15
+ "canvas-get",
16
+ "source-metadata"
17
+ ],
18
+ "status": "implemented",
19
+ "evidence": [],
20
+ "normalization": "Exact-title candidates, structured ambiguity errors, validated cloud recovery and cumulative append plan; host runtime evidence is separate."
21
+ },
22
+ {
23
+ "capability_id": "product.media.collect",
24
+ "implementation": {
25
+ "type": "cli",
26
+ "command": "tbcli",
27
+ "discovery": "PATH or configured TBCLI_ENTRY/SEALSEEK_MEDIA_ENTRY; skill source; capabilities"
28
+ },
29
+ "features": [
30
+ "four-media-categories",
31
+ "product-shop-identity",
32
+ "ordered-urls",
33
+ "coverage"
34
+ ],
35
+ "status": "implemented",
36
+ "evidence": [],
37
+ "normalization": "CLI JSON; files and IDs are verified by application; errors stop the owning node."
38
+ },
39
+ {
40
+ "capability_id": "canvas.assets.write",
41
+ "implementation": {
42
+ "type": "cli",
43
+ "command": "sealseek-media",
44
+ "discovery": "PATH or configured TBCLI_ENTRY/SEALSEEK_MEDIA_ENTRY; skill source; capabilities"
45
+ },
46
+ "features": [
47
+ "frame-batch",
48
+ "text-batch",
49
+ "explicit-placement",
50
+ "media-inspect",
51
+ "source-metadata"
52
+ ],
53
+ "status": "implemented",
54
+ "evidence": [],
55
+ "normalization": "CLI JSON; files and IDs are verified by application; errors stop the owning node."
56
+ }
57
+ ],
58
+ "operating_systems": {
59
+ "macos": "implemented",
60
+ "windows": "unresolved"
61
+ }
62
+ }
@@ -0,0 +1,62 @@
1
+ {
2
+ "schema": "portable-skill-adapter",
3
+ "schema_version": "1.0.0",
4
+ "platform": "sealseek",
5
+ "mappings": [
6
+ {
7
+ "capability_id": "canvas.product.append",
8
+ "implementation": {
9
+ "type": "cli",
10
+ "command": "competitor-visual-canvas",
11
+ "discovery": "Current package CLI; find --title and append --title/--canvas/--base-run"
12
+ },
13
+ "features": [
14
+ "canvas-list",
15
+ "canvas-get",
16
+ "source-metadata"
17
+ ],
18
+ "status": "implemented",
19
+ "evidence": [],
20
+ "normalization": "Exact-title candidates, structured ambiguity errors, validated cloud recovery and cumulative append plan; host runtime evidence is separate."
21
+ },
22
+ {
23
+ "capability_id": "product.media.collect",
24
+ "implementation": {
25
+ "type": "cli",
26
+ "command": "tbcli",
27
+ "discovery": "PATH or configured TBCLI_ENTRY/SEALSEEK_MEDIA_ENTRY; skill source; capabilities"
28
+ },
29
+ "features": [
30
+ "four-media-categories",
31
+ "product-shop-identity",
32
+ "ordered-urls",
33
+ "coverage"
34
+ ],
35
+ "status": "implemented",
36
+ "evidence": [],
37
+ "normalization": "CLI JSON; files and IDs are verified by application; errors stop the owning node."
38
+ },
39
+ {
40
+ "capability_id": "canvas.assets.write",
41
+ "implementation": {
42
+ "type": "cli",
43
+ "command": "sealseek-media",
44
+ "discovery": "PATH or configured TBCLI_ENTRY/SEALSEEK_MEDIA_ENTRY; skill source; capabilities"
45
+ },
46
+ "features": [
47
+ "frame-batch",
48
+ "text-batch",
49
+ "explicit-placement",
50
+ "media-inspect",
51
+ "source-metadata"
52
+ ],
53
+ "status": "implemented",
54
+ "evidence": [],
55
+ "normalization": "CLI JSON; files and IDs are verified by application; errors stop the owning node."
56
+ }
57
+ ],
58
+ "operating_systems": {
59
+ "macos": "implemented",
60
+ "windows": "unresolved"
61
+ }
62
+ }
@@ -0,0 +1,62 @@
1
+ {
2
+ "schema": "portable-skill-adapter",
3
+ "schema_version": "1.0.0",
4
+ "platform": "workbuddy",
5
+ "mappings": [
6
+ {
7
+ "capability_id": "canvas.product.append",
8
+ "implementation": {
9
+ "type": "cli",
10
+ "command": "competitor-visual-canvas",
11
+ "discovery": "Current package CLI; find --title and append --title/--canvas/--base-run"
12
+ },
13
+ "features": [
14
+ "canvas-list",
15
+ "canvas-get",
16
+ "source-metadata"
17
+ ],
18
+ "status": "implemented",
19
+ "evidence": [],
20
+ "normalization": "Exact-title candidates, structured ambiguity errors, validated cloud recovery and cumulative append plan; host runtime evidence is separate."
21
+ },
22
+ {
23
+ "capability_id": "product.media.collect",
24
+ "implementation": {
25
+ "type": "cli",
26
+ "command": "tbcli",
27
+ "discovery": "PATH or configured TBCLI_ENTRY/SEALSEEK_MEDIA_ENTRY; skill source; capabilities"
28
+ },
29
+ "features": [
30
+ "four-media-categories",
31
+ "product-shop-identity",
32
+ "ordered-urls",
33
+ "coverage"
34
+ ],
35
+ "status": "implemented",
36
+ "evidence": [],
37
+ "normalization": "CLI JSON; files and IDs are verified by application; errors stop the owning node."
38
+ },
39
+ {
40
+ "capability_id": "canvas.assets.write",
41
+ "implementation": {
42
+ "type": "cli",
43
+ "command": "sealseek-media",
44
+ "discovery": "PATH or configured TBCLI_ENTRY/SEALSEEK_MEDIA_ENTRY; skill source; capabilities"
45
+ },
46
+ "features": [
47
+ "frame-batch",
48
+ "text-batch",
49
+ "explicit-placement",
50
+ "media-inspect",
51
+ "source-metadata"
52
+ ],
53
+ "status": "implemented",
54
+ "evidence": [],
55
+ "normalization": "CLI JSON; files and IDs are verified by application; errors stop the owning node."
56
+ }
57
+ ],
58
+ "operating_systems": {
59
+ "macos": "implemented",
60
+ "windows": "unresolved"
61
+ }
62
+ }
@@ -0,0 +1,3 @@
1
+ interface:
2
+ display_name: "竞品视觉素材画板"
3
+ short_description: "收集淘宝天猫商品主图、详情、SKU图和视频,按区域整理到无限画板"
@@ -0,0 +1,81 @@
1
+ {
2
+ "schema": "portable-skill-capabilities",
3
+ "schema_version": "1.0.0",
4
+ "skill": "competitor-visual-canvas",
5
+ "target_platforms": [
6
+ "codex",
7
+ "sealseek",
8
+ "workbuddy"
9
+ ],
10
+ "capabilities": [
11
+ {
12
+ "id": "canvas.product.append",
13
+ "purpose": "Resolve an exact canvas title or ID, recover application layout, and append products without moving existing assets",
14
+ "required": true,
15
+ "required_features": ["canvas-list", "canvas-get", "source-metadata"],
16
+ "input_fields": ["canvas_title_or_id_or_base_run", "product_ids", "new_run_directory"],
17
+ "output_fields": ["canvas_id", "recovered_base_run", "cumulative_plan"],
18
+ "errors": ["CANVAS_NOT_FOUND", "AMBIGUOUS_CANVAS", "CANVAS_STATE_UNSUPPORTED", "CANVAS_CHANGED"],
19
+ "side_effects": {
20
+ "reads": "authenticated canvas list and content",
21
+ "writes": "private recovery snapshot and new run files; canvas only on explicit submit",
22
+ "authorization": "user requested appending products to the selected canvas",
23
+ "external_mutation": true,
24
+ "may_cost_money": false
25
+ }
26
+ },
27
+ {
28
+ "id": "product.media.collect",
29
+ "purpose": "Read ordered product media and source evidence",
30
+ "required": true,
31
+ "required_features": [
32
+ "four-media-categories",
33
+ "product-shop-identity",
34
+ "ordered-urls",
35
+ "coverage"
36
+ ],
37
+ "input_fields": [
38
+ "product_id"
39
+ ],
40
+ "output_fields": [
41
+ "raw",
42
+ "manifest"
43
+ ],
44
+ "side_effects": {
45
+ "reads": "authenticated product pages",
46
+ "writes": "private local files",
47
+ "authorization": "user requested collection",
48
+ "external_mutation": false,
49
+ "may_cost_money": false
50
+ }
51
+ },
52
+ {
53
+ "id": "canvas.assets.write",
54
+ "purpose": "Place explicit Frames and existing media",
55
+ "required": true,
56
+ "required_features": [
57
+ "frame-batch",
58
+ "text-batch",
59
+ "explicit-placement",
60
+ "media-inspect",
61
+ "source-metadata"
62
+ ],
63
+ "input_fields": [
64
+ "canvas_id",
65
+ "frames",
66
+ "media"
67
+ ],
68
+ "output_fields": [
69
+ "canvas_url",
70
+ "readback"
71
+ ],
72
+ "side_effects": {
73
+ "writes": "new canvas and media elements",
74
+ "cost": "no media generation",
75
+ "authorization": "user requested canvas collection",
76
+ "external_mutation": true,
77
+ "may_cost_money": false
78
+ }
79
+ }
80
+ ]
81
+ }
@@ -0,0 +1,104 @@
1
+ import path from 'node:path';
2
+ import crypto from 'node:crypto';
3
+ import {stateRoot} from './core.mjs';
4
+ import {invoke,fail,uid,create,REGIONS,validateLayout,matchesBatch,appendRun} from './workflow.mjs';
5
+
6
+ const labels=['主图区','主图视频区','SKU区','详情页区'];
7
+ const geometry=e=>Object.fromEntries(['x','y','width','height'].map(k=>[k,e[k]]));
8
+ const reject=message=>fail('CANVAS_STATE_UNSUPPORTED',message+' Use a completed --base-run when available. Existing canvas content was preserved.');
9
+
10
+ // Discovery is read-only; service keyword search is fuzzy, application selection is exact.
11
+ export async function findCanvases(title,call=invoke){
12
+ if(typeof title!=='string'||!title.trim())fail('INVALID_INPUT','Provide an exact canvas title.');
13
+ const found=new Map();let page=1;
14
+ for(;;){
15
+ const result=await call('sealseek-media',['canvas','list','--keyword',title,'--page',String(page),'--limit','100','--json']);
16
+ if(!Array.isArray(result.canvases))fail('OUTPUT_CONTRACT_FAILED','Invalid canvas list.');
17
+ for(const b of result.canvases)if(b.title===title)found.set(String(b.canvas_id),b);
18
+ const pages=Number(result.pages??1);
19
+ if(!Number.isInteger(pages)||pages<0||pages>10000)fail('OUTPUT_CONTRACT_FAILED','Invalid canvas pagination.');
20
+ if(page>=pages)break;page++;
21
+ }
22
+ return {title,count:found.size,canvases:[...found.values()]};
23
+ }
24
+
25
+ export async function selectCanvas({title,canvas},call=invoke){
26
+ let id=canvas;
27
+ if(title&&!canvas){
28
+ const result=await findCanvases(title,call);
29
+ if(!result.count)fail('CANVAS_NOT_FOUND','No canvas has this exact title.');
30
+ if(result.count>1)throw Object.assign(new Error('Multiple canvases have this title; select a canvas ID.'),{code:'AMBIGUOUS_CANVAS',details:result});
31
+ id=result.canvases[0].canvas_id;
32
+ }
33
+ if(!/^\d+$/.test(id||''))fail('INVALID_INPUT','Provide --title TEXT or --canvas ID.');
34
+ const board=await call('sealseek-media',['canvas','get',String(id),'--json']);
35
+ if(String(board.canvas_id)!==String(id))fail('OUTPUT_CONTRACT_FAILED','Canvas identity mismatch.');
36
+ if(title&&board.title!==title)fail('CANVAS_CHANGED','Selected canvas title differs.');
37
+ return board;
38
+ }
39
+
40
+ // The application reconstructs only its own four-region contract. No provider API is duplicated.
41
+ // Frame hashes bind even empty video Frames to their product, category and collection run.
42
+ export function restoreCanvas(board,run_id=crypto.randomUUID()){
43
+ if(!board.canvas_id||!Array.isArray(board.elements))reject('Canvas content is unavailable.');
44
+ const elements=board.elements.filter(e=>!e.isDeleted),images=elements.filter(e=>e.type==='image');
45
+ const texts=labels.map(text=>{const found=elements.filter(e=>e.type==='text'&&e.text===text&&typeof e.id==='string'&&e.id.startsWith('region-'));if(found.length!==1)reject('Four unique region headings are required.');const e=found[0];if(!['x','y','width','height','fontSize'].every(k=>Number.isFinite(e[k]))||e.width<=0||e.height<=0||e.fontSize<8||e.fontSize>200)reject('Invalid heading geometry.');return {id:e.id,text,...geometry(e),fontSize:e.fontSize};});
46
+ if(texts.some((t,i)=>i&&t.x<=texts[i-1].x))reject('Unsupported region order.');
47
+ const products=new Map(),identities=new Map(),media=[],taskIds=new Set();
48
+ for(const e of images){
49
+ const s=e.customData?.sourceMetadata,task=e.customData?.cliTaskId;
50
+ if(!s||!REGIONS.includes(s.category)||!/^\d+$/.test(s.productId||'')||typeof s.assetId!=='string'||typeof task!=='string'||e.customData.cliOutputIndex!==0)reject('An image lacks application source metadata.');
51
+ const suffix='-'+s.assetId+'-'+s.category;
52
+ if(!task.endsWith(suffix))reject('Invalid application media identity.');
53
+ if(taskIds.has(task))reject('Duplicate application media identity.');taskIds.add(task);
54
+ const origin=task.slice(0,-suffix.length),key=origin+'|'+s.productId;
55
+ if(!origin)reject('Missing collection identity.');
56
+ if(!products.has(s.productId))products.set(s.productId,{id:s.productId,title:'商品 '+s.productId,url:s.sourceUrl,shop:{shopId:s.shopId,shopName:''},coverage:{recovered_from_canvas:true,original_collection:'unavailable'},assets:Object.fromEntries(REGIONS.map(c=>[c,[]]))});
57
+ identities.set(key,{origin,productId:s.productId});
58
+ const kind=s.category==='main_videos'?'video':'image',url=kind==='video'?e.customData.videoUrl:e.customData.fileUrl;
59
+ if(!url||(kind==='video'&&!e.customData.fileUrl)||!['x','y','width','height'].every(k=>Number.isFinite(e[k]))||e.width<=0||e.height<=0)reject('Invalid media geometry or URL.');
60
+ media.push({id:task,kind,urls:[url],frameId:e.frameId,placement:{mode:'explicit',positions:[geometry(e)]},...(kind==='video'?{posterUrl:e.customData.fileUrl}:{}),sourceMetadata:structuredClone(s)});
61
+ products.get(s.productId).assets[s.category].push({asset_id:s.assetId,kind,url,width:e.width,height:e.height,recovered:true,source_index:0});
62
+ }
63
+ if(!products.size)reject('No recoverable products were found.');
64
+ const frames=[],members=new Map(REGIONS.map(c=>[c,[]])),frameKeys=new Set();
65
+ for(const e of elements.filter(e=>e.type==='frame')){
66
+ const candidates=[];
67
+ for(const v of identities.values())for(const category of REGIONS)if(e.id==='frame-'+uid([v.origin,v.productId,category]))candidates.push({...v,category});
68
+ if(candidates.length!==1)reject('A Frame cannot be bound to an application product.');
69
+ const identity=candidates[0],p=products.get(identity.productId);
70
+ const key=identity.productId+'|'+identity.category;
71
+ if(frameKeys.has(key)||typeof e.name!=='string'||!e.name.trim()||!['x','y','width','height'].every(k=>Number.isFinite(e[k]))||e.width<=0||e.height<=0)reject('Duplicate Frame identity or invalid geometry.');
72
+ frameKeys.add(key);
73
+ if(p.shop.shopName&&p.shop.shopName!==e.name)reject('Product shop names differ across Frames.');
74
+ p.shop.shopName=e.name;
75
+ const frame={id:e.id,name:e.name,...geometry(e)};frames.push(frame);members.get(identity.category).push(frame);
76
+ for(const m of media.filter(m=>m.frameId===e.id))if(m.sourceMetadata.productId!==identity.productId||m.sourceMetadata.category!==identity.category)reject('Media source and Frame identity disagree.');
77
+ }
78
+ if(frames.length!==products.size*4||REGIONS.some(c=>members.get(c).length!==products.size)||media.some(m=>!frames.some(f=>f.id===m.frameId)))reject('Product Frames are incomplete.');
79
+ const known=new Set([...frames,...texts].map(e=>e.id));
80
+ if(elements.some(e=>!known.has(e.id)&&e.type!=='image'&&!(e.type==='rectangle'&&e.customData?.type==='frame-background'&&frames.some(f=>f.id===e.customData.ownerFrameId))))reject('The canvas contains unsupported elements.');
81
+ const regions=REGIONS.map((category,i)=>{
82
+ const list=members.get(category),horizontal=category==='detail_images',x=texts[i].x,y=Math.min(...list.map(f=>f.y));
83
+ list.sort((a,b)=>horizontal?a.x-b.x:a.y-b.y);
84
+ if(list.some(f=>horizontal?f.y!==y:f.x!==x)||list[0].x!==x)reject('Unsupported Frame alignment.');
85
+ const width=horizontal?Math.max(...list.map(f=>f.width)):texts[i+1].x-x-240;
86
+ if(list.some(f=>!horizontal&&f.width>width)||width<=0)reject('Unsupported region bounds.');
87
+ const bottom=Math.max(...list.map(f=>f.y+f.height)),right=Math.max(...list.map(f=>f.x+f.width));
88
+ return {category,x,y,width,axis:horizontal?'horizontal':'vertical',next_x:horizontal?right+96:x,next_y:horizontal?y:bottom+96,bottom,right,frame_ids:list.map(f=>f.id)};
89
+ });
90
+ const plan={schema_version:2,presentation_version:1,run_id,title:board.title,layout_options:{width:320,gap:32,padding:32,frameGap:96,regionGap:240},regions,batch:{frames,media,texts},recovered_from_canvas:String(board.canvas_id)};
91
+ validateLayout(plan);
92
+ if(!matchesBatch(board,plan.batch))reject('Recovered canvas does not match its source content.');
93
+ return {plan,manifest:{schema_version:1,run_id,captured_at:new Date().toISOString(),recovered_from_canvas:String(board.canvas_id),products:[...products.values()]}};
94
+ }
95
+
96
+ export async function appendToCanvas(run,selector,inputs,rawFiles=[],call=invoke){
97
+ const board=await selectCanvas(selector,call),{plan,manifest}=restoreCanvas(board);
98
+ const base=path.join(stateRoot(),'recovered-canvases',plan.run_id);
99
+ await create(path.join(base,'layout.plan.json'),plan);
100
+ await create(path.join(base,'canvas.batch.json'),plan.batch);
101
+ await create(path.join(base,'assets.manifest.json'),manifest);
102
+ await create(path.join(base,'canvas.state.json'),{run_id:plan.run_id,status:'completed',canvas_id:String(board.canvas_id),canvas_url:board.canvas_url});
103
+ return {...await appendRun(run,base,inputs,rawFiles),canvas_id:String(board.canvas_id),title:board.title,recovered_base_run:base};
104
+ }
package/src/core.mjs ADDED
@@ -0,0 +1,38 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+ import crypto from 'node:crypto';
5
+ import { fileURLToPath } from 'node:url';
6
+
7
+ export const ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
8
+ export const META = JSON.parse(await fs.readFile(path.join(ROOT, 'package.json'), 'utf8'));
9
+ export class MediaError extends Error {
10
+ constructor(code, message, details = {}) { super(message); this.code = code; this.details = details; }
11
+ }
12
+ export function requireValue(condition, code, message, details) {
13
+ if (!condition) throw new MediaError(code, message, details);
14
+ }
15
+ export function stateRoot() { return path.resolve(process.env.COMPETITOR_CANVAS_STATE_DIR || path.join(os.homedir(), '.local', 'state', 'competitor-visual-canvas')); }
16
+ export function hash(value) { return crypto.createHash('sha256').update(typeof value === 'string' || Buffer.isBuffer(value) ? value : JSON.stringify(value)).digest('hex'); }
17
+ export async function exists(p) { try { return await fs.lstat(p); } catch (e) { if (e.code === 'ENOENT') return null; throw e; } }
18
+ export async function readJson(p) { return JSON.parse(await fs.readFile(p, 'utf8')); }
19
+ export async function createJson(p, value) {
20
+ await fs.mkdir(path.dirname(p), { recursive: true, mode: 0o700 });
21
+ await fs.writeFile(p, JSON.stringify(value, null, 2) + '\n', { flag: 'wx', mode: 0o600 });
22
+ }
23
+ // Mutable state is package-owned and validated before each atomic replacement.
24
+ export async function replaceJob(p, value, {rename=fs.rename} = {}) {
25
+ const current = await readJson(p);
26
+ requireValue(current.owner === META.name && current.id === value.id, 'UNMANAGED_STATE', 'State ownership mismatch.');
27
+ const tmp = `${p}.${crypto.randomUUID()}.tmp`;
28
+ await createJson(tmp, value);
29
+ // Windows readers and antivirus can briefly block an atomic replacement.
30
+ // Keep the old file and complete temporary file intact while retrying rename.
31
+ for(let attempt=0;;attempt++) {
32
+ try { await rename(tmp,p);break; }
33
+ catch(e) { if(!['EPERM','EBUSY','EACCES'].includes(e.code)||attempt>=7)throw e;await new Promise(r=>setTimeout(r,Math.min(20*2**attempt,200))); }
34
+ }
35
+ }
36
+ export function publicError(e) {
37
+ return { code: e.code && /^[A-Z_]+$/.test(e.code) ? e.code : 'PROVIDER_FAILURE', message: e instanceof MediaError ? e.message : 'Operation failed; inspect the structured error and saved run before retrying.', ...(e.details ? { details: e.details } : {}) };
38
+ }
@@ -0,0 +1,9 @@
1
+ export {ROOT,META} from './core.mjs';
2
+ import {SOURCE,status,install} from './skills.mjs';
3
+ import {META} from './core.mjs';
4
+ export async function skill(action,agent='codex',custom){
5
+ if(action==='source')return {source:SOURCE,name:'competitor-visual-canvas',version:META.version};
6
+ if(action==='status'){const s=await status(agent,custom);return {...s,target:s.destination};}
7
+ if(['install','update'].includes(action)){const s=await install(agent,{path:custom});return {...s,target:s.destination};}
8
+ throw Object.assign(Error('Use skill source/status/install/update'),{code:'INVALID_INPUT'});
9
+ }
package/src/reflow.mjs ADDED
@@ -0,0 +1,38 @@
1
+ import path from 'node:path';
2
+ import crypto from 'node:crypto';
3
+ import {read,layout,invoke,create,checkpoint,fail} from './workflow.mjs';
4
+ export function backgroundPlacements(elements,frames){
5
+ const bounds=e=>Object.fromEntries(['x','y','width','height'].map(k=>[k,e[k]]));
6
+ return elements.filter(e=>!e.isDeleted&&e.customData?.type==='frame-background'&&e.customData.ownerFrameId===e.frameId).flatMap(e=>{
7
+ const frame=frames.find(f=>f.id===e.frameId);
8
+ return frame?[{id:e.id,...bounds(frame),expected:bounds(e)}]:[];
9
+ });
10
+ }
11
+ export async function reflowRun(run,submit=false){
12
+ const previous=await read(path.join(run,'layout.plan.json'));
13
+ if(previous.schema_version===2&&previous.presentation_version===1)return {changed:false,reason:'Current presentation already active.'};
14
+ const manifest=await read(path.join(run,'assets.manifest.json')),state=await read(path.join(run,'canvas.state.json'));
15
+ if(!state.canvas_id)fail('STATE_CONFLICT','No existing canvas for this run.');
16
+ const next=layout(manifest,{title:previous.title});
17
+ for(const r of next.regions){const old=previous.regions?.find(v=>v.category===r.category);if(old)r.frame_ids=r.frame_ids.map((id,i)=>{const oldId=old.frame_ids?.[i];if(oldId){next.batch.frames.find(f=>f.id===id).id=oldId;for(const m of next.batch.media)if(m.frameId===id)m.frameId=oldId;}return oldId||id;});}
18
+ for(const m of next.batch.media){const old=previous.batch.media.find(v=>v.sourceMetadata?.assetId===m.sourceMetadata.assetId&&v.sourceMetadata?.category===m.sourceMetadata.category&&v.sourceMetadata?.productId===m.sourceMetadata.productId);if(old)m.id=old.id;}
19
+ const oldBatch=await read(path.join(run,'canvas.batch.json'));
20
+ if(![previous.batch,next.batch].some(b=>JSON.stringify(b)===JSON.stringify(oldBatch)))fail('STATE_CONFLICT','Batch differs from the owned layout.');
21
+ const live=await invoke('sealseek-media',['canvas','get',state.canvas_id,'--json']);
22
+ const placements=[];
23
+ const bounds=e=>Object.fromEntries(['x','y','width','height'].map(k=>[k,e[k]]));
24
+ for(const f of next.batch.frames){const current=live.elements.find(e=>e.id===f.id&&!e.isDeleted);if(!current)fail('ELEMENT_NOT_FOUND','Frame missing during migration.');placements.push({id:current.id,...bounds(f),expected:bounds(current)});}
25
+ for(const m of next.batch.media){const current=live.elements.find(e=>e.customData?.cliTaskId===m.id&&!e.isDeleted);if(!current)fail('ELEMENT_NOT_FOUND','Media missing during migration.');placements.push({id:current.id,...m.placement.positions[0],expected:bounds(current)});}
26
+ placements.push(...backgroundPlacements(live.elements,next.batch.frames));
27
+ if(!submit)return {dry_run:true,canvas_id:state.canvas_id,frames:next.batch.frames.length,media:next.batch.media.length,regions:next.regions.map(r=>({category:r.category,x:r.x,axis:r.axis}))};
28
+ const file=path.join(run,'reflow-'+crypto.randomUUID()+'.json');await create(file,placements);
29
+ const saved=await invoke('sealseek-media',['canvas','place',state.canvas_id,'--file',file,'--submit','--json']);
30
+ const batchFile=path.join(run,'reflow-batch-'+crypto.randomUUID()+'.json');await create(batchFile,next.batch);
31
+ await invoke('sealseek-media',['canvas','batch',state.canvas_id,'--file',batchFile,'--submit','--json']);
32
+ const after=await invoke('sealseek-media',['canvas','get',state.canvas_id,'--json']);
33
+ if(!placements.every(p=>{const e=after.elements.find(e=>e.id===p.id);return e&&['x','y','width','height'].every(k=>e[k]===p[k]);}))fail('OUTPUT_CONTRACT_FAILED','Reflow readback mismatch.');
34
+ await checkpoint(path.join(run,'canvas.batch.json'),next.batch);
35
+ await checkpoint(path.join(run,'layout.plan.json'),next);
36
+ await checkpoint(path.join(run,'canvas.state.json'),{...state,status:'completed',layout_version:2,presentation_version:1});
37
+ return {saved:true,canvas_id:state.canvas_id,canvas_url:after.canvas_url,...saved};
38
+ }
package/src/skills.mjs ADDED
@@ -0,0 +1,98 @@
1
+ import fs from 'node:fs/promises';
2
+ import {existsSync} from 'node:fs';
3
+ import path from 'node:path';
4
+ import os from 'node:os';
5
+ import crypto from 'node:crypto';
6
+ import { ROOT, META, exists, requireValue, hash, createJson, readJson, stateRoot } from './core.mjs';
7
+
8
+ export const SOURCE = path.join(ROOT, 'skills', 'competitor-visual-canvas');
9
+ const MARKER = '.competitor-visual-canvas-install.json';
10
+ export function renderSkill(text, agent) { return text; }
11
+ export function destination(agent, custom) {
12
+ const roots = { codex:'.codex', workbuddy:'.workbuddy', sealseek:'.sealseek' };
13
+ requireValue(agent in roots, 'UNKNOWN_TARGET', 'Supported agents: codex, workbuddy, sealseek.');
14
+ if(custom)return path.resolve(custom);
15
+ if(agent==='sealseek'){
16
+ const configured=process.env.SEALSEEK_SKILLS_DIR;
17
+ if(configured)return path.join(path.resolve(configured),'competitor-visual-canvas');
18
+ const home=process.env.SEALSEEK_HOME||path.join(os.homedir(),'.sealseek');
19
+ const candidates=[path.join(home,'workspaces','default','skills'),path.join(home,'workspace','skills')].filter(p=>existsSync(p));
20
+ if(candidates.length===1)return path.join(candidates[0],'competitor-visual-canvas');
21
+ requireValue(false,'TARGET_UNRESOLVED','Specify the active SealSeek Skill root with SEALSEEK_SKILLS_DIR or --path.');
22
+ }
23
+ return path.join(os.homedir(), roots[agent], 'skills','competitor-visual-canvas');
24
+ }
25
+ export async function hashes(dir) {
26
+ const result={};
27
+ async function walk(base='') {
28
+ for (const item of await fs.readdir(path.join(dir,base),{ withFileTypes:true })) {
29
+ if (item.name === MARKER) continue;
30
+ const rel=path.join(base,item.name);
31
+ requireValue(!item.isSymbolicLink(), 'UNMANAGED_TARGET', 'Nested symlinks are not managed Skill content.');
32
+ if (item.isDirectory()) await walk(rel); else result[rel]=hash(await fs.readFile(path.join(dir,rel)));
33
+ }
34
+ }
35
+ await walk();return result;
36
+ }
37
+ export async function status(agent, custom) {
38
+ const target=destination(agent,custom), stat=await exists(target), sourceHashes=await hashes(SOURCE);
39
+ sourceHashes['SKILL.md'] = hash(renderSkill(await fs.readFile(path.join(SOURCE,'SKILL.md'),'utf8'),agent));
40
+ if (!stat) return { ok:true, agent, destination:target, installed:false, managed:false, current:false };
41
+ if (stat.isSymbolicLink()) {
42
+ const actual=await fs.realpath(target).catch(()=>null);
43
+ return { ok:true, agent, destination:target, installed:true, managed:actual === await fs.realpath(SOURCE), current:actual === await fs.realpath(SOURCE), mode:'link' };
44
+ }
45
+ const manifest=await readJson(path.join(target,MARKER)).catch(()=>null);
46
+ const managed=manifest?.owner===META.name;
47
+ const actual=stat.isDirectory() ? await hashes(target) : {};
48
+ const edited=managed && (Object.entries(manifest.hashes).some(([name,digest])=> actual[name]!==digest && name !== path.join('agents','openai.yaml')) || Object.keys(actual).some(name=>!(name in manifest.hashes)));
49
+ return { ok:true,agent,destination:target,installed:true,managed,current:managed && !edited && manifest.version===META.version && manifest.source===SOURCE && Object.keys(actual).length===Object.keys(sourceHashes).length && Object.entries(sourceHashes).every(([name,digest])=> actual[name]===digest || name === path.join('agents','openai.yaml')), edited,mode:'copy',version:manifest?.version };
50
+ }
51
+ export async function install(agent, options = {}) {
52
+ const rename = options.rename || fs.rename;
53
+ const info=await status(agent,options.path), target=info.destination;
54
+ requireValue(!info.installed || info.managed, 'UNMANAGED_TARGET', 'Existing Skill is user-owned. Choose an unused --path.');
55
+ requireValue(!info.edited || options.force, 'LOCAL_EDITS', 'Managed Skill has local edits. Review them before --force; update will create a backup.');
56
+ if (info.current) return { ...info, state:'unchanged' };
57
+ await fs.mkdir(path.dirname(target), { recursive:true });
58
+ let backup;
59
+ if (info.installed) {
60
+ backup=path.join(stateRoot(),'skill-backups',`${agent}-${crypto.randomUUID()}`);
61
+ requireValue(!await exists(backup), 'TARGET_EXISTS', 'Backup target exists.');
62
+ await fs.mkdir(path.dirname(backup),{recursive:true,mode:0o700});
63
+ await fs.cp(target,backup,{ recursive:true,errorOnExist:true,force:false,dereference:true });
64
+ const originalHashes = await hashes(target), backupHashes = await hashes(backup);
65
+ requireValue(JSON.stringify(originalHashes) === JSON.stringify(backupHashes), 'BACKUP_VERIFICATION_FAILED', 'Backup hashes differ; update stopped.');
66
+ }
67
+ const staging=`${target}.staging-${crypto.randomUUID()}`;
68
+ await fs.cp(SOURCE,staging,{ recursive:true,errorOnExist:true,force:false });
69
+ const skillText = await fs.readFile(path.join(staging,'SKILL.md'),'utf8');
70
+ await fs.writeFile(path.join(staging,'SKILL.md'),renderSkill(skillText,agent));
71
+ if (backup && await exists(path.join(target,'agents','openai.yaml'))) {
72
+ const oldManifest=await readJson(path.join(target,MARKER));
73
+ const name=path.join('agents','openai.yaml');
74
+ if(hash(await fs.readFile(path.join(target,name)))!==oldManifest.hashes[name]) await fs.copyFile(path.join(target,name),path.join(staging,name));
75
+ }
76
+ const digest=await hashes(staging);
77
+ await createJson(path.join(staging,MARKER),{ owner:META.name,version:META.version,source:SOURCE,hashes:digest });
78
+ let retired;
79
+ try {
80
+ if (info.installed) { const candidate=`${target}.retired-${crypto.randomUUID()}`; await rename(target,candidate); retired=candidate; }
81
+ await rename(staging,target);
82
+ } catch (e) {
83
+ if (retired && !await exists(target)) await fs.rename(retired,target);
84
+ if (['EPERM','EBUSY','EACCES'].includes(e.code) && backup) {
85
+ // Recoverable in-place sync is restricted to known managed files.
86
+ for (const name of Object.keys(digest)) {
87
+ const q=path.join(target,name); await fs.mkdir(path.dirname(q),{ recursive:true }); await fs.copyFile(path.join(staging,name),q);
88
+ }
89
+ for (const name of Object.keys(await hashes(target))) if (!(name in digest)) await fs.unlink(path.join(target,name));
90
+ await fs.copyFile(path.join(staging,MARKER),path.join(target,MARKER));
91
+ } else throw e;
92
+ }
93
+ const actual=await hashes(target);
94
+ requireValue(Object.keys(actual).length===Object.keys(digest).length && Object.entries(digest).every(([name,h])=>actual[name]===h), 'INSTALL_VERIFICATION_FAILED', 'Installed Skill hashes differ; recover from the returned backup.', { backup });
95
+ if (await exists(staging)) await fs.rm(staging,{recursive:true});
96
+ if (retired && await exists(retired)) await fs.rm(retired,{recursive:true});
97
+ return { ...await status(agent,options.path), state:'installed', ...(backup ? { backup } : {}) };
98
+ }
package/src/update.mjs ADDED
@@ -0,0 +1,114 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import crypto from 'node:crypto';
4
+ import { spawnSync } from 'node:child_process';
5
+ import { ROOT,META,stateRoot,exists,readJson,createJson,replaceJob,requireValue,publicError,hash } from './core.mjs';
6
+ import { status } from './skills.mjs';
7
+
8
+ const DAY=86400000;
9
+ export function newer(candidate,current) {
10
+ if(!/^\d+\.\d+\.\d+$/.test(candidate)||!/^\d+\.\d+\.\d+$/.test(current))return false;
11
+ const a=candidate.split('.').map(Number),b=current.split('.').map(Number);
12
+ for(let i=0;i<3;i++){if(a[i]!==b[i])return a[i]>b[i];}return false;
13
+ }
14
+ export async function settings(dir=stateRoot()) {
15
+ const p=path.join(dir,'updates.json');
16
+ const value=await exists(p)?await readJson(p):{owner:META.name,id:'updates',enabled:true};
17
+ requireValue(value.owner===META.name&&value.id==='updates','UNMANAGED_STATE','Update settings ownership mismatch.');return value;
18
+ }
19
+ async function store(value,dir) {
20
+ const p=path.join(dir,'updates.json');
21
+ if(await exists(p))await replaceJob(p,value);else await createJson(p,value);
22
+ }
23
+ export async function setAutomatic(enabled) {
24
+ const dir=stateRoot(),value=await settings(dir),p=path.join(dir,'updates.json');
25
+ if(await exists(p))await createJson(path.join(dir,'update-backups',crypto.randomUUID()+'.json'),value);
26
+ value.enabled=enabled;await store(value,dir);return {ok:true,...await updateStatus()};
27
+ }
28
+ export async function updateStatus() {
29
+ const {owner,id,...value}=await settings();return {...value,channel:'latest',check_interval_hours:24,trigger:'CLI invocation',development_checkout:!!await exists(path.join(ROOT,'.git'))};
30
+ }
31
+ export async function latestVersion() {
32
+ const r=await fetch(`https://registry.npmjs.org/${encodeURIComponent(META.name)}/latest`,{signal:AbortSignal.timeout(3000)});
33
+ requireValue(r.ok,'REGISTRY_UNAVAILABLE','Stable npm registry metadata is unavailable.');
34
+ const v=await r.json();requireValue(v.name===META.name&&/^\d+\.\d+\.\d+$/.test(v.version),'INVALID_REGISTRY_METADATA','Expected this package and a stable semantic version.');return v.version;
35
+ }
36
+ export async function npmCli() {
37
+ const candidates=[process.env.npm_execpath,path.join(path.dirname(process.execPath),'node_modules','npm','bin','npm-cli.js'),... (process.env.PATH||'').split(path.delimiter).map(p=>path.join(p,process.platform==='win32'?'npm.cmd':'npm'))].filter(Boolean);
38
+ for(const candidate of candidates){
39
+ if(!await exists(candidate))continue;
40
+ const real=await fs.realpath(candidate);
41
+ const script=real.endsWith('npm-cli.js')?real:path.join(path.dirname(real),'node_modules','npm','bin','npm-cli.js');
42
+ if(await exists(script))return script;
43
+ }
44
+ requireValue(false,'NPM_UNAVAILABLE','A working npm installation is required for package updates.');
45
+ }
46
+ function run(script,args) {
47
+ const r=spawnSync(process.execPath,[script,...args],{encoding:'utf8',timeout:120000,windowsHide:true});
48
+ requireValue(r.status===0,'UPDATE_FAILED','npm update operation failed. Existing backups remain available.');return r.stdout.trim();
49
+ }
50
+ export async function globalInstallation(root=ROOT) {
51
+ const script=await npmCli(),global=run(script,['root','--global']);
52
+ const expected=path.join(global,...META.name.split('/'));
53
+ return (await fs.realpath(expected).catch(()=>null))===await fs.realpath(root);
54
+ }
55
+ export async function managedTargets() {
56
+ const targets=[];
57
+ for(const agent of ['codex','workbuddy','sealseek']) {
58
+ const info=await status(agent).catch(e=>{if(e.code==='TARGET_UNRESOLVED')return {managed:false};throw e;});
59
+ requireValue(!info.managed||!info.edited,'LOCAL_EDITS','A managed Skill has local edits; review it before updating.',{agent});
60
+ if(info.managed)targets.push(agent);
61
+ }return targets;
62
+ }
63
+ export async function activeWork(dir=stateRoot()) {
64
+ for(const folder of ['jobs','auth-sessions']) {
65
+ for(const file of await fs.readdir(path.join(dir,folder)).catch(()=>[])) {
66
+ if(!file.endsWith('.json'))continue;
67
+ const j=await readJson(path.join(dir,folder,file)).catch(()=>null);
68
+ if(j?.owner!==META.name||!['queued','running','generated','pending','verifying','starting'].includes(j.status))continue;
69
+ if(j.pid){try{process.kill(j.pid,0);return true;}catch(e){if(e.code!=='ESRCH')return true;}}
70
+ else if(Date.now()-Date.parse(j.created_at)<120000)return true;
71
+ }
72
+ }return false;
73
+ }
74
+ export async function installVersion(version,targets,{root=ROOT,dir=stateRoot(),npmScript=npmCli,execute=run,synchronize}={}) {
75
+ requireValue(/^\d+\.\d+\.\d+$/.test(version),'INVALID_INPUT','Expected a stable semantic version.');
76
+ const backup=path.join(dir,'package-backups',crypto.randomUUID());
77
+ requireValue(!await exists(backup),'TARGET_EXISTS','Package backup already exists.');
78
+ await fs.mkdir(path.dirname(backup),{recursive:true,mode:0o700});
79
+ await fs.cp(root,backup,{recursive:true,errorOnExist:true,force:false,dereference:true});
80
+ for(const file of ['package.json','bin/competitor-visual-canvas.mjs'])requireValue(hash(await fs.readFile(path.join(root,file)))===hash(await fs.readFile(path.join(backup,file))),'BACKUP_VERIFICATION_FAILED','Package backup verification failed.');
81
+ try {
82
+ await execute(await npmScript(),['install','--global',`${META.name}@${version}`,'--ignore-scripts','--no-audit','--no-fund']);
83
+ const installed=await readJson(path.join(root,'package.json'));
84
+ requireValue(installed.name===META.name&&installed.version===version,'UPDATE_VERIFICATION_FAILED','Installed package version mismatch.');
85
+ for(const agent of targets){
86
+ const r=synchronize?await synchronize(agent):spawnSync(process.execPath,[path.join(root,'bin','competitor-visual-canvas.mjs'),'skill','update','--agent',agent,'--json'],{encoding:'utf8',timeout:120000,env:{...process.env,COMPETITOR_CANVAS_AUTO_UPDATE:'0'},windowsHide:true});
87
+ requireValue(r.status===0,'SKILL_SYNC_FAILED','Package installed, but managed Skill synchronization failed.',{agent,backup});
88
+ }
89
+ return {version,backup,skills_synchronized:targets};
90
+ }catch(e){e.details={...e.details,backup};throw e;}
91
+ }
92
+ export async function performUpdate({automatic=true,dir=stateRoot(),root=ROOT,current=META.version,env=process.env,now=Date.now(),latest=latestVersion,isGlobal=globalInstallation,targets=managedTargets,busy=activeWork,install}={}) {
93
+ install ||= (version,selected)=>installVersion(version,selected,{root,dir});
94
+ if(await exists(path.join(root,'.git'))){requireValue(automatic,'DEVELOPMENT_CHECKOUT','Update a Git checkout through Git.');return {ok:true,skipped:'development_checkout'};}
95
+ const config=await settings(dir);
96
+ if(automatic&&(env.COMPETITOR_CANVAS_AUTO_UPDATE==='0'||!config.enabled))return {ok:true,skipped:'disabled'};
97
+ if(automatic&&now-(config.last_check||0)<DAY)return {ok:true,skipped:'not_due'};
98
+ const lock=path.join(dir,'update-lock.json'),id=crypto.randomUUID();
99
+ try{await createJson(lock,{owner:META.name,id,pid:process.pid});}
100
+ catch(e){if(e.code!=='EEXIST')throw e;const held=await readJson(lock);requireValue(held.owner===META.name,'UNMANAGED_STATE','Update lock ownership mismatch.');let alive=true;try{process.kill(held.pid,0);}catch(err){alive=err.code!=='ESRCH';}if(alive)return {ok:true,skipped:'update_in_progress'};await fs.unlink(lock);return performUpdate({automatic,dir,root,current,env,now,latest,isGlobal,targets,busy,install});}
101
+ try {
102
+ if(automatic&&!await isGlobal(root))return {ok:true,skipped:'package_manager_owned'};
103
+ requireValue(await isGlobal(root),'UNSUPPORTED_INSTALLATION','Automatic updates support global npm installations; use your package manager for other installations.');
104
+ if(await busy(dir))return {ok:true,skipped:'active_work'};
105
+ const selected=await targets();
106
+ config.last_check=now;await store(config,dir);
107
+ const version=await latest();config.latest=version;delete config.error;
108
+ if(!newer(version,current)){await store(config,dir);return {ok:true,current,latest:version,updated:false};}
109
+ const result=await install(version,selected);config.last_update=Date.now();config.installed=version;delete config.error;await store(config,dir);
110
+ return {ok:true,updated:true,...result};
111
+ }catch(e){
112
+ config.error=publicError(e);await store(config,dir);if(!automatic)throw e;return {ok:false,error:config.error};
113
+ }finally{const held=await readJson(lock).catch(()=>null);if(held?.id===id)await fs.unlink(lock);}
114
+ }
@@ -0,0 +1,167 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import crypto from 'node:crypto';
4
+ import os from 'node:os';
5
+ import {spawn} from 'node:child_process';
6
+ import {createRequire} from 'node:module';
7
+ const dependencyRequire=createRequire(import.meta.url);
8
+ function bundledEntry(tool){try{const name=tool==='tbcli'?'@petercjl/tbcli':'@petercjl/sealseek-media-cli';const file=dependencyRequire.resolve(name+'/package.json');const pkg=dependencyRequire(name+'/package.json');return path.join(path.dirname(file),pkg.bin[tool]);}catch{return null;}}
9
+
10
+ export const fail=(code,message)=>{throw Object.assign(new Error(message),{code});};
11
+ export const uid=x=>crypto.createHash('sha256').update(JSON.stringify(x)).digest('hex').slice(0,24);
12
+ export const read=p=>fs.readFile(p,'utf8').then(JSON.parse);
13
+ export async function exists(p){try{await fs.lstat(p);return true;}catch(e){if(e.code==='ENOENT')return false;throw e;}}
14
+ export async function create(p,v){if(await exists(p))fail('OUTPUT_EXISTS','Output exists: '+p);await fs.mkdir(path.dirname(p),{recursive:true});await fs.writeFile(p,JSON.stringify(v,null,2)+'\n',{flag:'wx',mode:0o600});}
15
+ export async function checkpoint(p,v){if(await exists(p)){const old=await read(p);if(old.run_id!==v.run_id)fail('STATE_CONFLICT','Run state ownership mismatch.');const backup=p+'.'+crypto.randomUUID()+'.backup';await fs.copyFile(p,backup,fs.constants.COPYFILE_EXCL);if((await fs.stat(backup)).size!==(await fs.stat(p)).size)fail('BACKUP_FAILED','Backup failed.');await fs.writeFile(p,JSON.stringify(v,null,2)+'\n',{mode:0o600});}else await create(p,v);}
16
+ export const TOOLS_FILE=path.join(os.homedir(),'.config','competitor-visual-canvas','tools.json');
17
+ export async function configureTool(tool,entry){
18
+ if(!['tbcli','sealseek-media'].includes(tool))fail('INVALID_INPUT','Unknown dependency.');
19
+ if(!path.isAbsolute(entry)||!await exists(entry))fail('INVALID_INPUT','Entry must be an existing absolute CLI path.');
20
+ const old=await exists(TOOLS_FILE)?await read(TOOLS_FILE):{run_id:'competitor-visual-canvas-tools',owner:'@petercjl/competitor-visual-canvas',entries:{}};
21
+ if(old.owner!=='@petercjl/competitor-visual-canvas')fail('STATE_CONFLICT','User-owned tool configuration preserved.');
22
+ old.entries[tool]=entry;await checkpoint(TOOLS_FILE,old);return {configured:true,tool,entry,configuration:TOOLS_FILE};
23
+ }
24
+ export async function invoke(tool,args){
25
+ const cfg=await exists(TOOLS_FILE)?await read(TOOLS_FILE):null;
26
+ const entry=process.env[tool==='tbcli'?'TBCLI_ENTRY':'SEALSEEK_MEDIA_ENTRY']||cfg?.entries?.[tool]||bundledEntry(tool)||tool;
27
+ const node=/\.mjs$|\.js$/.test(entry),cmd=node?process.execPath:entry,argv=node?[entry,...args]:args;
28
+ return new Promise((resolve,reject)=>{const child=spawn(cmd,argv,{env:{...process.env,TBCLI_UPDATE_CHECK:'0',SEALSEEK_MEDIA_AUTO_UPDATE:'0'},stdio:['ignore','pipe','pipe']});let out='',err='';const timer=setTimeout(()=>child.kill(),180000);child.stdout.on('data',x=>out+=x);child.stderr.on('data',x=>err+=x);child.on('error',reject);child.on('close',code=>{clearTimeout(timer);if(code!==0)return reject(Object.assign(new Error(tool+' operation failed: '+(err||out).slice(-1200)),{code:'DEPENDENCY_FAILED'}));try{resolve(JSON.parse(out));}catch{reject(Object.assign(new Error(tool+' returned invalid JSON'),{code:'OUTPUT_CONTRACT_FAILED'}));}});});
29
+ }
30
+ export function normalizeInputs(inputs){
31
+ const seen=new Set(),result=[];
32
+ for(const input of inputs){let id,platform='taobao';if(/^\d{1,30}$/.test(input))id=input;else{let u;try{u=new URL(input);}catch{fail('INVALID_INPUT','Provide a product ID or Taobao/Tmall detail URL.');}if(!['item.taobao.com','detail.tmall.com','detail.tmall.hk'].includes(u.hostname))fail('FEATURE_UNSUPPORTED','Unsupported product URL; resolve share links with tbcli first.');id=u.searchParams.get('id');if(u.hostname!=='item.taobao.com')platform='tmall';}
33
+ if(!/^\d{1,30}$/.test(id||''))fail('INVALID_INPUT','Invalid product ID.');if(seen.has(id))continue;seen.add(id);result.push({id,platform,input,url:`https://${platform==='taobao'?'item.taobao.com/item.htm':'detail.tmall.com/item.htm'}?id=${id}`});
34
+ }if(!result.length)fail('INVALID_INPUT','Provide at least one product.');return result;
35
+ }
36
+ export function normalizeProduct(input,raw){
37
+ const d=raw.detail;if(raw.itemId!==input.id||d?.itemId!==input.id)fail('ITEM_ID_MISMATCH','Collection identity mismatch.');if(!d.title||!d.shop?.shopName)fail('OUTPUT_CONTRACT_FAILED','Product title and shop name are required.');
38
+ const make=(kind,values)=>{const seen=new Map();values.forEach((value,i)=>{const a=typeof value==='string'?{url:value}:value;if(!a?.url)return;const old=seen.get(a.url);if(old){if(a.skuOptions)old.skuOptions.push(...a.skuOptions);return;}seen.set(a.url,{...a,asset_id:uid([input.id,kind,a.url]),kind:kind==='main_videos'?'video':'image',source_index:i,...(a.skuOptions?{skuOptions:[...a.skuOptions]}:{})});});return [...seen.values()];};
39
+ return {...input,title:d.title,shop:d.shop,coverage:raw.coverage,assets:{
40
+ main_images:make('main_images',d.mainImages||[]),main_videos:make('main_videos',raw.mainVideos?.records||[]),
41
+ detail_images:make('detail_images',raw.description?.images||[]),
42
+ sku_images:make('sku_images',(d.properties||[]).flatMap(p=>p.values.filter(v=>v.imageUrl).map(v=>({url:v.imageUrl,skuOptions:[{pid:p.pid,property:p.name,vid:v.vid,name:v.name,skuIds:(d.skus||[]).filter(s=>s.options.some(o=>o.pid===p.pid&&o.vid===v.vid)).map(s=>s.skuId)}]}))))
43
+ }};
44
+ }
45
+ export async function collect(run,inputs,rawFiles=[]){
46
+ if(await exists(run))fail('OUTPUT_EXISTS','Choose a new run directory.');
47
+ const products=normalizeInputs(inputs),run_id=crypto.randomUUID();await fs.mkdir(run,{recursive:true,mode:0o700});await create(path.join(run,'inputs.json'),{run_id,products});
48
+ const manifest={schema_version:1,run_id,captured_at:new Date().toISOString(),products:[]};
49
+ for(const input of products){
50
+ let raw;if(rawFiles.length){for(const file of rawFiles){const r=await read(file);if(r.itemId===input.id){raw=r;break;}}if(!raw)fail('INPUT_MISSING','No raw collection for '+input.id);}else{const out=path.join(run,'raw',input.id+'.json');await fs.mkdir(path.dirname(out),{recursive:true});await invoke('tbcli',['item','media','--item-id',input.id,'--out',out,'--json']);raw=await read(out);}
51
+ if(rawFiles.length)await create(path.join(run,'raw',input.id+'.json'),raw);
52
+ manifest.products.push(normalizeProduct(input,raw));await checkpoint(path.join(run,'assets.manifest.json'),manifest);
53
+ }return manifest;
54
+ }
55
+ export const REGIONS=['main_images','main_videos','sku_images','detail_images'];
56
+ export function layout(manifest,options={}){
57
+ const base=options.basePlan;
58
+ if(base&&(base.schema_version!==2||base.presentation_version!==1))fail('LAYOUT_MIGRATION_REQUIRED','Migrate the existing run with reflow before appending.');
59
+ const {width=320,gap=32,padding=32,frameGap=96,regionGap=240}=base?.layout_options||options;
60
+ if(![width,gap,padding,frameGap,regionGap].every(Number.isFinite)||width<=0||[gap,padding,frameGap,regionGap].some(x=>x<0))fail('INVALID_INPUT','Invalid layout dimensions.');
61
+ const local={};
62
+ for(const category of REGIONS){local[category]=[];
63
+ for(const product of manifest.products){
64
+ const id='frame-'+uid([manifest.run_id,product.id,category]),placements=[];let localX=padding,localY=padding,maxX=padding,maxY=padding,rowHeight=0;
65
+ for(const [i,a] of product.assets[category].entries()){
66
+ if(!(a.width>0&&a.height>0))fail('METADATA_REQUIRED','Missing real dimensions for '+a.asset_id);
67
+ const h=width*a.height/a.width;
68
+ if(category==='sku_images'&&i>0&&i%5===0){localX=padding;localY+=rowHeight+gap;rowHeight=0;}
69
+ placements.push({asset:a,x:localX,y:localY,width,height:h});maxX=Math.max(maxX,localX+width);maxY=Math.max(maxY,localY+h);rowHeight=Math.max(rowHeight,h);
70
+ if(category==='detail_images')localY+=h;else localX+=width+gap;
71
+ }
72
+ local[category].push({product,placements,frame:{id,name:product.shop.shopName,x:0,y:0,width:Math.max(width+padding*2,maxX+padding),height:Math.max(120,maxY+padding)}});
73
+ }
74
+ }
75
+ const frames=structuredClone(base?.batch.frames||[]),media=structuredClone(base?.batch.media||[]),regions=[];
76
+ const reserve={main_images:2400,main_videos:1600,sku_images:1840,...options.columnWidths};let regionX=0;
77
+ for(const category of REGIONS){
78
+ const previous=base?.regions.find(r=>r.category===category);
79
+ const horizontal=category==='detail_images';
80
+ const columnWidth=previous?.width??Math.max(reserve[category]||0,...local[category].map(v=>v.frame.width));
81
+ const x=previous?.x??regionX,y=previous?.y??80;
82
+ let fx=horizontal?(previous?.next_x??x):x,fy=horizontal?y:(previous?.next_y??y);
83
+ const frameIds=[...(previous?.frame_ids||[])];
84
+ for(const {product,placements,frame:f} of local[category]){
85
+ if(!horizontal&&f.width>columnWidth)fail('REGION_CAPACITY_EXCEEDED',category+' requires '+f.width+' units; reserved '+columnWidth+'. Existing elements were preserved.');
86
+ f.x=fx;f.y=fy;frames.push(f);frameIds.push(f.id);
87
+ for(const p of placements){const a=p.asset;media.push({id:manifest.run_id+'-'+a.asset_id+'-'+category,kind:a.kind,urls:[a.url],frameId:f.id,placement:{mode:'explicit',positions:[{x:fx+p.x,y:fy+p.y,width:p.width,height:p.height}]},...(a.posterUrl?{posterUrl:a.posterUrl}:{}),sourceMetadata:{sourceUrl:product.url,productId:product.id,shopId:String(product.shop.shopId||''),category,assetId:a.asset_id}});}
88
+ if(horizontal)fx+=f.width+frameGap;else fy+=f.height+frameGap;
89
+ }
90
+ const members=frames.filter(f=>frameIds.includes(f.id));
91
+ regions.push({category,x,y,width:columnWidth,axis:horizontal?'horizontal':'vertical',next_x:horizontal?fx:x,next_y:horizontal?y:fy,bottom:Math.max(y,...members.map(f=>f.y+f.height)),right:Math.max(x,...members.map(f=>f.x+f.width)),frame_ids:frameIds});
92
+ regionX=x+columnWidth+regionGap;
93
+ }
94
+ const name=base?.title||options.title||manifest.products[0].title+(manifest.products.length>1?` 等${manifest.products.length}款`:'');
95
+ if(name.length>200)fail('INVALID_INPUT','Canvas title exceeds 200 characters; provide --title.');
96
+ const labels={main_images:'主图区',main_videos:'主图视频区',sku_images:'SKU区',detail_images:'详情页区'};
97
+ const texts=base?.batch.texts?structuredClone(base.batch.texts):regions.map(r=>({id:'region-'+uid([manifest.run_id,r.category]),text:labels[r.category],x:r.x,y:r.y-72,width:320,height:40,fontSize:28}));
98
+ const result={schema_version:2,presentation_version:1,run_id:manifest.run_id,title:name,layout_options:{width,gap,padding,frameGap,regionGap},regions,batch:{frames,media,texts},...(base?{base_run_id:base.run_id}:{})};validateLayout(result);return result;
99
+ }
100
+ export function validateLayout(plan){
101
+ const overlap=(a,b)=>a.x<b.x+b.width-1e-6&&a.x+a.width>b.x+1e-6&&a.y<b.y+b.height-1e-6&&a.y+a.height>b.y+1e-6;
102
+ const {frames,media}=plan.batch;
103
+ for(let i=0;i<frames.length;i++)for(let j=i+1;j<frames.length;j++)if(overlap(frames[i],frames[j]))fail('LAYOUT_OVERLAP','Frames overlap.');
104
+ for(const f of frames){const items=media.filter(m=>m.frameId===f.id).map(m=>m.placement.positions[0]);for(const a of items)if(a.x<f.x||a.y<f.y||a.x+a.width>f.x+f.width+1e-6||a.y+a.height>f.y+f.height+1e-6)fail('LAYOUT_BOUNDS','Media outside frame.');for(let i=0;i<items.length;i++)for(let j=i+1;j<items.length;j++)if(overlap(items[i],items[j]))fail('LAYOUT_OVERLAP','Media overlaps.');}
105
+ return true;
106
+ }
107
+ export async function planRun(run,options={}){
108
+ for(const file of ['layout.plan.json','canvas.batch.json'])if(await exists(path.join(run,file)))fail('OUTPUT_EXISTS','Plan already exists; preserve it or use a new run.');
109
+ const manifest=await read(path.join(run,'assets.manifest.json'));let base,baseManifest,state;
110
+ if(options.baseRun){
111
+ base=await read(path.join(options.baseRun,'layout.plan.json'));baseManifest=await read(path.join(options.baseRun,'assets.manifest.json'));state=await read(path.join(options.baseRun,'canvas.state.json'));
112
+ if(base.schema_version!==2)fail('LAYOUT_MIGRATION_REQUIRED','Run reflow on the base canvas before append.');
113
+ if(state.status!=='completed'||!state.canvas_id)fail('STATE_CONFLICT','Base canvas must be completed.');
114
+ if(manifest.products.some(p=>baseManifest.products.some(old=>old.id===p.id)))fail('PRODUCT_ALREADY_PRESENT','A product is already in this canvas; existing materials are preserved.');
115
+ }
116
+ const cache=new Map(),all=manifest.products.flatMap(p=>Object.values(p.assets).flat()),urls=[...new Map(all.map(a=>[a.url,a])).values()];
117
+ for(let i=0;i<urls.length;i+=4)await Promise.all(urls.slice(i,i+4).map(async a=>cache.set(a.url,a.width>0&&a.height>0?{width:a.width,height:a.height}:await invoke('sealseek-media',['media','inspect','--url',a.url,'--kind',a.kind,'--json']))));
118
+ for(const a of all){const d=cache.get(a.url);a.width=d.width;a.height=d.height;}
119
+ const plan=layout(manifest,{...options,basePlan:base});
120
+ if(base){plan.target_canvas_id=state.canvas_id;plan.base_snapshot=base.batch;manifest.products=[...baseManifest.products,...manifest.products];}
121
+ await checkpoint(path.join(run,'assets.manifest.json'),manifest);
122
+ await create(path.join(run,'layout.plan.json'),plan);await create(path.join(run,'canvas.batch.json'),plan.batch);
123
+ return {run_id:plan.run_id,frames:plan.batch.frames.length,media:plan.batch.media.length,title:plan.title,...(state?{canvas_id:state.canvas_id,appending:true}:{})};
124
+ }
125
+ export async function appendRun(run,baseRun,inputs,rawFiles=[]){
126
+ const previous=await read(path.join(baseRun,'assets.manifest.json')),plan=await read(path.join(baseRun,'layout.plan.json'));
127
+ if(plan.schema_version!==2)fail('LAYOUT_MIGRATION_REQUIRED','Run reflow on base canvas first.');
128
+ const fresh=normalizeInputs(inputs).filter(p=>!previous.products.some(old=>old.id===p.id));
129
+ if(!fresh.length)return {changed:false,reason:'All requested products are already present.',base_run:baseRun};
130
+ await collect(run,fresh.map(p=>p.input),rawFiles);
131
+ return planRun(run,{baseRun});
132
+ }
133
+ export function matchesBatch(readback,batch){
134
+ const elements=readback.elements.filter(e=>!e.isDeleted);
135
+ if(elements.filter(e=>e.type==='frame').length!==batch.frames.length||elements.filter(e=>e.type==='image').length!==batch.media.length)return false;
136
+ if(!(batch.texts||[]).every(t=>{const e=elements.find(e=>e.id===t.id);return e&&e.type==='text'&&['text','x','y','width','height','fontSize'].every(k=>e[k]===t[k]);}))return false;
137
+ return batch.frames.every(f=>{const e=elements.find(e=>e.id===f.id);return e&&['name','x','y','width','height'].every(k=>e[k]===f[k]);})&&batch.media.every(m=>{const e=elements.find(e=>e.customData?.cliTaskId===m.id);return e&&e.frameId===m.frameId&&['x','y','width','height'].every(k=>e[k]===m.placement.positions[0][k])&&(m.kind==='video'?e.customData.videoUrl:e.customData.fileUrl)===m.urls[0];});
138
+ }
139
+ // Keep the actual server geometry for numerically equivalent bounds and caller-owned label formatting.
140
+ export function reconcileBatch(readback,batch){
141
+ const result=structuredClone(batch),elements=readback.elements.filter(e=>!e.isDeleted);
142
+ const near=(a,b)=>Number.isFinite(a)&&Number.isFinite(b)&&Math.abs(a-b)<=1e-6;
143
+ for(const f of result.frames){const e=elements.find(e=>e.id===f.id);if(e&&e.type==='frame'&&e.name===f.name&&['x','y','width','height'].every(k=>near(e[k],f[k])))for(const k of ['x','y','width','height'])f[k]=e[k];}
144
+ for(const m of result.media){const e=elements.find(e=>e.customData?.cliTaskId===m.id),p=m.placement.positions[0];if(e&&e.frameId===m.frameId&&['x','y','width','height'].every(k=>near(e[k],p[k])))for(const k of ['x','y','width','height'])p[k]=e[k];}
145
+ for(const t of result.texts||[]){const e=elements.find(e=>e.id===t.id);if(e&&e.type==='text'&&e.text===t.text&&near(e.x,t.x)&&near(e.y,t.y)&&['width','height','fontSize'].every(k=>Number.isFinite(e[k])&&e[k]>0))for(const k of ['x','y','width','height','fontSize'])t[k]=e[k];}
146
+ return result;
147
+ }
148
+ export async function publishCanvas(run,submit=false){
149
+ const plan=await read(path.join(run,'layout.plan.json'));validateLayout(plan);
150
+ const file=path.join(run,'canvas.batch.json');let batch=await read(file);if(JSON.stringify(batch)!==JSON.stringify(plan.batch))fail('STATE_CONFLICT','Batch differs from validated layout.');
151
+ if(!submit)return {dry_run:true,title:plan.title,frames:batch.frames.length,media:batch.media.length};
152
+ await invoke('sealseek-media',['canvas','batch','preview','--file',file,'--json']);
153
+ if(plan.target_canvas_id){const current=await invoke('sealseek-media',['canvas','get',plan.target_canvas_id,'--json']);const baseline=reconcileBatch(current,plan.base_snapshot),normalized=reconcileBatch(current,batch);if(!matchesBatch(current,baseline)&&!matchesBatch(current,normalized))fail('CANVAS_CHANGED','Base canvas differs from the saved layout. Use the newest completed run; existing elements are preserved.');if(JSON.stringify(normalized)!==JSON.stringify(batch)){batch=normalized;plan.batch=batch;plan.base_snapshot=baseline;validateLayout(plan);await checkpoint(file,batch);await checkpoint(path.join(run,'layout.plan.json'),plan);}}
154
+ const statePath=path.join(run,'canvas.state.json');let state=await exists(statePath)?await read(statePath):{run_id:plan.run_id,status:'ready',...(plan.target_canvas_id?{canvas_id:plan.target_canvas_id}:{})};
155
+ if(state.run_id!==plan.run_id)fail('STATE_CONFLICT','Canvas state is from another run.');
156
+ if(!state.canvas_id){if(state.status==='creating')fail('SUBMISSION_UNCERTAIN','Canvas creation outcome unknown. Inspect canvas list and bind recovered ID before retrying.');state.status='creating';await checkpoint(statePath,state);const board=await invoke('sealseek-media',['canvas','create','--title',plan.title,'--submit','--json']);if(!board.canvas_id)fail('SUBMISSION_UNCERTAIN','No canvas ID.');state={...state,...board,status:'created'};await checkpoint(statePath,state);}
157
+ const saved=await invoke('sealseek-media',['canvas','batch',state.canvas_id,'--file',file,'--submit','--json']);
158
+ const readback=await invoke('sealseek-media',['canvas','get',state.canvas_id,'--json']);
159
+ const elements=readback.elements.filter(e=>!e.isDeleted),frames=elements.filter(e=>e.type==='frame'),media=elements.filter(e=>e.type==='image');
160
+ if(frames.length!==batch.frames.length||media.length!==batch.media.length)fail('OUTPUT_CONTRACT_FAILED','Readback element count mismatch.');
161
+ for(const f of batch.frames){const found=frames.find(e=>e.id===f.id);if(!found||!['name','x','y','width','height'].every(k=>found[k]===f[k]))fail('OUTPUT_CONTRACT_FAILED','Frame readback mismatch.');}
162
+ for(const m of batch.media){const e=media.find(e=>e.customData?.cliTaskId===m.id);if(!e||e.frameId!==m.frameId||!['x','y','width','height'].every(k=>e[k]===m.placement.positions[0][k])||(m.kind==='video'?e.customData.videoUrl:e.customData.fileUrl)!==m.urls[0])fail('OUTPUT_CONTRACT_FAILED','Media readback mismatch.');}
163
+ if(!matchesBatch(readback,batch))fail('OUTPUT_CONTRACT_FAILED','Canvas labels or content readback mismatch.');
164
+ await checkpoint(statePath,{...state,status:'completed',canvas_url:readback.canvas_url});
165
+ const manifest=await read(path.join(run,'assets.manifest.json')),report={run_id:plan.run_id,status:'completed',canvas_id:state.canvas_id,canvas_url:readback.canvas_url,frames:frames.length,media:media.length,products:manifest.products.map(p=>({id:p.id,title:p.title,shop:p.shop.shopName,coverage:p.coverage,counts:Object.fromEntries(Object.entries(p.assets).map(([k,v])=>[k,v.length]))})),saved,verification:'saved content read back and layout checked; browser visual inspection is separate'};
166
+ await checkpoint(path.join(run,'collection-report.json'),report);return report;
167
+ }