Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@

## Unreleased source - 2026-10-21

- Add a loopback graphical workbench for real Skill and contract directories,
with native macOS selection, read-only cancellation and offline report review.
- Review safe fixes, explicitly apply with backup, and recheck separately. Bind
plans to the selected input snapshot; limit child output and saved evidence.

- Open offline verification reports at the first priority needing review and
jump directly to other severity groups, including without JavaScript.
- Keep complete counts and the existing detail budget; omit empty groups and
Expand Down
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,21 @@ GitHub v0.3.0 adds a declared cross-file checklist to HTML, text and JSON report

[![Terminal demo](https://raw.githubusercontent.com/Chumaniac/skillsync/main/docs/assets/verify-demo.svg)](https://github.com/Chumaniac/skillsync/blob/main/docs/assets/verify-demo.svg)

## Local graphical workbench

Run `skillsync gui --state-dir /absolute/private/report-directory`, then open
`http://127.0.0.1:14986`. Select a Skill directory, or select a contract and its
artifact directory. The macOS folder/file picker also accepts an explicit path.
No Skill instructions run. Reports, plans and receipts stay in the private state
directory, outside the selected input.

For a single Skill, review the plan, explicitly confirm safe changes with backup,
then run **Recheck**. Applying a plan does not establish a passing verification.
Changed inputs invalidate the plan. The server accepts loopback clients only,
runs one bounded CLI process, and stops at 64 MiB of saved evidence rather than
deleting prior results. Use a new state directory when full. Cancelling a
read-only operation reaps its child; applying changes must finish before recheck.

## Install

**From GitHub releases (no npm account required):**
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,8 @@
"build": "tsc",
"lint": "eslint .",
"check": "npm test && npm run type-check && npm run lint && npm run build",
"prepack": "npm run build"
"prepack": "npm run build",
"pretest": "npm run build"
},
"dependencies": {
"commander": "^13.1.0",
Expand Down
14 changes: 14 additions & 0 deletions src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,20 @@ export function createCli(io: CliIO = defaultCliIO): Command {
.description("Provenance, compatibility, and behavior verification for Agent Skills.")
.version(VERSION);

program.command("gui")
.description("Open the loopback-only graphical verification workbench.")
.requiredOption("--state-dir <path>", "Private directory for plans and reports (outside Skill roots)")
.option("--port <port>", "Loopback port", "14986")
.action(async (options: {stateDir: string; port: string}) => {
const port = Number(options.port);
if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error("Invalid port.");
const { startWorkbench } = await import("../workbench/server.js");
const workbench = await startWorkbench({port, stateDir: options.stateDir});
io.writeOut(`SkillSync: ${workbench.url}\n`);
const close = () => { void workbench.close(); };
process.once("SIGINT", close); process.once("SIGTERM", close);
});

program
.command("scan")
.description("Inspect skill files without executing their instructions.")
Expand Down
34 changes: 34 additions & 0 deletions src/workbench/page.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/** Static local UI. All evidence is inserted as text or in a scriptless report frame. */
export function workbenchPage(nonce: string): string {
return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="client" content="${nonce}"><title>SkillSync 核验台</title><link rel="stylesheet" href="/style.css"></head><body><main>
<header><span>SkillSync · 本机</span><h1>核验台</h1><p>选择实际目录,检查结果,再决定是否修复。</p></header>
<section aria-labelledby="selection"><h2 id="selection">1. 选择目录</h2><label for="root">Skill 或交付目录</label><div class="row"><input id="root" placeholder="粘贴完整目录路径" autocomplete="off"><button id="choose" type="button">选择目录…</button></div><label for="kind">核验内容</label><select id="kind"><option value="skill">Skill 结构与兼容性</option><option value="artifacts">合同与交付文件</option></select><div id="contract-fields" hidden><label for="contract">合同文件路径(YAML / JSON)</label><div class="row"><input id="contract" autocomplete="off"><button id="choose-contract" type="button">选择合同…</button></div><label><input id="delivery" type="checkbox">这是 SkillTape 交付包(包含 delivery.json)</label></div><div class="row actions"><button id="verify" type="button">核验目录</button><button id="cancel" type="button" disabled>取消核验</button></div></section>
<p id="status" role="status" aria-live="polite">尚未核验</p><section id="result" hidden aria-labelledby="result-title"><h2 id="result-title">2. 核验结果</h2><iframe id="report" title="核验报告" sandbox=""></iframe><button id="plan" type="button">生成修复计划</button></section>
<section id="fix" hidden aria-labelledby="fix-title"><h2 id="fix-title">3. 审阅修复计划</h2><pre id="changes"></pre><label><input id="confirm" type="checkbox">我已审阅,应用以上安全修复并保留备份</label><div class="row actions"><button id="apply" type="button" disabled>应用并备份</button><button id="reverify" type="button">重新核验</button></div><p id="receipt"></p></section><footer>只调用本机核验器,不执行 Skill 指令。修复计划、回执和报告保存在私有状态目录。</footer></main><script src="/app.js"></script></body></html>`;
}

export const workbenchStyle = `:root{font:16px/1.6 system-ui;color:#19343c;background:#f3f6f6}*{box-sizing:border-box}main{max-width:1040px;margin:auto;padding:32px 20px}header{margin-bottom:28px}h1{margin:8px 0;font-size:36px}h2{font-size:20px}section{padding:20px;background:white;border:1px solid #c8d8dc;border-radius:8px;margin:20px 0}label{display:block;margin:12px 0 6px}input:not([type=checkbox]),select{font:inherit;min-height:44px;padding:8px;width:100%;border:1px solid #829fa7;border-radius:4px}input[type=checkbox]{width:24px;height:24px;vertical-align:middle;margin-right:8px}label:has(input[type=checkbox]){min-height:44px;display:flex;align-items:center}button{font:inherit;min-height:44px;padding:8px 18px;border:1px solid #6f939d;border-radius:4px;color:#19343c;background:#edf5f6;cursor:pointer}button:disabled{opacity:.55;cursor:default}.row{display:flex;gap:12px;align-items:center}.row input{flex:1;min-width:0}.row button{white-space:nowrap}.actions{flex-wrap:wrap;margin-top:18px}:focus-visible{outline:3px solid #087a91;outline-offset:3px}iframe{width:100%;height:580px;border:1px solid #c8d8dc;margin-bottom:16px}pre{white-space:pre-wrap;overflow-wrap:anywhere;font:14px/1.7 ui-monospace,monospace}footer{font-size:13px;color:#496771}#status{padding:12px;border-left:3px solid #318496}[hidden]{display:none!important}@media(max-width:580px){.row{flex-wrap:wrap}.row input{flex-basis:100%}main{padding:16px}section{padding:16px}}`;

export const workbenchScript = `"use strict";
const $ = id => document.getElementById(id);
const nonce = document.querySelector('meta[name="client"]').content;
let busy = false, planId = null, selected = null;
async function api(action, body = {}) {
const response = await fetch('/api/' + action, {method:'POST',headers:{'Content-Type':'application/json','X-SkillSync-Client':nonce},body:JSON.stringify(body)});
const data = await response.json(); if(!response.ok) throw new Error(data.error || '请求未完成'); return data;
}
function status(text){ $('status').textContent = text; }
function lock(value, applying = false){ busy=value; ['verify','plan','reverify','choose','choose-contract','root','contract','kind','delivery'].forEach(id => $(id).disabled=value); $('cancel').disabled=!value || applying; $('apply').disabled=value || !$('confirm').checked || !planId; }
function invalidate(){ selected=null; planId=null; $('result').hidden=true; $('fix').hidden=true; $('confirm').checked=false; $('report').srcdoc=''; status('目录或模式已更改,请重新核验'); }
['root','contract','kind','delivery'].forEach(id => $(id).addEventListener('change', () => {invalidate(); $('contract-fields').hidden=$('kind').value!=='artifacts';}));
for(const [button,field] of [['choose','root'],['choose-contract','contract']]) $(button).onclick=async()=>{ lock(true,true); try { const data=await api('choose',{file:field==='contract'}); $(field).value=data.path; invalidate(); } catch(e){status(e.message);} finally{lock(false);} };
async function verify(recheck=false){ lock(true); status(recheck?'正在重新核验…':'正在核验…'); try {
if(!recheck){ await api('select',{path:$('root').value,kind:$('kind').value,contract:$('contract').value,delivery:$('delivery').checked}); planId=null; $('fix').hidden=true; $('confirm').checked=false; }
const data=await api('verify'); selected=true; $('report').srcdoc=data.html; $('result').hidden=false; $('plan').hidden=$('kind').value!=='skill'; status((recheck?'重新核验完成':'核验完成')+' · '+data.status); if(recheck) $('receipt').textContent='最新核验:'+data.status;
}catch(e){status(e.message);}finally{lock(false);} }
$('verify').onclick=()=>verify(); $('reverify').onclick=()=>verify(true);
$('cancel').onclick=async()=>{try{await api('cancel');status('已请求取消,等待核验器退出…');}catch(e){status(e.message);}};
$('plan').onclick=async()=>{if(!selected)return;lock(true);try{const data=await api('plan');planId=data.id; $('changes').textContent=data.description; $('fix').hidden=false; $('confirm').checked=false; $('receipt').textContent='仅生成计划,尚未应用。'; status('计划已生成,请审阅');}catch(e){status(e.message);}finally{lock(false);}};
$('confirm').onchange=()=>{$('apply').disabled=busy||!$('confirm').checked||!planId;};
$('apply').onclick=async()=>{lock(true,true);try{const data=await api('apply',{id:planId,confirm:$('confirm').checked}); planId=null; $('confirm').checked=false; $('receipt').textContent=data.message; status('修复已应用,尚未重新核验');}catch(e){planId=null;$('confirm').checked=false;status(e.message+';请重新核验当前目录');}finally{lock(false);}};
`;
Loading
Loading