@lovek4d/kickflip 0.1.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/CONTEXT.md +239 -104
  2. package/README.md +54 -4
  3. package/SKILL.md +63 -42
  4. package/bin/doctor.mjs +4 -3
  5. package/bin/install.mjs +3 -3
  6. package/bin/kickflip.mjs +103 -40
  7. package/bin/statusline.mjs +4 -3
  8. package/bin/wait-events.mjs +2 -2
  9. package/board/arch-review.mjs +5 -0
  10. package/board/auto-review.mjs +2 -0
  11. package/board/bin/agent.mjs +1 -1
  12. package/board/bin/board.mjs +371 -82
  13. package/board/bin/builder-stop-hook.mjs +4 -3
  14. package/board/domain.mjs +8 -3
  15. package/board/finish.mjs +3 -0
  16. package/board/headless.mjs +3 -0
  17. package/board/on-host.mjs +1 -0
  18. package/board/permissions.mjs +2 -0
  19. package/board/public/app-arch.js +75 -0
  20. package/board/public/app-asks.js +66 -28
  21. package/board/public/app-banner.js +2 -2
  22. package/board/public/app-cards.js +354 -77
  23. package/board/public/app-core.js +40 -8
  24. package/board/public/app-header.js +33 -8
  25. package/board/public/app-lanes.js +238 -34
  26. package/board/public/app-needs.js +2 -0
  27. package/board/public/app-snapshot.js +45 -11
  28. package/board/public/app-threads.js +20 -21
  29. package/board/public/app.html +6 -2
  30. package/board/public/app.js +114 -33
  31. package/board/public/attach.js +39 -3
  32. package/board/public/dialogs.js +161 -118
  33. package/board/public/hub-kit.js +27 -7
  34. package/board/public/hub.css +26 -33
  35. package/board/public/linear-key.js +143 -0
  36. package/board/public/log.html +48 -0
  37. package/board/public/prompts.js +9 -8
  38. package/board/public/prototype-kit.js +6 -6
  39. package/board/public/prototype-switcher.js +13 -5
  40. package/board/public/prototype.html +1 -0
  41. package/board/public/router.js +14 -10
  42. package/board/public/rules-editor.js +4 -5
  43. package/board/public/shell.js +21 -7
  44. package/board/public/sidebar.css +51 -11
  45. package/board/public/sidebar.js +124 -19
  46. package/board/public/style.css +244 -56
  47. package/board/public/toasts.js +5 -3
  48. package/board/public/ui-cards.js +236 -66
  49. package/board/public/ui-enums.js +5 -4
  50. package/board/public/ui-header.js +23 -14
  51. package/board/public/ui-hub.js +34 -16
  52. package/board/public/ui-links.js +18 -10
  53. package/board/public/ui-markdown.js +1 -1
  54. package/board/public/ui-order.js +21 -0
  55. package/board/public/ui-page-header.js +13 -8
  56. package/board/public/ui-patch.js +53 -0
  57. package/board/public/ui-phase-bars.js +17 -0
  58. package/board/public/ui-prompts.js +8 -8
  59. package/board/public/ui-questions.js +35 -11
  60. package/board/public/ui-sidebar.js +83 -21
  61. package/board/public/ui-snapshot.js +30 -13
  62. package/board/public/ui-threads.js +38 -23
  63. package/board/public/ui.js +96 -16
  64. package/board/public/view-frame.js +1 -1
  65. package/board/public/view-hub-settings.js +67 -44
  66. package/board/public/view-hub.js +105 -13
  67. package/board/public/view-notes.js +8 -5
  68. package/board/public/view-questions.js +123 -52
  69. package/board/public/view-settings.js +235 -62
  70. package/board/review.mjs +1 -0
  71. package/board/server.mjs +18 -7
  72. package/board/store.mjs +15 -6
  73. package/board/tickets.mjs +3 -3
  74. package/board/worktree.mjs +20 -13
  75. package/docs/hub-session.md +4 -4
  76. package/hooks-plugin/hooks/codex-share-auto.mjs +7 -5
  77. package/hooks-plugin/hooks/watcher-stop.mjs +41 -0
  78. package/hub/awake.mjs +1 -0
  79. package/hub/board-repo.mjs +1 -1
  80. package/hub/boards.mjs +4 -4
  81. package/hub/close-board.mjs +1 -1
  82. package/hub/duration.mjs +1 -0
  83. package/hub/github.mjs +3 -4
  84. package/hub/home.mjs +1 -1
  85. package/hub/hub.mjs +12 -10
  86. package/hub/incoming.mjs +2 -0
  87. package/hub/linear.mjs +2 -2
  88. package/hub/live.mjs +2 -2
  89. package/hub/new-board.mjs +2 -2
  90. package/hub/permission-denied-hook.mjs +2 -0
  91. package/hub/pr-report.mjs +3 -0
  92. package/hub/presence.mjs +1 -1
  93. package/hub/prompts.mjs +1 -1
  94. package/hub/push.mjs +2 -2
  95. package/hub/readiness.mjs +2 -0
  96. package/hub/repos.mjs +3 -2
  97. package/hub/restart-board.mjs +1 -0
  98. package/hub/routes.mjs +1 -1
  99. package/hub/schedule.mjs +1 -1
  100. package/hub/sessions.mjs +2 -2
  101. package/hub/shell.mjs +2 -1
  102. package/hub/slack.mjs +2 -0
  103. package/hub/stream.mjs +12 -12
  104. package/hub/sw.js +2 -2
  105. package/hub/trust.mjs +2 -0
  106. package/hub/try.mjs +8 -3
  107. package/hub/upgrade.mjs +1 -0
  108. package/hub/usage.mjs +3 -3
  109. package/hub/views.mjs +1 -1
  110. package/package.json +5 -1
  111. package/shared/card-prs.mjs +1 -0
  112. package/shared/colors.css +9 -6
  113. package/shared/fake-gh.mjs +105 -0
  114. package/shared/repos.mjs +1 -1
  115. package/shared/service.mjs +16 -16
  116. package/shared/skills.mjs +2 -0
  117. package/shared/usage-gate.mjs +1 -1
  118. package/starter/public/claude.js +3 -2
  119. package/bin/web-ts.mjs +0 -2
  120. package/board/public/view-polling.js +0 -87
package/bin/doctor.mjs CHANGED
@@ -1,3 +1,4 @@
1
- var X=Object.defineProperty;var r=(o,a)=>X(o,"name",{value:a,configurable:!0});import T from"node:fs";import z from"node:os";import J from"node:path";import{cloneRoot as G}from"../shared/repos.mjs";import{codexStatus as Q}from"../shared/codex.mjs";import{hubService as V,runCommand as W}from"../shared/service.mjs";import{inspectLinks as Y,ownsStatusLine as Z,hubStatus as tt,tailscaleRoot as et,runInstall as st}from"./install.mjs";const c="kickflip doctor --fix",U={ok:"ok",fixable:"fixable",yours:"yours",optional:"optional",note:"note"},d=r(o=>`'${o.replaceAll("'","'\\''")}'`,"quote"),nt={darwin:{git:"xcode-select --install",gh:"brew install gh",tailscale:"brew install --cask tailscale"},other:{git:"sudo apt install git",gh:"sudo apt install gh",tailscale:"curl -fsSL https://tailscale.com/install.sh | sh"}};async function it({home:o=z.homedir(),cloneRoot:a=G(),port:i=Number(process.env.KICKFLIP_HUB_PORT||4500),uid:b=process.getuid(),env:u=process.env,run:x=W,platform:h=process.platform,fetch:m=globalThis.fetch}={}){const l=nt[h==="darwin"?"darwin":"other"],f=[];let y;const s=r((t,e,n,$=null)=>f.push({group:y,item:t,state:e,detail:n,fix:e==="ok"||e==="note"?null:$}),"add"),S=r((t,e)=>{try{return x(t,e,{env:u})}catch(n){return{status:1,error:n.message}}},"execute"),R=r(t=>{try{return t.status===0?JSON.parse(t.stdout):null}catch{return null}},"json"),w=r((t,e,n,$,_="not on this session\u2019s PATH")=>{const B=S(e,n),I=B.status===0;return s(t,I?"ok":"yours",I?(B.stdout||"installed").trim().split(`
2
- `)[0]:_,$),I},"installed");y="prereqs",w("git","git",["--version"],l.git),w("node on Claude\u2019s PATH","node",["--version"],`add ${d(J.dirname(process.execPath))} to PATH in your shell profile, then restart Claude Code`,`not on this session\u2019s PATH (${u.PATH||"empty"})`);const F=w("gh","gh",["--version"],l.gh),D=F&&S("gh",["auth","status"]).status===0;s("gh signed in",D?"ok":"yours",D?"signed in":F?"not signed in":"needs gh","gh auth login");const N=w("Tailscale","tailscale",["version"],l.tailscale),A=N?R(S("tailscale",["status","--json"])):null,v=A?.BackendState==="Running";s("Tailscale signed in",v?"ok":"yours",v?`signed in${A.Self?.DNSName?" as "+A.Self.DNSName.replace(/\.$/,""):""}`:N?"not connected":"needs Tailscale","tailscale up");const L=Q({home:o,env:u,platform:h});s("Codex",L.usable?"ok":"optional",L.usable?"installed and logged in":`${L.reason}; Codex builds and reviews need it`,L.fix),y="setup";const K=["kickflip skill link","kickflip-hooks link","~/.claude/statusline.sh"];for(const[t,e]of Y({home:o,cloneRoot:a}).entries())if(e.refusal){const n=T.lstatSync(e.link);s(K[t],"yours",n.isSymbolicLink()?`links to ${T.readlinkSync(e.link)}, not kickflip`:`a real ${n.isDirectory()?"folder":"file"}, not kickflip\u2019s link`,`${n.isSymbolicLink()?`rm ${d(e.link)}`:`mv ${d(e.link)} ${d(e.link+".backup")}`} && ${c}`)}else s(K[t],e.created||e.replaced?"fixable":"ok",`${e.created?"missing":e.replaced?"needs relinking":"linked"} -> ${e.target}`,c);const C=J.join(o,".claude","settings.json");let p={},E;try{if(p=JSON.parse(T.readFileSync(C,"utf8")),!p||typeof p!="object"||Array.isArray(p))throw new Error("not a settings object")}catch(t){t.code!=="ENOENT"&&(E=t.message)}const j="settings.json statusLine";E?s(j,"yours",`unreadable: ${E}`,`repair the JSON in ${d(C)}, then ${c}`):Object.hasOwn(p,"statusLine")?Z(p.statusLine,o)?s(j,"ok","runs ~/.claude/statusline.sh"):s(j,"yours","another tool\u2019s status line is selected",`remove statusLine from ${d(C)}, then ${c}`):s(j,"fixable","missing",c);const g=V({platform:h,home:o,uid:b,env:u,run:x}),M=g.status();s(g.item,M.running?"ok":g.manager?"fixable":"yours",M.detail,g.manager?c:`run ${g.restartCommand} under a service manager of your own`);for(const t of g.notes())s(t.item,t.state,t.detail,t.fix);y="hub";const O=await tt({port:i,fetch:m});s("hub",O?"ok":"fixable",`${O?"running":"not running"} on 127.0.0.1:${i}`,c);const P=N?et(R(S("tailscale",["serve","status","--json"]))):{root:null,paths:[]};let H=!1;try{const t=new URL(P.root);H=t.protocol==="http:"&&["127.0.0.1","localhost","[::1]"].includes(t.hostname)&&Number(t.port)===i&&t.pathname==="/"&&!t.search&&!t.hash}catch{}const q=P.paths.filter(t=>t!=="/");s("tailscale /",H?"ok":v?"fixable":"yours",`${P.root||"nothing"}${H?"":` (expected the hub on ${i})`}${q.length?"; other mounts: "+q.join(", "):""}`,v?c:N?"tailscale up":l.tailscale);let k=O?.sites;if(!k){const t=J.join(o,".claude","kickflip","manifest.json");try{const e=JSON.parse(T.readFileSync(t,"utf8"));k=Object.entries(e.sites||{}).map(([n,$])=>({...$,route:n,state:"hub not running"}))}catch(e){e.code!=="ENOENT"&&s("site manifest","yours",e.message,`repair the JSON in ${d(t)}`),k=[]}}k.length||s("sites","ok","none");for(const t of k){const e=t.port?`port ${t.port}`:`files ${t.dir}`,n=`${t.state} ${e} owner ${t.owner?.label||t.owner?.session||"-"} ${t.title||""}`.trimEnd();t.state==="up"?s("/"+t.route,"ok",n):O?t.serves==="port"?s("/"+t.route,"note",`${n}; its session runs this server, not the hub`):t.serves==="files"?s("/"+t.route,"note",`${n}; its folder is missing`):s("/"+t.route,"note",`${n}; the hub restarts it, see kickflip logs ${t.route}`):s("/"+t.route,"fixable",n,c)}return f}r(it,"doctorReport");function ot(o){const a=Math.max(...Object.values(U).map(i=>i.length));return o.flatMap(i=>[`${U[i.state].padEnd(a)} ${i.item}: ${i.detail}`,...i.fix?[`${" ".repeat(a+1)}\u2192 ${i.fix}`]:[]])}r(ot,"doctorLines");async function pt({fix:o=!1,dryRun:a=!1,json:i=!1,write:b=console.log,error:u=console.error,install:x=st,...h}={}){let m=!1;if(o){try{await x({...h,dryRun:a,log:i?u:b})}catch(f){m=!0,u("kickflip: "+f.message)}if(a)return{items:[],exitCode:m?1:0}}const l=await it(h);return b(i?JSON.stringify({items:l},null,2):ot(l).join(`
3
- `)),{items:l,exitCode:m||l.some(f=>f.state==="fixable"||f.state==="yours")?1:0}}r(pt,"runDoctor");export{ot as doctorLines,it as doctorReport,pt as runDoctor};
1
+ var ot=Object.defineProperty;var f=(i,c)=>ot(i,"name",{value:c,configurable:!0});import g from"node:fs";import K from"node:os";import m from"node:path";import{isTryPort as J}from"../hub/try.mjs";import{githubGitArgv as nt,gitReachFault as it}from"../hub/repos.mjs";import{buildsLocally as rt,isBoardSite as at}from"../hub/boards.mjs";import{cloneRoot as z,reposDir as lt}from"../shared/repos.mjs";import{codexStatus as ct}from"../shared/codex.mjs";import{REVIEW_SKILLS as ut,SKILLS_SOURCE as ft,addCommand as dt,skillsState as ht,updateCommand as mt}from"../shared/skills.mjs";import{hubService as pt,runCommand as gt}from"../shared/service.mjs";import{inspectLinks as kt,ownsStatusLine as bt,hasWatcherStop as $t,hubStatus as yt,tailscaleState as wt,runInstall as xt}from"./install.mjs";const d="kickflip doctor --fix",Q={ok:"ok",fixable:"fixable",yours:"yours",optional:"optional",note:"note"},$=f(i=>`'${i.replaceAll("'","'\\''")}'`,"quote"),St={darwin:{git:"xcode-select --install",gh:"brew install gh",tmux:"brew install tmux"},other:{git:"sudo apt install git",gh:"sudo apt install gh",tmux:"sudo apt install tmux"}},jt="cli/cli",Ot=15e3,Y=f(i=>{try{return g.realpathSync(i)}catch{return null}},"realPath"),Z=f(i=>i.length?`${i.map(c=>`/${c}`).join(", ")} ${i.length>1?"use":"uses"} it`:"","usedBy");function tt({home:i=K.homedir(),cloneRoot:c=z()}={}){let a={};try{a=JSON.parse(g.readFileSync(m.join(i,".claude","kickflip","manifest.json"),"utf8"))}catch{}const y=[...Object.entries(a.sites||{}).map(([n,r])=>[n,r?.owner?.repo]),...Object.entries(a.closed||{}).map(([n,r])=>[n,r?.site?.owner?.repo]),...Object.entries(a.starting||{}).map(([n,r])=>[n,r?.repo])].map(([n,r])=>[n,r&&Y(r)]).filter(([,n])=>n),h=`${m.sep}.repos${m.sep}`,C=new Set([Y(m.join(c,".repos")),...y.map(([,n])=>n.includes(h)?n.slice(0,n.indexOf(h)+h.length-1):null)].filter(Boolean)),k=[],w=f(n=>{for(const r of g.readdirSync(n,{withFileTypes:!0})){if(!r.isDirectory())continue;const u=m.join(n,r.name);g.existsSync(m.join(u,".git"))?k.push(u):w(u)}},"walk");for(const n of C)w(n);return k.sort().map(n=>({clone:n,routes:[...new Set(y.filter(([,r])=>r===n||r.startsWith(n+m.sep)).map(([r])=>r))]}))}f(tt,"oldBoardClones");async function Ct({home:i=K.homedir(),cloneRoot:c=z(),port:a=Number(process.env.KICKFLIP_HUB_PORT||4500),uid:y=process.getuid(),env:h=process.env,run:C=gt,platform:k=process.platform,username:w=K.userInfo().username,fetch:n=globalThis.fetch}={}){const r=St[k==="darwin"?"darwin":"other"],u=[];let j;const s=f((t,e,o,l=null)=>u.push({group:j,item:t,state:e,detail:o,fix:e==="ok"||e==="note"?null:l}),"add"),p=f((t,e,o=null,l=void 0)=>{try{return C(t,e,{env:o?{...h,...o}:h,...l&&{timeout:l}})}catch(S){return{status:1,error:S.message}}},"execute"),P=f((t,e,o,l,S="not on this session\u2019s PATH")=>{const F=p(e,o),G=F.status===0;return s(t,G?"ok":"yours",G?(F.stdout||"installed").trim().split(`
2
+ `)[0]:S,l),G},"installed");j="prereqs";const q=P("git","git",["--version"],r.git);P("node on Claude\u2019s PATH","node",["--version"],`add ${$(m.dirname(process.execPath))} to PATH in your shell profile, then restart Claude Code`,`not on this session\u2019s PATH (${h.PATH||"empty"})`);const U=P("gh","gh",["--version"],r.gh),L=U&&p("gh",["auth","status"]).status===0;if(s("gh signed in",L?"ok":"yours",L?"signed in":U?"not signed in":"needs gh","gh auth login"),q){const t=L?p("gh",["repo","list","--limit","1","--json","nameWithOwner","--jq",".[0].nameWithOwner"]):null,e=t?.status===0&&t.stdout.trim()||jt,o=p("git",nt(["ls-remote",`https://github.com/${e}.git`,"HEAD"]),{GIT_TERMINAL_PROMPT:"0"});s("GitHub clone",o.status===0?"ok":"yours",o.status===0?`git reaches ${e} over https`:`git can\u2019t reach ${e} over https: ${o.error}`,L?"gh auth refresh -h github.com, then kickflip doctor":"check this machine reaches https://github.com, then kickflip doctor")}else s("GitHub clone","yours","needs git",r.git);const T=await wt({run:f((t,e)=>p(t,e),"run"),platform:k,uid:y,username:w,port:a});for(const t of T.prereqs)s(t.item,t.state,t.detail,t.fix);const M=p("tmux",["-V"]);s("tmux",M.status===0?"ok":"optional",M.status===0?(M.stdout||"installed").trim().split(`
3
+ `)[0]:"not installed; a hub board\u2019s session (kickflip join --hub, kickflip hub) runs in it",r.tmux);const A=ct({home:i,env:h,platform:k});s("Codex",A.usable?"ok":"optional",A.usable?"installed and logged in":`${A.reason}; Codex builds and reviews need it`,A.fix);const x=ht({home:i,run:p,gh:L});x.missing.length?s("mattpocock skills","optional",`${x.missing.join(", ")} not installed; architecture reviews need ${x.missing.length===1?"it":"them"}`,dt(x.missing)):x.stale.length?s("mattpocock skills","optional",`${x.stale.join(", ")} out of date with ${ft}`,mt(x.stale)):s("mattpocock skills","ok",`${ut.join(", ")} installed${x.checked?" and current":""}`),j="setup";const V=["kickflip skill link","kickflip-hooks link","~/.claude/statusline.sh"];for(const[t,e]of kt({home:i,cloneRoot:c}).entries())if(e.refusal){const o=g.lstatSync(e.link);s(V[t],"yours",o.isSymbolicLink()?`links to ${g.readlinkSync(e.link)}, not kickflip`:`a real ${o.isDirectory()?"folder":"file"}, not kickflip\u2019s link`,`${o.isSymbolicLink()?`rm ${$(e.link)}`:`mv ${$(e.link)} ${$(e.link+".backup")}`} && ${d}`)}else s(V[t],e.created||e.replaced?"fixable":"ok",`${e.created?"missing":e.replaced?"needs relinking":"linked"} -> ${e.target}`,d);const E=m.join(i,".claude","settings.json");let b={},O;try{if(b=JSON.parse(g.readFileSync(E,"utf8")),!b||typeof b!="object"||Array.isArray(b))throw new Error("not a settings object")}catch(t){t.code!=="ENOENT"&&(O=t.message)}const I="settings.json statusLine";O?s(I,"yours",`unreadable: ${O}`,`repair the JSON in ${$(E)}, then ${d}`):Object.hasOwn(b,"statusLine")?bt(b.statusLine,i)?s(I,"ok","runs ~/.claude/statusline.sh"):s(I,"yours","another tool\u2019s status line is selected",`remove statusLine from ${$(E)}, then ${d}`):s(I,"fixable","missing",d);const W="settings.json watcher Stop hook";O?s(W,"yours",`unreadable: ${O}`,`repair the JSON in ${$(E)}, then ${d}`):$t(b)?s(W,"ok","runs hooks-plugin/hooks/watcher-stop.mjs"):s(W,"fixable","missing; a board session can end a turn with no live watcher or a message left Working on it",d);const B="settings.json autoCompactEnabled";O?s(B,"yours",`unreadable: ${O}`,`repair the JSON in ${$(E)}, then ${d}`):b.autoCompactEnabled===!0?s(B,"ok","on"):s(B,"fixable",`${Object.hasOwn(b,"autoCompactEnabled")?"off":"not set"}; a board session that fills its context stops answering`,d);const N=pt({platform:k,home:i,uid:y,env:h,run:C}),X=N.status();s(N.item,X.running?"ok":N.manager?"fixable":"yours",X.detail,N.manager?d:`run ${N.restartCommand} under a service manager of your own`);for(const t of N.notes())s(t.item,t.state,t.detail,t.fix);j="hub";const H=await yt({port:a,fetch:n});s("hub",H?"ok":"fixable",`${H?"running":"not running"} on 127.0.0.1:${a}`,d);const{serveConfig:D}=T;s(T.root.item,T.root.state,T.root.detail,T.root.fix);let v=H?.sites;if(!v){const t=m.join(i,".claude","kickflip","manifest.json");try{const e=JSON.parse(g.readFileSync(t,"utf8"));v=Object.entries(e.sites||{}).map(([o,l])=>({...l,route:o,state:"hub not running"}))}catch(e){e.code!=="ENOENT"&&s("site manifest","yours",e.message,`repair the JSON in ${$(t)}`),v=[]}}let _={};try{_=JSON.parse(g.readFileSync(m.join(i,".claude","kickflip","manifest.json"),"utf8"))}catch{}const R=new Map,et=[...Object.entries(_.sites||{}),...Object.entries(_.closed||{}).map(([t,e])=>[t,e.site]),...v.map(t=>[t.route,t])];for(const[t,e]of et){if(!e?.dir)continue;let o;try{o=JSON.parse(g.readFileSync(m.join(e.dir,"data","try.json"),"utf8"))}catch{continue}J(o.main_port)&&R.set(Number(o.main_port),`/${t} main`),J(o.main?.https)&&R.set(Number(o.main.https),`/${t} main`);for(const[l,S]of Object.entries(o.cards||{}))J(S.https)&&R.set(Number(S.https),`/${t} ${l}`)}const st=Object.keys(D?.TCP||{}).filter(J).sort((t,e)=>t-e);s("try ports","ok",st.map(t=>{const o=Object.entries(D.Web||{}).find(([l])=>l.endsWith(`:${t}`))?.[1]?.Handlers?.["/"]?.Proxy||D.TCP[t].TCPForward||"HTTPS";return`${t} \u2192 ${o} (${R.get(Number(t))||"unknown"})`}).join("; ")||"none"),v.length||s("sites","ok","none");for(const t of v){const e=t.port?`port ${t.port}`:`files ${t.dir}`,o=`${t.state} ${e} owner ${t.owner?.label||t.owner?.session||"-"} ${t.title||""}`.trimEnd();t.state==="up"?s("/"+t.route,"ok",o):H?t.serves==="port"?s("/"+t.route,"note",`${o}; its session runs this server, not the hub`):t.serves==="files"?s("/"+t.route,"note",`${o}; its folder is missing`):s("/"+t.route,"note",`${o}; the hub restarts it, see kickflip logs ${t.route}`):s("/"+t.route,"fixable",o,d)}if(q)for(const[t,e]of Object.entries(_.sites||{})){const o=e?.owner?.repo;if(!at(e)||rt(e)||!o)continue;const l=p("git",["-C",o,"ls-remote","--exit-code","origin","HEAD"],{GIT_TERMINAL_PROMPT:"0"},Ot),{error:S,hint:F}=it(l.error,{github:e.github||"",repo:o});s(`/${t} repo`,l.status===0?"ok":"yours",l.status===0?`git reaches ${e.github||"origin"} from ${o}`:`git can\u2019t reach ${e.github||"origin"} from ${o}: ${S}`,F)}for(const{clone:t,routes:e}of tt({home:i,cloneRoot:c}))s("old board clone","optional",[`${t} is in an old place`,`board clones now live in ${lt(i)}`,Z(e)].filter(Boolean).join("; "),d);return u}f(Ct,"doctorReport");function Tt(i){const c=Math.max(...Object.values(Q).map(a=>a.length));return i.flatMap(a=>[`${Q[a.state].padEnd(c)} ${a.item}: ${a.detail}`,...a.fix?[`${" ".repeat(c+1)}\u2192 ${a.fix}`]:[]])}f(Tt,"doctorLines");async function Mt({fix:i=!1,dryRun:c=!1,json:a=!1,write:y=console.log,error:h=console.error,install:C=xt,ask:k,...w}={}){let n=!1;if(i){const u=a?h:y;try{await C({...w,dryRun:c,log:u})}catch(s){n=!0,h("kickflip: "+s.message)}const j=tt(w);if(c){for(const{clone:s}of j)u(`would ask to remove the old board clone ${s}`);return{items:[],exitCode:n?1:0}}for(const{clone:s,routes:p}of k?j:[]){const P=await k(`Remove the old board clone ${s}? ${p.length?`${Z(p)}. `:""}(y/N) `);/^y(es)?$/i.test(P.trim())&&(g.rmSync(s,{recursive:!0,force:!0}),u(`removed ${s}`))}}const r=await Ct(w);return y(a?JSON.stringify({items:r},null,2):Tt(r).join(`
4
+ `)),{items:r,exitCode:n||r.some(u=>u.state==="fixable"||u.state==="yours")?1:0}}f(Mt,"runDoctor");export{Tt as doctorLines,Ct as doctorReport,tt as oldBoardClones,Mt as runDoctor};
package/bin/install.mjs CHANGED
@@ -1,3 +1,3 @@
1
- var C=Object.defineProperty;var j=(t,i)=>C(t,"name",{value:i,configurable:!0});import n from"node:fs";import g from"node:os";import e from"node:path";import{cloneRoot as v}from"../shared/repos.mjs";import{hubService as F,runCommand as _}from"../shared/service.mjs";function H({home:t=g.homedir(),skillsDir:i=e.join(t,".claude","skills"),dryRun:s=!1}={}){const r=[],a=[],o=j(d=>n.lstatSync(d,{throwIfNoEntry:!1}),"lstat"),c=j((d,b,x)=>{if(s){r.push(`would ${b}`);return}x(),r.push(d)},"act"),y=e.join(t,".claude","web-ts"),l=e.join(t,".claude","kickflip");o(y)?.isDirectory()&&(o(l)?a.push(`${y} and ${l} are both folders; merge them by hand, then leave a link at ${y}`):c(`moved ${y} -> ${l}, linked from the old path`,`move ${y} -> ${l} and link the old path to it`,()=>{try{n.renameSync(y,l)}catch(d){if(!o(l)||o(y)?.isDirectory())throw d}I(y,"kickflip")}));const k=e.join(i,"join-web-ts"),f=e.join(i,"kickflip"),u=o(k);if(u&&!o(f)){const d=u.isSymbolicLink()?e.resolve(i,n.readlinkSync(k)):k;c(`linked ${f} -> ${d}`,`link ${f} -> ${d}`,()=>n.symlinkSync(d,f,"dir"))}const p=e.join(i,"web-ts-hooks"),m=e.join(i,"kickflip-hooks"),$=j(d=>{try{return n.realpathSync(d)}catch{return null}},"real"),S=$(e.join(u?k:f,"hooks-plugin"));if(o(p)?.isSymbolicLink()&&S&&$(p)===S){const d=e.join(f,"hooks-plugin");o(m)||c(`linked ${m} -> ${d}`,`link ${m} -> ${d}`,()=>n.symlinkSync(d,m,"dir")),c(`removed ${p}`,`remove ${p}`,()=>n.unlinkSync(p))}const w=e.join(t,".claude","statusline.sh");if(o(w)?.isSymbolicLink()&&e.resolve(e.dirname(w),n.readlinkSync(w))===e.join(k,"bin","statusline.mjs")&&(u||o(f))){const d=e.join(f,"bin","statusline.mjs");c(`linked ${w} -> ${d}`,`link ${w} -> ${d}`,()=>I(w,d))}return{steps:r,conflicts:a}}j(H,"migrateHome");function Y(){try{return H()}catch(t){return{steps:[],conflicts:[t.message]}}}j(Y,"migrateOnStart");function I(t,i){const s=`${t}.${process.pid}.${Date.now()}.link`;n.symlinkSync(i,s);try{n.renameSync(s,t)}catch(r){try{n.rmdirSync(t),n.renameSync(s,t)}catch{throw n.unlinkSync(s),r}}}j(I,"replaceWithLink");function B(t,{home:i=g.homedir(),skillsDir:s=e.join(i,".claude","skills"),cloneRoot:r=v()}={}){if(typeof t!="string"||!e.isAbsolute(t))return t;const a=e.relative(r,t);if(a===".."||a.startsWith(`..${e.sep}`)||e.isAbsolute(a))return t;const o=e.resolve(s,"kickflip");let c=!1;try{c=n.realpathSync(o)===n.realpathSync(r)}catch{}return c?e.join(o,a):t}j(B,"storedPath");function J({home:t=g.homedir(),skillsDir:i=e.join(t,".claude","skills"),cloneRoot:s=v()}={}){const r=n.realpathSync(s),a=[["kickflip",r],["kickflip-hooks",e.join(r,"hooks-plugin")]].map(([u,p])=>{if(!n.statSync(p,{throwIfNoEntry:!1})?.isDirectory())throw new Error(`not a directory: ${p}`);const m=u==="kickflip"?p:e.resolve(i,"kickflip","hooks-plugin"),$=e.resolve(i,u),S=n.lstatSync($,{throwIfNoEntry:!1});let w;if(S){let d;try{d=n.realpathSync($)}catch(b){if(b.code!=="ENOENT")throw b}S.isSymbolicLink()?d!==n.realpathSync(p)&&(w=`refusing to replace link ${$} -> ${n.readlinkSync($)}; remove that link explicitly, then rerun kickflip doctor --fix`):(!S.isDirectory()||d!==n.realpathSync(p))&&(w=`refusing to replace real path ${$}; move it aside explicitly, then rerun kickflip doctor --fix`)}return{link:$,target:m,created:!S,...w?{refusal:w}:{}}}),o=e.join(r,"bin","statusline.mjs");if(!n.statSync(o,{throwIfNoEntry:!1})?.isFile())throw new Error(`not a file: ${o}`);const c=e.join(t,".claude","statusline.sh"),y=e.resolve(i,"kickflip","bin","statusline.mjs"),l=n.lstatSync(c,{throwIfNoEntry:!1});let k=!1,f;if(l)if(!l.isSymbolicLink())f=`refusing to replace real path ${c}; move it aside explicitly, then rerun kickflip doctor --fix`;else{let u;try{u=n.realpathSync(c)}catch(S){if(S.code!=="ENOENT")throw S}const p=e.join(t,".claude","kickflip","sites");let m=p;try{m=n.realpathSync(p)}catch{}const $=!!u&&[p,m].some(S=>u.startsWith(S+e.sep));u&&u!==n.realpathSync(o)&&!$&&(f=`refusing to replace link ${c} -> ${n.readlinkSync(c)}; remove that link explicitly, then rerun kickflip doctor --fix`),k=$||e.resolve(e.dirname(c),n.readlinkSync(c))!==y||!u}return a.push({link:c,target:y,created:!l,...k?{replaced:!0}:{},...f?{refusal:f}:{}}),a}j(J,"inspectLinks");function T({home:t=g.homedir(),skillsDir:i=e.join(t,".claude","skills"),cloneRoot:s=v(),dryRun:r=!1}={}){const a=J({home:t,skillsDir:i,cloneRoot:s}),o=a.find(c=>c.refusal);if(o)throw new Error(o.refusal);if(r)return a;n.mkdirSync(i,{recursive:!0}),n.mkdirSync(e.join(t,".claude"),{recursive:!0});for(const{link:c,target:y,created:l,replaced:k}of a)k&&n.unlinkSync(c),(l||k)&&n.symlinkSync(y,c,c===e.join(t,".claude","statusline.sh")?"file":"dir");return a}j(T,"installLinks");function K(t,i=g.homedir()){const s=e.join(i,".claude","statusline.sh"),r=[s,`"${s}"`,`'${s.replaceAll("'","'\\''")}'`,"~/.claude/statusline.sh","$HOME/.claude/statusline.sh",'"$HOME/.claude/statusline.sh"',"${HOME}/.claude/statusline.sh",'"${HOME}/.claude/statusline.sh"'];return t?.type==="command"&&r.includes(t.command)}j(K,"ownsStatusLine");async function P({port:t=Number(process.env.KICKFLIP_HUB_PORT||4500),fetch:i=globalThis.fetch}={}){try{const s=await i(`http://127.0.0.1:${t}/_hub/status.json`,{signal:AbortSignal.timeout(1500)});if(!s.ok)return null;const r=await s.json();return Array.isArray(r?.sites)?r:null}catch{return null}}j(P,"hubStatus");function W(t){const i=Object.values(t?.Web||{}).flatMap(s=>Object.entries(s.Handlers||{}));return{root:i.find(([s])=>s==="/")?.[1]?.Proxy||null,paths:[...new Set(i.map(([s])=>s))]}}j(W,"tailscaleRoot");async function Z({home:t=g.homedir(),cloneRoot:i=v(),port:s=Number(process.env.KICKFLIP_HUB_PORT||4500),uid:r=process.getuid(),env:a=process.env,run:o=_,platform:c=process.platform,wait:y=j(u=>new Promise(p=>setTimeout(p,u)),"wait"),fetch:l=globalThis.fetch,dryRun:k=!1,log:f=console.log}={}){const u=e.join(t,".claude","kickflip"),p=H({home:t,dryRun:k});for(const h of p.steps)f(h);if(p.conflicts.length)throw new Error(p.conflicts.join(`
2
- `));const m=F({platform:c,home:t,uid:r,env:a,run:o});if(k){try{for(const{link:h,target:E,created:O,replaced:N}of T({home:t,cloneRoot:i,dryRun:k}))f(`would ${O||N?"link":"check"} ${h} -> ${E}`)}catch(h){f(`would refuse: ${h.message}`)}f(`would merge statusLine and remove stale hooks in ${e.join(t,".claude","settings.json")}`);for(const h of await m.install(null,{wait:y,dryRun:k}))f(h);f(`would map tailscale / to the hub on ${s} and remove old site mounts`);return}for(const{link:h,target:E,created:O,replaced:N}of T({home:t,cloneRoot:i}))f(`${O||N?"linked":"checked"} ${h} -> ${E}`);for(const h of U({home:t}))f(h);const $=n.existsSync("/opt/homebrew/bin/node")?"/opt/homebrew/bin/node":process.execPath,S=B(e.join(i,"hub","hub.mjs"),{home:t,cloneRoot:i});n.mkdirSync(u,{recursive:!0});const w=e.join(u,"hub.log"),d=m.definition({node:$,entry:S,state:u,port:s,log:w});for(const h of await m.install(d,{wait:y}))f(h);let b=!1;for(let h=0;h<30&&!b;h++)await y(300),b=!!await P({port:s,fetch:l});if(!b)throw new Error("hub did not start; see "+w);const x=o("tailscale",["serve","--bg",String(s)],{env:a});if(x.status!==0)throw new Error(x.error||"tailscale serve failed");const M=o("tailscale",["serve","status","--json"],{env:a});let L;try{L=JSON.parse(M.stdout)}catch{return}let A={};try{A=JSON.parse(n.readFileSync(e.join(u,"manifest.json"),"utf8")).sites||{}}catch(h){if(h.code!=="ENOENT")throw h}for(const h of W(L).paths)h!=="/"&&Object.hasOwn(A,h.replace(/^\//,"").replace(/\/$/,""))&&o("tailscale",["serve","--https=443","--set-path="+h,"off"],{env:a})}j(Z,"runInstall");function U({home:t=g.homedir()}={}){const i=e.join(t,".claude","settings.json");let s={},r=384;try{s=JSON.parse(n.readFileSync(i,"utf8")),r=n.statSync(i).mode}catch(l){if(l.code!=="ENOENT")throw l}if(!s||typeof s!="object"||Array.isArray(s))throw new Error(`not a settings object: ${i}`);const a=[];let o=!1;const c=e.join(t,".claude","statusline.sh");if(Object.hasOwn(s,"statusLine"))K(s.statusLine,t)||a.push(`kept existing statusLine in ${i}; it points elsewhere`);else{const l=/^[\w/.-]+$/.test(c)?c:`'${c.replaceAll("'","'\\''")}'`;s.statusLine={type:"command",command:l},o=!0,a.push(`set statusLine in ${i} -> ${l}`)}const y=e.join(t,".claude","hooks","no-paths.py");if(!n.existsSync(y)&&s.hooks&&typeof s.hooks=="object"){const l=j(u=>u.replace(/[.*+?^${}()|[\]\\]/g,"\\$&"),"escape"),k=[y,"~/.claude/hooks/no-paths.py","$HOME/.claude/hooks/no-paths.py","${HOME}/.claude/hooks/no-paths.py"].map(l).join("|"),f=new RegExp(`^\\s*(?:exec\\s+)?(?:(?:/usr/bin/env\\s+)?(?:[^\\s]+/)?python(?:\\d+(?:\\.\\d+)*)?(?:\\s+-[^\\s]+)*\\s+)?(?:"(?:${k})"|'(?:${k})'|(?:${k}))(?=$|\\s)`);for(const[u,p]of Object.entries(s.hooks))Array.isArray(p)&&(s.hooks[u]=p.flatMap(m=>{if(!Array.isArray(m?.hooks))return[m];const $=m.hooks.filter(S=>S?.type!=="command"||typeof S.command!="string"||!f.test(S.command)?!0:(a.push(`removed stale hook ${u}: ${S.command}`),o=!0,!1));return $.length===m.hooks.length?[m]:$.length?[{...m,hooks:$}]:[]}))}if(o){n.mkdirSync(e.dirname(i),{recursive:!0});const l=`${i}.${process.pid}.${Date.now()}.tmp`;try{n.writeFileSync(l,JSON.stringify(s,null,2)+`
3
- `,{mode:r,flag:"wx"}),n.renameSync(l,i)}finally{try{n.unlinkSync(l)}catch{}}}return a}j(U,"installClaudeSettings");function D({home:t=g.homedir(),skillsDir:i=e.join(t,".claude","skills"),cloneRoot:s=v()}={}){const r=e.join(t,".claude","statusline.sh");if(!n.lstatSync(r,{throwIfNoEntry:!1})?.isSymbolicLink())return!1;let o=e.resolve(e.dirname(r),n.readlinkSync(r))===e.resolve(i,"kickflip","bin","statusline.mjs");try{o||=n.realpathSync(r)===n.realpathSync(e.join(s,"bin","statusline.mjs"))}catch{}return o?(n.unlinkSync(r),!0):!1}j(D,"uninstallStatusLine");export{P as hubStatus,J as inspectLinks,U as installClaudeSettings,T as installLinks,H as migrateHome,Y as migrateOnStart,K as ownsStatusLine,Z as runInstall,B as storedPath,W as tailscaleRoot,D as uninstallStatusLine};
1
+ var R=Object.defineProperty;var m=(e,s)=>R(e,"name",{value:s,configurable:!0});import r from"node:fs";import v from"node:os";import n from"node:path";import{SKILLS_SOURCE as L,addArgv as J,addCommand as I,missingSkills as _}from"../shared/skills.mjs";import{cloneRoot as O}from"../shared/repos.mjs";import{hubService as W,runCommand as H}from"../shared/service.mjs";const M=m(()=>r.existsSync("/opt/homebrew/bin/node")?"/opt/homebrew/bin/node":process.execPath,"nodeBin"),ot=m((e,s,t=O())=>`${M()} ${n.join(t,"bin","wait-events.mjs")} ${e} ${s}`,"watcherCommand");function D(e,{home:s=v.homedir(),skillsDir:t=n.join(s,".claude","skills"),cloneRoot:o=O()}={}){if(typeof e!="string"||!n.isAbsolute(e))return e;const i=n.relative(o,e);if(i===".."||i.startsWith(`..${n.sep}`)||n.isAbsolute(i))return e;const l=n.resolve(t,"kickflip");let c=!1;try{c=r.realpathSync(l)===r.realpathSync(o)}catch{}return c?n.join(l,i):e}m(D,"storedPath");function q({home:e=v.homedir(),skillsDir:s=n.join(e,".claude","skills"),cloneRoot:t=O()}={}){const o=r.realpathSync(t),i=[["kickflip",o],["kickflip-hooks",n.join(o,"hooks-plugin")]].map(([u,f])=>{if(!r.statSync(f,{throwIfNoEntry:!1})?.isDirectory())throw new Error(`not a directory: ${f}`);const h=u==="kickflip"?f:n.resolve(s,"kickflip","hooks-plugin"),k=n.resolve(s,u),y=r.lstatSync(k,{throwIfNoEntry:!1});let j;if(y){let b;try{b=r.realpathSync(k)}catch(g){if(g.code!=="ENOENT")throw g}y.isSymbolicLink()?b!==r.realpathSync(f)&&(j=`refusing to replace link ${k} -> ${r.readlinkSync(k)}; remove that link explicitly, then rerun kickflip doctor --fix`):(!y.isDirectory()||b!==r.realpathSync(f))&&(j=`refusing to replace real path ${k}; move it aside explicitly, then rerun kickflip doctor --fix`)}return{link:k,target:h,created:!y,...j?{refusal:j}:{}}}),l=n.join(o,"bin","statusline.mjs");if(!r.statSync(l,{throwIfNoEntry:!1})?.isFile())throw new Error(`not a file: ${l}`);const c=n.join(e,".claude","statusline.sh"),w=n.resolve(s,"kickflip","bin","statusline.mjs"),a=r.lstatSync(c,{throwIfNoEntry:!1});let $=!1,d;if(a)if(!a.isSymbolicLink())d=`refusing to replace real path ${c}; move it aside explicitly, then rerun kickflip doctor --fix`;else{let u;try{u=r.realpathSync(c)}catch(y){if(y.code!=="ENOENT")throw y}const f=n.join(e,".claude","kickflip","sites");let h=f;try{h=r.realpathSync(f)}catch{}const k=!!u&&[f,h].some(y=>u.startsWith(y+n.sep));u&&u!==r.realpathSync(l)&&!k&&(d=`refusing to replace link ${c} -> ${r.readlinkSync(c)}; remove that link explicitly, then rerun kickflip doctor --fix`),$=k||n.resolve(n.dirname(c),r.readlinkSync(c))!==w||!u}return i.push({link:c,target:w,created:!a,...$?{replaced:!0}:{},...d?{refusal:d}:{}}),i}m(q,"inspectLinks");function P({home:e=v.homedir(),skillsDir:s=n.join(e,".claude","skills"),cloneRoot:t=O(),dryRun:o=!1}={}){const i=q({home:e,skillsDir:s,cloneRoot:t}),l=i.find(c=>c.refusal);if(l)throw new Error(l.refusal);if(o)return i;r.mkdirSync(s,{recursive:!0}),r.mkdirSync(n.join(e,".claude"),{recursive:!0});for(const{link:c,target:w,created:a,replaced:$}of i)$&&r.unlinkSync(c),(a||$)&&r.symlinkSync(w,c,c===n.join(e,".claude","statusline.sh")?"file":"dir");return i}m(P,"installLinks");function z(e,s=v.homedir()){const t=n.join(s,".claude","statusline.sh"),o=[t,`"${t}"`,`'${t.replaceAll("'","'\\''")}'`,"~/.claude/statusline.sh","$HOME/.claude/statusline.sh",'"$HOME/.claude/statusline.sh"',"${HOME}/.claude/statusline.sh",'"${HOME}/.claude/statusline.sh"'];return e?.type==="command"&&o.includes(e.command)}m(z,"ownsStatusLine");const B='node "$HOME/.claude/skills/kickflip/hooks-plugin/hooks/watcher-stop.mjs"',G=m(e=>(Array.isArray(e?.hooks?.Stop)?e.hooks.Stop:[]).some(s=>Array.isArray(s?.hooks)&&s.hooks.some(t=>t?.type==="command"&&/\/hooks-plugin\/hooks\/watcher-stop\.mjs\b/.test(t.command))),"hasWatcherStop");async function Q({port:e=Number(process.env.KICKFLIP_HUB_PORT||4500),fetch:s=globalThis.fetch}={}){try{const t=await s(`http://127.0.0.1:${e}/_hub/status.json`,{signal:AbortSignal.timeout(1500)});if(!t.ok)return null;const o=await t.json();return Array.isArray(o?.sites)?o:null}catch{return null}}m(Q,"hubStatus");function U(e){const s=Object.entries(e?.Web||{}).filter(([t])=>!/:\d+$/.test(t)||t.endsWith(":443")).flatMap(([,t])=>Object.entries(t.Handlers||{}));return{root:s.find(([t])=>t==="/")?.[1]?.Proxy||null,paths:[...new Set(s.map(([t])=>t))]}}m(U,"tailscaleRoot");function rt(e,{run:s=H}={}){const t=s("tailscale",["status","--json"]);let o=null;try{o=t.status===0?JSON.parse(t.stdout):null}catch{}if(o?.BackendState!=="Running")throw new Error("Tailscale isn't running on this host; start it, or run tailscale up");const i=o.Self?.DNSName?.replace(/\.$/,"")||"";if(!i||!o.CertDomains?.includes(i))throw new Error("HTTPS isn't on for this tailnet; turn on HTTPS Certificates on the DNS page of the Tailscale admin console");return`https://${i}/${e?e+"/":""}`}m(rt,"tailnetUrl");const F=m((e=process.platform)=>e==="darwin"?"brew install --cask tailscale":"curl -fsSL https://tailscale.com/install.sh | sh","tailscaleInstallHint");async function it({run:e=H,platform:s=process.platform,uid:t=process.getuid(),username:o=v.userInfo().username,port:i=Number(process.env.KICKFLIP_HUB_PORT||4500)}={}){const l=m(async S=>{try{return await e("tailscale",S)}catch(E){return{status:1,error:E.message}}},"execute"),c=m(S=>{try{return S.status===0?JSON.parse(S.stdout):null}catch{return null}},"json"),w=await l(["version"]),a=w.status===0,$=a?c(await l(["status","--json"])):null,d=$?.BackendState==="Running",u=$?.Self?.DNSName?.replace(/\.$/,"")||"",f=a&&s==="linux"&&t!==0,h=f?c(await l(["debug","prefs"]))?.OperatorUser:null,k=a?c(await l(["serve","status","--json"])):null,y=U(k);let j=!1;try{const S=new URL(y.root);j=S.protocol==="http:"&&["127.0.0.1","localhost","[::1]"].includes(S.hostname)&&Number(S.port)===i&&S.pathname==="/"&&!S.search&&!S.hash}catch{}const b=m((S,E,p,x,A="yours")=>({item:S,state:E?"ok":A,detail:p,fix:E?null:x}),"entry"),g=[b("Tailscale",a,a?(w.stdout||"installed").trim().split(`
2
+ `)[0]:"not on this session\u2019s PATH",F(s)),b("Tailscale signed in",d,d?`signed in${u?" as "+u:""}`:a?"not connected":"needs Tailscale","tailscale up")];f&&g.push(b("Tailscale operator",h===o,h===o?o:`${h||"none"} (expected ${o})`,"sudo tailscale set --operator=$USER"));const N=y.paths.filter(S=>S!=="/"),T=b("tailscale /",j,`${y.root||"nothing"}${j?"":` (expected the hub on ${i})`}${N.length?"; other mounts: "+N.join(", "):""}`,d?"kickflip doctor --fix":a?"tailscale up":F(s),d?"fixable":"yours");return{installed:a,signedIn:d,dns:u,operatorChecked:f,operator:h,username:o,pointsAtHub:j,prereqs:g,root:T,serveConfig:k}}m(it,"tailscaleState");async function at({home:e=v.homedir(),cloneRoot:s=O(),port:t=Number(process.env.KICKFLIP_HUB_PORT||4500),uid:o=process.getuid(),env:i=process.env,run:l=H,platform:c=process.platform,wait:w=m(u=>new Promise(f=>setTimeout(f,u)),"wait"),fetch:a=globalThis.fetch,dryRun:$=!1,log:d=console.log}={}){const u=n.join(e,".claude","kickflip"),f=W({platform:c,home:e,uid:o,env:i,run:l});if($){try{for(const{link:x,target:A,created:C,replaced:K}of P({home:e,cloneRoot:s,dryRun:$}))d(`would ${C||K?"link":"check"} ${x} -> ${A}`)}catch(x){d(`would refuse: ${x.message}`)}d(`would merge statusLine and the watcher Stop hook, turn on autoCompactEnabled and remove stale hooks in ${n.join(e,".claude","settings.json")}`);const p=_(e);p.length&&d(`would add ${p.join(", ")} from ${L} with ${I(p)}`);for(const x of await f.install(null,{wait:w,dryRun:$}))d(x);d(`would map tailscale / to the hub on ${t} and remove old site mounts`);return}for(const{link:p,target:x,created:A,replaced:C}of P({home:e,cloneRoot:s}))d(`${A||C?"linked":"checked"} ${p} -> ${x}`);for(const p of V({home:e}))d(p);const h=_(e);if(h.length){const p=l("npx",J(h),{env:i,timeout:3e5});d(p.status===0?`added ${h.join(", ")} from ${L}`:`could not add ${h.join(", ")} from ${L}: ${p.error}; run ${I(h)}`)}const k=M(),y=D(n.join(s,"hub","hub.mjs"),{home:e,cloneRoot:s});r.mkdirSync(u,{recursive:!0});const j=n.join(u,"hub.log"),b=f.definition({node:k,entry:y,state:u,port:t,log:j});for(const p of await f.install(b,{wait:w}))d(p);let g=!1;for(let p=0;p<30&&!g;p++)await w(300),g=!!await Q({port:t,fetch:a});if(!g)throw new Error("hub did not start; see "+j);const N=l("tailscale",["serve","--bg",String(t)],{env:i});if(N.status!==0)throw new Error(N.error||"tailscale serve failed");const T=l("tailscale",["serve","status","--json"],{env:i});let S;try{S=JSON.parse(T.stdout)}catch{return}let E={};try{E=JSON.parse(r.readFileSync(n.join(u,"manifest.json"),"utf8")).sites||{}}catch(p){if(p.code!=="ENOENT")throw p}for(const p of U(S).paths)p!=="/"&&Object.hasOwn(E,p.replace(/^\//,"").replace(/\/$/,""))&&l("tailscale",["serve","--https=443","--set-path="+p,"off"],{env:i})}m(at,"runInstall");function V({home:e=v.homedir()}={}){const s=n.join(e,".claude","settings.json");let t={},o=384;try{t=JSON.parse(r.readFileSync(s,"utf8")),o=r.statSync(s).mode}catch(a){if(a.code!=="ENOENT")throw a}if(!t||typeof t!="object"||Array.isArray(t))throw new Error(`not a settings object: ${s}`);const i=[];let l=!1;const c=n.join(e,".claude","statusline.sh");if(Object.hasOwn(t,"statusLine"))z(t.statusLine,e)||i.push(`kept existing statusLine in ${s}; it points elsewhere`);else{const a=/^[\w/.-]+$/.test(c)?c:`'${c.replaceAll("'","'\\''")}'`;t.statusLine={type:"command",command:a},l=!0,i.push(`set statusLine in ${s} -> ${a}`)}if(!G(t)){const a=t.hooks&&typeof t.hooks=="object"?t.hooks:{};t.hooks={...a,Stop:[...Array.isArray(a.Stop)?a.Stop:[],{hooks:[{type:"command",command:B}]}]},l=!0,i.push(`added the watcher Stop hook to ${s} -> ${B}`)}t.autoCompactEnabled!==!0&&(t.autoCompactEnabled=!0,l=!0,i.push(`turned on autoCompactEnabled in ${s}`));const w=n.join(e,".claude","hooks","no-paths.py");if(!r.existsSync(w)&&t.hooks&&typeof t.hooks=="object"){const a=m(u=>u.replace(/[.*+?^${}()|[\]\\]/g,"\\$&"),"escape"),$=[w,"~/.claude/hooks/no-paths.py","$HOME/.claude/hooks/no-paths.py","${HOME}/.claude/hooks/no-paths.py"].map(a).join("|"),d=new RegExp(`^\\s*(?:exec\\s+)?(?:(?:/usr/bin/env\\s+)?(?:[^\\s]+/)?python(?:\\d+(?:\\.\\d+)*)?(?:\\s+-[^\\s]+)*\\s+)?(?:"(?:${$})"|'(?:${$})'|(?:${$}))(?=$|\\s)`);for(const[u,f]of Object.entries(t.hooks))Array.isArray(f)&&(t.hooks[u]=f.flatMap(h=>{if(!Array.isArray(h?.hooks))return[h];const k=h.hooks.filter(y=>y?.type!=="command"||typeof y.command!="string"||!d.test(y.command)?!0:(i.push(`removed stale hook ${u}: ${y.command}`),l=!0,!1));return k.length===h.hooks.length?[h]:k.length?[{...h,hooks:k}]:[]}))}if(l){r.mkdirSync(n.dirname(s),{recursive:!0});const a=`${s}.${process.pid}.${Date.now()}.tmp`;try{r.writeFileSync(a,JSON.stringify(t,null,2)+`
3
+ `,{mode:o,flag:"wx"}),r.renameSync(a,s)}finally{try{r.unlinkSync(a)}catch{}}}return i}m(V,"installClaudeSettings");function lt({home:e=v.homedir(),skillsDir:s=n.join(e,".claude","skills"),cloneRoot:t=O()}={}){const o=n.join(e,".claude","statusline.sh");if(!r.lstatSync(o,{throwIfNoEntry:!1})?.isSymbolicLink())return!1;let l=n.resolve(n.dirname(o),r.readlinkSync(o))===n.resolve(s,"kickflip","bin","statusline.mjs");try{l||=r.realpathSync(o)===r.realpathSync(n.join(t,"bin","statusline.mjs"))}catch{}return l?(r.unlinkSync(o),!0):!1}m(lt,"uninstallStatusLine");export{B as WATCHER_STOP,G as hasWatcherStop,Q as hubStatus,q as inspectLinks,V as installClaudeSettings,P as installLinks,M as nodeBin,z as ownsStatusLine,at as runInstall,D as storedPath,rt as tailnetUrl,F as tailscaleInstallHint,U as tailscaleRoot,it as tailscaleState,lt as uninstallStatusLine,ot as watcherCommand};
package/bin/kickflip.mjs CHANGED
@@ -3,25 +3,40 @@
3
3
  // kickflip doctor [--fix [--dry-run]] [--json]
4
4
  // one line per prereq, installed piece, the hub, Tailscale / and each site (./doctor.mjs): ok,
5
5
  // fixable (by --fix), yours (with the command to run), optional or note. Changes nothing.
6
- // --fix runs install's steps, then reports again. --json prints {items:[{group,item,state,detail,fix}]}.
6
+ // --fix runs init's steps, asks in a terminal whether to remove each board clone left in an old place
7
+ // (outside ~/.claude/kickflip/repos), then reports again. --json prints {items:[{group,item,state,detail,fix}]}
8
+ // and asks nothing.
7
9
  // Exit 1 when anything is fixable or yours
8
- // kickflip join <route> (--port <n> | --dir <folder> [--cmd '<json array>' [--port <n>]])
9
- // [--title <t>] [--description <d>] [--icon <key>] [--color <css hex>]
10
- // a plain page: no session, owner, watcher or needs (docs/adr/0087-a-page-joins-without-a-session.md).
10
+ // kickflip join [--name <board name>] [--hub]
11
+ // run in a git repo's folder: adds it as a board (docs/adr/2026-11-03-0248-kickflip-join-adds-the-current-repository-as-a-board.md).
12
+ // Asks for the board name, default the folder's name; with no terminal to ask in, --name is needed. The name
13
+ // takes letters, digits and spaces, and gives the route, lowercased with dashes for spaces. Records the
14
+ // folder's origin as `github` and its default branch (origin's, else the checked-out one); the board's
15
+ // builders branch from the folder's local copy of that branch and land there (`local: true`). Marks the folder trusted for Claude Code, then starts the board's session in
16
+ // the background as `start` does, or with --hub flags the board as the hub board, starts its session in
17
+ // the foreground in tmux and attaches this terminal to it, as `hub` does. Refuses a folder outside git (exit 2), and exits 1 while the hub isn't
18
+ // running. A folder that already has a board names it, records its default branch again when that
19
+ // changed, saying so, and offers to restart its session
20
+ // Every other join names its site with --name <name>, which gives its title and its route as above (any characters,
21
+ // other than letters and digits, become dashes). A name that is the route itself keeps the site's title.
22
+ // kickflip join --name <name> (--port <n> | --dir <folder> [--cmd '<json array>' [--port <n>]])
23
+ // [--description <d>] [--icon <key>] [--color <css hex>]
24
+ // a plain page: no session, owner, watcher or needs (docs/adr/2026-11-01-1446-a-page-joins-without-a-session.md).
11
25
  // --port forwards /<route>/ to a server the session already runs, which reads Down while it doesn't
12
26
  // answer; --dir alone has the hub serve the folder's files; --cmd has the hub run that command in --dir
13
27
  // (default the cwd) on --port or a free one. Prints {route, url, serves, port|dir, answering?, hub}.
14
- // A rejoin changes how the page is served only when one of those flags is given. Exit 3 when a
15
- // session owns the route
16
- // kickflip join <route> --session <id> [--watch [<file,file>]] [--label <name>] [--title <t>] [--description <d>]
28
+ // A rejoin passes --port, --dir or --cmd again. Exit 3 when a session owns the route
29
+ // kickflip join --name <name> --session <id> [--watch [<file,file>]] [--label <name>] [--description <d>]
17
30
  // [--dir <path>] [--cmd '<json array>'] [--port <n>]
18
31
  // [--filter <regex>] [--template board [--items <waves|file>]] [--force]
19
32
  // [--icon <key>] [--color <css hex>] [--repo <dir>] [--model <m>] [--hub | --no-hub]
20
33
  // a site with an owning session: events, the watcher, Needs you. --watch is optional with --session;
21
34
  // with files it watches those (relative to the dir) in place of data/events.jsonl
22
- // --hub makes this board the hub board, as `set --hub` does; a join without either keeps the flag
35
+ // --hub makes this board the hub board, as `set --hub` does; a join without either keeps the flag.
36
+ // The hub board's session runs in the foreground in tmux (`hub`); every other board's in the background
23
37
  // --template board runs the shared board (../board/server.mjs) from the site dir,
24
- // writing its board.json; --items is "waves" (default) or a wave.json-format file.
38
+ // writing its board.json; --items is "waves" (default) or a wave.json-format file, made with no
39
+ // items when missing.
25
40
  // It then runs handshake, unless this session already answered the board's hello, and
26
41
  // asks the hub for one poll (as poll-now --keep-queue), so the board's GitHub link passes
27
42
  // without waiting for the next slot. Prints poll: "reached this board", or why not
@@ -37,7 +52,7 @@
37
52
  // kickflip start <route> [--repo <dir>] [--model <m>] [--prompt <text>]
38
53
  // starts a background session (`claude --bg`, auto mode, worktree isolation off) in --repo, else
39
54
  // the route's recorded folder, else the cwd, named after the board, whose first prompt is
40
- // `/kickflip <route>` plus --prompt. Records it as the route's owner, or in `starting` until a
55
+ // `/kickflip --name '<board name>'` plus --prompt. Records it as the route's owner, or in `starting` until a
41
56
  // join takes it up when the route has no site yet. Prints {route, session, confirmed, repo, argv, output}
42
57
  // kickflip resume <route>
43
58
  // `claude --resume <id> --bg` for the route's recorded session in its folder, prompting it to rejoin
@@ -45,59 +60,80 @@
45
60
  // {route, session, previous, confirmed, repo, argv, output}. Exit 1 when no session or folder is recorded
46
61
  // kickflip close <route> --name <board title>
47
62
  // stops its session, closes builder records and removes the site; keeps its folder and open PRs.
48
- // --name must match the board title exactly. The board flagged --hub cannot be closed.
63
+ // --name must match the board title exactly.
49
64
  // kickflip reopen <route>
50
65
  // restores the closed board's manifest entry and resumes its recorded session
66
+ // kickflip export <route> [--out <file>]
67
+ // writes one .tgz (default ./kickflip-<route>.tgz, never over an existing file) holding the board's
68
+ // folder, without its watcher cursors and watcher record; its manifest entry with only EXPORT_KEYS below,
69
+ // dropping its folder, port, command, owner session and restart times; the owner's label and model;
70
+ // settings.json's EXPORT_SETTINGS; and a README.md of the import steps. No other hub file and no key
71
+ // goes in. Refuses a site that isn't a board, a board whose cards live outside its folder, and a board
72
+ // with an open builder record. Prints {route, file, entry, dropped, settings}
73
+ // kickflip import <file>
74
+ // unpacks an export's board into ~/.claude/kickflip/sites/<route>/, adds its manifest entry with a free
75
+ // port and this host's board server, and writes its settings over this host's for those keys. A hub
76
+ // flag is kept only when no other site has it. Refuses a route this host has open, closed or starting,
77
+ // or whose folder exists. Prints {route, dir, port, settings, hub, join}: `join` is the line a fresh
78
+ // session runs to take the board over
51
79
  // kickflip hub [--repo <dir>]
52
- // opens the hub board's recorded session in this terminal, in its recorded folder and model, or in
53
- // --repo when no folder is recorded; with no owner, starts a fresh session in --repo (default cwd)
54
- // that joins the board with --hub. Points it to docs/hub-session.md.
55
- // Exit 2 when no board has the hub flag (`set --hub`); otherwise keeps claude's exit code
80
+ // attaches this terminal to the hub board's tmux session (tmux attach; switch-client inside tmux).
81
+ // With none running, the hub board's session starts in it first, in the foreground: its recorded
82
+ // session resumed in its recorded folder and model, or in --repo when no folder is recorded; with no
83
+ // owner, a fresh session in --repo (default cwd) that joins the board with --hub. Points it to
84
+ // docs/hub-session.md. While the tmux session runs, nothing else starts
85
+ // (docs/adr/2026-11-03-0313-boards-run-in-the-background-and-the-hub-board-in-tmux.md).
86
+ // Exit 2 when no board has the hub flag (`set --hub`), 3 while its session is live outside tmux;
87
+ // otherwise keeps tmux's exit code
56
88
  // kickflip sessions [--json]
57
89
  // one line per board: route, session id, live (background or terminal) or not, folder
58
90
  // kickflip handshake <route>
59
91
  // tests a board's hub links (../hub/boards.mjs handshake): appends a `hello` event, which the
60
- // owning session answers with `board.mjs hello-ack <eventId>`. The board shows a setup checklist
61
- // until the session answered and the hub's GitHub check reached it. Prints {route, hello}
92
+ // owning session's watcher answers when it prints it. The board shows a setup checklist
93
+ // until the watcher answered and the hub's GitHub check reached it. Prints {route, hello}
62
94
  // kickflip set <route> [--icon <key>] [--color <hex>] [--hub | --no-hub]
63
95
  // changes an existing site's icon or color without joining; '' clears one. --hub makes the board the
64
- // hub board, whose session `hub` opens (`hub: true` in its entry), clearing the flag from any other
96
+ // hub board, whose session runs in tmux and `hub` attaches to (`hub: true` in its entry), clearing the flag from any other
65
97
  // site; --no-hub clears it. Exit 2 for --hub on a site that isn't a board
66
98
  // kickflip leave <route> [--session <id>] [--force]
67
99
  // removes the entry and keeps its files; --session (or --force) only for a route a session owns
100
+ // kickflip url [route]
101
+ // prints the hub's HTTPS address on the tailnet, the one to open on the phone, or with a route that
102
+ // site's (./install.mjs tailnetUrl). Exit 1 when Tailscale isn't running or HTTPS isn't on for the tailnet,
103
+ // 2 for a route with no site
68
104
  // kickflip restart <route>
69
105
  // kickflip logs <route> [--lines <n>]
70
- // kickflip install [--dry-run] | uninstall
71
- // install is the first-run name for doctor --fix. It first moves a host set up under the web-ts names to
72
- // the kickflip ones (./install.mjs migrateHome), then links the running package or clone, hooks plugin
106
+ // kickflip init [--dry-run] | uninstall
107
+ // init is the first-run name for doctor --fix. It links the running package or clone, hooks plugin
73
108
  // and status line, configures Claude settings, starts the hub's service in place of the running one (../shared/service.mjs:
74
- // a launchd login item on macOS, including one under the old label; a systemd user unit on Linux),
109
+ // a launchd login item on macOS; a systemd user unit on Linux),
75
110
  // and maps Tailscale / to the hub.
76
111
  // Conflicting skill links and real paths are refused. A dangling status-line link can be replaced.
77
- // --dry-run prints what install would move, link and start, and changes nothing.
112
+ // --dry-run prints what init would move, link and start, and changes nothing.
78
113
  // uninstall stops the hub and removes its service, kickflip's status-line link and Tailscale /;
79
114
  // Claude settings and skill links are kept
80
115
  // kickflip update
81
116
  // installs @lovek4d/kickflip@latest globally with npm, then restarts the hub.
82
117
  // A clone refuses (exit 2); update it with git pull
83
- // kickflip push --kind action|prompt|fault --title <t> [--body <b>] [--url </route/path>] [--tag <tag>]
118
+ // kickflip push --kind action|prompt|fault|reminder --title <t> [--body <b>] [--url </route/path>] [--tag <tag>]
84
119
  // [--now | --urgent]
85
120
  // notify every device that turned on notifications on the Hub page; a tap opens --url.
86
121
  // --kind says what waits on the owner (../hub/push.mjs PUSH_KINDS): action, a card's Action needed
87
122
  // appearing or changing; prompt, a background session's prompt; fault, something only the owner
88
- // can fix. A push without one is refused (exit 2): news stays on the board.
123
+ // can fix; reminder, a push the owner asked for in words. A push without one is refused (exit 2):
124
+ // news stays on the board.
89
125
  // The push queues, replacing a queued one with the same
90
- // --tag, and goes out after the hub's next GitHub check on the polling interval
91
- // (/_hub/settings/polling) or when a monitor tick ends (board.mjs checked), each
92
- // queued push as its own notification. --now sends at once unless pushes are held
93
- // (overnight, snoozed or the weekend), and queues if no device takes it; it is for questions,
126
+ // --tag, and goes out after the hub's next GitHub check, every minute while the owner is here
127
+ // and every 5 while away, or when a GitHub tick ends (board.mjs checked); a board's
128
+ // several queued pushes go as one summary. --now sends at once unless pushes are held
129
+ // (quiet hours, snoozed or the weekend), and queues if no device takes it; it is for questions,
94
130
  // steps only the owner can run and prototypes. --urgent sends at once through
95
131
  // those holds, for a production fault. While notifications are off every push is dropped, urgent ones too.
96
132
  // Prints "sent", "queued" with the next send time, or "dropped". Exit 3 when no device has turned them on.
97
- // kickflip polling
98
- // prints {every, until, next, held, held_until, held_why, notifications}: the current polling
99
- // window's minutes, when it ends, the next check, whether pushes are held now, until when
100
- // ("never" while off) and why (off, snoozed, weekend or overnight), and the notifications settings
133
+ // kickflip quiet
134
+ // prints {quiet, held, held_until, held_why, notifications}: quiet hours {start, end, hold}, whether
135
+ // pushes are held now, until when ("never" while off, "forever" while snoozed until ended) and why
136
+ // (off, snoozed, weekend or overnight), and the notifications settings
101
137
  // kickflip notifications [on | off | snooze <length> | unsnooze | weekends on|off]
102
138
  // sets the Hub page's Notifications controls and prints them; off drops the queued pushes.
103
139
  // A snooze length is a hub/schedule.mjs snoozeEnd choice. Without a change it only prints them
@@ -107,7 +143,7 @@
107
143
  // builders take over until it resets.
108
144
  // always (Always on): the same past the pause line; while Codex is out of usage, that work pauses until a
109
145
  // usage-reset check succeeds.
110
- // off: no Codex. While Codex isn't installed or logged in, the setting reads off and a change is refused.
146
+ // off: Claude builds and reviews. While Codex isn't installed or logged in, the setting reads off and a change is refused.
111
147
  // kickflip usage-gate [pause-at <50..100> | keep-going | stop]
112
148
  // prints {pause_at, state, window, used, resets_at, codex: {past, window, used, resets_at}}; a read exits 0.
113
149
  // pause-at saves the line in steps of 5 (default 90, 100 Off); keep-going lifts the current Claude pause
@@ -117,9 +153,36 @@
117
153
  // checked.json, then the queue unless --keep-queue) and prints its result
118
154
  // Exit codes: 0 ok, 1 error, 2 bad usage, 3 route owned by another session (join/leave), or its recorded session is
119
155
  // live (start/resume/hub). hub passes through claude's exit code.
120
- var ie=Object.defineProperty;var h=(e,n)=>ie(e,"name",{value:n,configurable:!0});import g from"node:fs";import f from"node:path";import K from"node:os";import z from"node:net";import{execFileSync as M,spawnSync as re}from"node:child_process";import{fileURLToPath as ae}from"node:url";import{ICON_KEYS as U}from"../hub/icons.mjs";import{liveSessions as W}from"../hub/live.mjs";import{isBoardSite as G,writeManifest as O}from"../hub/boards.mjs";import{routeRecord as p,validRoute as Y}from"../hub/routes.mjs";import{migrateOnStart as ce,uninstallStatusLine as de,storedPath as ue}from"./install.mjs";import{hubService as Q}from"../shared/service.mjs";import{runDoctor as le}from"./doctor.mjs";import{cloneRoot as fe}from"../shared/repos.mjs";const b=fe(),J=f.join(K.homedir(),".claude","kickflip"),he=f.join(J,"manifest.json"),V=f.join(J,"logs"),_=Number(process.env.KICKFLIP_HUB_PORT||4500),E=4600,pe=/^#([0-9a-f]{3}|[0-9a-f]{6})$/i,[L,...m]=process.argv.slice(2);["install","doctor"].includes(L)||ce();const t=Object.assign(Object.create(null),{_:[]});for(let e=0;e<m.length;e++){const n=m[e];if(n.startsWith("--")){const s=n.slice(2);s==="fix"||s==="force"||s==="json"||s==="urgent"||s==="now"||s==="keep-queue"||s==="hub"||s==="no-hub"||s==="dry-run"||s==="watch"&&(e+1>=m.length||m[e+1].startsWith("--"))?t[s]=!0:t[s]=m[++e]}else t._.push(n)}const l=h(e=>console.log(typeof e=="string"?e:JSON.stringify(e,null,2)),"out");class N extends Error{static{h(this,"Exit")}constructor(n,s){super(n),this.code=s}}const i=h((e,n=1)=>{throw new N(e,n)},"fail");function B(){t.icon&&!U.includes(t.icon)&&i(`--icon takes one of: ${U.join(", ")}`,2),t.color&&!pe.test(t.color)&&i("--color takes a CSS hex color, such as #3b82f6",2)}h(B,"checkLook");function C(e){for(const n of["icon","color"])t[n]===""?delete e[n]:t[n]!==void 0&&(e[n]=t[n])}h(C,"applyLook");const X=h(()=>t.hub&&t["no-hub"]&&i("pass --hub or --no-hub, not both",2),"checkHub"),Z=h(e=>i(`/${e} is not a board; only a board can be the hub board`,2),"notBoard");function ee(e,n,s){if(t["no-hub"]&&delete s.hub,!!t.hub){G(s)||Z(n);for(const[o,u]of Object.entries(e.sites))o!==n&&u?.hub&&delete u.hub;s.hub=!0}}h(ee,"applyHub");const D=h(()=>g.existsSync("/opt/homebrew/bin/node")?"/opt/homebrew/bin/node":process.execPath,"nodeBin");function y(){try{const e=JSON.parse(g.readFileSync(he,"utf8"));return e.sites||={},e}catch(e){if(e.code==="ENOENT")return{version:1,sites:{}};i("manifest unreadable: "+e.message)}}h(y,"readManifest");const me=h(e=>{try{return JSON.parse(g.readFileSync(e,"utf8"))}catch{return null}},"readJSON"),I=h(()=>import(f.join(b,"hub","sessions.mjs")),"sessionsModule");async function P(e){const{SessionError:n}=await I();try{return await e()}catch(s){throw s instanceof n&&i(s.message,s.reason==="live"?3:s.reason==="hub"?2:1),s}}h(P,"sessionCall");async function te(e){const{handshake:n}=await import(f.join(b,"hub","boards.mjs"));try{return await n(e,y())}catch(s){return i(s.message,2)}}h(te,"runHandshake");async function we(e){for(let n=0;n<2;n++){let s;try{s=await fetch(`http://127.0.0.1:${_}/_hub/poll`,{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({flush:!1}),signal:AbortSignal.timeout(5*6e4)})}catch(u){return`not run (${u.message})`}if(!s.ok)return`not run (the hub answered ${s.status})`;const o=await s.json();if(o.checked?.includes(e))return"reached this board";if(o.failed?.length)return`missed this board: ${o.failed[0]}`}return"missed this board"}h(we,"pollFor");async function $(e){const{withManifestLock:n}=await import(f.join(b,"hub","boards.mjs"));try{return await n(e)}catch(s){throw s.code==="ELOCKED"&&i(s.message),s}}h($,"withLock");function se(e){return new Promise(n=>{const s=z.connect({port:e,host:"127.0.0.1"}),o=h(u=>{s.destroy(),n(u)},"done");s.setTimeout(500,()=>o(!1)),s.on("connect",()=>o(!0)),s.on("error",()=>o(!1))})}h(se,"connects");function ge(e){return new Promise(n=>{const s=z.createServer();s.once("error",()=>n(!1)),s.listen(e,"0.0.0.0",()=>s.close(()=>n(!0)))})}h(ge,"bindable");async function ne(e){const n=new Set([...Object.values(e.sites),...Object.values(e.closed||{}).map(s=>s.site)].map(s=>s?.port));for(let s=E;s<E+400;s++)if(!n.has(s)&&!await se(s)&&await ge(s))return s;i("no free port found from "+E)}h(ne,"freePort");function be(){try{return(JSON.parse(M("tailscale",["status","--json"],{encoding:"utf8",stdio:["ignore","pipe","ignore"]})).Self?.DNSName||"").replace(/\.$/,"")}catch{return""}}h(be,"tailnetHost");const oe=h(e=>{const n=be();return n?`https://${n}/${e}/`:`http://127.0.0.1:${_}/${e}/`},"siteUrl");async function R(){try{return await(await fetch(`http://127.0.0.1:${_}/_hub/status.json`,{signal:AbortSignal.timeout(1500)})).json()}catch{return null}}h(R,"hubStatus");const ye=h(e=>{try{return g.statSync(e).isDirectory()}catch{return!1}},"isFolder");async function Se(e){B();const n=t.port===void 0?void 0:Number(t.port);n!==void 0&&!(Number.isInteger(n)&&n>0&&n<65536)&&i("--port takes a port number",2),n===_&&i(`--port ${n} is the hub's own port`,2);let s;if(t.cmd!==void 0){try{s=JSON.parse(t.cmd)}catch{s=null}(!Array.isArray(s)||!s.length||!s.every(r=>typeof r=="string"))&&i(`--cmd takes a JSON array of strings, such as '["/abs/node","/abs/server.mjs"]'`,2)}const o=t.dir===void 0?void 0:f.resolve(t.dir);o!==void 0&&!s&&n===void 0&&!ye(o)&&i(`--dir ${o} is not a folder`,2);const u=await $(async()=>{const r=y();p(r.closed,e)&&i("That board is closed; use kickflip reopen "+e,2),p(r.starting,e)&&i(`/${e} is a board that is starting; pick another route`,2);const a=p(r.sites,e);if(a?.owner?.session)throw l({error:"owned",route:e,owner:a.owner,hint:"a session owns this route: pick another route, or that session leaves it first"}),new N("",3);!a&&n===void 0&&o===void 0&&!s&&i("join needs --port <n> (a server this session runs) or --dir <folder> (files the hub serves), or --session <id> for a watched site",2);const S=new Date().toISOString(),w=a?{...a}:{created:S};if(delete w.watch,delete w.filter,n!==void 0||o!==void 0||s){const x=s?{cmd:s,dir:o||a?.dir||process.cwd(),port:n??(a?.cmd?a.port:void 0)??await ne(r)}:n!==void 0?{port:n,...o?{dir:o}:{}}:{dir:o};for(const k of["cmd","dir","port","cwd","env"])delete w[k];Object.assign(w,x)}return t.title&&(w.title=t.title),t.description&&(w.description=t.description),C(w),w.title||=e,w.updated=S,r.sites={...r.sites,[e]:w},await O(r),w}),c=u.cmd?"cmd":u.port?"port":"files",d=await R();l({route:e,url:oe(e),serves:c,...u.port?{port:u.port}:{},...u.dir?{dir:u.dir}:{},...c==="port"?{answering:await se(u.port)}:{},hub:d?"running":"NOT running: run `kickflip doctor --fix`"})}h(Se,"joinPage");const A={async doctor(){t["dry-run"]&&!t.fix&&i("--dry-run needs --fix",2);const{exitCode:e}=await le({fix:!!t.fix,dryRun:!!t["dry-run"],json:!!t.json});process.exitCode=e},async join(){const e=t._[0];if(Y(e)||i("join needs a route: lowercase letters, digits and dashes",2),!t.session){const c=["watch","template","filter","label","model","repo","hub","force"].find(d=>t[d]!==void 0);return c&&i(`--${c} needs --session <id> (this session's id)`,2),Se(e)}t.template&&t.template!=="board"&&i('--template takes "board"',2),B(),X();const n=await $(async()=>{const c=y();p(c.closed,e)&&i("That board is closed; use kickflip reopen "+e,2);const d=new Date().toISOString(),r=p(c.sites,e);if(r?.owner?.session&&r.owner.session!==t.session&&!t.force)throw l({error:"owned",route:e,owner:r.owner,hint:"ask the user, then rerun with --force to take it over"}),new N("",3);t.hub&&t.template!=="board"&&!G({dir:"",cmd:t.cmd?JSON.parse(t.cmd):r?.cmd})&&Z(e);const a=r?{...r}:{};let S=!1;if(r||(a.dir=f.resolve(t.dir||f.join(J,"sites",e)),(!g.existsSync(a.dir)||g.readdirSync(a.dir).length===0)&&!t.cmd&&!t.template&&(g.cpSync(f.join(b,"starter"),a.dir,{recursive:!0}),S=!0),a.port=t.port?Number(t.port):await ne(c),a.cmd=t.cmd?JSON.parse(t.cmd):[D(),f.join(a.dir,"server.mjs")],a.watch=["data/events.jsonl"],a.created=d),t.dir&&r&&(a.dir=f.resolve(t.dir)),t.port&&r&&(a.port=Number(t.port)),t.cmd&&r&&(a.cmd=JSON.parse(t.cmd)),t.template==="board"){g.mkdirSync(f.join(a.dir,"data"),{recursive:!0});const j=f.join(a.dir,"board.json"),v=g.existsSync(j)?JSON.parse(g.readFileSync(j,"utf8")):{};v.title=t.title||v.title||e,v.items=ue(t.items||v.items||"waves");const F=p(c.starting,e);!r&&F?.tickets&&(!F.session||F.session===t.session)&&(v.tickets=F.tickets),g.writeFileSync(j,JSON.stringify(v,null,2)+`
121
- `),a.cmd=[D(),f.join(b,"board","server.mjs"),a.dir],S=!r}t.title&&(a.title=t.title),t.description&&(a.description=t.description),typeof t.watch=="string"&&(a.watch=t.watch.split(",").map(j=>j.trim()).filter(Boolean)),t.filter!==void 0&&(a.filter=t.filter||void 0),C(a),ee(c,e,a),a.title||=e;const w=r?.owner&&r.owner.session!==t.session?r.owner:null,x=p(c.starting,e)?.session===t.session?p(c.starting,e):null,k=p(c.starting,e);!r&&k?.github&&(!k.session||k.session===t.session)&&(a.github=k.github,a.branch=k.branch);const H=r?.owner?.session===t.session?r.owner:null,T=H||x||w;a.owner={...H||{session:t.session,since:d},label:t.label||T?.label||"",repo:t.repo?f.resolve(t.repo):a.github&&T?.repo||W().get(t.session)?.cwd||T?.repo||process.cwd()};const q=t.model||T?.model;return q&&(a.owner.model=q),p(c.starting,e)&&(delete c.starting[e],Object.keys(c.starting).length||delete c.starting),a.updated=d,c.sites={...c.sites,[e]:a},await O(c),{route:e,created:S,previousOwner:w,site:a}}),s=await R();let o,u;if(t.template==="board"){const c=me(f.join(n.site.dir,"data","handshake.json"));o=c?.ack&&c.hello?.session===t.session?"answered before":await te(e),u=s?await we(e):"not run (the hub is not running)"}l({route:e,url:oe(e),dir:n.site.dir,port:n.site.port,owner:n.site.owner,createdFromStarter:n.created,previousOwner:n.previousOwner,log:f.join(V,e+".log"),watcher:`${D()} ${f.join(b,"bin","wait-events.mjs")} ${e} ${t.session}`,hub:s?"running":"NOT running: run `kickflip doctor --fix`",...o?{handshake:o}:{},...u?{poll:u}:{}})},async handshake(){const e=t._[0];e||i("handshake needs a route",2),l(await te(e))},async leave(){const e=t._[0];e||i("leave needs a route",2),await $(async()=>{const n=y(),s=p(n.sites,e);if(s||i("no site at /"+e),s.owner?.session&&!t.session&&!t.force&&i("a session owns /"+e+"; leave needs --session <id>, or --force",2),s.owner?.session&&s.owner.session!==t.session&&!t.force)throw l({error:"owned",route:e,owner:s.owner}),new N("",3);delete n.sites[e],await O(n),l({left:e,filesKept:s.dir})})},async start(){const e=t._[0];Y(e)||i("start needs a route: lowercase letters, digits and dashes",2);const{startSession:n,withSessionLock:s}=await I();await s(e,async()=>{const o=await P(()=>n(e,{manifest:y(),repo:t.repo,model:t.model,prompt:t.prompt}));await $(async()=>{const u=y(),c={session:o.session,label:o.name,since:new Date().toISOString(),repo:o.repo,...o.model?{model:o.model}:{}};p(u.sites,e)?(u.sites[e].owner=c,u.sites[e].updated=c.since):u.starting={...u.starting,[e]:{...p(u.starting,e),...c}},await O(u)}),l({route:o.route,session:o.session,confirmed:o.confirmed,repo:o.repo,argv:o.argv,output:o.output})})},async resume(){const e=t._[0];e||i("resume needs a route",2);const{resumeSession:n}=await I();l(await P(()=>n(e,{manifest:y()})))},async close(){const{closeBoard:e}=await import("../hub/close-board.mjs");try{l(await e(t._[0],t.name))}catch(n){i(n.message,n.status?2:1)}},async reopen(){const{reopenBoard:e}=await import("../hub/close-board.mjs");try{l(await P(()=>e(t._[0])))}catch(n){throw n.status&&i(n.message,2),n}},async sessions(){const{sessionOf:e}=await I(),{listBoards:n}=await import(f.join(b,"hub","boards.mjs")),s=y(),o=W(),c=[...n(s).map(d=>d.route),...Object.keys(s.starting||{}).filter(d=>!p(s.sites,d))].map(d=>{const r=e(s,d),a=r&&o.get(r.session);return{route:d,session:r?.session||null,live:a?a.kind==="bg"?"background":"terminal":null,repo:r?.repo||null,starting:!p(s.sites,d)}});if(t.json)return l(c);if(!c.length)return l("no boards");for(const d of c)l(`/${d.route} ${d.session||"no session"} ${d.live?`live (${d.live})`:"not live"} ${d.repo||"no folder"}${d.starting?" (starting: not joined yet)":""}`)},async hub(){const{hubSession:e}=await I(),n=await P(()=>e({manifest:y(),repo:t.repo}));process.exitCode=n.status},async set(){const e=t._[0];e||i("set needs a route",2),t.icon===void 0&&t.color===void 0&&!t.hub&&!t["no-hub"]&&i("set needs --icon, --color, --hub or --no-hub",2),B(),X(),await $(async()=>{const n=y(),s=p(n.sites,e)||i("no site at /"+e);C(s),ee(n,e,s),s.updated=new Date().toISOString(),await O(n),l({route:e,icon:s.icon??null,color:s.color??null,hub:s.hub===!0})})},async restart(){const e=t._[0];await $(async()=>{const n=y();p(n.sites,e)||i("no site at /"+e),n.sites[e].restart_at=Date.now(),await O(n)}),l(`restarting /${e}; the hub picks it up within 2 seconds`)},async logs(){const e=t._[0],n=f.join(V,e+".log");g.existsSync(n)||i("no log yet for /"+e);const s=Number(t.lines||40);l(g.readFileSync(n,"utf8").split(`
122
- `).slice(-s-1).join(`
123
- `))},async install(){t.fix=!0,await A.doctor()},async update(){g.existsSync(f.join(b,".git"))&&i(`${b} is a clone; update it with git pull`,2);const e=re("npm",["i","-g","@lovek4d/kickflip@latest"],{stdio:"inherit"});e.status!==0&&i(`npm update failed${e.error?": "+e.error.message:""}`);const n=Q(),s=n.restart();s&&i(`update finished but the hub did not restart: ${s}; run ${n.restartCommand}`),l("updated kickflip and restarted the hub")},async uninstall(){de()&&l(`removed ${f.join(K.homedir(),".claude","statusline.sh")}`);const e=Q();e.remove();try{M("tailscale",["serve","--https=443","--set-path=/","off"],{stdio:"ignore"})}catch{}l(`hub stopped and removed from ${e.manager??"the host"}; tailscale / unmapped. The manifest and sites are kept in ${J}`)},async push(){const{deliver:e,pushKindError:n}=await import(f.join(b,"hub","push.mjs")),s=n(t.kind);s&&i(s,2),t.title||i("push needs --title",2),t.url&&!t.url.startsWith("/")&&i("--url is a path on the hub, such as /<route>/page.html",2);const{clock:o,holdLine:u}=await import(f.join(b,"hub","schedule.mjs"));t.now&&t.urgent&&i("pass --now or --urgent, not both",2);const c=await e({title:t.title,body:t.body||"",url:t.url||"/",tag:t.tag},{kind:t.kind,urgent:!!t.urgent,immediate:!!t.now});if(c.noDevice&&i("no device has turned on notifications; open Hub \u2192 Notifications (/_hub/#notifications) in the Home Screen app and tap Turn on",3),c.dropped)return l("dropped (notifications are off on the Hub page)");if(c.queued){const{count:d,replaced:r,nextFlushAt:a,hold:S,sendFailed:w}=c.queued,x=t.now?S?`${u(S)}; `:w?"no device took it now; ":"":"";l(`queued (${x}${d} in queue${r?"; replaced the queued push with this tag":""}); next send ${o(a)} (${a.toISOString()})`),await R()||l("warning: the hub is not running, so the queue goes out only once it runs");return}l(c.sent),c.sent.some(d=>d.status>=200&&d.status<300)||i("every push was refused",1),l(t.urgent?"sent (urgent)":"sent")},async polling(){const{readSettings:e}=await import(f.join(b,"hub","push.mjs")),{windowAt:n,nextSlot:s,hold:o}=await import(f.join(b,"hub","schedule.mjs")),u=new Date,c=e(),d=n(u,c),r=o(u,c);l({every:d.every_minutes,until:d.endsAt.toISOString(),next:s(u,c).toISOString(),held:r!==null,held_until:r?r.until instanceof Date?r.until.toISOString():r.until:null,held_why:r?.why??null,notifications:c.notifications})},async notifications(){const{setNotifications:e,readSettings:n}=await import(f.join(b,"hub","push.mjs")),[s,o]=t._,c=s===void 0?null:s==="on"||s==="off"?{on:s==="on"}:s==="weekends"?{weekends_off:h((r,a)=>r==="on"?!0:r==="off"?!1:i(`${a} takes on or off`,2),"onOff")(o,"weekends")}:s==="snooze"?{snooze:o||i("snooze takes a length: <n>m, <n>h, <n>d, <n>w, tonight, morning, monday or a date and time",2)}:s==="unsnooze"?{snooze:"off"}:i("notifications takes on, off, snooze <length>, unsnooze, or weekends on|off",2);if(!c)return l(n().notifications);let d;try{d=await e(c)}catch(r){throw r.status===400&&i(r.message,2),r}l(d.dropped?{...d.notifications,dropped:d.dropped}:d.notifications)},async"codex-share"(){(m.length>1||m.length&&t._.length!==1)&&i("codex-share takes auto, always, or off",2);const{readCodexShare:n,setCodexShare:s}=await import(f.join(b,"hub","push.mjs"));if(!m.length)return l(n());try{l(s(m[0]))}catch(o){throw o.status===400&&i(o.message,2),o.status===409&&i(o.message),o}},async"usage-gate"(){const{readUsageGate:e,setPauseAt:n,setKeepGoing:s}=await import("../shared/usage-gate.mjs");try{m.length&&(m[0]==="pause-at"&&m.length===2?n(m[1]):m[0]==="keep-going"&&m.length===1?s("on"):m[0]==="stop"&&m.length===1?s("off"):i("usage-gate takes pause-at <50..100>, keep-going, or stop",2)),l(e())}catch(o){throw o.status&&i(o.message,o.status===400?2:1),o}},async"poll-now"(){let e;try{e=await fetch(`http://127.0.0.1:${_}/_hub/poll`,{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({flush:!t["keep-queue"]}),signal:AbortSignal.timeout(10*6e4)})}catch(n){i("the hub is not running: "+n.message)}e.ok||i(`the hub answered ${e.status}: ${(await e.text()).slice(0,200)}`),l(await e.json())}};if(!A[L]){const e=g.readFileSync(ae(import.meta.url),"utf8").split(`
124
- `).slice(1);l(e.slice(0,e.findIndex(n=>!n.startsWith("//"))).map(n=>n.replace(/^\/\/ ?/,"")).join(`
125
- `)),process.exit(L?2:0)}try{await A[L]()}catch(e){if(!(e instanceof N))throw e;e.message&&console.error("kickflip: "+e.message),process.exit(e.code)}
156
+ var ve=Object.defineProperty;var p=(e,t)=>ve(e,"name",{value:t,configurable:!0});import w from"node:fs";import u from"node:path";import L from"node:os";import oe from"node:net";import Oe from"node:readline/promises";import{execFileSync as ie,spawnSync as M}from"node:child_process";import{fileURLToPath as xe}from"node:url";import{ICON_KEYS as re}from"../hub/icons.mjs";import{liveSessions as ae}from"../hub/live.mjs";import{isBoardSite as P,writeManifest as v}from"../hub/boards.mjs";import{joinName as _e,routeFromName as F,routeRecord as g,validRoute as C}from"../hub/routes.mjs";import{defaultBranch as Ne,githubFromOrigin as Te}from"../hub/repos.mjs";import{uninstallStatusLine as Ie,storedPath as Fe,nodeBin as K,watcherCommand as Ce,tailnetUrl as Re}from"./install.mjs";import{hubService as ce}from"../shared/service.mjs";import{runDoctor as Ee}from"./doctor.mjs";import{cloneRoot as Be}from"../shared/repos.mjs";const j=Be(),_=u.join(L.homedir(),".claude","kickflip"),De=u.join(_,"manifest.json"),de=u.join(_,"logs"),T=Number(process.env.KICKFLIP_HUB_PORT||4500),U=4600,Le=/^#([0-9a-f]{3}|[0-9a-f]{6})$/i,[G,...S]=process.argv.slice(2),s=Object.assign(Object.create(null),{_:[]});for(let e=0;e<S.length;e++){const t=S[e];if(t.startsWith("--")){const n=t.slice(2);n==="fix"||n==="force"||n==="json"||n==="urgent"||n==="now"||n==="keep-queue"||n==="hub"||n==="no-hub"||n==="dry-run"||n==="watch"&&(e+1>=S.length||S[e+1].startsWith("--"))?s[n]=!0:s[n]=S[++e]}else s._.push(t)}const h=p(e=>console.log(typeof e=="string"?e:JSON.stringify(e,null,2)),"out");class I extends Error{static{p(this,"Exit")}constructor(t,n){super(t),this.code=n}}const i=p((e,t=1)=>{throw new I(e,t)},"fail");function W(){s.icon&&!re.includes(s.icon)&&i(`--icon takes one of: ${re.join(", ")}`,2),s.color&&!Le.test(s.color)&&i("--color takes a CSS hex color, such as #3b82f6",2)}p(W,"checkLook");function Y(e){for(const t of["icon","color"])s[t]===""?delete e[t]:s[t]!==void 0&&(e[t]=s[t])}p(Y,"applyLook");const ue=p(()=>s.hub&&s["no-hub"]&&i("pass --hub or --no-hub, not both",2),"checkHub"),le=p(e=>i(`/${e} is not a board; only a board can be the hub board`,2),"notBoard");function Q(e,t,n){if(s["no-hub"]&&delete n.hub,!!s.hub){P(n)||le(t);for(const[o,l]of Object.entries(e.sites))o!==t&&l?.hub&&delete l.hub;n.hub=!0}}p(Q,"applyHub");function y(){try{const e=JSON.parse(w.readFileSync(De,"utf8"));return e.sites||={},e}catch(e){if(e.code==="ENOENT")return{version:1,sites:{}};i("manifest unreadable: "+e.message)}}p(y,"readManifest");const fe=p(e=>{try{return JSON.parse(w.readFileSync(e,"utf8"))}catch{return null}},"readJSON"),R=p(()=>import(u.join(j,"hub","sessions.mjs")),"sessionsModule");async function A(e){const{SessionError:t}=await R();try{return await e()}catch(n){throw n instanceof t&&i(n.message,n.reason==="live"?3:n.reason==="hub"?2:1),n}}p(A,"sessionCall");async function he(e){const{handshake:t}=await import(u.join(j,"hub","boards.mjs"));try{return await t(e,y())}catch(n){return i(n.message,2)}}p(he,"runHandshake");async function Pe(e){for(let t=0;t<2;t++){let n;try{n=await fetch(`http://127.0.0.1:${T}/_hub/poll`,{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({flush:!1}),signal:AbortSignal.timeout(5*6e4)})}catch(l){return`not run (${l.message})`}if(!n.ok)return`not run (the hub answered ${n.status})`;const o=await n.json();if(o.checked?.includes(e))return"reached this board";if(o.failed?.length)return`missed this board: ${o.failed[0]}`}return"missed this board"}p(Pe,"pollFor");async function O(e){const{withManifestLock:t}=await import(u.join(j,"hub","boards.mjs"));try{return await t(e)}catch(n){throw n.code==="ELOCKED"&&i(n.message),n}}p(O,"withLock");function pe(e){return new Promise(t=>{const n=oe.connect({port:e,host:"127.0.0.1"}),o=p(l=>{n.destroy(),t(l)},"done");n.setTimeout(500,()=>o(!1)),n.on("connect",()=>o(!0)),n.on("error",()=>o(!1))})}p(pe,"connects");function Ae(e){return new Promise(t=>{const n=oe.createServer();n.once("error",()=>t(!1)),n.listen(e,"0.0.0.0",()=>n.close(()=>t(!0)))})}p(Ae,"bindable");async function J(e){const t=new Set([...Object.values(e.sites),...Object.values(e.closed||{}).map(n=>n.site)].map(n=>n?.port));for(let n=U;n<U+400;n++)if(!t.has(n)&&!await pe(n)&&await Ae(n))return n;i("no free port found from "+U)}p(J,"freePort");function Je(){try{return(JSON.parse(ie("tailscale",["status","--json"],{encoding:"utf8",stdio:["ignore","pipe","ignore"]})).Self?.DNSName||"").replace(/\.$/,"")}catch{return""}}p(Je,"tailnetHost");const X=p(e=>{const t=Je();return t?`https://${t}/${e}/`:`http://127.0.0.1:${T}/${e}/`},"siteUrl");async function H(){try{return await(await fetch(`http://127.0.0.1:${T}/_hub/status.json`,{signal:AbortSignal.timeout(1500)})).json()}catch{return null}}p(H,"hubStatus");const me=p(e=>{try{return w.statSync(e).isDirectory()}catch{return!1}},"isFolder"),He=p(e=>{try{return w.realpathSync(e)}catch{return null}},"realPath");function we(e=t=>{}){(typeof s.name!="string"||!s.name.trim())&&i('join needs --name "<name>": the board or page name, which gives its route',2);const t=s.name.trim().replace(/\s+/g," "),n=F(t);return C(n)||i(`--name "${t}" needs a letter or digit for its route`,2),{route:n,title:t===n&&e(n)?.title||t}}p(we,"named");function ge(e,{title:t,items:n,tickets:o}){w.mkdirSync(u.join(e,"data"),{recursive:!0});const l=u.join(e,"board.json"),c=w.existsSync(l)?JSON.parse(w.readFileSync(l,"utf8")):{};c.title=t,c.items=Fe(n||c.items||"waves"),o&&(c.tickets=o),w.writeFileSync(l,JSON.stringify(c,null,2)+`
157
+ `);const r=c.items==="waves"?null:u.resolve(e,c.items);return r&&!w.existsSync(r)&&(w.mkdirSync(u.dirname(r),{recursive:!0}),w.writeFileSync(r,JSON.stringify({title:c.title,items:[]},null,2)+`
158
+ `)),[K(),u.join(j,"board","server.mjs"),e]}p(ge,"boardFiles");async function Z(e,t){const{startSession:n,withSessionLock:o}=await R();return o(e,async()=>{const l=await A(()=>n(e,{manifest:y(),...t}));return await O(async()=>{const c=y(),r={session:l.session,label:l.name,since:new Date().toISOString(),repo:l.repo,...l.model?{model:l.model}:{}};g(c.sites,e)?(c.sites[e].owner=r,c.sites[e].updated=r.since):c.starting={...c.starting,[e]:{...g(c.starting,e),...r}},await v(c)}),l})}p(Z,"startRecorded");async function q(e){const{hubSession:t}=await R(),n=await A(()=>t({manifest:y(),repo:e}));process.exitCode=n.status}p(q,"openHub");const be=p((e,...t)=>{const n=M("git",["-C",e,...t],{encoding:"utf8"});return n.status===0?n.stdout.trim():null},"git"),V=p(()=>!!(process.stdin.isTTY&&process.stdout.isTTY),"canAsk");async function ee(e,t="stopped at the prompt; nothing changed"){const n=Oe.createInterface({input:process.stdin,output:process.stdout});try{return(await n.question(e)).trim()}catch(o){throw o.code==="ABORT_ERR"&&i(t),o}finally{n.close()}}p(ee,"ask");const ye=p((e,t)=>!!(g(e.sites,t)||g(e.starting,t)||g(e.closed,t)),"taken");function qe(e){return!/^[A-Za-z0-9 ]+$/.test(e)||!C(F(e))?"A board name takes letters, digits and spaces, up to 41 characters of route.":ye(y(),F(e))?`/${F(e)} is taken; pick another name.`:null}p(qe,"nameProblem");const ke="--template board --items data/wave.json";async function ze(){const e=be(process.cwd(),"rev-parse","--show-toplevel");e||i(`${process.cwd()} is not in a git repo; run kickflip join in the folder of the repo to add as a board`,2);const t=w.realpathSync(e);await H()||i("the hub is not running; run kickflip init to set it up, then kickflip join here again");const n=Object.entries(y().sites).find(([,f])=>P(f)&&f.owner?.repo&&He(f.owner.repo)===t),o=await Ne(t);if(n)return Me(...n,o);const l=u.basename(t).replace(/[^A-Za-z0-9]+/g," ").trim();let c=s.name?.trim();for(c===void 0&&!V()&&i('join needs --name "<Board name>" when it has no terminal to ask in',2);;){c===void 0&&(c=await ee(`Board name${l?` (${l})`:""}: `)||l);const f=qe(c);if(!f)break;s.name!==void 0&&i(f,2),h(f),c=void 0}const r=c.replace(/\s+/g," "),m=F(r),d=be(t,"remote","get-url","origin"),a=d?Te(d):null,{trustFolder:b}=await import("../hub/trust.mjs");try{b(t)}catch(f){i(`Couldn't trust this folder for Claude Code: ${f.message}`)}if(await O(async()=>{const f=y();ye(f,m)&&i(`/${m} is taken; pick another name`,2);const k=u.join(_,"sites",m),$=new Date().toISOString(),x={dir:k,port:await J(f),cmd:ge(k,{title:r,items:"data/wave.json"}),watch:["data/events.jsonl"],title:r,local:!0,...a?{github:a}:{},...o?{branch:o}:{},owner:{repo:t},created:$,updated:$};f.sites={...f.sites,[m]:x},Q(f,m,x),await v(f)}),h(`Made ${r} at ${X(m)}, working in ${t}${a?` (${a})`:""}.`),s.hub)return h("It is the hub board, so its session runs in tmux, attached here."),q(t);try{await Z(m,{repo:t,prompt:ke})}catch(f){throw f instanceof I&&i(`${f.message}
159
+ The board is made; run kickflip join here again to start its session.`,f.code),f}h("Its session is starting in the background, and joins the board shortly.")}p(ze,"joinFolder");async function Me(e,t,n){const o=t.title||e;h(`This folder already has a board: ${o} (/${e}).`),n&&n!==t.branch&&(await O(async()=>{const m=y();Object.assign(g(m.sites,e),{branch:n,updated:new Date().toISOString()}),await v(m)}),h(`Its branch is now ${n}${t.branch?` (it was ${t.branch})`:""}.`)),V()||i(t.hub===!0?"Open its session with kickflip hub.":"Run kickflip join in a terminal to restart its session, or tap Restart on its row of the Hub page.");const l=(await ee("Restart its session? (Y/n) ")).toLowerCase();if(l&&!"yes".startsWith(l))return;if(!t.owner.session)return t.hub===!0?q(t.owner.repo):(await Z(e,{repo:t.owner.repo,prompt:ke}),h("Its session is starting in the background."));const c=await fetch(`http://127.0.0.1:${T}/_hub/restart-board`,{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({route:e}),signal:AbortSignal.timeout(5*6e4)}),r=await c.json();if(c.ok||i(r.error),h(`Restarted ${o}'s session.`),t.hub===!0)return q(t.owner.repo)}p(Me,"restartFolderBoard");const te=["title","description","icon","color","created","watch","filter","hub","github","remote","branch","local"],Se=["notifications","quiet","codexShare"],E="kickflip-board",Ke=/^(\.watch-cursor-.*\.json|watcher\.json)$/,B=p((e,t)=>Object.fromEntries(t.filter(n=>e[n]!==void 0).map(n=>[n,e[n]])),"pick"),z=p(e=>/^[\w@%+=:,./-]+$/.test(e)?e:`'${String(e).replace(/'/g,"'\\''")}'`,"shellQuote");function je(e){const t=M("tar",e[0]==="-czf"?["--no-xattrs",...e]:e,{encoding:"utf8",env:{...process.env,COPYFILE_DISABLE:"1"}});t.status!==0&&i(`tar ${e[0]} failed: ${(t.stderr||t.error?.message||"").trim()}`)}p(je,"tar");function Ue(e,t){const n=e.entry.title||e.route,o=Object.keys(e.settings).map(l=>({notifications:"Notifications",quiet:"Quiet hours",codexShare:"Codex share"})[l]);return`# Moving ${n} (/${e.route}) to another machine
160
+
161
+ Exported from ${e.from} at ${e.exported_at}. This bundle holds the board's folder and manifest entry${o.length?`, and the hub's ${o.length>1?`${o.slice(0,-1).join(", ")} and ${o.at(-1)}`:o[0]} settings, which import writes over the new machine's`:""}.
162
+
163
+ ## On the new machine
164
+
165
+ 1. Install Git, Node 22+, Claude Code, gh and Tailscale, and turn on HTTPS certificates for the tailnet.
166
+ 2. Run \`npm i -g @lovek4d/kickflip\`, then \`kickflip init\`, and fix what its report marks yours.
167
+ 3. Run \`kickflip import ${t}\`. It refuses when /${e.route} already exists there.
168
+ 4. Start a fresh Claude Code session in the folder the board works in, and have it run the \`join\` line the import prints, with its own session id. \`--force\` takes the board over.
169
+ 5. Once the board answers there, stop its session on the old machine and run \`kickflip leave ${e.route} --force\` there, so only one session acts on its cards. Its files stay on the old machine.
170
+
171
+ ## Set up again
172
+
173
+ The bundle carries no secret and nothing tied to the old machine. Set these up on the new machine:
174
+
175
+ - GitHub sign-in: \`gh auth login\`.
176
+ - Linear API key: Hub \u2192 Linear.
177
+ - Slack: Claude's Slack connector in the new session, and the hub's Slack identity with \`board.mjs slack set --self-dm <D\u2026> --user <U\u2026>\`.
178
+ - Codex: \`codex login\`, if Codex builds.
179
+ - Notifications: open the Hub page on each phone and tap Turn on.
180
+ - Other hub settings, such as the pause line and Rules for every board, stay on the old machine.
181
+
182
+ Manifest fields left out, which the new machine sets itself: ${e.dropped.length?e.dropped.join(", "):"none"}.
183
+ `}p(Ue,"exportReadme");async function Ge(){W();const e=s.port===void 0?void 0:Number(s.port);e!==void 0&&!(Number.isInteger(e)&&e>0&&e<65536)&&i("--port takes a port number",2),e===T&&i(`--port ${e} is the hub's own port`,2);let t;if(s.cmd!==void 0){try{t=JSON.parse(s.cmd)}catch{t=null}(!Array.isArray(t)||!t.length||!t.every(d=>typeof d=="string"))&&i(`--cmd takes a JSON array of strings, such as '["/abs/node","/abs/server.mjs"]'`,2)}const n=s.dir===void 0?void 0:u.resolve(s.dir);n!==void 0&&!t&&e===void 0&&!me(n)&&i(`--dir ${n} is not a folder`,2);const{route:o,title:l}=we(d=>g(y().sites,d)),c=await O(async()=>{const d=y();g(d.closed,o)&&i("That board is closed; use kickflip reopen "+o,2),g(d.starting,o)&&i(`/${o} is a board that is starting; pick another route`,2);const a=g(d.sites,o);if(a?.owner?.session)throw h({error:"owned",route:o,owner:a.owner,hint:"a session owns this route: pick another route, or that session leaves it first"}),new I("",3);const b=new Date().toISOString(),f=a?{...a}:{created:b};delete f.watch,delete f.filter;{const k=t?{cmd:t,dir:n||a?.dir||process.cwd(),port:e??(a?.cmd?a.port:void 0)??await J(d)}:e!==void 0?{port:e,...n?{dir:n}:{}}:{dir:n};for(const $ of["cmd","dir","port","cwd","env"])delete f[$];Object.assign(f,k)}return f.title=l,s.description&&(f.description=s.description),Y(f),f.updated=b,d.sites={...d.sites,[o]:f},await v(d),f}),r=c.cmd?"cmd":c.port?"port":"files",m=await H();h({route:o,url:X(o),serves:r,...c.port?{port:c.port}:{},...c.dir?{dir:c.dir}:{},...r==="port"?{answering:await pe(c.port)}:{},hub:m?"running":"NOT running: run `kickflip doctor --fix`"})}p(Ge,"joinPage");const ne={async doctor(){s["dry-run"]&&!s.fix&&i("--dry-run needs --fix",2);const e=V()&&!s.json?n=>ee(n,"stopped at the prompt; the clones not yet answered are kept"):void 0,{exitCode:t}=await Ee({fix:!!s.fix,dryRun:!!s["dry-run"],json:!!s.json,ask:e});process.exitCode=t},async join(){if(s._.length&&i(`join takes no route: --name "<name>" gives the title and the route, as in kickflip join --name ${z(s._[0])}`,2),s.title!==void 0&&i('join takes no --title: --name "<name>" gives the title and the route',2),!s.session){const r=["port","dir","cmd"].some(d=>s[d]!==void 0),m=["watch","template","filter","label","model","repo","force","items",...r?["hub"]:[]].find(d=>s[d]!==void 0);return m&&i(`--${m} needs --session <id> (this session's id)`,2),r?Ge():ze()}const{route:e,title:t}=we(r=>g(y().sites,r));s.template&&s.template!=="board"&&i('--template takes "board"',2),W(),ue();const n=await O(async()=>{const r=y();g(r.closed,e)&&i("That board is closed; use kickflip reopen "+e,2);const m=new Date().toISOString(),d=g(r.sites,e);if(d?.owner?.session&&d.owner.session!==s.session&&!s.force)throw h({error:"owned",route:e,owner:d.owner,hint:"ask the user, then rerun with --force to take it over"}),new I("",3);s.hub&&s.template!=="board"&&!P({dir:"",cmd:s.cmd?JSON.parse(s.cmd):d?.cmd})&&le(e);const a=d?{...d}:{};let b=!1;if(d||(a.dir=u.resolve(s.dir||u.join(_,"sites",e)),(!w.existsSync(a.dir)||w.readdirSync(a.dir).length===0)&&!s.cmd&&!s.template&&(w.cpSync(u.join(j,"starter"),a.dir,{recursive:!0}),b=!0),a.port=s.port?Number(s.port):await J(r),a.cmd=s.cmd?JSON.parse(s.cmd):[K(),u.join(a.dir,"server.mjs")],a.watch=["data/events.jsonl"],a.created=m),s.dir&&d&&(a.dir=u.resolve(s.dir)),s.port&&d&&(a.port=Number(s.port)),s.cmd&&d&&(a.cmd=JSON.parse(s.cmd)),s.template==="board"){const N=g(r.starting,e),$e=!d&&N?.tickets&&(!N.session||N.session===s.session)?N.tickets:void 0;a.cmd=ge(a.dir,{title:t,items:s.items,tickets:$e}),b=!d}a.title=t,s.description&&(a.description=s.description),typeof s.watch=="string"&&(a.watch=s.watch.split(",").map(N=>N.trim()).filter(Boolean)),s.filter!==void 0&&(a.filter=s.filter||void 0),Y(a),Q(r,e,a);const f=d?.owner&&d.owner.session!==s.session?d.owner:null,k=g(r.starting,e)?.session===s.session?g(r.starting,e):null,$=g(r.starting,e);!d&&($?.github||$?.remote)&&(!$.session||$.session===s.session)&&($.github?a.github=$.github:a.remote=$.remote,a.branch=$.branch);const x=d?.owner?.session===s.session?d.owner:null,D=x||k||f;a.owner={...x||{session:s.session,since:m},label:s.label||D?.label||"",repo:s.repo?u.resolve(s.repo):(a.github||a.remote)&&D?.repo||ae()?.get(s.session)?.cwd||D?.repo||process.cwd()};const se=s.model||D?.model;return se&&(a.owner.model=se),g(r.starting,e)&&(delete r.starting[e],Object.keys(r.starting).length||delete r.starting),a.updated=m,r.sites={...r.sites,[e]:a},await v(r),{route:e,created:b,previousOwner:f,site:a}}),o=await H();let l,c;if(s.template==="board"){const r=fe(u.join(n.site.dir,"data","handshake.json"));l=r?.ack&&r.hello?.session===s.session?"answered before":await he(e),c=o?await Pe(e):"not run (the hub is not running)"}h({route:e,url:X(e),dir:n.site.dir,port:n.site.port,owner:n.site.owner,createdFromStarter:n.created,previousOwner:n.previousOwner,log:u.join(de,e+".log"),watcher:Ce(e,s.session,j),hub:o?"running":"NOT running: run `kickflip doctor --fix`",...l?{handshake:l}:{},...c?{poll:c}:{}})},async handshake(){const e=s._[0];e||i("handshake needs a route",2),h(await he(e))},async leave(){const e=s._[0];e||i("leave needs a route",2),await O(async()=>{const t=y(),n=g(t.sites,e);if(n||i("no site at /"+e),n.owner?.session&&!s.session&&!s.force&&i("a session owns /"+e+"; leave needs --session <id>, or --force",2),n.owner?.session&&n.owner.session!==s.session&&!s.force)throw h({error:"owned",route:e,owner:n.owner}),new I("",3);delete t.sites[e],await v(t),h({left:e,filesKept:n.dir})})},async start(){const e=s._[0];C(e)||i("start needs a route: lowercase letters, digits and dashes",2);const t=await Z(e,{repo:s.repo,model:s.model,prompt:s.prompt});h({route:t.route,session:t.session,confirmed:t.confirmed,repo:t.repo,argv:t.argv,output:t.output})},async resume(){const e=s._[0];e||i("resume needs a route",2);const{resumeSession:t}=await R();h(await A(()=>t(e,{manifest:y()})))},async close(){const{closeBoard:e}=await import("../hub/close-board.mjs");try{h(await e(s._[0],s.name))}catch(t){i(t.message,t.status?2:1)}},async reopen(){const{reopenBoard:e}=await import("../hub/close-board.mjs");try{h(await A(()=>e(s._[0])))}catch(t){throw t.status&&i(t.message,2),t}},async export(){const e=s._[0];C(e)||i("export needs a route: lowercase letters, digits and dashes",2);const t=u.resolve(s.out||`kickflip-${e}.tgz`);w.existsSync(t)&&i(`${t} already exists; pass another --out`,2);const n=g(y().sites,e)||i("no site at /"+e);P(n)||i(`/${e} is not a board; export moves a board`,2);const o=w.realpathSync(n.dir),{itemSources:l,openBoardBuilders:c}=await import("../board/store.mjs"),r=(await l(o)).find(k=>u.relative(o,k.file).startsWith(".."));r&&i(`/${e}'s cards live outside its folder (${r.file}); export moves only a board whose board.json items is a file in its folder`);const m=await c(o);m.length&&i(`a builder is open on /${e}: ${m.map(k=>`${k.key} (${k.label})`).join(", ")}; export once it is done`);const{readSettingsFile:d}=await import("../hub/push.mjs"),a=B(n,te),b={kickflip_export:1,route:e,exported_at:new Date().toISOString(),from:L.hostname(),entry:a,owner:B(n.owner||{},["label","model"]),settings:B(d(),Se),dropped:Object.keys(n).filter(k=>!te.includes(k))},f=w.mkdtempSync(u.join(L.tmpdir(),"kickflip-export-"));try{const k=u.join(f,E),$=u.join(o,"data");w.cpSync(o,u.join(k,"site"),{recursive:!0,filter:p(x=>!(u.dirname(x)===$&&Ke.test(u.basename(x))),"filter")}),w.writeFileSync(u.join(k,"export.json"),JSON.stringify(b,null,2)+`
184
+ `),w.writeFileSync(u.join(k,"README.md"),Ue(b,u.basename(t))),je(["-czf",t,"-C",f,E])}finally{w.rmSync(f,{recursive:!0,force:!0})}h({route:e,file:t,entry:Object.keys(a),dropped:b.dropped,settings:Object.keys(b.settings)})},async import(){s._[0]||i("import needs the file kickflip export wrote",2);const e=u.resolve(s._[0]);w.existsSync(e)||i(`no file at ${e}`,2),w.mkdirSync(_,{recursive:!0});const t=w.mkdtempSync(u.join(_,".import-"));try{je(["-xzf",e,"-C",t]);const n=fe(u.join(t,E,"export.json"));(n?.kickflip_export!==1||!C(n.route)||!me(u.join(t,E,"site")))&&i(`${e} is not a kickflip export`,2);const{route:o}=n,l=u.join(_,"sites",o),c=await O(async()=>{const a=y();(g(a.sites,o)||g(a.closed,o)||g(a.starting,o))&&i(`/${o} already exists on this host; import never replaces a board`),w.existsSync(l)&&i(`${l} already exists; import never replaces a board's folder`);const b=B(n.entry||{},te);b.hub&&Object.values(a.sites).some(k=>k?.hub)&&delete b.hub;const f={...b,dir:l,port:await J(a),cmd:[K(),u.join(j,"board","server.mjs"),l],updated:new Date().toISOString()};return w.mkdirSync(u.dirname(l),{recursive:!0}),w.renameSync(u.join(t,E,"site"),l),a.sites={...a.sites,[o]:f},await v(a),f}),r=B(n.settings||{},Se);if(Object.keys(r).length){const{readSettingsFile:a,writeSettingsFile:b}=await import("../hub/push.mjs");b({...a(),...r})}const{label:m,model:d}=n.owner||{};h({route:o,dir:l,port:c.port,settings:Object.keys(r),hub:c.hub===!0,join:`kickflip join --name ${z(_e(o,c.title))} --session <this session's id> --watch --template board${m?` --label ${z(m)}`:""}${d?` --model ${z(d)}`:""} --force`})}finally{w.rmSync(t,{recursive:!0,force:!0})}},async sessions(){const{sessionOf:e}=await R(),{listBoards:t}=await import(u.join(j,"hub","boards.mjs")),n=y(),o=ae(),c=[...t(n).map(r=>r.route),...Object.keys(n.starting||{}).filter(r=>!g(n.sites,r))].map(r=>{const m=e(n,r),d=m&&o?.get(m.session),a=m?o?d?d.kind==="bg"?"background":"terminal":null:"unknown":null;return{route:r,session:m?.session||null,live:a,repo:m?.repo||null,starting:!g(n.sites,r)}});if(s.json)return h(c);if(!c.length)return h("no boards");for(const r of c)h(`/${r.route} ${r.session||"no session"} ${r.live==="unknown"?"live unknown (ps gave no answer)":r.live?`live (${r.live})`:"not live"} ${r.repo||"no folder"}${r.starting?" (starting: not joined yet)":""}`)},async hub(){await q(s.repo)},async set(){const e=s._[0];e||i("set needs a route",2),s.icon===void 0&&s.color===void 0&&!s.hub&&!s["no-hub"]&&i("set needs --icon, --color, --hub or --no-hub",2),W(),ue(),await O(async()=>{const t=y(),n=g(t.sites,e)||i("no site at /"+e);Y(n),Q(t,e,n),n.updated=new Date().toISOString(),await v(t),h({route:e,icon:n.icon??null,color:n.color??null,hub:n.hub===!0})})},async restart(){const e=s._[0];await O(async()=>{const t=y();g(t.sites,e)||i("no site at /"+e),t.sites[e].restart_at=Date.now(),await v(t)}),h(`restarting /${e}; the hub picks it up within 2 seconds`)},async url(){const e=s._[0];e&&!g(y().sites,e)&&i("no site at /"+e,2);try{h(Re(e))}catch(t){i(t.message)}},async logs(){const e=s._[0],t=u.join(de,e+".log");w.existsSync(t)||i("no log yet for /"+e);const n=Number(s.lines||40);h(w.readFileSync(t,"utf8").split(`
185
+ `).slice(-n-1).join(`
186
+ `))},async init(){s.fix=!0,await ne.doctor()},async update(){w.existsSync(u.join(j,".git"))&&i(`${j} is a clone; update it with git pull`,2);const e=M("npm",["i","-g","@lovek4d/kickflip@latest"],{stdio:"inherit"});e.status!==0&&i(`npm update failed${e.error?": "+e.error.message:""}`);const t=ce(),n=t.restart();n&&i(`update finished but the hub did not restart: ${n}; run ${t.restartCommand}`),h("updated kickflip and restarted the hub")},async uninstall(){Ie()&&h(`removed ${u.join(L.homedir(),".claude","statusline.sh")}`);const e=ce();e.remove();try{ie("tailscale",["serve","--https=443","--set-path=/","off"],{stdio:"ignore"})}catch{}h(`hub stopped and removed from ${e.manager??"the host"}; tailscale / unmapped. The manifest and sites are kept in ${_}`)},async push(){const{deliver:e,pushKindError:t}=await import(u.join(j,"hub","push.mjs")),n=t(s.kind);n&&i(n,2),s.title||i("push needs --title",2),s.url&&!s.url.startsWith("/")&&i("--url is a path on the hub, such as /<route>/page.html",2);const{sendWords:o,holdLine:l}=await import(u.join(j,"hub","schedule.mjs"));s.now&&s.urgent&&i("pass --now or --urgent, not both",2);const c=await e({title:s.title,body:s.body||"",url:s.url||"/",tag:s.tag},{kind:s.kind,urgent:!!s.urgent,immediate:!!s.now});if(c.noDevice&&i("no device has turned on notifications; open Hub \u2192 Notifications (/_hub/#notifications) in the Home Screen app and tap Turn on",3),c.dropped)return h("dropped (notifications are off on the Hub page)");if(c.queued){const{count:r,replaced:m,nextFlushAt:d,hold:a,sendFailed:b}=c.queued,f=s.now?a?`${l(a)}; `:b?"no device took it now; ":"":"";h(`queued (${f}${r} in queue${m?"; replaced the queued push with this tag":""}); next send ${o(d)}${d instanceof Date?` (${d.toISOString()})`:""}`),await H()||h("warning: the hub is not running, so the queue goes out only once it runs");return}h(c.sent),c.sent.some(r=>r.status>=200&&r.status<300)||i("every push was refused",1),h(s.urgent?"sent (urgent)":"sent")},async quiet(){const{readSettings:e}=await import(u.join(j,"hub","push.mjs")),{hold:t}=await import(u.join(j,"hub","schedule.mjs")),n=e(),o=t(new Date,n);h({quiet:n.quiet,held:o!==null,held_until:o?o.until instanceof Date?o.until.toISOString():o.until:null,held_why:o?.why??null,notifications:n.notifications})},async notifications(){const{setNotifications:e,readSettings:t}=await import(u.join(j,"hub","push.mjs")),[n,o]=s._,c=n===void 0?null:n==="on"||n==="off"?{on:n==="on"}:n==="weekends"?{weekends_off:p((m,d)=>m==="on"?!0:m==="off"?!1:i(`${d} takes on or off`,2),"onOff")(o,"weekends")}:n==="snooze"?{snooze:o||i("snooze takes a length: <n>m, <n>h, <n>d, <n>w, tonight, morning, monday, forever or a date and time",2)}:n==="unsnooze"?{snooze:"off"}:i("notifications takes on, off, snooze <length>, unsnooze, or weekends on|off",2);if(!c)return h(t().notifications);let r;try{r=await e(c)}catch(m){throw m.status===400&&i(m.message,2),m}h(r.dropped?{...r.notifications,dropped:r.dropped}:r.notifications)},async"codex-share"(){(S.length>1||S.length&&s._.length!==1)&&i("codex-share takes auto, always, or off",2);const{readCodexShare:t,setCodexShare:n}=await import(u.join(j,"hub","push.mjs"));if(!S.length)return h(t());try{h(n(S[0]))}catch(o){throw o.status===400&&i(o.message,2),o.status===409&&i(o.message),o}},async"usage-gate"(){const{readUsageGate:e,setPauseAt:t,setKeepGoing:n}=await import("../shared/usage-gate.mjs");try{S.length&&(S[0]==="pause-at"&&S.length===2?t(S[1]):S[0]==="keep-going"&&S.length===1?n("on"):S[0]==="stop"&&S.length===1?n("off"):i("usage-gate takes pause-at <50..100>, keep-going, or stop",2)),h(e())}catch(o){throw o.status&&i(o.message,o.status===400?2:1),o}},async"poll-now"(){let e;try{e=await fetch(`http://127.0.0.1:${T}/_hub/poll`,{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({flush:!s["keep-queue"]}),signal:AbortSignal.timeout(10*6e4)})}catch(t){i("the hub is not running: "+t.message)}e.ok||i(`the hub answered ${e.status}: ${(await e.text()).slice(0,200)}`),h(await e.json())}};if(!ne[G]){const e=w.readFileSync(xe(import.meta.url),"utf8").split(`
187
+ `).slice(1);h(e.slice(0,e.findIndex(t=>!t.startsWith("//"))).map(t=>t.replace(/^\/\/ ?/,"")).join(`
188
+ `)),process.exit(G?2:0)}try{await ne[G]()}catch(e){if(!(e instanceof I))throw e;e.message&&console.error("kickflip: "+e.message),process.exit(e.code)}
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
- var d=Object.defineProperty;var s=(e,t)=>d(e,"name",{value:t,configurable:!0});import c from"node:fs";import f from"node:os";import m from"node:path";import{execFileSync as _}from"node:child_process";import{migrateOnStart as g}from"./install.mjs";process.stdout.on("error",()=>{});function h(e){if(!e)return"";const t=s((...r)=>_("git",["-C",e,...r],{encoding:"utf8",stdio:["ignore","pipe","ignore"],timeout:500,env:{...process.env,GIT_OPTIONAL_LOCKS:"0"}}).trim(),"git");try{if(t("rev-parse","--is-inside-work-tree")!=="true")return"";const r=t("symbolic-ref","--short","HEAD");try{t("diff","--quiet"),t("diff","--cached","--quiet")}catch{return r+"*"}return r}catch{return""}}s(h,"branchOf");function S(e){if(!e)return"";const t=f.homedir(),r=e===t?"~":e.startsWith(t+"/")?"~"+e.slice(t.length):e,n=r.split("/");return n.length>2?n.slice(-2).join("/"):r}s(S,"shortDir");function b(e){if(e==null||e==="")return null;const t=typeof e=="number"||/^\d+(\.\d+)?$/.test(String(e)),r=Number(e),n=t?r>1e12?r:r*1e3:Date.parse(String(e));return Number.isFinite(n)&&Number.isFinite(new Date(n).getTime())?new Date(n).toISOString():null}s(b,"isoOf");function p(e){if(!e||typeof e!="object")return null;const t=e.used_percentage;return{used_percentage:t==null||t===""||!Number.isFinite(Number(t))?null:Number(t),resets_at:b(e.resets_at),resets_at_raw:e.resets_at??null}}s(p,"windowOf");function N(e){const t=e.rate_limits;if(!t||typeof t!="object"||Array.isArray(t))return;const r=m.join(f.homedir(),".claude","kickflip","usage.json");let n={};try{const o=JSON.parse(c.readFileSync(r,"utf8"));o&&typeof o=="object"&&!Array.isArray(o)&&(n=o)}catch{}const l={five_hour:p(t.five_hour),seven_day:p(t.seven_day),at:new Date().toISOString(),...e.session_id?{session:e.session_id}:{}};c.mkdirSync(m.dirname(r),{recursive:!0});const a=`${r}.${process.pid}.tmp`;try{c.writeFileSync(a,JSON.stringify({...n,claude:l},null,2)+`
3
- `,{mode:384}),c.renameSync(a,r)}finally{try{c.unlinkSync(a)}catch{}}}s(N,"saveUsage");try{const e=JSON.parse(c.readFileSync(0,"utf8"));if(e&&typeof e=="object"&&!Array.isArray(e)){const t=typeof e.cwd=="string"?e.cwd:"",r=typeof e.model?.display_name=="string"?e.model.display_name:"",n=s(i=>i==null||i===""||!Number.isFinite(Number(i))?null:Math.floor(Number(i)),"percent"),l=n(e.context_window?.used_percentage),a=[["5h",e.rate_limits?.five_hour],["7d",e.rate_limits?.seven_day]].map(([i,u])=>[i,n(u?.used_percentage)]).filter(([,i])=>i!=null).map(([i,u])=>`${i}: ${u}%`).join(" / "),o=s((i,u)=>i.filter(Boolean).join(u),"join"),y=o([o([S(t),h(t)]," "),o([r,l==null?"":`${l}% ctx`]," / "),a]," \xB7 ");process.stdout.write(y+`
4
- `,()=>{try{g(),N(e)}catch{}})}}catch{}process.exitCode=0;
2
+ var d=Object.defineProperty;var s=(e,t)=>d(e,"name",{value:t,configurable:!0});import o from"node:fs";import m from"node:os";import f from"node:path";import{execFileSync as h}from"node:child_process";process.stdout.on("error",()=>{});function _(e){if(!e)return"";const t=s((...r)=>h("git",["-C",e,...r],{encoding:"utf8",stdio:["ignore","pipe","ignore"],timeout:500,env:{...process.env,GIT_OPTIONAL_LOCKS:"0"}}).trim(),"git");try{if(t("rev-parse","--is-inside-work-tree")!=="true")return"";const r=t("symbolic-ref","--short","HEAD");try{t("diff","--quiet"),t("diff","--cached","--quiet")}catch{return r+"*"}return r}catch{return""}}s(_,"branchOf");function g(e){if(!e)return"";const t=m.homedir(),r=e===t?"~":e.startsWith(t+"/")?"~"+e.slice(t.length):e,n=r.split("/");return n.length>2?n.slice(-2).join("/"):r}s(g,"shortDir");function S(e){if(e==null||e==="")return null;const t=typeof e=="number"||/^\d+(\.\d+)?$/.test(String(e)),r=Number(e),n=t?r>1e12?r:r*1e3:Date.parse(String(e));return Number.isFinite(n)&&Number.isFinite(new Date(n).getTime())?new Date(n).toISOString():null}s(S,"isoOf");function y(e){if(!e||typeof e!="object")return null;const t=e.used_percentage;return{used_percentage:t==null||t===""||!Number.isFinite(Number(t))?null:Number(t),resets_at:S(e.resets_at),resets_at_raw:e.resets_at??null}}s(y,"windowOf");function b(e){const t=e.rate_limits;if(!t||typeof t!="object"||Array.isArray(t))return;const r=f.join(m.homedir(),".claude","kickflip","usage.json");let n={};try{const u=JSON.parse(o.readFileSync(r,"utf8"));u&&typeof u=="object"&&!Array.isArray(u)&&(n=u)}catch{}const a={five_hour:y(t.five_hour),seven_day:y(t.seven_day),at:new Date().toISOString(),...e.session_id?{session:e.session_id}:{}};o.mkdirSync(f.dirname(r),{recursive:!0});const c=`${r}.${process.pid}.tmp`;try{o.writeFileSync(c,JSON.stringify({...n,claude:a},null,2)+`
3
+ `,{mode:384}),o.renameSync(c,r)}finally{try{o.unlinkSync(c)}catch{}}}s(b,"saveUsage");function N(e,t){const r=e.session_id;if(t==null||typeof r!="string"||!/^[\w-]+$/.test(r))return;const n=f.join(m.homedir(),".claude","kickflip","context"),a=f.join(n,`${r}.json`);o.mkdirSync(n,{recursive:!0});const c=`${a}.${process.pid}.tmp`;try{o.writeFileSync(c,JSON.stringify({percent:t,at:new Date().toISOString()})+`
4
+ `,{mode:384}),o.renameSync(c,a)}finally{try{o.unlinkSync(c)}catch{}}}s(N,"saveContext");try{const e=JSON.parse(o.readFileSync(0,"utf8"));if(e&&typeof e=="object"&&!Array.isArray(e)){const t=typeof e.cwd=="string"?e.cwd:"",r=typeof e.model?.display_name=="string"?e.model.display_name:"",n=s(i=>i==null||i===""||!Number.isFinite(Number(i))?null:Math.floor(Number(i)),"percent"),a=n(e.context_window?.used_percentage),c=[["5h",e.rate_limits?.five_hour],["7d",e.rate_limits?.seven_day]].map(([i,l])=>[i,n(l?.used_percentage)]).filter(([,i])=>i!=null).map(([i,l])=>`${i}: ${l}%`).join(" / "),u=s((i,l)=>i.filter(Boolean).join(l),"join"),p=u([u([g(t),_(t)]," "),u([r,a==null?"":`${a}% ctx`]," / "),c]," \xB7 ");process.stdout.write(p+`
5
+ `,()=>{try{b(e)}catch{}try{N(e,a)}catch{}})}}catch{}process.exitCode=0;