diff --git a/go.mod b/go.mod index 9e81dd1..14cd1ed 100644 --- a/go.mod +++ b/go.mod @@ -24,11 +24,15 @@ require ( go.uber.org/mock v0.6.0 golang.org/x/sys v0.36.0 golang.org/x/term v0.35.0 + google.golang.org/genai v1.39.0 gopkg.in/yaml.v3 v3.0.1 modernc.org/sqlite v1.39.0 ) require ( + cloud.google.com/go v0.116.0 // indirect + cloud.google.com/go/auth v0.9.3 // indirect + cloud.google.com/go/compute/metadata v0.5.0 // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/catppuccin/go v0.3.0 // indirect @@ -42,6 +46,11 @@ require ( github.com/dustin/go-humanize v1.0.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect + github.com/google/go-cmp v0.6.0 // indirect + github.com/google/s2a-go v0.1.8 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect + github.com/gorilla/websocket v1.5.3 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect @@ -61,9 +70,15 @@ require ( github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + go.opencensus.io v0.24.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/crypto v0.36.0 // indirect golang.org/x/exp v0.0.0-20251002181428-27f1f14c8bb9 // indirect + golang.org/x/net v0.38.0 // indirect golang.org/x/text v0.29.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 // indirect + google.golang.org/grpc v1.66.2 // indirect + google.golang.org/protobuf v1.34.2 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect modernc.org/libc v1.66.10 // indirect modernc.org/mathutil v1.7.1 // indirect diff --git a/go.sum b/go.sum index 6f5216e..85a8b93 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,11 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.116.0 h1:B3fRrSDkLRt5qSHWe40ERJvhvnQwdZiHu0bJOpldweE= +cloud.google.com/go v0.116.0/go.mod h1:cEPSRWPzZEswwdr9BxE6ChEn01dWlTaF05LiC2Xs70U= +cloud.google.com/go/auth v0.9.3 h1:VOEUIAADkkLtyfr3BLa3R8Ed/j6w1jTBmARx+wb5w5U= +cloud.google.com/go/auth v0.9.3/go.mod h1:7z6VY+7h3KUdRov5F1i8NDP5ZzWKYmEPO842BgCsmTk= +cloud.google.com/go/compute/metadata v0.5.0 h1:Zr0eK8JbFv6+Wi4ilXAR8FJ3wyNdpxHKJNPos6LTZOY= +cloud.google.com/go/compute/metadata v0.5.0/go.mod h1:aHnloV2TPI38yx4s9+wAZhHykWvVCfu7hQbF+9CWoiY= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= @@ -10,6 +18,7 @@ github.com/aymerick/raymond v2.0.2+incompatible h1:VEp3GpgdAnv9B2GFyTvqgcKvY+mfK github.com/aymerick/raymond v2.0.2+incompatible/go.mod h1:osfaiScAUVup+UC9Nfq76eWqDhXlp+4UYaA8uhTBO6g= github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/censys/censys-sdk-go v0.22.3 h1:CuTV5pS9HhUmrDuKa+qTnD+kCsfqfA8lqZllAZjSw2o= github.com/censys/censys-sdk-go v0.22.3/go.mod h1:vyRClQGsBluBX6rSJoHhUn9LQMWtHpNiCXB+aZfgBqI= github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= @@ -40,8 +49,10 @@ github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8 github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI= github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY= github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= @@ -51,6 +62,10 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= @@ -62,12 +77,39 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= +github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/s2a-go v0.1.8 h1:zZDs9gcbt9ZPLV0ndSyQk6Kacx2g/X+SKYovpnz3SMM= +github.com/google/s2a-go v0.1.8/go.mod h1:6iNWHTpQ+nfNRN5E00MSdfDwVesa8hhS32PhPO8deJA= +github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/enterprise-certificate-proxy v0.3.4 h1:XYIDZApgAnrN1c855gTgghdIA6Stxb52D5RnLI1SLyw= +github.com/googleapis/enterprise-certificate-proxy v0.3.4/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -108,6 +150,7 @@ github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= @@ -139,6 +182,7 @@ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSS github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= @@ -153,18 +197,44 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= +go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= +golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20251002181428-27f1f14c8bb9 h1:TQwNpfvNkxAVlItJf6Cr5JTsVZoC/Sj7K3OZv2Pc14A= golang.org/x/exp v0.0.0-20251002181428-27f1f14c8bb9/go.mod h1:TwQYMMnGpvZyc+JpB/UAuTNIsVJifOlSkrZkhcvpVUk= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U= golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= +golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211110154304-99a53858aa08/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -175,10 +245,45 @@ golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.14.0/go.mod h1:TySc+nGkYR6qt8km8wUhuFRTVSMIX3XPR58y2lC8vww= golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ= golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE= golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/genai v1.39.0 h1:80I1sYFGROliWNxEgPWDklNYVO8xq/bNvw70BFh6XmA= +google.golang.org/genai v1.39.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 h1:pPJltXNxVzT4pK9yD8vR9X75DaWYYmLGMsEvBfFQZzQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= +google.golang.org/grpc v1.66.2 h1:3QdXkuq3Bkh7w+ywLdLvM56cmGvQHUMZpiCzt6Rqaoo= +google.golang.org/grpc v1.66.2/go.mod h1:s3/l6xSSCURdVfAnL+TqCNMyTDAGN6+lZeVxnZR128Y= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= +google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= +google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= @@ -187,6 +292,8 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4= modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A= diff --git a/internal/app/chartgen/dto.go b/internal/app/chartgen/dto.go new file mode 100644 index 0000000..1ab7b5d --- /dev/null +++ b/internal/app/chartgen/dto.go @@ -0,0 +1,31 @@ +package chartgen + +import ( + "github.com/censys/cencli/internal/app/chartgen/prompts" +) + +// Params contains parameters for chart generation. +type Params struct { + // Query is the Censys query string used to generate the data. + Query string + // Field is the aggregation field. + Field string + // ChartType is the type of chart to generate (e.g., "geomap", "pie", "bar"). + ChartType string + // Buckets contains the aggregation data. + Buckets []prompts.Bucket + // TotalCount is the total count across all buckets. + TotalCount uint64 + // OtherCount is the count of items not in the top buckets. + OtherCount uint64 + // NumImages is the number of images to generate. + NumImages int +} + +// Result contains the generated chart images. +type Result struct { + // Images contains the generated PNG image bytes. + Images [][]byte + // Prompt is the prompt used for generation. + Prompt string +} diff --git a/internal/app/chartgen/prompts/fragments.go b/internal/app/chartgen/prompts/fragments.go new file mode 100644 index 0000000..561f196 --- /dev/null +++ b/internal/app/chartgen/prompts/fragments.go @@ -0,0 +1,140 @@ +package prompts + +const BasePrompt = ` + Using the supplied data, create a high-fidelity visualization for marketing material that is striking and dramatic. + + CRITICAL LAYOUT REQUIREMENTS: + - Generate exactly ONE card containing the visualization. Do NOT create a multi-card dashboard layout. + - The card should be dominated by the main visualization. + - Any supplementary statistics (totals, top items, etc.) should appear as subtle annotations WITHIN the card, not as separate cards or sidebars. + - Annotations should be minimal and placed in corners or along edges so they don't obstruct the main visualization. + + REQUIRED ELEMENTS (must appear on every visualization): + - TITLE: A clear, bold title at the top of the card describing the data + - SUBTITLE: A descriptive subtitle below the title explaining the aggregation field used + - QUERY: Display the exact Censys query string used to generate this data, formatted in a monospace font at the bottom of the card + + STYLE GUIDE: + - Modern minimalist SaaS aesthetic in "light mode" with clean layout and generous white space. + - Single pure white card (#FFFFFF) with subtle 1px light gray border (#E2E2E2) against pale gray background (#F9FAFB). + - Typography is clean sans-serif: dark charcoal (#333333) for primary values, muted cool gray (#757575) for labels. + - Primary data is emphasized using soft apricot (#F4C495). + - Label accents use muted slate teal (#4F8A96). + - Do not include any interactive components such as buttons or filters. + + Wherever possible, include a small, simple icon next to each category label that visually conveys the meaning or theme of the category. + Icons should be: + - Minimalist and monochrome (e.g., neutral gray) + - Small enough not to distract, but large enough to be recognizable + - Consistent in style, weight, and alignment + ` + +// ChartPrompts contains chart-specific prompt fragments that can be added on top of the base prompt. +// Each chart type has its own prompt fragment that describes how to visualize the data. +// Use the chart type constants (e.g., ChartTypeGeographicMap) as keys. +var ChartPrompts = map[string]string{ + "geomap": ` + Represent the geographic data as 3D pillars on an accurate stylized map. + Pillar heights should be proportional to the data values. + Each pillar should have a label showing the location name and value. + Data pillars use soft apricot (#F4C495). + The 3D map should be highly stylized and accurate, with the geographic region clearly recognizable. + `, + "voronoi": ` + Represent the data as a Voronoi chart. The visualization should be minimalist. + Do not include any visual elements that distract from the data. Include a subtle + gradient that emphasizes the data. + `, + "pie": ` + Represent the data as a pie chart. The visualization should be minimalist. + Do not include any visual elements that distract from the data. + `, + "wordmap": ` + Represent the data as a word map. More frequently appearing words should be larger. + The visualization should be minimalist. + Do not include any visual elements that distract from the data. + `, + "choropleth": ` + Represent the geographic data as a clean, minimalist choropleth map. Color all regions using a gradient from light to dark soft apricot (#F4C495), where darker shades correspond to higher values. Include clear, accurate geographic boundaries appropriate to the dataset (e.g., countries, provinces, states, or custom regions). + Label every region using its short region code (e.g., ISO country code, state/province code, or other provided identifier). Place each code unobtrusively within or near its region. + Highlight only the top N regions by value (e.g., the top 5–10): + - add a second label showing the numeric value near the region code, + - optionally increase border weight or add a subtle outline for emphasis, + - ensure highlighted labels are placed cleanly and do not overlap. + Include a minimalist legend showing the value range and the color gradient. + The visualization should be accurate, proportionally scaled, and stylistically minimal, with smooth color transitions, crisp boundaries, and no unnecessary elements. + `, + "hextile": ` + Represent the country data as a tile chart instead of a traditional map. + Each state should be shown as a uniform tile of identical shape and size (for example, a simple square), with no original geographic boundaries or irregular state shapes. + Arrange the tiles in a layout that roughly follows the geographic position of the states across the US, but keep the grid/tile structure clean and consistent. + Use soft apricot (#F4C495) for the tiles, with darker shades for higher values. + Include state labels and values on or near each tile. + The visualization should be minimalist and modern. + `, + "globe": ` + Represent the geographic exposure data as a clean, high-resolution 3D globe. + Use a country-level choropleth, where each country is shaded using a smooth gradient from soft apricot (#F4C495) to darker tones for higher exposure values. + Keep the shading bounded to each country (avoid diffuse blobs or exaggerated heatmaps). + Orient the globe so that major regions with significant exposure (e.g., North America, Europe, Asia) are clearly visible. + Draw thin, minimalist country outlines to maintain geographic clarity. + Include subtle, well-placed labels for key countries and regions, ensuring they do not overlap or obscure shaded areas. + Use soft ambient lighting and a modern aesthetic without visual clutter. + Avoid glowing artifacts, random graph elements, or distortions. + The final visualization should feel clean, minimalist, and information-rich. + `, + "bubble": ` + Represent the data as a bubble chart using uniformly styled circular bubbles. + Each bubble's area (not its radius) must scale proportionally to its value to ensure accurate visual comparison. + Arrange the bubbles using a non-overlapping layout (e.g., force-directed packing) that produces a clear visual hierarchy, with larger bubbles naturally drawing more attention. Maintain even spacing and avoid visual clutter. + Style each bubble in soft apricot (#F4C495) with subtle transparency and a thin neutral outline for definition. + Place all text labels (category name and value) fully inside each bubble. + Do NOT place any labels, badges, icons, or annotations outside the bubbles. + Ensure that labels remain legible, centered, and non-overlapping regardless of bubble size. + Use a minimalist, clean aesthetic: no gridlines, axes, or unnecessary decorations—prioritize clarity, balance, and modern visual design. + `, + "bar": ` + Represent the data as a vertical bar chart, using only the top 8–10 categories ranked by value. + Bars must be sorted in strict descending order, with the highest-value category on the far left and the lowest on the right. + Scale each bar's height proportionally to its value. Fill bars with soft apricot (#F4C495) and use a thin, neutral outline for definition. + For each category, display a small minimalist icon above or next to the label to visually reinforce the meaning of the category. Icons should be + - simple and monochrome, + - consistent in size and stroke weight, + - unobtrusive and aligned cleanly with the label. + Include clear, readable labels for each category at the bottom of each bar, and show numeric values either at the top of the bars or immediately above them. Labels must not overlap. + Use a minimalist layout—no heavy gridlines, no axis clutter, no unnecessary decorations. Maintain generous spacing between bars and balanced margins so the chart feels clean, modern, and easy to interpret. + `, + "smallmultiplesbar": ` + Represent the geographic data as a set of small multiples. + Create a uniform grid of small charts, where each chart corresponds to one geographic region (country or state). + Use a consistent visual encoding across all multiples—such as a single vertical bar, a compact area sparkline, or a filled color-intensity tile—to represent that region's data value. The encoding must be identical in style, scale, and orientation for every region to allow direct comparison. + Apply soft apricot (#F4C495) as the primary encoding color across all panels, using darker or more saturated tones only when necessary to indicate higher values. + Each region's multiple should include: + - a clear region label (e.g., the country or state name), + - the data value, placed unobtrusively but legibly, + - consistent margins and spacing so that every panel aligns cleanly. + Arrange the multiples in a logical, structured grid—for example grouped by continent, subregion, or alphabetical order. Ensure the grid remains balanced and easy to scan. + Keep the overall design minimalist and modern, with no heavy borders, excessive text, or redundant axes. Only include minimal visual cues necessary to compare values across regions. + `, + "smallmultiplesmap": ` + Represent the geographic data as small multiples with mini geographic maps. + Create a uniform grid of small tiles, where each tile contains a simplified outline of the country or state, rendered as a small geographic shape. + Inside each geographic outline, apply a choropleth-style fill using soft apricot (#F4C495), with darker or more saturated tones indicating higher values. The shading should stay within the country boundary, not extend outside it. + All country outlines must use the same visual style: thin, neutral strokes, simplified geometry, and consistent scaling so that shapes remain recognizable but comparable across tiles. + Each tile must include: + - a country/region label, + - the data value, placed cleanly below or beside the mini-map, + - consistent padding and alignment. + Arrange all tiles in a logical grid layout, grouped by continent or subregion when applicable. + Keep the overall design minimalist and modern—no icons, no bars, no heavy borders, no unnecessary UI elements. The primary focus should be the geographic mini-map and its choropleth shading, enabling quick visual comparison between regions. + `, +} + +// ChartTypes returns all available chart type names. +func ChartTypes() []string { + types := make([]string, 0, len(ChartPrompts)) + for k := range ChartPrompts { + types = append(types, k) + } + return types +} diff --git a/internal/app/chartgen/prompts/prompt.go b/internal/app/chartgen/prompts/prompt.go new file mode 100644 index 0000000..fd384ba --- /dev/null +++ b/internal/app/chartgen/prompts/prompt.go @@ -0,0 +1,75 @@ +package prompts + +import ( + "fmt" + "strings" +) + +// Bucket represents a single aggregation bucket with a key and count. +type Bucket struct { + Key string + Count uint64 +} + +// PromptBuilder builds prompts for chart generation from aggregation data. +type PromptBuilder struct { + prompt string + data string + style string + query string + field string + totalCount uint64 + otherCount uint64 +} + +// New creates a new PromptBuilder from aggregation buckets. +func New(buckets []Bucket, totalCount, otherCount uint64) *PromptBuilder { + var dataStr string + if len(buckets) > 0 { + var sb strings.Builder + sb.WriteString(fmt.Sprintf("Total Count, %d\n", totalCount)) + sb.WriteString(fmt.Sprintf("Other Count, %d\n", otherCount)) + for _, bucket := range buckets { + sb.WriteString(fmt.Sprintf("%s, %d\n", bucket.Key, bucket.Count)) + } + dataStr = sb.String() + } + return &PromptBuilder{ + prompt: BasePrompt, + data: dataStr, + totalCount: totalCount, + otherCount: otherCount, + } +} + +// WithQuery sets the query string for the prompt. +func (p *PromptBuilder) WithQuery(query string) *PromptBuilder { + p.query = query + return p +} + +// WithField sets the aggregation field for the prompt. +func (p *PromptBuilder) WithField(field string) *PromptBuilder { + p.field = field + return p +} + +// WithChartType sets the chart type for the prompt. +func (p *PromptBuilder) WithChartType(chartType string) *PromptBuilder { + if style, ok := ChartPrompts[chartType]; ok { + p.style = style + } + return p +} + +// Build constructs the final prompt string. +func (p *PromptBuilder) Build() string { + metadata := "" + if p.query != "" { + metadata += fmt.Sprintf("\nCensys Query: %s", p.query) + } + if p.field != "" { + metadata += fmt.Sprintf("\nAggregation Field: %s", p.field) + } + return fmt.Sprintf("%s\n%s\n%s\nDATA:\n%s", p.prompt, p.style, metadata, p.data) +} diff --git a/internal/app/chartgen/service.go b/internal/app/chartgen/service.go new file mode 100644 index 0000000..3231af5 --- /dev/null +++ b/internal/app/chartgen/service.go @@ -0,0 +1,99 @@ +package chartgen + +import ( + "context" + "fmt" + + "google.golang.org/genai" + + "github.com/censys/cencli/internal/app/chartgen/prompts" + "github.com/censys/cencli/internal/pkg/cenclierrors" +) + +const ( + // DefaultModel is the Gemini model used for image generation. + DefaultModel = "gemini-3-pro-image-preview" + // DefaultNumImages is the default number of images to generate. + DefaultNumImages = 1 +) + +//go:generate mockgen -destination=../../../gen/app/chartgen/mocks/chartgenservice_mock.go -package=mocks -mock_names Service=MockChartgenService . Service + +// Service generates charts from aggregation data using Gemini AI. +type Service interface { + // GenerateChart generates chart images from aggregation data. + GenerateChart(ctx context.Context, params Params) (Result, cenclierrors.CencliError) +} + +type chartgenService struct { + apiKey string +} + +// New creates a new chartgen service with the given Gemini API key. +func New(apiKey string) Service { + return &chartgenService{apiKey: apiKey} +} + +func (s *chartgenService) GenerateChart(ctx context.Context, params Params) (Result, cenclierrors.CencliError) { + // Create Gemini client + client, err := genai.NewClient(ctx, &genai.ClientConfig{ + APIKey: s.apiKey, + Backend: genai.BackendGeminiAPI, + }) + if err != nil { + return Result{}, cenclierrors.NewCencliError(fmt.Errorf("failed to create Gemini client: %w", err)) + } + + // Build prompt + promptBuilder := prompts. + New(params.Buckets, params.TotalCount, params.OtherCount). + WithQuery(params.Query). + WithField(params.Field) + + if params.ChartType != "" { + promptBuilder = promptBuilder.WithChartType(params.ChartType) + } + + prompt := promptBuilder.Build() + + // Determine number of images + numImages := params.NumImages + if numImages <= 0 { + numImages = DefaultNumImages + } + + // Generate images + images := make([][]byte, 0, numImages) + for i := 0; i < numImages; i++ { + result, err := client.Models.GenerateContent( + ctx, + DefaultModel, + genai.Text(prompt), + &genai.GenerateContentConfig{}, + ) + if err != nil { + return Result{}, cenclierrors.NewCencliError(fmt.Errorf("failed to generate image %d: %w", i+1, err)) + } + + // Extract image from response + if len(result.Candidates) == 0 || result.Candidates[0].Content == nil { + return Result{}, cenclierrors.NewCencliError(fmt.Errorf("no content in response for image %d", i+1)) + } + + for _, part := range result.Candidates[0].Content.Parts { + if part.InlineData != nil && len(part.InlineData.Data) > 0 { + images = append(images, part.InlineData.Data) + break + } + } + } + + if len(images) == 0 { + return Result{}, cenclierrors.NewCencliError(fmt.Errorf("no images generated")) + } + + return Result{ + Images: images, + Prompt: prompt, + }, nil +} diff --git a/internal/command/aggregate/aggregate.go b/internal/command/aggregate/aggregate.go index a35e2be..49296e9 100644 --- a/internal/command/aggregate/aggregate.go +++ b/internal/command/aggregate/aggregate.go @@ -137,7 +137,10 @@ func (c *Command) Init() error { false, "output raw data", ) - return nil + // Add chart subcommand + return c.AddSubCommands( + newChartCommand(c.Context), + ) } func (c *Command) PreRun(cmd *cobra.Command, args []string) cenclierrors.CencliError { diff --git a/internal/command/aggregate/chart.go b/internal/command/aggregate/chart.go new file mode 100644 index 0000000..f25dcae --- /dev/null +++ b/internal/command/aggregate/chart.go @@ -0,0 +1,361 @@ +package aggregate + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/google/uuid" + "github.com/samber/mo" + "github.com/spf13/cobra" + + "github.com/censys/cencli/internal/app/aggregate" + "github.com/censys/cencli/internal/app/chartgen" + "github.com/censys/cencli/internal/app/chartgen/prompts" + "github.com/censys/cencli/internal/command" + "github.com/censys/cencli/internal/config" + "github.com/censys/cencli/internal/pkg/cenclierrors" + "github.com/censys/cencli/internal/pkg/domain/identifiers" + "github.com/censys/cencli/internal/pkg/flags" + "github.com/censys/cencli/internal/pkg/formatter" + "github.com/censys/cencli/internal/store" +) + +const ( + chartCmdName = "chart" + defaultChartNumImages = 1 + defaultChartDir = "output" + defaultChartName = "chart" +) + +type chartCommand struct { + *command.BaseCommand + // services + aggregateSvc aggregate.Service + chartgenSvc chartgen.Service + // flags + flags chartCommandFlags + // state + collectionID mo.Option[identifiers.CollectionID] + orgID mo.Option[identifiers.OrganizationID] + query string + field string + numBuckets int64 + dir string + name string + chartType string + numImages int +} + +type chartCommandFlags struct { + orgID flags.OrgIDFlag + collectionID flags.UUIDFlag + numBuckets flags.IntegerFlag + dir flags.StringFlag + name flags.StringFlag + chartType flags.StringFlag + numImages flags.IntegerFlag +} + +var _ command.Command = (*chartCommand)(nil) + +func newChartCommand(cmdContext *command.Context) *chartCommand { + return &chartCommand{ + BaseCommand: command.NewBaseCommand(cmdContext), + } +} + +func (c *chartCommand) Use() string { + return fmt.Sprintf("%s ", chartCmdName) +} + +func (c *chartCommand) Short() string { + return "Generate a chart image from aggregation data using AI" +} + +func (c *chartCommand) Long() string { + return `Generate a chart image from aggregation data using Gemini AI. + +This command fetches aggregation data from Censys and uses Google's Gemini AI +to generate a visualization chart. The generated chart is saved as a PNG file. + +Before using this command, configure your Gemini API key: + censys config gemini set + +Available chart types: ` + strings.Join(prompts.ChartTypes(), ", ") +} + +func (c *chartCommand) Args() command.PositionalArgs { + return command.ExactArgs(2) +} + +func (c *chartCommand) Examples() []string { + return []string{ + `"host.location.continent='Asia'" host.location.city --type geomap`, + `"host.services.port=443" host.services.software.product --type bar --dir ./charts`, + `"host.services.port=22" host.location.country --type choropleth --name ssh-by-country`, + } +} + +func (c *chartCommand) Init() error { + c.flags.orgID = flags.NewOrgIDFlag(c.Flags(), "") + c.flags.collectionID = flags.NewUUIDFlag( + c.Flags(), + false, + "collection-id", + "c", + mo.None[uuid.UUID](), + "collection to aggregate within (optional)", + ) + c.flags.numBuckets = flags.NewIntegerFlag( + c.Flags(), + false, + "num-buckets", + "n", + mo.Some[int64](500), + "number of buckets to use for aggregation", + mo.Some[int64](minNumBuckets), + mo.Some[int64](maxNumBuckets), + ) + c.flags.dir = flags.NewStringFlag( + c.Flags(), + false, + "dir", + "d", + defaultChartDir, + "output directory for generated files", + ) + c.flags.name = flags.NewStringFlag( + c.Flags(), + false, + "name", + "", + defaultChartName, + "base name for output files (without extension)", + ) + c.flags.chartType = flags.NewStringFlag( + c.Flags(), + false, + "type", + "t", + "", + "chart type ("+strings.Join(prompts.ChartTypes(), ", ")+")", + ) + c.flags.numImages = flags.NewIntegerFlag( + c.Flags(), + false, + "tries", + "", + mo.Some[int64](defaultChartNumImages), + "number of chart images to generate", + mo.Some[int64](1), + mo.Some[int64](10), + ) + return nil +} + +func (c *chartCommand) PreRun(cmd *cobra.Command, args []string) cenclierrors.CencliError { + var err cenclierrors.CencliError + + // Get aggregate service + c.aggregateSvc, err = c.AggregateService() + if err != nil { + return err + } + + // Get Gemini API key and create chartgen service + apiKey, storeErr := c.Store().GetLastUsedGlobalByName(cmd.Context(), config.GeminiAPIKeyGlobalName) + if storeErr != nil { + if errors.Is(storeErr, store.ErrGlobalNotFound) { + return newGeminiAPIKeyNotConfiguredError() + } + return cenclierrors.NewCencliError(fmt.Errorf("failed to get Gemini API key: %w", storeErr)) + } + c.chartgenSvc = chartgen.New(apiKey.Value) + + // Parse args + c.query = args[0] + c.field = args[1] + + // Validate flags + c.orgID, err = c.flags.orgID.Value() + if err != nil { + return err + } + + collectionID, err := c.flags.collectionID.Value() + if err != nil { + return err + } + if collectionID.IsPresent() { + c.collectionID = mo.Some(identifiers.NewCollectionID(collectionID.MustGet())) + } + + numBuckets, err := c.flags.numBuckets.Value() + if err != nil { + return err + } + if numBuckets.IsPresent() { + c.numBuckets = numBuckets.MustGet() + } + + c.dir, err = c.flags.dir.Value() + if err != nil { + return err + } + + c.name, err = c.flags.name.Value() + if err != nil { + return err + } + + c.chartType, err = c.flags.chartType.Value() + if err != nil { + return err + } + + numImages, err := c.flags.numImages.Value() + if err != nil { + return err + } + if numImages.IsPresent() { + c.numImages = int(numImages.MustGet()) + } else { + c.numImages = defaultChartNumImages + } + + return nil +} + +func (c *chartCommand) Run(cmd *cobra.Command, args []string) cenclierrors.CencliError { + logger := c.Logger(chartCmdName).With( + "query", c.query, + "field", c.field, + "chartType", c.chartType, + "numBuckets", c.numBuckets, + ) + + // Fetch aggregation data + var aggResult aggregate.Result + err := c.WithProgress( + cmd.Context(), + logger, + "Fetching aggregation data...", + func(pctx context.Context) cenclierrors.CencliError { + params := aggregate.Params{ + OrgID: c.orgID, + CollectionID: c.collectionID, + Query: c.query, + Field: c.field, + NumBuckets: c.numBuckets, + } + var fetchErr cenclierrors.CencliError + aggResult, fetchErr = c.aggregateSvc.Aggregate(pctx, params) + return fetchErr + }, + ) + if err != nil { + return err + } + + if len(aggResult.Buckets) == 0 { + formatter.Printf(formatter.Stdout, "No aggregation results found for query.\n") + return nil + } + + // Convert buckets to chartgen format + chartBuckets := make([]prompts.Bucket, len(aggResult.Buckets)) + var totalCount uint64 + for i, b := range aggResult.Buckets { + chartBuckets[i] = prompts.Bucket{ + Key: b.Key, + Count: b.Count, + } + totalCount += b.Count + } + + // Generate chart + var chartResult chartgen.Result + err = c.WithProgress( + cmd.Context(), + logger, + "Generating chart with AI...", + func(pctx context.Context) cenclierrors.CencliError { + params := chartgen.Params{ + Query: c.query, + Field: c.field, + ChartType: c.chartType, + Buckets: chartBuckets, + TotalCount: totalCount, + OtherCount: 0, + NumImages: c.numImages, + } + var genErr cenclierrors.CencliError + chartResult, genErr = c.chartgenSvc.GenerateChart(pctx, params) + return genErr + }, + ) + if err != nil { + return err + } + + // Prepare output directory + dir := c.dir + if !filepath.IsAbs(dir) { + wd, wdErr := os.Getwd() + if wdErr != nil { + return cenclierrors.NewCencliError(fmt.Errorf("failed to get working directory: %w", wdErr)) + } + dir = filepath.Join(wd, dir) + } + + if mkErr := os.MkdirAll(dir, 0755); mkErr != nil { + return cenclierrors.NewCencliError(fmt.Errorf("failed to create output directory: %w", mkErr)) + } + + // Write prompt file + promptPath := filepath.Join(dir, c.name+".txt") + if writeErr := os.WriteFile(promptPath, []byte(chartResult.Prompt), 0644); writeErr != nil { + return cenclierrors.NewCencliError(fmt.Errorf("failed to write prompt file: %w", writeErr)) + } + formatter.Printf(formatter.Stdout, "✅ Wrote prompt: %s\n", promptPath) + + // Write image files + for i, imageData := range chartResult.Images { + imagePath := filepath.Join(dir, fmt.Sprintf("%s_%d.png", c.name, i)) + if writeErr := os.WriteFile(imagePath, imageData, 0644); writeErr != nil { + return cenclierrors.NewCencliError(fmt.Errorf("failed to write image file: %w", writeErr)) + } + formatter.Printf(formatter.Stdout, "✅ Wrote image: %s\n", imagePath) + } + + return nil +} + +// GeminiAPIKeyNotConfiguredError indicates the Gemini API key needs to be set up. +type GeminiAPIKeyNotConfiguredError interface { + cenclierrors.CencliError +} + +type geminiAPIKeyNotConfiguredError struct{} + +var _ GeminiAPIKeyNotConfiguredError = &geminiAPIKeyNotConfiguredError{} + +func newGeminiAPIKeyNotConfiguredError() GeminiAPIKeyNotConfiguredError { + return &geminiAPIKeyNotConfiguredError{} +} + +func (e *geminiAPIKeyNotConfiguredError) Error() string { + return "Gemini API key not configured. Run 'censys config gemini set' to configure it." +} + +func (e *geminiAPIKeyNotConfiguredError) Title() string { + return "Gemini API Key Not Configured" +} + +func (e *geminiAPIKeyNotConfiguredError) ShouldPrintUsage() bool { + return false +} diff --git a/internal/command/config/config.go b/internal/command/config/config.go index 2f03a30..fb35481 100644 --- a/internal/command/config/config.go +++ b/internal/command/config/config.go @@ -29,6 +29,7 @@ func (c *Command) Init() error { return c.AddSubCommands( newAuthCommand(c.Context), newOrganizationIDCommand(c.Context), + newGeminiCommand(c.Context), newPrintCommand(c.Context), ) } diff --git a/internal/command/config/gemini.go b/internal/command/config/gemini.go new file mode 100644 index 0000000..d0a8cf1 --- /dev/null +++ b/internal/command/config/gemini.go @@ -0,0 +1,263 @@ +package config + +import ( + "errors" + "fmt" + + "github.com/charmbracelet/huh" + "github.com/spf13/cobra" + + "github.com/censys/cencli/internal/command" + "github.com/censys/cencli/internal/config" + "github.com/censys/cencli/internal/pkg/cenclierrors" + "github.com/censys/cencli/internal/pkg/flags" + "github.com/censys/cencli/internal/pkg/formatter" + "github.com/censys/cencli/internal/pkg/ui/form" + "github.com/censys/cencli/internal/store" +) + +type geminiCommand struct { + *command.BaseCommand +} + +var _ command.Command = (*geminiCommand)(nil) + +func newGeminiCommand(cmdContext *command.Context) *geminiCommand { + cmd := &geminiCommand{ + BaseCommand: command.NewBaseCommand(cmdContext), + } + return cmd +} + +func (c *geminiCommand) Use() string { return "gemini" } +func (c *geminiCommand) Short() string { return "Manage Gemini API key for chart generation" } +func (c *geminiCommand) Long() string { + return "View and manage your Gemini API key used for AI-powered chart generation." +} + +func (c *geminiCommand) Init() error { + return c.AddSubCommands( + newSetGeminiCommand(c.Context), + newDeleteGeminiCommand(c.Context), + ) +} + +func (c *geminiCommand) Args() command.PositionalArgs { return command.ExactArgs(0) } +func (c *geminiCommand) PreRun(cmd *cobra.Command, args []string) cenclierrors.CencliError { + return nil +} + +func (c *geminiCommand) Run(cmd *cobra.Command, args []string) cenclierrors.CencliError { + value, err := c.Store().GetLastUsedGlobalByName(cmd.Context(), config.GeminiAPIKeyGlobalName) + if err != nil { + if errors.Is(err, store.ErrGlobalNotFound) { + formatter.Printf(formatter.Stdout, "No Gemini API key configured. Use `%s` to add one.\n", cmd.CommandPath()+" add") + return nil + } + return cenclierrors.NewCencliError(fmt.Errorf("failed to get Gemini API key: %w", err)) + } + + // Show masked key + maskedKey := maskAPIKey(value.Value) + formatter.Printf(formatter.Stdout, "Gemini API key: %s\n", maskedKey) + formatter.Printf(formatter.Stdout, "Description: %s\n", value.Description) + formatter.Printf(formatter.Stdout, "Added: %s\n", value.CreatedAt.Format("2006-01-02 15:04:05")) + return nil +} + +// maskAPIKey masks all but the last 4 characters of the API key +func maskAPIKey(key string) string { + if len(key) <= 4 { + return "****" + } + return "****" + key[len(key)-4:] +} + +// setGeminiCommand sets the Gemini API key +type setGeminiCommand struct { + *command.BaseCommand + accessible bool + flags setGeminiCommandFlags +} + +type setGeminiCommandFlags struct { + accessible flags.BoolFlag + value flags.StringFlag + name flags.StringFlag +} + +var _ command.Command = (*setGeminiCommand)(nil) + +func newSetGeminiCommand(cmdContext *command.Context) *setGeminiCommand { + cmd := &setGeminiCommand{ + BaseCommand: command.NewBaseCommand(cmdContext), + } + return cmd +} + +func (c *setGeminiCommand) Use() string { return "add" } +func (c *setGeminiCommand) Short() string { return "Set the Gemini API key" } +func (c *setGeminiCommand) Long() string { + return "Set the Gemini API key used for AI-powered chart generation. Get your API key from https://aistudio.google.com/apikey" +} + +func (c *setGeminiCommand) Args() command.PositionalArgs { return command.ExactArgs(0) } + +func (c *setGeminiCommand) Init() error { + c.flags.accessible = flags.NewBoolFlag( + c.Flags(), + "accessible", + "a", + false, + "enable accessible mode (non-redrawing)", + ) + c.flags.value = flags.NewStringFlag( + c.Flags(), + false, + "value", + "", + "", + "Gemini API key value (non-interactive)", + ) + c.flags.name = flags.NewStringFlag( + c.Flags(), + false, + "name", + "n", + "default", + "friendly name/description for this key", + ) + return nil +} + +func (c *setGeminiCommand) PreRun(cmd *cobra.Command, args []string) cenclierrors.CencliError { + var err cenclierrors.CencliError + c.accessible, err = c.flags.accessible.Value() + if err != nil { + return err + } + return nil +} + +func (c *setGeminiCommand) Run(cmd *cobra.Command, args []string) cenclierrors.CencliError { + // Non-interactive path when value provided + valueStr, _ := c.flags.value.Value() + if valueStr != "" { + name, nerr := c.flags.name.Value() + if nerr != nil { + return nerr + } + if name == "" { + name = "default" + } + + // Delete existing key if present + existing, _ := c.Store().GetValuesForGlobal(cmd.Context(), config.GeminiAPIKeyGlobalName) + for _, v := range existing { + _, _ = c.Store().DeleteValueForGlobal(cmd.Context(), v.ID) + } + + _, aerr := c.Store().AddValueForGlobal(cmd.Context(), config.GeminiAPIKeyGlobalName, name, valueStr) + if aerr != nil { + return cenclierrors.NewCencliError(fmt.Errorf("failed to set Gemini API key: %w", aerr)) + } + + formatter.Printf(formatter.Stdout, "✅ Gemini API key configured [%s]\n", name) + return nil + } + + // Interactive mode + var value string + var name string + + f := form.NewForm( + huh.NewForm( + huh.NewGroup( + huh.NewInput(). + EchoMode(huh.EchoModePassword). + Title("Enter your Gemini API key"). + Description("Get your API key from https://aistudio.google.com/apikey"). + Value(&value). + Validate(form.NonEmpty("API key cannot be empty")), + huh.NewInput(). + Title("Enter a name for this key"). + Description("A friendly name to help identify this key"). + Placeholder("default"). + Value(&name), + ), + ), + form.WithAccessible(c.accessible), + ) + + err := f.RunWithContext(cmd.Context()) + if err != nil { + if errors.Is(err, form.ErrUserAborted) { + return nil + } + return cenclierrors.NewCencliError(err) + } + + if name == "" { + name = "default" + } + + // Delete existing key if present + existing, _ := c.Store().GetValuesForGlobal(cmd.Context(), config.GeminiAPIKeyGlobalName) + for _, v := range existing { + _, _ = c.Store().DeleteValueForGlobal(cmd.Context(), v.ID) + } + + _, serr := c.Store().AddValueForGlobal(cmd.Context(), config.GeminiAPIKeyGlobalName, name, value) + if serr != nil { + return cenclierrors.NewCencliError(fmt.Errorf("failed to set Gemini API key: %w", serr)) + } + + formatter.Printf(formatter.Stdout, "✅ Gemini API key configured [%s]\n", name) + return nil +} + +// deleteGeminiCommand deletes the Gemini API key +type deleteGeminiCommand struct { + *command.BaseCommand +} + +var _ command.Command = (*deleteGeminiCommand)(nil) + +func newDeleteGeminiCommand(ctx *command.Context) *deleteGeminiCommand { + return &deleteGeminiCommand{BaseCommand: command.NewBaseCommand(ctx)} +} + +func (c *deleteGeminiCommand) Use() string { return "delete" } +func (c *deleteGeminiCommand) Short() string { return "Delete the Gemini API key" } + +func (c *deleteGeminiCommand) Args() command.PositionalArgs { return command.ExactArgs(0) } + +func (c *deleteGeminiCommand) PreRun(cmd *cobra.Command, args []string) cenclierrors.CencliError { + return nil +} + +func (c *deleteGeminiCommand) Run(cmd *cobra.Command, args []string) cenclierrors.CencliError { + values, err := c.Store().GetValuesForGlobal(cmd.Context(), config.GeminiAPIKeyGlobalName) + if err != nil { + if errors.Is(err, store.ErrGlobalNotFound) { + formatter.Printf(formatter.Stdout, "No Gemini API key configured.\n") + return nil + } + return cenclierrors.NewCencliError(fmt.Errorf("failed to get Gemini API key: %w", err)) + } + + if len(values) == 0 { + formatter.Printf(formatter.Stdout, "No Gemini API key configured.\n") + return nil + } + + for _, v := range values { + _, derr := c.Store().DeleteValueForGlobal(cmd.Context(), v.ID) + if derr != nil { + return cenclierrors.NewCencliError(fmt.Errorf("failed to delete Gemini API key: %w", derr)) + } + } + + formatter.Printf(formatter.Stdout, "✅ Gemini API key deleted\n") + return nil +} diff --git a/internal/config/store.go b/internal/config/store.go index 48417c9..88c937c 100644 --- a/internal/config/store.go +++ b/internal/config/store.go @@ -1,6 +1,7 @@ package config const ( - AuthName = "personal-access-token" - OrgIDGlobalName = "org-id" + AuthName = "personal-access-token" + OrgIDGlobalName = "org-id" + GeminiAPIKeyGlobalName = "gemini-api-key" )