diff --git a/README.md b/README.md index c964542..358ca14 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,8 @@ KQL CLI — query Azure Data Explorer (Kusto) from the command line. Like `jq` for JSON, but for Kusto/KQL. Run raw KQL, keep a git-versioned library of parameterized queries, and pipe results straight into your shell. +![kq demo](docs/demo.gif) + ## Installation ```bash diff --git a/docs/LAUNCH_POST.md b/docs/LAUNCH_POST.md new file mode 100644 index 0000000..4492ad5 --- /dev/null +++ b/docs/LAUNCH_POST.md @@ -0,0 +1,118 @@ +# Launch post drafts + +Three formats below: a blog/dev.to post, short Reddit/forum variants, and a +one-line social post. Pick per venue; they say the same thing at different +lengths. + +--- + +## A) Blog / dev.to — "I built jq for Kusto" + +**Title:** I built `jq` for Kusto — a tiny CLI for querying Azure Data Explorer + +I run a lot of KQL against Azure Data Explorer, and I kept hitting the same +friction: the web UI is great for exploring but terrible for automation, and +`az kusto` isn't built for quick, pipeable, everyday queries. I wanted the thing +`jq` is for JSON — small, fast, lives in the terminal, composes with pipes. + +So I built **`kq`**. + +```bash +pip install kql-cli # the command is `kq` + +kq config set default_cluster https://mycluster.kusto.windows.net +kq config set default_database mydb +kq auth login + +kq "MyTable | take 5" +kq "MyTable | summarize count() by bin(Timestamp, 1h)" -f json | jq . +``` + +The part I actually care about most is **saved, git-versioned queries**. You +write parameterized queries in YAML and run them by name: + +```yaml +# ~/.config/kq/queries/ops.yaml +queries: + - name: errors + description: Recent errors for a service + parameters: + - {name: service, required: true} + - {name: hours, default: "24"} + query: | + Traces + | where Timestamp > ago({hours}h) + | where Service == '{service}' + | where Level == 'Error' + | order by Timestamp desc +``` + +```bash +kq run ops.errors checkout 6 +``` + +Queries resolve from a project-local `./.kq/`, then your user queries, then +bundled examples — so a repo can ship its own query library, and your personal +queries never get clobbered by an update. + +A few other things it does: + +- **Auth that just works** — service principal → Azure CLI → cached device code, + with ~90-day silent refresh (handy on WSL/headless). +- **`table` / `json` / `csv`** output — the JSON pipes straight into `jq`. +- **Query safety levels** (`safe` / `caution` / `dangerous`) to keep expensive + full-table scans honest. +- **Plays nicely with LLM coding agents** — clean, deterministic output. + +It's MIT-licensed, tested on Python 3.9–3.13, and on PyPI as `kql-cli`. + +Repo: https://github.com/cptfinch/kq + +If you work with Kusto/ADX, I'd love feedback — especially on what saved-query +patterns you'd want built in. + +--- + +## B) Reddit / Hacker News "Show" variants + +**Title options** +- Show: kq — jq for Kusto/KQL (query Azure Data Explorer from the CLI) +- I built a small CLI for querying Azure Data Explorer (like jq, but for KQL) + +**Body (r/AZURE, r/dataengineering, r/kusto):** + +I kept wanting a `jq`-style tool for KQL — something small and pipeable for +querying Azure Data Explorer from the terminal, instead of the web UI or +`az kusto`. So I made `kq`. + +- `kq "MyTable | take 5"` for raw KQL, `-f json|csv` for output +- Saved, parameterized queries in YAML that you can git-version per project +- Auth chain: service principal → Azure CLI → cached device code (~90-day refresh) +- Query safety levels to flag expensive scans + +`pip install kql-cli` (the command is `kq`). MIT, tested on 3.9–3.13. + +https://github.com/cptfinch/kq + +Curious what saved-query patterns people would find useful — and how others are +scripting against ADX today. + +--- + +## C) One-liner (X / Mastodon / LinkedIn) + +`kq` — jq, but for Kusto/KQL. Query Azure Data Explorer from the terminal: raw +KQL, git-versioned saved queries, json/csv output, sane auth. + +`pip install kql-cli` · MIT · https://github.com/cptfinch/kq + +--- + +## Posting notes + +- Best-fit communities: r/AZURE, r/dataengineering, r/kusto, the Azure Data + Explorer community, and Show HN. +- Post *after* PyPI is live so `pip install kql-cli` works from the first click. +- The README demo GIF (`docs/demo.gif`) is worth embedding in the dev.to post + too — a moving demo roughly doubles conversion. +- Lead with the `jq` analogy every time; it's the fastest way people "get it." diff --git a/docs/RELEASE_NOTES_v1.0.0.md b/docs/RELEASE_NOTES_v1.0.0.md new file mode 100644 index 0000000..05ae02f --- /dev/null +++ b/docs/RELEASE_NOTES_v1.0.0.md @@ -0,0 +1,56 @@ +# kq v1.0.0 — first public release + +Paste this into the GitHub Release body when tagging `v1.0.0`. + +--- + +`kq` is a KQL CLI for Azure Data Explorer (Kusto). Like `jq` for JSON, but for +KQL — run raw queries, keep a git-versioned library of parameterized queries, +and pipe results straight into your shell. + +## Install + +```bash +pip install kql-cli +``` + +The command is `kq`. (The PyPI package is `kql-cli` because `kq` was already +taken on PyPI by an unrelated project.) + +## Quick start + +```bash +kq config set default_cluster https://mycluster.westeurope.kusto.windows.net +kq config set default_database mydb +kq auth login + +kq "MyTable | take 5" # raw KQL +kq "MyTable | take 5" -f json # or -f csv +kq list # saved queries +kq run examples.sample MyTable 10 # run a saved query +``` + +## Highlights + +- **Raw KQL and saved queries** — run KQL directly, or curate reusable, + parameterized queries in YAML and run them by name. +- **Git-versionable query libraries** — queries resolve from `./.kq/` (project), + `~/.config/kq/queries/` (user), then bundled examples. Your queries are never + overwritten by updates. +- **Multi-cluster config** — named clusters, XDG-compliant config in + `~/.config/kq/config.yaml`. +- **Flexible auth** — service principal → Azure CLI → cached device code, with + ~90-day silent token refresh (great for WSL/headless). +- **Output formats** — `table`, `json`, `csv`. +- **Query safety levels** — mark queries `safe` / `caution` / `dangerous` to + keep expensive full-table scans honest. +- **LLM-native & Unix-friendly** — clean, pipeable output that works well with + Claude Code, Copilot, scripts, and automation. + +## Quality + +- Test suite + `ruff` linting. +- GitHub Actions CI across Python 3.9–3.13. +- Published via PyPI Trusted Publishing (OIDC — no stored tokens). + +**Full changelog:** see [CHANGELOG.md](../CHANGELOG.md). diff --git a/docs/demo.cast b/docs/demo.cast new file mode 100644 index 0000000..36a6a04 --- /dev/null +++ b/docs/demo.cast @@ -0,0 +1,98 @@ +{"version": 2, "width": 94, "height": 34, "env": {"TERM": "xterm-256color", "SHELL": "/bin/bash"}, "title": "kq \u2014 jq for Kusto/KQL"} +[0.6, "o", "\u001b[36m~/kq\u001b[0m \u001b[32m$\u001b[0m "] +[0.645, "o", "k"] +[0.69, "o", "q"] +[0.735, "o", " "] +[0.78, "o", "-"] +[0.825, "o", "-"] +[0.87, "o", "v"] +[0.915, "o", "e"] +[0.96, "o", "r"] +[1.005, "o", "s"] +[1.05, "o", "i"] +[1.095, "o", "o"] +[1.14, "o", "n"] +[1.49, "o", "\r\n"] +[1.64, "o", "kq 1.0.0\r\n"] +[3.44, "o", "\u001b[36m~/kq\u001b[0m \u001b[32m$\u001b[0m "] +[3.485, "o", "k"] +[3.53, "o", "q"] +[3.575, "o", " "] +[3.62, "o", "l"] +[3.665, "o", "i"] +[3.71, "o", "s"] +[3.755, "o", "t"] +[4.105, "o", "\r\n"] +[4.255, "o", "\u001b[3m \u001b[0m\u001b[1;3mexamples\u001b[0m\u001b[3m \u001b[0m\r\n\u250f\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513\r\n\u2503\u001b[1m \u001b[0m\u001b[1mQuery \u001b[0m\u001b[1m \u001b[0m\u2503\u001b[1m \u001b[0m\u001b[1mDescription \u001b[0m\u001b[1m \u001b[0m\u2503\u001b[1m \u001b[0m\u001b[1mParams \u001b[0m\u001b[1m \u001b[0m\u2503\r\n\u2521\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2529\r\n\u2502\u001b[36m \u001b[0m\u001b[36mcolumns\u001b[0m\u001b[36m \u001b[0m\u2502 List distinct values in a column \u2502\u001b[2m \u001b[0m\u001b[2mtable, column, hours\u001b[0m\u001b[2m \u001b[0m\u2502\r\n\u2502\u001b[36m \u001b[0m\u001b[36mcount \u001b[0m\u001b[36m \u001b[0m\u2502 Count rows in a table (with optional time filter) \u2502\u001b[2m \u001b[0m\u001b[2mtable, hours \u001b[0m\u001b[2m \u001b[0m\u2502\r\n\u2502\u001b[36m \u001b[0m\u001b[36msample \u001b[0m\u001b[36m \u001b[0m\u2502 Get sample rows from a table \u2502\u001b[2m \u001b[0m\u001b[2mtable, count \u001b[0m\u001b[2m \u001b[0m\u2502\r\n\u2502\u001b[36m \u001b[0m\u001b[36mschema \u001b[0m\u001b[36m \u001b[0m\u2502 Show schema for a table \u2502\u001b[2m \u001b[0m\u001b[2mtable \u001b[0m\u001b[2m \u001b[0m\u2502\r\n\u2502\u001b[36m \u001b[0m\u001b[36mtables \u001b[0m\u001b[36m \u001b[0m\u2502 List all tables in the database \u2502\u001b[2m \u001b[0m\u001b[2m- \u001b[0m\u001b[2m \u001b[0m\u2502\r\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\r\n\r\n"] +[6.055, "o", "\u001b[36m~/kq\u001b[0m \u001b[32m$\u001b[0m "] +[6.1, "o", "k"] +[6.145, "o", "q"] +[6.19, "o", " "] +[6.235, "o", "s"] +[6.28, "o", "h"] +[6.325, "o", "o"] +[6.37, "o", "w"] +[6.415, "o", " "] +[6.46, "o", "e"] +[6.505, "o", "x"] +[6.55, "o", "a"] +[6.595, "o", "m"] +[6.64, "o", "p"] +[6.685, "o", "l"] +[6.73, "o", "e"] +[6.775, "o", "s"] +[6.82, "o", "."] +[6.865, "o", "s"] +[6.91, "o", "a"] +[6.955, "o", "m"] +[7.0, "o", "p"] +[7.045, "o", "l"] +[7.09, "o", "e"] +[7.44, "o", "\r\n"] +[7.59, "o", "\u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e\r\n\u2502 \u001b[1mexamples.sample\u001b[0m \u2502\r\n\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 safe \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f\r\n\u001b[2mDescription:\u001b[0m Get sample rows from a table\r\n\u001b[2mSource:\u001b[0m \u001b[35m/home/user/kq/src/kq/queries/\u001b[0m\u001b[95mexamples.yaml\u001b[0m\r\n\r\n\u001b[2mParameters:\u001b[0m\r\n \u001b[31m*\u001b[0mtable: Table name\r\n count: Number of rows \u001b[1m(\u001b[0mdefault: \u001b[1;36m10\u001b[0m\u001b[1m)\u001b[0m\r\n\r\n\u001b[2mQuery:\u001b[0m\r\n\u001b[38;5;198;48;5;16m{\u001b[0m\u001b[38;5;81;48;5;235mtable\u001b[0m\u001b[38;5;198;48;5;16m}\u001b[0m\u001b[48;5;235m \u001b[0m\r\n\u001b[38;5;204;48;5;235m|\u001b[0m\u001b[38;5;231;48;5;235m \u001b[0m\u001b[38;5;231;48;5;235mtake\u001b[0m\u001b[38;5;231;48;5;235m \u001b[0m\u001b[38;5;198;48;5;16m{\u001b[0m\u001b[38;5;81;48;5;235mcount\u001b[0m\u001b[38;5;198;48;5;16m}\u001b[0m\u001b[48;5;235m \u001b[0m\r\n\u001b[48;5;235m \u001b[0m\r\n\r\n\u001b[2mExample:\u001b[0m kq run examples.sample MyTable \u001b[1;36m5\u001b[0m\r\n"] +[9.39, "o", "\u001b[36m~/kq\u001b[0m \u001b[32m$\u001b[0m "] +[9.435, "o", "k"] +[9.48, "o", "q"] +[9.525, "o", " "] +[9.57, "o", "r"] +[9.615, "o", "u"] +[9.66, "o", "n"] +[9.705, "o", " "] +[9.75, "o", "e"] +[9.795, "o", "x"] +[9.84, "o", "a"] +[9.885, "o", "m"] +[9.93, "o", "p"] +[9.975, "o", "l"] +[10.02, "o", "e"] +[10.065, "o", "s"] +[10.11, "o", "."] +[10.155, "o", "s"] +[10.2, "o", "a"] +[10.245, "o", "m"] +[10.29, "o", "p"] +[10.335, "o", "l"] +[10.38, "o", "e"] +[10.425, "o", " "] +[10.47, "o", "M"] +[10.515, "o", "y"] +[10.56, "o", "T"] +[10.605, "o", "a"] +[10.65, "o", "b"] +[10.695, "o", "l"] +[10.74, "o", "e"] +[10.785, "o", " "] +[10.83, "o", "5"] +[10.875, "o", " "] +[10.92, "o", "-"] +[10.965, "o", "-"] +[11.01, "o", "d"] +[11.055, "o", "r"] +[11.1, "o", "y"] +[11.145, "o", "-"] +[11.19, "o", "r"] +[11.235, "o", "u"] +[11.28, "o", "n"] +[11.63, "o", "\r\n"] +[11.78, "o", "\u001b[2mQuery \u001b[0m\u001b[1;2m(\u001b[0m\u001b[2mdry run\u001b[0m\u001b[1;2m)\u001b[0m\u001b[2m:\u001b[0m\r\n\u001b[38;5;231;48;5;235mMyTable\u001b[0m\u001b[48;5;235m \u001b[0m\r\n\u001b[38;5;204;48;5;235m|\u001b[0m\u001b[38;5;231;48;5;235m \u001b[0m\u001b[38;5;231;48;5;235mtake\u001b[0m\u001b[38;5;231;48;5;235m \u001b[0m\u001b[38;5;141;48;5;235m5\u001b[0m\u001b[48;5;235m \u001b[0m\r\n"] +[14.28, "o", "\u001b[36m~/kq\u001b[0m \u001b[32m$\u001b[0m "] diff --git a/docs/demo.gif b/docs/demo.gif new file mode 100644 index 0000000..5eb8fb8 Binary files /dev/null and b/docs/demo.gif differ