cc-plan-viewer 0.1.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.
- package/LICENSE +21 -0
- package/README.md +38 -0
- package/dist/client/assets/index-B9Ku0w1F.js +99 -0
- package/dist/client/assets/index-CFUAZEOC.css +1 -0
- package/dist/client/index.html +16 -0
- package/dist/server/bin/cc-plan-viewer.js +152 -0
- package/dist/server/server/index.js +188 -0
- package/dist/server/server/lifecycle.js +56 -0
- package/dist/server/server/planParser.js +67 -0
- package/dist/server/server/planWatcher.js +32 -0
- package/dist/server/server/reviewStore.js +36 -0
- package/hooks/plan-viewer-hook.cjs +201 -0
- package/package.json +56 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
*,:before,:after{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }*,:before,:after{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}:before,:after{--tw-content: ""}html,:host{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:Inter,system-ui,sans-serif;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dl,dd,h1,h2,h3,h4,h5,h6,hr,figure,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}ol,ul,menu{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}button,[role=button]{cursor:pointer}:disabled{cursor:default}img,svg,video,canvas,audio,iframe,embed,object{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.\!container{width:100%!important}.container{width:100%}@media(min-width:640px){.\!container{max-width:640px!important}.container{max-width:640px}}@media(min-width:768px){.\!container{max-width:768px!important}.container{max-width:768px}}@media(min-width:1024px){.\!container{max-width:1024px!important}.container{max-width:1024px}}@media(min-width:1280px){.\!container{max-width:1280px!important}.container{max-width:1280px}}@media(min-width:1536px){.\!container{max-width:1536px!important}.container{max-width:1536px}}.visible{visibility:visible}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.inset-0{top:0;right:0;bottom:0;left:0}.-left-10{left:-2.5rem}.bottom-0{bottom:0}.left-0{left:0}.right-0{right:0}.top-0{top:0}.top-2{top:.5rem}.z-10{z-index:10}.z-20{z-index:20}.z-50{z-index:50}.float-right{float:right}.mx-4{margin-left:1rem;margin-right:1rem}.mx-auto{margin-left:auto;margin-right:auto}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-lg{margin-bottom:24px}.mb-md{margin-bottom:16px}.mb-sm{margin-bottom:8px}.mb-xs{margin-bottom:4px}.ml-4{margin-left:1rem}.mt-1{margin-top:.25rem}.mt-2{margin-top:.5rem}.mt-lg{margin-top:24px}.block{display:block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.hidden{display:none}.h-2{height:.5rem}.h-5{height:1.25rem}.h-7{height:1.75rem}.max-h-\[80vh\]{max-height:80vh}.min-h-\[80px\]{min-height:80px}.min-h-screen{min-height:100vh}.w-2{width:.5rem}.w-5{width:1.25rem}.w-64{width:16rem}.w-7{width:1.75rem}.w-\[300px\]{width:300px}.w-full{width:100%}.max-w-2xl{max-width:42rem}.max-w-3xl{max-width:48rem}.max-w-\[1200px\]{max-width:1200px}.max-w-md{max-width:28rem}.flex-1{flex:1 1 0%}.shrink-0{flex-shrink:0}.scale-\[1\.02\]{--tw-scale-x: 1.02;--tw-scale-y: 1.02;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.resize-none{resize:none}.resize-y{resize:vertical}.items-start{align-items:flex-start}.items-center{align-items:center}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-0\.5{gap:.125rem}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-lg{gap:24px}.gap-sm{gap:8px}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem * var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.75rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem * var(--tw-space-y-reverse))}.overflow-hidden{overflow:hidden}.overflow-y-auto{overflow-y:auto}.truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.rounded{border-radius:.25rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:20px}.rounded-md{border-radius:12px}.rounded-sm{border-radius:6px}.border{border-width:1px}.border-l-2{border-left-width:2px}.border-t{border-top-width:1px}.border-none{border-style:none}.border-claude-accent-light{--tw-border-opacity: 1;border-color:rgb(205 115 87 / var(--tw-border-opacity, 1))}.border-claude-border-light{--tw-border-opacity: 1;border-color:rgb(223 215 198 / var(--tw-border-opacity, 1))}.bg-black\/40{background-color:#0006}.bg-claude-accent-light{--tw-bg-opacity: 1;background-color:rgb(205 115 87 / var(--tw-bg-opacity, 1))}.bg-claude-bg-light{--tw-bg-opacity: 1;background-color:rgb(245 240 229 / var(--tw-bg-opacity, 1))}.bg-claude-success-light{--tw-bg-opacity: 1;background-color:rgb(72 149 172 / var(--tw-bg-opacity, 1))}.bg-claude-surface-light{--tw-bg-opacity: 1;background-color:rgb(251 250 241 / var(--tw-bg-opacity, 1))}.bg-claude-surface-light\/80{background-color:#fbfaf1cc}.bg-claude-text-tertiary-light{--tw-bg-opacity: 1;background-color:rgb(142 131 112 / var(--tw-bg-opacity, 1))}.bg-transparent{background-color:transparent}.p-1\.5{padding:.375rem}.p-3{padding:.75rem}.p-lg{padding:24px}.p-md{padding:16px}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.px-md{padding-left:16px;padding-right:16px}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-xl{padding-top:40px;padding-bottom:40px}.pb-1{padding-bottom:.25rem}.pb-2{padding-bottom:.5rem}.pb-40{padding-bottom:10rem}.pl-2\.5{padding-left:.625rem}.pt-2\.5{padding-top:.625rem}.pt-md{padding-top:16px}.text-center{text-align:center}.text-2xl{font-size:1.5rem;line-height:2rem}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.italic{font-style:italic}.leading-none{line-height:1}.leading-relaxed{line-height:1.625}.leading-snug{line-height:1.375}.leading-tight{line-height:1.25}.tracking-wide{letter-spacing:.025em}.text-claude-accent-light{--tw-text-opacity: 1;color:rgb(205 115 87 / var(--tw-text-opacity, 1))}.text-claude-text-primary-light{--tw-text-opacity: 1;color:rgb(40 34 24 / var(--tw-text-opacity, 1))}.text-claude-text-secondary-light{--tw-text-opacity: 1;color:rgb(102 102 86 / var(--tw-text-opacity, 1))}.text-claude-text-tertiary-light{--tw-text-opacity: 1;color:rgb(142 131 112 / var(--tw-text-opacity, 1))}.text-white{--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity, 1))}.opacity-0{opacity:0}.opacity-100{opacity:1}.shadow-2xl{--tw-shadow: 0 25px 50px -12px rgb(0 0 0 / .25);--tw-shadow-colored: 0 25px 50px -12px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-md{--tw-shadow: 0 4px 6px -1px rgb(0 0 0 / .1), 0 2px 4px -2px rgb(0 0 0 / .1);--tw-shadow-colored: 0 4px 6px -1px var(--tw-shadow-color), 0 2px 4px -2px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-sm{--tw-shadow: 0 1px 2px 0 rgb(0 0 0 / .05);--tw-shadow-colored: 0 1px 2px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.outline-none{outline:2px solid transparent;outline-offset:2px}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.backdrop-blur-lg{--tw-backdrop-blur: blur(16px);-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.backdrop-blur-sm{--tw-backdrop-blur: blur(4px);-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.transition-all{transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-opacity{transition-property:opacity;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-transform{transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.duration-200{transition-duration:.2s}*{box-sizing:border-box}body{margin:0;font-family:Inter,system-ui,sans-serif;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.prose{color:#282218;line-height:1.75;max-width:none;font-size:15px}@media(prefers-color-scheme:dark){.prose{color:#ede7dc}}.prose h1,.prose h2,.prose h3,.prose h4,.prose h5,.prose h6{font-weight:700;line-height:1.35;margin-top:1.75em;margin-bottom:.6em;color:#1a1610}@media(prefers-color-scheme:dark){.prose h1,.prose h2,.prose h3,.prose h4,.prose h5,.prose h6{color:#f5f0e5}}.prose h1{font-size:1.75rem;margin-top:0}.prose h2{font-size:1.35rem;padding-bottom:.35em;border-bottom:1px solid #DFD7C6}.prose h3{font-size:1.15rem}.prose h4{font-size:1rem;font-weight:600}@media(prefers-color-scheme:dark){.prose h2{border-bottom-color:#44403b}}.prose p{margin-top:.75em;margin-bottom:.75em}.prose ul{margin-top:.5em;margin-bottom:.5em;padding-left:1.6em;list-style-type:disc}.prose ol{margin-top:.5em;margin-bottom:.5em;padding-left:1.6em;list-style-type:decimal}.prose li{margin-top:.3em;margin-bottom:.3em}.prose li::marker{color:#8e8370}@media(prefers-color-scheme:dark){.prose li::marker{color:#6d676a}}.prose strong{font-weight:700;color:#1a1610}@media(prefers-color-scheme:dark){.prose strong{color:#f5f0e5}}.prose em{font-style:italic}.prose a{color:#b05a3c;text-decoration:underline;text-underline-offset:2px;text-decoration-color:#b05a3c66}.prose a:hover{text-decoration-color:#b05a3c}@media(prefers-color-scheme:dark){.prose a{color:#d6896e;text-decoration-color:#d6896e66}.prose a:hover{text-decoration-color:#d6896e}}.prose code{font-family:SF Mono,Fira Code,JetBrains Mono,Cascadia Code,Menlo,Consolas,monospace;font-size:.85em;padding:.2em .45em;border-radius:5px;background:#e8e0d0;color:#5c3d2e;font-weight:500}@media(prefers-color-scheme:dark){.prose code{background:#382f2a;color:#e0c4a8}}.prose pre{margin-top:1em;margin-bottom:1em;padding:1.1em 1.3em;border-radius:10px;overflow-x:auto;background:#2b2420;border:1px solid #3D3530;line-height:1.6}@media(prefers-color-scheme:dark){.prose pre{background:#1a1514;border-color:#332c28}}.prose pre code{padding:0;background:none;border-radius:0;font-size:.8125em;font-weight:400;color:#e8ded0}.prose pre code .hljs-keyword,.prose pre code .hljs-selector-tag,.prose pre code .hljs-built_in{color:#e8a87c}.prose pre code .hljs-string,.prose pre code .hljs-attr{color:#a8c9a0}.prose pre code .hljs-number,.prose pre code .hljs-literal{color:#d4a574}.prose pre code .hljs-type,.prose pre code .hljs-title,.prose pre code .hljs-class .hljs-title{color:#c4b4e0;font-weight:500}.prose pre code .hljs-function,.prose pre code .hljs-title.function_{color:#8cc8d0}.prose pre code .hljs-comment,.prose pre code .hljs-doctag{color:#7a7068;font-style:italic}.prose pre code .hljs-variable,.prose pre code .hljs-template-variable{color:#e0c4a8}.prose pre code .hljs-property{color:#d0b8a0}.prose pre code .hljs-meta,.prose pre code .hljs-preprocessor{color:#b0a090}.prose pre code .hljs-punctuation{color:#9a8e80}.prose pre code .hljs-addition{color:#a8c9a0;background:#a8c9a01a}.prose pre code .hljs-deletion{color:#d08080;background:#d080801a}.prose blockquote{border-left:3px solid #CD7357;padding-left:1em;margin-left:0;margin-top:.75em;margin-bottom:.75em;color:#504838}@media(prefers-color-scheme:dark){.prose blockquote{border-left-color:#d67f63;color:#b8aea0}}.prose table{width:100%;border-collapse:collapse;margin-top:1em;margin-bottom:1em;font-size:.875em}.prose th,.prose td{border:1px solid #DFD7C6;padding:.6em .85em;text-align:left}.prose th{font-weight:600;background:#ede5d5;color:#1a1610}@media(prefers-color-scheme:dark){.prose th,.prose td{border-color:#44403b}.prose th{background:#332c28;color:#f5f0e5}}.prose hr{border:none;border-top:1px solid #DFD7C6;margin:2em 0}@media(prefers-color-scheme:dark){.prose hr{border-top-color:#44403b}}mark.comment-highlight{background-color:#cd735724;border-bottom:2px solid rgba(205,115,87,.45);padding:1px 0;border-radius:2px;cursor:pointer;transition:background-color .2s ease,border-color .2s ease;color:inherit}mark.comment-highlight:hover,mark.comment-highlight.active{background-color:#cd73574d;border-bottom-color:#cd7357cc}mark.comment-highlight-pending{background-color:#cd73572e;border-bottom:2px solid rgba(205,115,87,.5);padding:1px 0;border-radius:2px;color:inherit}@media(prefers-color-scheme:dark){mark.comment-highlight{background-color:#d67f6324;border-bottom-color:#d67f6373}mark.comment-highlight:hover,mark.comment-highlight.active{background-color:#d67f634d;border-bottom-color:#d67f63cc}mark.comment-highlight-pending{background-color:#d67f632e;border-bottom-color:#d67f6380}}::-moz-selection{background:#cd735738;color:inherit}::selection{background:#cd735738;color:inherit}@media(prefers-color-scheme:dark){::-moz-selection{background:#d67f6347}::selection{background:#d67f6347}}.placeholder\:text-claude-text-tertiary-light::-moz-placeholder{--tw-text-opacity: 1;color:rgb(142 131 112 / var(--tw-text-opacity, 1))}.placeholder\:text-claude-text-tertiary-light::placeholder{--tw-text-opacity: 1;color:rgb(142 131 112 / var(--tw-text-opacity, 1))}.hover\:scale-110:hover{--tw-scale-x: 1.1;--tw-scale-y: 1.1;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.hover\:bg-claude-border-light\/30:hover{background-color:#dfd7c64d}.hover\:bg-claude-border-light\/50:hover{background-color:#dfd7c680}.hover\:bg-red-50:hover{--tw-bg-opacity: 1;background-color:rgb(254 242 242 / var(--tw-bg-opacity, 1))}.hover\:text-claude-accent-light:hover{--tw-text-opacity: 1;color:rgb(205 115 87 / var(--tw-text-opacity, 1))}.hover\:text-claude-text-primary-light:hover{--tw-text-opacity: 1;color:rgb(40 34 24 / var(--tw-text-opacity, 1))}.hover\:text-red-500:hover{--tw-text-opacity: 1;color:rgb(239 68 68 / var(--tw-text-opacity, 1))}.hover\:opacity-90:hover{opacity:.9}.focus\:border-claude-accent-light:focus{--tw-border-opacity: 1;border-color:rgb(205 115 87 / var(--tw-border-opacity, 1))}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-30:disabled{opacity:.3}.disabled\:opacity-40:disabled{opacity:.4}.disabled\:opacity-50:disabled{opacity:.5}.group:hover .group-hover\:opacity-100{opacity:1}@media(min-width:768px){.md\:p-xl{padding:40px}}@media(min-width:1280px){.xl\:block{display:block}.xl\:hidden{display:none}}@media(prefers-color-scheme:dark){.dark\:border-claude-accent-dark{--tw-border-opacity: 1;border-color:rgb(214 127 99 / var(--tw-border-opacity, 1))}.dark\:border-claude-border-dark{--tw-border-opacity: 1;border-color:rgb(68 64 59 / var(--tw-border-opacity, 1))}.dark\:bg-claude-accent-dark{--tw-bg-opacity: 1;background-color:rgb(214 127 99 / var(--tw-bg-opacity, 1))}.dark\:bg-claude-bg-dark{--tw-bg-opacity: 1;background-color:rgb(32 25 24 / var(--tw-bg-opacity, 1))}.dark\:bg-claude-success-dark{--tw-bg-opacity: 1;background-color:rgb(111 187 148 / var(--tw-bg-opacity, 1))}.dark\:bg-claude-surface-dark{--tw-bg-opacity: 1;background-color:rgb(42 39 35 / var(--tw-bg-opacity, 1))}.dark\:bg-claude-surface-dark\/80{background-color:#2a2723cc}.dark\:bg-claude-text-tertiary-dark{--tw-bg-opacity: 1;background-color:rgb(109 103 106 / var(--tw-bg-opacity, 1))}.dark\:text-claude-accent-dark{--tw-text-opacity: 1;color:rgb(214 127 99 / var(--tw-text-opacity, 1))}.dark\:text-claude-text-primary-dark{--tw-text-opacity: 1;color:rgb(237 231 220 / var(--tw-text-opacity, 1))}.dark\:text-claude-text-secondary-dark{--tw-text-opacity: 1;color:rgb(158 153 149 / var(--tw-text-opacity, 1))}.dark\:text-claude-text-tertiary-dark{--tw-text-opacity: 1;color:rgb(109 103 106 / var(--tw-text-opacity, 1))}.dark\:placeholder\:text-claude-text-tertiary-dark::-moz-placeholder{--tw-text-opacity: 1;color:rgb(109 103 106 / var(--tw-text-opacity, 1))}.dark\:placeholder\:text-claude-text-tertiary-dark::placeholder{--tw-text-opacity: 1;color:rgb(109 103 106 / var(--tw-text-opacity, 1))}.dark\:hover\:bg-claude-border-dark\/30:hover{background-color:#44403b4d}.dark\:hover\:bg-claude-border-dark\/50:hover{background-color:#44403b80}.dark\:hover\:bg-red-900\/20:hover{background-color:#7f1d1d33}.dark\:hover\:text-claude-accent-dark:hover{--tw-text-opacity: 1;color:rgb(214 127 99 / var(--tw-text-opacity, 1))}.dark\:hover\:text-claude-text-primary-dark:hover{--tw-text-opacity: 1;color:rgb(237 231 220 / var(--tw-text-opacity, 1))}.dark\:focus\:border-claude-accent-dark:focus{--tw-border-opacity: 1;border-color:rgb(214 127 99 / var(--tw-border-opacity, 1))}}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>cc-plan-viewer</title>
|
|
7
|
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
8
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
9
|
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
|
10
|
+
<script type="module" crossorigin src="/assets/index-B9Ku0w1F.js"></script>
|
|
11
|
+
<link rel="stylesheet" crossorigin href="/assets/index-CFUAZEOC.css">
|
|
12
|
+
</head>
|
|
13
|
+
<body>
|
|
14
|
+
<div id="root"></div>
|
|
15
|
+
</body>
|
|
16
|
+
</html>
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import { execSync } from 'node:child_process';
|
|
6
|
+
const SETTINGS_PATH = path.join(os.homedir(), '.claude', 'settings.json');
|
|
7
|
+
const HOOKS_DIR = path.join(os.homedir(), '.claude', 'hooks');
|
|
8
|
+
// Resolve the hook script path
|
|
9
|
+
// When compiled: dist/server/bin/ → dist/server/ → dist/ → pkg root → hooks/
|
|
10
|
+
// When running via tsx: bin/ → pkg root → hooks/
|
|
11
|
+
const hookSource = path.resolve(import.meta.dirname, '..', '..', '..', 'hooks', 'plan-viewer-hook.cjs');
|
|
12
|
+
// Fallback for dev mode (running from bin/ directly)
|
|
13
|
+
const hookSourceDev = path.resolve(import.meta.dirname, '..', 'hooks', 'plan-viewer-hook.cjs');
|
|
14
|
+
const resolvedHookSource = fs.existsSync(hookSource) ? hookSource : hookSourceDev;
|
|
15
|
+
function readSettings() {
|
|
16
|
+
try {
|
|
17
|
+
return JSON.parse(fs.readFileSync(SETTINGS_PATH, 'utf8'));
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return {};
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function writeSettings(settings) {
|
|
24
|
+
fs.writeFileSync(SETTINGS_PATH, JSON.stringify(settings, null, 2) + '\n', 'utf8');
|
|
25
|
+
}
|
|
26
|
+
function getHookCommand() {
|
|
27
|
+
return `node "${resolvedHookSource}"`;
|
|
28
|
+
}
|
|
29
|
+
function install() {
|
|
30
|
+
console.log('[cc-plan-viewer] Installing hook...');
|
|
31
|
+
// Ensure hooks directory exists
|
|
32
|
+
if (!fs.existsSync(HOOKS_DIR)) {
|
|
33
|
+
fs.mkdirSync(HOOKS_DIR, { recursive: true });
|
|
34
|
+
}
|
|
35
|
+
// Read current settings
|
|
36
|
+
const settings = readSettings();
|
|
37
|
+
// Ensure hooks object exists
|
|
38
|
+
if (!settings.hooks || typeof settings.hooks !== 'object') {
|
|
39
|
+
settings.hooks = {};
|
|
40
|
+
}
|
|
41
|
+
const hooks = settings.hooks;
|
|
42
|
+
// Ensure PostToolUse array exists
|
|
43
|
+
if (!Array.isArray(hooks.PostToolUse)) {
|
|
44
|
+
hooks.PostToolUse = [];
|
|
45
|
+
}
|
|
46
|
+
const hookCommand = getHookCommand();
|
|
47
|
+
// Check if already installed
|
|
48
|
+
const alreadyInstalled = hooks.PostToolUse.some((entry) => {
|
|
49
|
+
if (typeof entry !== 'object' || entry === null)
|
|
50
|
+
return false;
|
|
51
|
+
const e = entry;
|
|
52
|
+
if (!Array.isArray(e.hooks))
|
|
53
|
+
return false;
|
|
54
|
+
return e.hooks.some((h) => {
|
|
55
|
+
if (typeof h !== 'object' || h === null)
|
|
56
|
+
return false;
|
|
57
|
+
return h.command === hookCommand;
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
if (alreadyInstalled) {
|
|
61
|
+
console.log('[cc-plan-viewer] Hook already installed.');
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
// Add the hook entry
|
|
65
|
+
hooks.PostToolUse.push({
|
|
66
|
+
matcher: 'Write|Edit',
|
|
67
|
+
hooks: [
|
|
68
|
+
{
|
|
69
|
+
type: 'command',
|
|
70
|
+
command: hookCommand,
|
|
71
|
+
},
|
|
72
|
+
],
|
|
73
|
+
});
|
|
74
|
+
writeSettings(settings);
|
|
75
|
+
console.log('[cc-plan-viewer] Hook installed successfully.');
|
|
76
|
+
console.log(`[cc-plan-viewer] Hook script: ${resolvedHookSource}`);
|
|
77
|
+
console.log('[cc-plan-viewer] Added to: ~/.claude/settings.json (PostToolUse)');
|
|
78
|
+
}
|
|
79
|
+
function uninstall() {
|
|
80
|
+
console.log('[cc-plan-viewer] Uninstalling hook...');
|
|
81
|
+
const settings = readSettings();
|
|
82
|
+
const hooks = settings.hooks;
|
|
83
|
+
if (!hooks?.PostToolUse || !Array.isArray(hooks.PostToolUse)) {
|
|
84
|
+
console.log('[cc-plan-viewer] No hook found to remove.');
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const hookCommand = getHookCommand();
|
|
88
|
+
const before = hooks.PostToolUse.length;
|
|
89
|
+
hooks.PostToolUse = hooks.PostToolUse.filter((entry) => {
|
|
90
|
+
if (typeof entry !== 'object' || entry === null)
|
|
91
|
+
return true;
|
|
92
|
+
const e = entry;
|
|
93
|
+
if (!Array.isArray(e.hooks))
|
|
94
|
+
return true;
|
|
95
|
+
return !e.hooks.some((h) => {
|
|
96
|
+
if (typeof h !== 'object' || h === null)
|
|
97
|
+
return false;
|
|
98
|
+
const cmd = h.command;
|
|
99
|
+
return typeof cmd === 'string' && cmd.includes('plan-viewer-hook');
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
if (hooks.PostToolUse.length === before) {
|
|
103
|
+
console.log('[cc-plan-viewer] No hook found to remove.');
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
writeSettings(settings);
|
|
107
|
+
console.log('[cc-plan-viewer] Hook removed successfully.');
|
|
108
|
+
}
|
|
109
|
+
function start() {
|
|
110
|
+
console.log('[cc-plan-viewer] Starting server...');
|
|
111
|
+
// Import and run the server
|
|
112
|
+
import('../server/index.js');
|
|
113
|
+
}
|
|
114
|
+
function open(filename) {
|
|
115
|
+
const port = 3847;
|
|
116
|
+
const url = filename
|
|
117
|
+
? `http://localhost:${port}/?plan=${encodeURIComponent(filename)}`
|
|
118
|
+
: `http://localhost:${port}`;
|
|
119
|
+
try {
|
|
120
|
+
execSync(`open "${url}"`, { stdio: 'ignore' });
|
|
121
|
+
console.log(`[cc-plan-viewer] Opened ${url}`);
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
console.log(`[cc-plan-viewer] Open this URL in your browser: ${url}`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
// CLI
|
|
128
|
+
const command = process.argv[2];
|
|
129
|
+
switch (command) {
|
|
130
|
+
case 'install':
|
|
131
|
+
install();
|
|
132
|
+
break;
|
|
133
|
+
case 'uninstall':
|
|
134
|
+
uninstall();
|
|
135
|
+
break;
|
|
136
|
+
case 'start':
|
|
137
|
+
start();
|
|
138
|
+
break;
|
|
139
|
+
case 'open':
|
|
140
|
+
open(process.argv[3]);
|
|
141
|
+
break;
|
|
142
|
+
default:
|
|
143
|
+
console.log(`
|
|
144
|
+
cc-plan-viewer — Browser-based review UI for Claude Code plans
|
|
145
|
+
|
|
146
|
+
Commands:
|
|
147
|
+
install Add the PostToolUse hook to ~/.claude/settings.json
|
|
148
|
+
uninstall Remove the hook
|
|
149
|
+
start Start the server (for development)
|
|
150
|
+
open [file] Open a plan in the browser
|
|
151
|
+
`);
|
|
152
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import { createServer } from 'node:http';
|
|
3
|
+
import { WebSocketServer, WebSocket } from 'ws';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import os from 'node:os';
|
|
7
|
+
import { parsePlan } from './planParser.js';
|
|
8
|
+
import { saveReview, getReview } from './reviewStore.js';
|
|
9
|
+
import { writePidFile, writePortFile, cleanupFiles, resetIdleTimer } from './lifecycle.js';
|
|
10
|
+
import { watchPlansDir } from './planWatcher.js';
|
|
11
|
+
const PORT = parseInt(process.env.PORT || '3847', 10);
|
|
12
|
+
// Auto-detect plans directory
|
|
13
|
+
function findPlansDir() {
|
|
14
|
+
const home = os.homedir();
|
|
15
|
+
const candidates = [
|
|
16
|
+
path.join(home, '.claude-personal', 'plans'),
|
|
17
|
+
path.join(home, '.claude', 'plans'),
|
|
18
|
+
];
|
|
19
|
+
for (const dir of candidates) {
|
|
20
|
+
if (fs.existsSync(dir))
|
|
21
|
+
return dir;
|
|
22
|
+
}
|
|
23
|
+
// Default to first candidate even if it doesn't exist yet
|
|
24
|
+
return candidates[0];
|
|
25
|
+
}
|
|
26
|
+
const plansDir = findPlansDir();
|
|
27
|
+
const app = express();
|
|
28
|
+
const server = createServer(app);
|
|
29
|
+
const wss = new WebSocketServer({ server });
|
|
30
|
+
app.use(express.json());
|
|
31
|
+
// Reset idle timer on every request
|
|
32
|
+
app.use((_req, _res, next) => {
|
|
33
|
+
resetIdleTimer();
|
|
34
|
+
next();
|
|
35
|
+
});
|
|
36
|
+
// Health check
|
|
37
|
+
app.get('/health', (_req, res) => {
|
|
38
|
+
res.json({ status: 'ok', plansDir });
|
|
39
|
+
});
|
|
40
|
+
// List all plans
|
|
41
|
+
app.get('/api/plans', (_req, res) => {
|
|
42
|
+
try {
|
|
43
|
+
if (!fs.existsSync(plansDir)) {
|
|
44
|
+
res.json([]);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const files = fs.readdirSync(plansDir)
|
|
48
|
+
.filter(f => f.endsWith('.md') && !f.endsWith('.review.json'))
|
|
49
|
+
.map(f => {
|
|
50
|
+
const filePath = path.join(plansDir, f);
|
|
51
|
+
const stat = fs.statSync(filePath);
|
|
52
|
+
const review = getReview(filePath);
|
|
53
|
+
return {
|
|
54
|
+
filename: f,
|
|
55
|
+
modified: stat.mtime.toISOString(),
|
|
56
|
+
size: stat.size,
|
|
57
|
+
hasReview: !!review,
|
|
58
|
+
reviewAction: review?.action ?? null,
|
|
59
|
+
};
|
|
60
|
+
})
|
|
61
|
+
.sort((a, b) => new Date(b.modified).getTime() - new Date(a.modified).getTime());
|
|
62
|
+
res.json(files);
|
|
63
|
+
}
|
|
64
|
+
catch (err) {
|
|
65
|
+
res.status(500).json({ error: 'Failed to list plans' });
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
// Get a specific plan
|
|
69
|
+
app.get('/api/plans/:filename', (req, res) => {
|
|
70
|
+
const filename = req.params.filename;
|
|
71
|
+
if (!filename.endsWith('.md') || filename.includes('..')) {
|
|
72
|
+
res.status(400).json({ error: 'Invalid filename' });
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const filePath = path.join(plansDir, filename);
|
|
76
|
+
try {
|
|
77
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
78
|
+
const parsed = parsePlan(content);
|
|
79
|
+
const review = getReview(filePath);
|
|
80
|
+
res.json({ filename, parsed, review });
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
res.status(404).json({ error: 'Plan not found' });
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
// Hook notifies of plan update
|
|
87
|
+
app.post('/api/plan-updated', (req, res) => {
|
|
88
|
+
const { filePath, planOptions } = req.body;
|
|
89
|
+
const filename = path.basename(filePath || '');
|
|
90
|
+
// Broadcast to all WebSocket clients
|
|
91
|
+
const message = JSON.stringify({
|
|
92
|
+
type: 'plan-updated',
|
|
93
|
+
filename,
|
|
94
|
+
planOptions: planOptions || null,
|
|
95
|
+
});
|
|
96
|
+
for (const client of wss.clients) {
|
|
97
|
+
if (client.readyState === WebSocket.OPEN) {
|
|
98
|
+
client.send(message);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
res.json({ ok: true });
|
|
102
|
+
});
|
|
103
|
+
// Save a review
|
|
104
|
+
app.post('/api/reviews/:filename', (req, res) => {
|
|
105
|
+
const filename = req.params.filename;
|
|
106
|
+
if (!filename.endsWith('.md') || filename.includes('..')) {
|
|
107
|
+
res.status(400).json({ error: 'Invalid filename' });
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
const filePath = path.join(plansDir, filename);
|
|
111
|
+
if (!fs.existsSync(filePath)) {
|
|
112
|
+
res.status(404).json({ error: 'Plan not found' });
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
const review = {
|
|
116
|
+
planFile: filename,
|
|
117
|
+
action: req.body.action || 'feedback',
|
|
118
|
+
submittedAt: new Date().toISOString(),
|
|
119
|
+
consumedAt: null,
|
|
120
|
+
overallComment: req.body.overallComment || '',
|
|
121
|
+
inlineComments: req.body.inlineComments || [],
|
|
122
|
+
};
|
|
123
|
+
saveReview(filePath, review);
|
|
124
|
+
// Notify clients
|
|
125
|
+
const message = JSON.stringify({
|
|
126
|
+
type: 'review-submitted',
|
|
127
|
+
filename,
|
|
128
|
+
action: review.action,
|
|
129
|
+
});
|
|
130
|
+
for (const client of wss.clients) {
|
|
131
|
+
if (client.readyState === WebSocket.OPEN) {
|
|
132
|
+
client.send(message);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
res.json({ ok: true, review });
|
|
136
|
+
});
|
|
137
|
+
// Get a review
|
|
138
|
+
app.get('/api/reviews/:filename', (req, res) => {
|
|
139
|
+
const filename = req.params.filename;
|
|
140
|
+
const filePath = path.join(plansDir, filename);
|
|
141
|
+
const review = getReview(filePath);
|
|
142
|
+
if (!review) {
|
|
143
|
+
res.status(404).json({ error: 'No review found' });
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
res.json(review);
|
|
147
|
+
});
|
|
148
|
+
// Serve SPA static files
|
|
149
|
+
// Try multiple paths: dist/client relative to project root, or relative to compiled output
|
|
150
|
+
const clientDistCandidates = [
|
|
151
|
+
path.join(import.meta.dirname, '..', '..', 'client'), // prod: dist/server/server/ → dist/client/
|
|
152
|
+
path.join(import.meta.dirname, '..', 'dist', 'client'), // dev: server/ → dist/client/
|
|
153
|
+
];
|
|
154
|
+
const clientDist = clientDistCandidates.find((d) => fs.existsSync(d));
|
|
155
|
+
if (clientDist) {
|
|
156
|
+
app.use(express.static(clientDist));
|
|
157
|
+
app.get('/{*path}', (_req, res) => {
|
|
158
|
+
res.sendFile(path.join(clientDist, 'index.html'));
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
// WebSocket connection
|
|
162
|
+
wss.on('connection', (ws) => {
|
|
163
|
+
resetIdleTimer();
|
|
164
|
+
ws.on('message', () => resetIdleTimer());
|
|
165
|
+
});
|
|
166
|
+
// Start
|
|
167
|
+
server.listen(PORT, () => {
|
|
168
|
+
writePidFile();
|
|
169
|
+
writePortFile(PORT);
|
|
170
|
+
resetIdleTimer();
|
|
171
|
+
console.log(`[cc-plan-viewer] Server running at http://localhost:${PORT}`);
|
|
172
|
+
console.log(`[cc-plan-viewer] Plans directory: ${plansDir}`);
|
|
173
|
+
});
|
|
174
|
+
// Watch for plan file changes
|
|
175
|
+
watchPlansDir(plansDir, (filename, content) => {
|
|
176
|
+
const message = JSON.stringify({
|
|
177
|
+
type: 'plan-updated',
|
|
178
|
+
filename,
|
|
179
|
+
});
|
|
180
|
+
for (const client of wss.clients) {
|
|
181
|
+
if (client.readyState === WebSocket.OPEN) {
|
|
182
|
+
client.send(message);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
// Cleanup on exit
|
|
187
|
+
process.on('SIGINT', () => { cleanupFiles(); process.exit(0); });
|
|
188
|
+
process.on('SIGTERM', () => { cleanupFiles(); process.exit(0); });
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
const TMP_DIR = os.tmpdir();
|
|
5
|
+
const PID_FILE = path.join(TMP_DIR, 'cc-plan-viewer.pid');
|
|
6
|
+
const PORT_FILE = path.join(TMP_DIR, 'cc-plan-viewer-port');
|
|
7
|
+
const IDLE_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
|
|
8
|
+
let idleTimer = null;
|
|
9
|
+
export function writePidFile() {
|
|
10
|
+
fs.writeFileSync(PID_FILE, String(process.pid), 'utf8');
|
|
11
|
+
}
|
|
12
|
+
export function writePortFile(port) {
|
|
13
|
+
fs.writeFileSync(PORT_FILE, String(port), 'utf8');
|
|
14
|
+
}
|
|
15
|
+
export function cleanupFiles() {
|
|
16
|
+
try {
|
|
17
|
+
fs.unlinkSync(PID_FILE);
|
|
18
|
+
}
|
|
19
|
+
catch { }
|
|
20
|
+
try {
|
|
21
|
+
fs.unlinkSync(PORT_FILE);
|
|
22
|
+
}
|
|
23
|
+
catch { }
|
|
24
|
+
}
|
|
25
|
+
export function resetIdleTimer() {
|
|
26
|
+
if (idleTimer)
|
|
27
|
+
clearTimeout(idleTimer);
|
|
28
|
+
idleTimer = setTimeout(() => {
|
|
29
|
+
console.log('[cc-plan-viewer] Idle timeout reached, shutting down.');
|
|
30
|
+
cleanupFiles();
|
|
31
|
+
process.exit(0);
|
|
32
|
+
}, IDLE_TIMEOUT_MS);
|
|
33
|
+
// Don't keep the process alive just for the timer
|
|
34
|
+
idleTimer.unref();
|
|
35
|
+
}
|
|
36
|
+
export function readPortFile() {
|
|
37
|
+
try {
|
|
38
|
+
const port = parseInt(fs.readFileSync(PORT_FILE, 'utf8').trim(), 10);
|
|
39
|
+
return isNaN(port) ? null : port;
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
export function isServerRunning() {
|
|
46
|
+
try {
|
|
47
|
+
const pid = parseInt(fs.readFileSync(PID_FILE, 'utf8').trim(), 10);
|
|
48
|
+
if (isNaN(pid))
|
|
49
|
+
return false;
|
|
50
|
+
process.kill(pid, 0); // check if alive
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
function slugify(text) {
|
|
2
|
+
return text
|
|
3
|
+
.toLowerCase()
|
|
4
|
+
.replace(/[^a-z0-9\s-]/g, '')
|
|
5
|
+
.replace(/\s+/g, '-')
|
|
6
|
+
.replace(/-+/g, '-')
|
|
7
|
+
.trim();
|
|
8
|
+
}
|
|
9
|
+
export function parsePlan(markdown) {
|
|
10
|
+
const lines = markdown.split('\n');
|
|
11
|
+
const flatSections = [];
|
|
12
|
+
let title = '';
|
|
13
|
+
let currentSection = null;
|
|
14
|
+
for (let i = 0; i < lines.length; i++) {
|
|
15
|
+
const line = lines[i];
|
|
16
|
+
const headingMatch = line.match(/^(#{1,6})\s+(.+)$/);
|
|
17
|
+
if (headingMatch) {
|
|
18
|
+
const level = headingMatch[1].length;
|
|
19
|
+
const heading = headingMatch[2].trim();
|
|
20
|
+
if (level === 1 && !title) {
|
|
21
|
+
title = heading;
|
|
22
|
+
}
|
|
23
|
+
if (currentSection) {
|
|
24
|
+
currentSection.endLine = i;
|
|
25
|
+
currentSection.rawContent = lines
|
|
26
|
+
.slice(currentSection.startLine, i)
|
|
27
|
+
.join('\n');
|
|
28
|
+
}
|
|
29
|
+
currentSection = {
|
|
30
|
+
id: slugify(heading) || `section-${i}`,
|
|
31
|
+
heading,
|
|
32
|
+
level,
|
|
33
|
+
startLine: i,
|
|
34
|
+
endLine: lines.length,
|
|
35
|
+
rawContent: '',
|
|
36
|
+
children: [],
|
|
37
|
+
};
|
|
38
|
+
flatSections.push(currentSection);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
if (currentSection) {
|
|
42
|
+
currentSection.endLine = lines.length;
|
|
43
|
+
currentSection.rawContent = lines
|
|
44
|
+
.slice(currentSection.startLine)
|
|
45
|
+
.join('\n');
|
|
46
|
+
}
|
|
47
|
+
// Build hierarchy: h3s become children of preceding h2, etc.
|
|
48
|
+
const rootSections = [];
|
|
49
|
+
const stack = [];
|
|
50
|
+
for (const section of flatSections) {
|
|
51
|
+
while (stack.length > 0 && stack[stack.length - 1].level >= section.level) {
|
|
52
|
+
stack.pop();
|
|
53
|
+
}
|
|
54
|
+
if (stack.length > 0) {
|
|
55
|
+
stack[stack.length - 1].children.push(section);
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
rootSections.push(section);
|
|
59
|
+
}
|
|
60
|
+
stack.push(section);
|
|
61
|
+
}
|
|
62
|
+
return {
|
|
63
|
+
title: title || 'Untitled Plan',
|
|
64
|
+
sections: rootSections,
|
|
65
|
+
rawMarkdown: markdown,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
export function watchPlansDir(plansDir, onUpdate) {
|
|
4
|
+
if (!fs.existsSync(plansDir)) {
|
|
5
|
+
console.warn(`[cc-plan-viewer] Plans directory not found: ${plansDir}`);
|
|
6
|
+
return;
|
|
7
|
+
}
|
|
8
|
+
const debounceMap = new Map();
|
|
9
|
+
fs.watch(plansDir, (eventType, filename) => {
|
|
10
|
+
if (!filename || !filename.endsWith('.md'))
|
|
11
|
+
return;
|
|
12
|
+
// Ignore review files
|
|
13
|
+
if (filename.endsWith('.review.json'))
|
|
14
|
+
return;
|
|
15
|
+
// Debounce 300ms per file
|
|
16
|
+
const existing = debounceMap.get(filename);
|
|
17
|
+
if (existing)
|
|
18
|
+
clearTimeout(existing);
|
|
19
|
+
debounceMap.set(filename, setTimeout(() => {
|
|
20
|
+
debounceMap.delete(filename);
|
|
21
|
+
const filePath = path.join(plansDir, filename);
|
|
22
|
+
try {
|
|
23
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
24
|
+
onUpdate(filename, content);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
// File may have been deleted
|
|
28
|
+
}
|
|
29
|
+
}, 300));
|
|
30
|
+
});
|
|
31
|
+
console.log(`[cc-plan-viewer] Watching plans directory: ${plansDir}`);
|
|
32
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
function reviewPathFor(planPath) {
|
|
4
|
+
const dir = path.dirname(planPath);
|
|
5
|
+
const base = path.basename(planPath, '.md');
|
|
6
|
+
return path.join(dir, `${base}.review.json`);
|
|
7
|
+
}
|
|
8
|
+
export function saveReview(planPath, review) {
|
|
9
|
+
const reviewPath = reviewPathFor(planPath);
|
|
10
|
+
fs.writeFileSync(reviewPath, JSON.stringify(review, null, 2), 'utf8');
|
|
11
|
+
}
|
|
12
|
+
export function getReview(planPath) {
|
|
13
|
+
const reviewPath = reviewPathFor(planPath);
|
|
14
|
+
if (!fs.existsSync(reviewPath))
|
|
15
|
+
return null;
|
|
16
|
+
try {
|
|
17
|
+
return JSON.parse(fs.readFileSync(reviewPath, 'utf8'));
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export function getUnconsumedReview(planPath) {
|
|
24
|
+
const review = getReview(planPath);
|
|
25
|
+
if (review && !review.consumedAt)
|
|
26
|
+
return review;
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
export function markConsumed(planPath) {
|
|
30
|
+
const review = getReview(planPath);
|
|
31
|
+
if (review) {
|
|
32
|
+
review.consumedAt = new Date().toISOString();
|
|
33
|
+
const reviewPath = reviewPathFor(planPath);
|
|
34
|
+
fs.writeFileSync(reviewPath, JSON.stringify(review, null, 2), 'utf8');
|
|
35
|
+
}
|
|
36
|
+
}
|