gesh75's picture
Upload folder using huggingface_hub
1bd64c1 verified
Raw History Blame Contribute Delete
33.8 kB
<!doctype html>
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>CLI Studio — intent, migrate, recipes, parse, lab</title>
<meta name="description" content="CLI Studio for the Multivendor CLI Configurator: English-to-CLI intent, cross-vendor migrate, golden-path recipes, EOS/FRR/VyOS parsers, FRR lab map, hardening lint. Zero JS dependencies.">
<meta name="theme-color" content="#0b1020">
<meta property="og:title" content="CLI Studio — Multivendor CLI Configurator">
<meta property="og:description" content="Intent · migrate · recipes · parse · 10-node FRR lab. Concept-aligned CLI across 17 vendors.">
<meta property="og:image" content="https://gesh75.github.io/multivendor-cli-configurator/og-image.png">
<meta property="og:url" content="https://gesh75.github.io/multivendor-cli-configurator/studio.html">
<meta name="twitter:card" content="summary_large_image">
<link rel="icon" type="image/svg+xml" href="favicon.svg">
<link rel="canonical" href="https://gesh75.github.io/multivendor-cli-configurator/studio.html">
<style>
:root[data-theme="dark"]{
--bg:#0b1020; --panel:#0f1530; --panel-2:#121a3a; --ink:#cfd6f5; --ink-dim:#8892b8; --ink-muted:#5b6a99;
--accent:#22d3ee; --accent-2:#a78bfa; --good:#22c55e; --warn:#f59e0b; --bad:#ef4444;
--line:#1f2a55; --chip:#16204a; --code-bg:#070b1e;
}
:root[data-theme="light"]{
--bg:#f4f6fb; --panel:#ffffff; --panel-2:#eef1f8; --ink:#1b2340; --ink-dim:#4a5578; --ink-muted:#6b7694;
--accent:#0891b2; --accent-2:#6d28d9; --good:#15803d; --warn:#b45309; --bad:#b91c1c;
--line:#d5dbea; --chip:#e8edf8; --code-bg:#f8fafc;
}
*{box-sizing:border-box}
html,body{margin:0;background:var(--bg);color:var(--ink);font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Inter,Roboto,sans-serif}
a{color:var(--accent);text-decoration:none}
a:hover{text-decoration:underline}
:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
header{position:sticky;top:0;z-index:20;backdrop-filter:blur(12px);background:color-mix(in srgb,var(--bg) 82%,transparent);border-bottom:1px solid var(--line)}
.bar{max-width:1280px;margin:0 auto;padding:10px 16px;display:flex;align-items:center;gap:10px;flex-wrap:wrap}
.brand{display:flex;align-items:center;gap:8px;font-weight:700;text-decoration:none;color:var(--ink)}
.brand small{display:block;font-weight:500;color:var(--ink-muted);font-size:10px;letter-spacing:.14em;text-transform:uppercase}
.dot{width:9px;height:9px;border-radius:50%;background:var(--accent);box-shadow:0 0 10px var(--accent)}
.tabs{display:flex;gap:4px;flex-wrap:wrap;flex:1}
.tab{border:1px solid var(--line);background:transparent;color:var(--ink-dim);border-radius:999px;padding:6px 11px;cursor:pointer;font:inherit}
.tab[aria-selected="true"]{background:var(--accent);color:#041018;border-color:transparent;font-weight:650}
.tbtn{border:1px solid var(--line);background:var(--panel);color:var(--ink);border-radius:10px;padding:6px 10px;cursor:pointer;font:inherit;text-decoration:none}
main{max-width:1280px;margin:0 auto;padding:20px 16px 80px}
.hero h1{margin:0 0 6px;font-size:clamp(1.6rem,3vw,2.2rem);letter-spacing:-.03em}
.hero p{margin:0 0 18px;color:var(--ink-dim);max-width:48rem}
.grid{display:grid;gap:14px}
@media(min-width:900px){.grid.two{grid-template-columns:1.1fr .9fr}.grid.cov{grid-template-columns:repeat(2,1fr)}}
.card{background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:14px}
label{display:block;font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--ink-muted);margin-bottom:6px}
textarea,input,select{width:100%;background:var(--code-bg);color:var(--ink);border:1px solid var(--line);border-radius:10px;padding:10px;font:13px/1.45 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
textarea{min-height:140px;resize:vertical}
.row{display:flex;gap:8px;flex-wrap:wrap;margin:10px 0}
.chip{border:1px solid var(--line);background:var(--chip);color:var(--ink-dim);border-radius:999px;padding:4px 10px;cursor:pointer;font-size:12px}
.chip.on{background:var(--accent);color:#041018;border-color:transparent}
.badge{display:inline-flex;align-items:center;gap:4px;border-radius:999px;padding:2px 8px;font-size:11px;border:1px solid var(--line);color:var(--ink-dim)}
.badge.live{color:var(--good);border-color:color-mix(in srgb,var(--good) 40%,var(--line))}
.badge.high{color:var(--bad)}
.badge.med{color:var(--warn)}
pre,code,.cli{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
.cli{background:var(--code-bg);border:1px solid var(--line);border-radius:10px;padding:10px;white-space:pre-wrap;font-size:12px;margin:8px 0 0}
.cmd-head{display:flex;justify-content:space-between;gap:8px;align-items:center;flex-wrap:wrap}
.cmd-card{border:1px solid var(--line);border-radius:12px;padding:10px 12px;background:var(--panel-2);margin-top:8px}
.muted{color:var(--ink-dim)}
.k{font-size:11px;border:1px solid var(--line);border-radius:6px;padding:1px 5px;color:var(--ink-muted)}
table{width:100%;border-collapse:collapse;font-size:12px}
th,td{text-align:left;padding:6px 8px;border-bottom:1px solid var(--line);vertical-align:top}
th{color:var(--ink-muted);font-size:11px;letter-spacing:.08em;text-transform:uppercase}
.ok{color:var(--good)} .err{color:var(--bad)} .warn{color:var(--warn)}
footer{max-width:1280px;margin:0 auto;padding:0 16px 40px;color:var(--ink-muted);font-size:12px;display:flex;justify-content:space-between;gap:12px;flex-wrap:wrap}
svg.lab{width:100%;height:auto;background:var(--code-bg);border-radius:12px;border:1px solid var(--line)}
.node{cursor:pointer}
.empty{color:var(--ink-dim);padding:18px;text-align:center}
.snip-tabs{display:flex;gap:4px;flex-wrap:wrap;margin-top:8px}
.snip-tabs button{font-size:11px}
#toast{position:fixed;bottom:16px;right:16px;background:var(--ink);color:var(--bg);padding:8px 12px;border-radius:10px;display:none;z-index:50}
.cov-cell{width:10px;height:10px;border-radius:2px;display:inline-block;background:var(--line)}
.cov-cell.on{background:var(--accent)}
.cov-cell.live{background:var(--good)}
@media(max-width:720px){.tab{padding:6px 8px;font-size:12px} .lbl-d{display:none}}
</style>
</head>
<body>
<a class="skip-link" href="#app" style="position:absolute;left:-999px">Skip to content</a>
<header>
<div class="bar">
<a class="brand" href="studio.html">
<span class="dot"></span>
<span>CLI Studio<small>GESH · concept-aligned · 17 vendors</small></span>
</a>
<nav class="tabs" id="tabs" role="tablist" aria-label="Studio tools"></nav>
<a class="tbtn" href="./" title="Full 70,006-command cheatsheet">Cheatsheet</a>
<a class="tbtn" href="docs/">Docs</a>
<button class="tbtn" id="theme" title="Toggle theme">Theme</button>
</div>
</header>
<main id="app">
<div class="hero">
<p class="muted" style="letter-spacing:.16em;text-transform:uppercase;font-size:11px;margin-bottom:6px">Shipped to GitHub Pages</p>
<h1 id="title">English in. Seventeen CLIs out.</h1>
<p id="lede">Intent, migrate, recipes, and parsers that the 70k cheatsheet did not have a surface for — still zero npm, still static.</p>
</div>
<div id="view"></div>
</main>
<footer>
<div>Part of <a href="https://gesh75.github.io/">GESH Lab</a> · MIT · corpus August 2026</div>
<div>
<a href="https://github.com/gesh75/multivendor-cli-configurator">Source</a> ·
<a href="./">70,006-command cheatsheet</a> ·
<a href="https://gesh75.github.io/multivendor-ai-network-lab/">AI lab</a>
</div>
</footer>
<div id="toast"></div>
<script>
const TABS = [
{id:"intent", label:"Intent"},
{id:"recipes", label:"Recipes"},
{id:"migrate", label:"Migrate"},
{id:"parse", label:"Parse"},
{id:"lab", label:"Lab"},
{id:"lint", label:"Lint"},
{id:"coverage", label:"Coverage"},
];
const RECIPES = [
{id:"ebgp", title:"eBGP neighbor", concept:"bgp:peer", cat:"BGP", risk:"med", desc:"Stand up an external BGP session.", params:[{k:"asn",l:"Local ASN",d:"65001"},{k:"remote",l:"Remote ASN",d:"65002"},{k:"peer",l:"Peer IP",d:"10.0.0.2"}]},
{id:"ospf", title:"OSPF area 0", concept:"ospf:network", cat:"OSPF", risk:"med", desc:"Advertise a /24 into OSPF backbone.", params:[{k:"rid",l:"Router ID",d:"1.1.1.1"},{k:"net",l:"Network",d:"10.0.0.0/24"}]},
{id:"svi", title:"VLAN + SVI", concept:"vlan:create", cat:"VLAN", risk:"low", desc:"Create VLAN 10.", params:[{k:"vlan",l:"VLAN ID",d:"10"},{k:"name",l:"Name",d:"USERS"}]},
{id:"lacp", title:"LACP uplink", concept:"lacp:channel", cat:"EtherChannel", risk:"med", desc:"Bond two ports.", params:[{k:"id",l:"LAG ID",d:"1"}]},
{id:"evpn", title:"VXLAN EVPN leaf", concept:"evpn:vni", cat:"EVPN", risk:"high", desc:"VTEP + L2VNI — what the FRR lab runs.", params:[{k:"vni",l:"VNI",d:"10010"},{k:"loopback",l:"VTEP IP",d:"1.1.1.1"}]},
{id:"static", title:"Default static", concept:"static:route", cat:"Routing", risk:"low", desc:"0.0.0.0/0 at a next hop.", params:[{k:"nh",l:"Next hop",d:"10.0.0.2"}]},
{id:"acl", title:"Deny RFC1918 inbound", concept:"acl:ipv4", cat:"Security", risk:"high", desc:"Filter, not a silent permit.", params:[{k:"src",l:"Source prefix",d:"10.0.0.0/8"}]},
{id:"ntp", title:"NTP server", concept:"ntp:server", cat:"System", risk:"low", desc:"Single preferred time source.", params:[{k:"server",l:"NTP server",d:"10.0.0.10"}]},
{id:"bfd", title:"BFD on BGP", concept:"bfd:enable", cat:"BFD", risk:"med", desc:"300ms × 3 on the eBGP session.", params:[{k:"peer",l:"Peer IP",d:"10.0.0.2"}]},
{id:"stp", title:"RSTP / Rapid-PVST", concept:"stp:mode", cat:"Spanning-Tree", risk:"high", desc:"Mode + root priority.", params:[{k:"prio",l:"Priority",d:"4096"}]},
{id:"gnmi", title:"gNMI / telemetry", concept:"telemetry:gnmi", cat:"Telemetry", risk:"low", desc:"Model-driven telemetry — RESTCONF/gNMI, not just CLI.", params:[]},
];
const CONCEPTS = [
["bgp:peer",["bgp","ebgp","neighbor","peer as","remote-as"]],
["ospf:network",["ospf"]],
["evpn:vni",["vxlan","evpn","vni"]],
["lacp:channel",["lacp","port-channel","lag","etherchannel","eth-trunk"]],
["ntp:server",["ntp"]],
["acl:ipv4",["acl","access-list","firewall"]],
["static:route",["static","default route"]],
["vlan:create",["vlan"]],
["bfd:enable",["bfd"]],
["stp:mode",["stp","spanning"]],
["telemetry:gnmi",["gnmi","telemetry","grpc","restconf"]],
["iface:ipv4",["ip address","interface"]],
];
const NODES = [
{id:"spine-1",role:"spine",x:28,y:18,asn:65000,lo:"1.1.1.1"},
{id:"spine-2",role:"spine",x:72,y:18,asn:65000,lo:"1.1.1.2"},
{id:"leaf-1",role:"leaf",x:12,y:48,asn:65001,lo:"1.1.1.11"},
{id:"leaf-2",role:"leaf",x:37,y:48,asn:65002,lo:"1.1.1.12"},
{id:"leaf-3",role:"leaf",x:63,y:48,asn:65003,lo:"1.1.1.13"},
{id:"leaf-4",role:"leaf",x:88,y:48,asn:65004,lo:"1.1.1.14"},
{id:"ce-1",role:"ce",x:20,y:78,asn:65101,lo:"10.10.1.1"},
{id:"ce-2",role:"ce",x:45,y:78,asn:65102,lo:"10.10.2.1"},
{id:"host-a",role:"host",x:70,y:78,asn:0,lo:"10.10.10.10"},
{id:"host-b",role:"host",x:90,y:78,asn:0,lo:"10.10.20.10"},
];
const LINKS = [["spine-1","leaf-1"],["spine-1","leaf-2"],["spine-1","leaf-3"],["spine-1","leaf-4"],["spine-2","leaf-1"],["spine-2","leaf-2"],["spine-2","leaf-3"],["spine-2","leaf-4"],["leaf-1","ce-1"],["leaf-2","ce-2"],["leaf-3","host-a"],["leaf-4","host-b"]];
const PARSERS = [
{id:"cisco-bgp",vendor:"Cisco",label:"show ip bgp summary",
hint:"Neighbor table with ASN, state/pfx",
sample:"BGP router identifier 1.1.1.1, local AS number 65001\nNeighbor V AS MsgRcvd MsgSent TblVer InQ OutQ Up/Down State/PfxRcd\n10.0.0.2 4 65002 12841 12838 8 0 0 1w2d 42",
test:t=>/local AS number/i.test(t)&&/State\/PfxRcd/i.test(t),
cols:["neighbor","ver","asn","msgRcvd","msgSent","upDown","state"],
parse:t=>t.split(/\n/).map(l=>l.match(/^(\d{1,3}(?:\.\d{1,3}){3})\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\S+)\s+(\S+)/)).filter(Boolean).map(m=>[m[1],m[2],m[3],m[4],m[5],m[9],m[10]])},
{id:"cisco-route",vendor:"Cisco",label:"show ip route",
hint:"Codes + prefix lines",
sample:"Gateway of last resort is 10.0.0.2 to network 0.0.0.0\nB 10.2.0.0/24 [20/0] via 10.0.0.2, 1w2d\nO 10.3.0.0/24 [110/2] via 10.0.1.2, 00:14:02, GigabitEthernet0/1",
test:t=>/Gateway of last resort/i.test(t)||/^[A-Z*]{1,3}\s+\d+\.\d+\.\d+\.\d+/m.test(t),
cols:["proto","prefix","metric","via"],
parse:t=>t.split(/\n/).map(l=>l.match(/^([A-Z*]{1,3})\s+(\d+\.\d+\.\d+\.\d+\/\d+)\s+\[(\d+\/\d+)\]\s+via\s+(\d+\.\d+\.\d+\.\d+)/)).filter(Boolean).map(m=>[m[1],m[2],m[3],m[4]])},
{id:"junos-bgp",vendor:"Juniper",label:"show bgp summary",
hint:"Peer AS InPkt OutPkt State",
sample:"Peer AS InPkt OutPkt OutQ Flaps Last Up/Dwn State|#Active/Received/Accepted/Damped\n10.0.0.2 65002 12841 12838 0 0 1w2d Establ",
test:t=>/State\|#Active\/Received/i.test(t)||/Peer\s+AS\s+InPkt/i.test(t),
cols:["peer","asn","inPkt","outPkt","last","state"],
parse:t=>t.split(/\n/).map(l=>l.match(/^(\d+\.\d+\.\d+\.\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\S+)\s+(\S+)/)).filter(Boolean).map(m=>[m[1],m[2],m[3],m[4],m[7],m[8]])},
{id:"eos-bgp",vendor:"Arista",label:"show ip bgp summary (EOS)",
hint:"PfxRcd PfxAcc — the documented follow-up parser",
sample:"BGP summary information for VRF default\nRouter identifier 1.1.1.1, local AS number 65001\nNeighbor Status Codes: m - Under maintenance\n Neighbor V AS MsgRcvd MsgSent InQ OutQ Up/Down State PfxRcd PfxAcc\n 10.0.0.2 4 65002 12841 12838 0 0 1w2d Estab 42 42",
test:t=>/Neighbor Status Codes/i.test(t)||(/PfxRcd PfxAcc/i.test(t)&&/local AS number/i.test(t)),
cols:["neighbor","ver","asn","msgRcvd","state","pfxRcd","pfxAcc"],
parse:t=>t.split(/\n/).map(l=>l.match(/(\d+\.\d+\.\d+\.\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\S+)\s+(\S+)\s+(\d+)\s+(\d+)/)).filter(Boolean).map(m=>[m[1],m[2],m[3],m[4],m[9],m[10],m[11]])},
{id:"frr-bgp",vendor:"FRR",label:"show ip bgp summary (FRR)",
hint:"vtysh neighbor table — live lab parser",
sample:"IPv4 Unicast Summary (VRF default):\nBGP router identifier 1.1.1.1, local AS number 65001 vrf-id 0\nNeighbor V AS MsgRcvd MsgSent TblVer InQ OutQ Up/Down State/PfxRcd PfxSnt Desc\n10.0.0.2 4 65000 12841 12838 8 0 0 1w2d 42 12 n/a",
test:t=>/vrf-id/i.test(t)||/PfxSnt/i.test(t),
cols:["neighbor","ver","asn","msgRcvd","msgSent","upDown","state"],
parse:t=>t.split(/\n/).map(l=>l.match(/^(\d+\.\d+\.\d+\.\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)/)).filter(Boolean).map(m=>[m[1],m[2],m[3],m[4],m[5],"",""])},
{id:"vyos-bgp",vendor:"VyOS",label:"show ip bgp summary (VyOS)",
hint:"FRR-shaped table inside VyOS op-mode",
sample:"IPv4 Unicast Summary:\nBGP router identifier 1.1.1.1, local AS number 65001\nNeighbor V AS MsgRcvd MsgSent TblVer InQ OutQ Up/Down State/PfxRcd\n10.0.0.2 4 65002 12841 12838 8 0 0 1w2d 42",
test:t=>/IPv4 Unicast Summary/i.test(t)&&/State\/PfxRcd/i.test(t),
cols:["neighbor","ver","asn","msgRcvd","msgSent","upDown","state"],
parse:t=>t.split(/\n/).map(l=>l.match(/^(\d+\.\d+\.\d+\.\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\S+)\s+(\S+)/)).filter(Boolean).map(m=>[m[1],m[2],m[3],m[4],m[5],m[9],m[10]])},
];
let CORPUS = [];
let selectedVendors = [];
let currentTab = "intent";
let selNode = "leaf-1";
const $ = id => document.getElementById(id);
function toast(msg){const t=$("toast");t.textContent=msg;t.style.display="block";setTimeout(()=>t.style.display="none",1600)}
function copy(text){navigator.clipboard.writeText(text).then(()=>toast("Copied")).catch(()=>toast("Copy failed"))}
function esc(s){return String(s??"").replace(/[&<>"]/g,c=>({"&":"&","<":"<",">":">","\"":"""}[c]))}
function lookupConcept(text){
const hay=text.toLowerCase();
for(const [key,needles] of CONCEPTS){ if(needles.some(n=>hay.includes(n))) return key; }
return null;
}
function applyParams(cmd, p){
let out=cmd;
const map=[[/\b65001\b/g,p.asn],[/\b65002\b/g,p.remote],[/\b10\.0\.0\.2\b/g,p.peer||p.nh],[/\b10\.0\.0\.10\b/g,p.server],[/\b10\.0\.0\.1\b/g,p.ip],[/\bvlan 10\b/gi,p.vlan&&("vlan "+p.vlan)],[/\b10010\b/g,p.vni],[/\b1\.1\.1\.1\b/g,p.loopback||p.rid],[/\b4096\b/g,p.prio],[/\b10\.0\.0\.0\/8\b/g,p.src],[/\b10\.0\.0\.0\/24\b/g,p.net]];
for(const [re,val] of map){ if(val) out=out.replace(re,val); }
return out;
}
function cmdsFor(concept){
return CORPUS.filter(c=>c.concept===concept && (!selectedVendors.length || selectedVendors.includes(c.vendor)));
}
function vendorBar(){
const vs=[...new Set(CORPUS.map(c=>c.vendor))];
return `<div class="row" id="vbar">${vs.map(v=>`<button type="button" class="chip${selectedVendors.includes(v)?" on":""}" data-v="${esc(v)}">${esc(v)}</button>`).join("")}<button type="button" class="chip" data-v="">All</button></div>`;
}
function bindVendorBar(){
document.querySelectorAll("#vbar .chip").forEach(b=>b.addEventListener("click",()=>{
const v=b.dataset.v;
if(!v) selectedVendors=[];
else if(selectedVendors.includes(v)) selectedVendors=selectedVendors.filter(x=>x!==v);
else selectedVendors=[...selectedVendors,v];
render();
}));
}
function snippets(cmd, vendor, os, concept){
const peer=(cmd.match(/\b\d{1,3}(?:\.\d{1,3}){3}\b/)||["10.0.0.2"])[0];
const rest = `curl -sk -u "$NET_USER:$NET_PASS" -H "Accept: application/yang-data+json" \\
https://$NET_HOST/restconf/data/openconfig-network-instance:network-instances`;
const gnmi = `gnmi_cli -address $NET_HOST:57400 -insecure -username $NET_USER -password $NET_PASS \\
get --xpath /network-instances/network-instance[name=default]`;
const netmiko = `from netmiko import ConnectHandler
import os
dev = {"device_type":"${os==="junos"?"juniper_junos":os==="eos"?"arista_eos":os==="nxos"?"cisco_nxos":"cisco_ios"}",
"host":os.environ["NET_HOST"],"username":os.environ["NET_USER"],"password":os.environ["NET_PASS"]}
with ConnectHandler(**dev) as c:
print(c.send_config_set(${JSON.stringify(cmd.split("\n"))}))`;
const nornir = `from nornir import InitNornir
from nornir_netmiko.tasks import netmiko_send_config
nr = InitNornir(config_file="config.yaml")
nr.run(task=netmiko_send_config, config_commands=${JSON.stringify(cmd.split("\n"))})`;
const netconf = `<config xmlns="urn:ietf:params:xml:ns:netconf:base:1.0">
<cli xmlns="urn:cli-studio:native">${esc(cmd)}</cli>
</config>`;
return {CLI:cmd, RESTCONF:rest, gNMI:gnmi, Netmiko:netmiko, Nornir:nornir, NETCONF:netconf};
}
function cmdCards(list, params){
if(!list.length) return `<div class="empty">No matching commands for this vendor filter.</div>`;
return list.map(c=>{
const body=applyParams(c.cmd, params||{});
const sn=snippets(body,c.vendor,c.os,c.concept);
const keys=Object.keys(sn);
return `<article class="cmd-card" data-id="${esc(c.vendor+"-"+c.title)}">
<div class="cmd-head">
<div><b>${esc(c.vendor)}</b> <span class="muted">${esc(c.os)}</span> · ${esc(c.title)}
${c.live?'<span class="badge live">live lab</span>':''}
<span class="badge ${esc(c.risk)}">${esc(c.risk)}</span>
</div>
<button class="tbtn copy">Copy CLI</button>
</div>
<p class="muted" style="margin:4px 0 0">${esc(c.desc)}</p>
<pre class="cli">${esc(body)}</pre>
<div class="snip-tabs">${keys.map((k,i)=>`<button class="chip${i?"":" on"}" data-snip="${esc(k)}">${esc(k)}</button>`).join("")}</div>
<pre class="cli snip-body">${esc(sn.CLI)}</pre>
</article>`;
}).join("");
}
function bindCards(root){
root.querySelectorAll(".copy").forEach(b=>b.addEventListener("click",()=>{
const cli=b.closest(".cmd-card")?.querySelector("pre.cli")?.textContent||"";
copy(cli);
}));
root.querySelectorAll(".cmd-card").forEach(card=>{
const snips={};
const pre=card.querySelector(".snip-body");
const cli=card.querySelectorAll("pre.cli")[0]?.textContent||"";
const first=card.querySelector(".cmd-head b")?.textContent||"";
const c=CORPUS.find(x=>x.vendor===first && card.textContent.includes(x.title));
Object.assign(snips, snippets(cli, first, c?.os||"ios", c?.concept||""));
card.querySelectorAll("[data-snip]").forEach(btn=>btn.addEventListener("click",()=>{
card.querySelectorAll("[data-snip]").forEach(x=>x.classList.toggle("on",x===btn));
pre.textContent=snips[btn.dataset.snip]||cli;
}));
});
}
function viewIntent(){
return `<div class="grid two">
<div class="card">
<label for="intent">What do you want the box to do?</label>
<textarea id="intent" placeholder="eBGP to 10.0.0.2 in AS 65002, or OSPF area 0 on 10.0.0.0/24, or enable gNMI">eBGP neighbor 10.0.0.2 remote-as 65002</textarea>
${vendorBar()}
<div class="row">
<button class="tbtn" id="go">Translate</button>
<span class="muted">Try: <button class="chip ex" data-x="advertise VLAN 10 as EVPN VNI 10010">EVPN</button>
<button class="chip ex" data-x="NTP server 10.0.0.10">NTP</button>
<button class="chip ex" data-x="enable gNMI telemetry">gNMI</button></span>
</div>
</div>
<div class="card" id="intent-out"><div class="empty">Translate to emit concept-aligned CLI.</div></div>
</div><div id="intent-cmds"></div>`;
}
function runIntent(){
const text=$("intent").value.trim();
const concept=lookupConcept(text);
const out=$("intent-out");
if(!concept){out.innerHTML=`<div class="err">No concept matched. Try BGP, OSPF, VLAN, EVPN, LACP, NTP, ACL, BFD, STP, gNMI.</div>`;return;}
const recipe=RECIPES.find(r=>r.concept===concept);
const ips=text.match(/\b(?:\d{1,3}\.){3}\d{1,3}(?:\/\d{1,2})?\b/g)||[];
const asns=[...text.matchAll(/\bAS(?:N)?\s*(\d+)/gi)].map(m=>m[1]);
const vlan=(text.match(/\bvlan\s*(\d+)/i)||[])[1];
const params={peer:ips[0],nh:ips[1]||ips[0],asn:asns[0],remote:asns[1],vlan,server:ips[0],vni:(text.match(/\b(\d{4,8})\b/)||[])[1],loopback:ips[0]};
const list=cmdsFor(concept);
out.innerHTML=`<div class="badge">${esc(concept)}</div>
<p>${esc(recipe?recipe.title:concept)} · ${list.length} vendor surfaces · confidence ${recipe?"0.86":"0.62"}</p>
<p class="muted">${esc(recipe?recipe.desc:"Mapped via concept needles.")}</p>`;
$("intent-cmds").innerHTML=cmdCards(list,params);
bindCards($("intent-cmds"));
}
function viewRecipes(){
return `<div class="grid two">${RECIPES.map(r=>`<article class="card rec" data-id="${r.id}">
<div class="cmd-head"><b>${esc(r.title)}</b><span class="badge ${r.risk}">${r.risk}</span></div>
<p class="muted">${esc(r.desc)}</p>
<div class="muted" style="font-size:12px">${esc(r.cat)} · ${esc(r.concept)}</div>
</article>`).join("")}</div>
<div id="rec-out" style="margin-top:14px"></div>`;
}
function openRecipe(id){
const r=RECIPES.find(x=>x.id===id); if(!r) return;
const fields=r.params.map(p=>`<label>${esc(p.l)}<input data-k="${p.k}" value="${esc(p.d)}"></label>`).join("");
$("rec-out").innerHTML=`<div class="card"><h3 style="margin:0 0 8px">${esc(r.title)}</h3>${vendorBar()}<div class="grid two">${fields||"<p class='muted'>No parameters.</p>"}</div>
<div class="row"><button class="tbtn" id="bake">Generate</button></div><div id="rec-cmds"></div></div>`;
bindVendorBar();
const bake=()=>{
const p={};
$("rec-out").querySelectorAll("input[data-k]").forEach(i=>p[i.dataset.k]=i.value);
$("rec-cmds").innerHTML=cmdCards(cmdsFor(r.concept),p);
bindCards($("rec-cmds"));
};
$("bake").onclick=bake; bake();
}
function viewMigrate(){
return `<div class="grid two">
<div class="card">
<label>Source vendor config</label>
<select id="src">${[...new Set(CORPUS.map(c=>c.vendor))].map(v=>`<option>${esc(v)}</option>`).join("")}</select>
<textarea id="mig" style="margin-top:8px" placeholder="Paste a running snippet">router bgp 65001
neighbor 10.0.0.2 remote-as 65002</textarea>
${vendorBar()}
<button class="tbtn" id="do-mig">Migrate</button>
</div>
<div class="card" id="mig-out"><div class="empty">Paste one vendor, emit the others. Unified-diff style.</div></div>
</div>`;
}
function runMigrate(){
const src=$("src").value;
const text=$("mig").value;
const concept=lookupConcept(text)||"bgp:peer";
const params={};
const ips=text.match(/\b(?:\d{1,3}\.){3}\d{1,3}\b/g)||[];
if(ips[0]) params.peer=ips[0];
const asns=[...text.matchAll(/\b(\d{4,10})\b/g)].map(m=>m[1]);
if(asns[0]) params.asn=asns[0]; if(asns[1]) params.remote=asns[1];
const others=cmdsFor(concept).filter(c=>c.vendor!==src);
$("mig-out").innerHTML=`<p>Concept <b>${esc(concept)}</b> · ${others.length} target surfaces</p>`+
others.map(c=>{
const body=applyParams(c.cmd,params);
const diff=unifiedDiff(text,body,src,c.vendor);
return `<article class="cmd-card"><div class="cmd-head"><b>${esc(c.vendor)}</b><button class="tbtn copy" data-copy="${esc(body)}">Copy</button></div><pre class="cli">${esc(diff)}</pre></article>`;
}).join("");
bindCards($("mig-out"));
}
function unifiedDiff(a,b,an,bn){
const al=a.split(/\n/), bl=b.split(/\n/);
let out=`--- ${an}\n+++ ${bn}\n`;
al.forEach(l=>{ if(!bl.includes(l)) out+="- "+l+"\n"; });
bl.forEach(l=>{ if(!al.includes(l)) out+="+ "+l+"\n"; });
return out.trim();
}
function viewParse(){
return `<div class="card">
<label>Paste raw show output</label>
<div class="row"><select id="ptpl"><option value="auto">Auto-detect</option>${PARSERS.map(p=>`<option value="${p.id}">${esc(p.vendor)} — ${esc(p.label)}</option>`).join("")}</select>
<button class="tbtn" id="do-parse">Parse</button>
<button class="tbtn" id="sample">Insert sample</button></div>
<textarea id="ptext" placeholder="Cisco / Junos / EOS / FRR / VyOS BGP, route, OSPF…"></textarea>
<div id="pout" style="margin-top:12px"></div>
</div>`;
}
function runParse(){
const txt=$("ptext").value;
const key=$("ptpl").value;
let p=PARSERS.find(x=>x.id===key);
if(!p||key==="auto") p=PARSERS.find(x=>x.test(txt));
const box=$("pout");
if(!txt.trim()){box.innerHTML=`<div class="err">Paste output first.</div>`;return;}
if(!p){box.innerHTML=`<div class="err">No parser matched. Try Cisco / Junos / EOS / FRR / VyOS BGP or route output.</div>`;return;}
const rows=p.parse(txt);
if(!rows.length){box.innerHTML=`<div class="warn">Detected ${esc(p.label)} but extracted 0 rows.</div>`;return;}
box.innerHTML=`<p><span class="badge live">${esc(p.vendor)}</span> ${esc(p.label)} · ${rows.length} rows</p>
<div style="overflow:auto"><table><thead><tr>${p.cols.map(c=>`<th>${esc(c)}</th>`).join("")}</tr></thead>
<tbody>${rows.map(r=>`<tr>${r.map(c=>`<td>${esc(c)}</td>`).join("")}</tr>`).join("")}</tbody></table></div>`;
}
function viewLab(){
const pos=Object.fromEntries(NODES.map(n=>[n.id,n]));
const n=NODES.find(x=>x.id===selNode);
const live=CORPUS.filter(c=>c.live).slice(0,8);
return `<div class="grid two">
<div class="card">
<svg class="lab" viewBox="0 0 100 100" role="img" aria-label="FRR 10-node Clos">
${LINKS.map(([a,b])=>`<line x1="${pos[a].x}" y1="${pos[a].y}" x2="${pos[b].x}" y2="${pos[b].y}" stroke="currentColor" stroke-opacity=".25" stroke-width=".6"/>`).join("")}
${NODES.map(nd=>`<g class="node" data-id="${nd.id}">
<rect x="${nd.x-7}" y="${nd.y-5}" width="14" height="10" rx="1.2" fill="${nd.id===selNode?"#22d3ee":"#121a3a"}" stroke="#1f2a55" stroke-width=".4"/>
<text x="${nd.x}" y="${nd.y+1.2}" text-anchor="middle" font-size="2.4" fill="${nd.id===selNode?"#041018":"#cfd6f5"}">${nd.id}</text>
</g>`).join("")}
</svg>
</div>
<div class="card">
<h3 style="margin:0">${esc(n.id)}</h3>
<p class="muted">${esc(n.role)} · ASN ${n.asn||"—"} · loopback ${esc(n.lo)}</p>
<p>FRR rows in the 70k corpus carry a <span class="badge live">live</span> flag when the exact command was observed on this Docker Clos.</p>
${live.map(c=>`<pre class="cli">${esc(c.cmd)}</pre>`).join("")}
</div>
</div>`;
}
function viewLint(){
return `<div class="card">
<label>Paste a running-config (any vendor). Studio flags the boring things outages are made of.</label>
<textarea id="lint">hostname leaf-1
interface Ethernet1
ip address 10.0.0.1/24
router bgp 65001
neighbor 10.0.0.2 remote-as 65002</textarea>
<div class="row"><button class="tbtn" id="do-lint">Lint</button></div>
<div id="lint-out"></div>
</div>`;
}
function runLint(){
const t=$("lint").value.toLowerCase();
const checks=[
{id:"ntp", ok:/ntp/.test(t), msg:"No NTP / chrony server. Clocks drift, certs and logs lie."},
{id:"aaa", ok:/aaa|radius|tacacs/.test(t), msg:"No AAA / RADIUS / TACACS. Local-only auth on a fabric."},
{id:"ssh", ok:/ssh|transport input ssh|crypto key/.test(t), msg:"No SSH hardening. Telnet-era configs still ship."},
{id:"log", ok:/logging |syslog/.test(t), msg:"No syslog / logging host. The next incident has no paper trail."},
{id:"bgp", ok:/bgp|neighbor|peer-as/.test(t), warn:true, msg:"BGP present — confirm BFD or a sane hold-time."},
{id:"bfd", ok:/bfd/.test(t), msg:"No BFD. Three-minute dead timers are not a failover plan."},
{id:"secret", ok:!/password\s+\S+|community\s+\S+/.test(t), msg:"Literal password or SNMP community in the paste. Redact before tickets."},
];
$("lint-out").innerHTML=`<ul>${checks.map(c=>`<li class="${c.ok?"ok":(c.warn?"warn":"err")}"><b>${c.ok?"pass":"fail"}</b> — ${esc(c.msg)}</li>`).join("")}</ul>
<p class="muted">Heuristic only. Pair with <a href="https://gesh75.github.io/aegis/">AEGIS</a> before anything touches a box.</p>`;
}
function viewCoverage(){
const vendors=[...new Set(CORPUS.map(c=>c.vendor))];
const concepts=[...new Set(CORPUS.map(c=>c.concept))];
const cell=(v,k)=>{
const hits=CORPUS.filter(c=>c.vendor===v&&c.concept===k);
if(!hits.length) return `<span class="cov-cell" title="${v} ${k}"></span>`;
return `<span class="cov-cell ${hits.some(h=>h.live)?"live":"on"}" title="${v} ${k} (${hits.length})"></span>`;
};
return `<div class="card" style="overflow:auto">
<p class="muted">Studio corpus: ${CORPUS.length} golden-path rows covering the concepts the 70k page is too wide to teach. Green = live on the FRR lab.</p>
<table><thead><tr><th>Vendor</th>${concepts.map(k=>`<th title="${esc(k)}">${esc(k.split(":")[0])}</th>`).join("")}<th>n</th></tr></thead>
<tbody>${vendors.map(v=>`<tr><td>${esc(v)}</td>${concepts.map(k=>`<td>${cell(v,k)}</td>`).join("")}<td>${CORPUS.filter(c=>c.vendor===v).length}</td></tr>`).join("")}</tbody></table>
</div>`;
}
const VIEWS={intent:viewIntent,recipes:viewRecipes,migrate:viewMigrate,parse:viewParse,lab:viewLab,lint:viewLint,coverage:viewCoverage};
const TITLES={
intent:["English in. Seventeen CLIs out.","Type what you mean. Studio maps it onto a concept, then the vendor surfaces."],
recipes:["Golden paths, parameterized.","BGP, OSPF, EVPN, LACP, ACL, NTP, BFD, STP, gNMI — bake IPs and ASNs in."],
migrate:["One config. The other sixteen.","Paste Cisco, emit Junos / EOS / FRR / SR Linux as a unified diff."],
parse:["Show output, structured.","EOS, FRR, and VyOS parsers ship — the follow-up ARCHITECTURE.md called out."],
lab:["Ten nodes. Provenance that is not a guess.","Click a Clos member. Live-flagged FRR commands were captured here."],
lint:["The boring failures.","NTP, AAA, SSH, syslog, BFD. Heuristic lint before AEGIS or a change window."],
coverage:["Honest matrix.","Golden-path coverage across 17 vendors. Not a marketing heatmap."],
};
function render(){
const tabs=$("tabs");
tabs.innerHTML=TABS.map(t=>`<button class="tab" role="tab" aria-selected="${t.id===currentTab}" data-tab="${t.id}">${t.label}</button>`).join("");
tabs.querySelectorAll(".tab").forEach(b=>b.onclick=()=>{currentTab=b.dataset.tab;history.replaceState(null,"","#"+currentTab);render();});
$("title").textContent=TITLES[currentTab][0];
$("lede").textContent=TITLES[currentTab][1];
$("view").innerHTML=VIEWS[currentTab]();
bindVendorBar();
if(currentTab==="intent"){
$("go").onclick=runIntent;
document.querySelectorAll(".ex").forEach(b=>b.onclick=()=>{$("intent").value=b.dataset.x;runIntent();});
runIntent();
}
if(currentTab==="recipes"){
document.querySelectorAll(".rec").forEach(c=>c.onclick=()=>openRecipe(c.dataset.id));
}
if(currentTab==="migrate"){ $("do-mig").onclick=runMigrate; }
if(currentTab==="parse"){
$("do-parse").onclick=runParse;
$("sample").onclick=()=>{
const key=$("ptpl").value;
const p=PARSERS.find(x=>x.id===key)||PARSERS.find(x=>x.id==="eos-bgp");
$("ptext").value=p.sample; runParse();
};
}
if(currentTab==="lab"){
document.querySelectorAll(".node").forEach(g=>g.addEventListener("click",()=>{selNode=g.dataset.id;render();}));
}
if(currentTab==="lint"){ $("do-lint").onclick=runLint; runLint(); }
}
$("theme").onclick=()=>{
const html=document.documentElement;
html.dataset.theme=html.dataset.theme==="dark"?"light":"dark";
};
fetch("studio-data.json").then(r=>{
if(!r.ok) throw new Error(r.status);
return r.json();
}).then(data=>{
CORPUS=data;
const hash=(location.hash||"#intent").slice(1);
if(TABS.some(t=>t.id===hash)) currentTab=hash;
render();
}).catch(()=>{
$("view").innerHTML=`<div class="card err">Could not load studio-data.json. Serve over HTTP (GitHub Pages or python3 -m http.server) — file:// is empty on purpose, same contract as the cheatsheet.</div>`;
});
</script>
</body>
</html>