commit 60710fab204e07c758da4aaa945351e02817f596 Author: Jev Kuznetsov Date: Thu Apr 16 11:36:48 2026 +0200 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..35e8f1d --- /dev/null +++ b/.gitignore @@ -0,0 +1,44 @@ +# Environment +.env +.env.gpg + +# copied files +docker/python-dev/CLAUDE.md + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual environments +venv/ +env/ +ENV/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +.python-version diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..bb6ae2e --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,23 @@ +stages: + - build + +build: + stage: build + image: docker:latest + services: + - docker:dind + before_script: + - apk add --no-cache git + - docker version + - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY + - docker run --rm --privileged multiarch/qemu-user-static --reset -p yes + - docker buildx create --name mybuilder --use + - docker buildx inspect --bootstrap + script: + - docker buildx build --platform linux/amd64,linux/arm64 -t $CI_REGISTRY_IMAGE --push ./docker/python-dev + when: manual + only: + changes: + - docker/python-dev/**/* + refs: + - main diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..92a891d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,28 @@ +# CLAUDE.md + +## Commands + +```bash +uv run cli-tools --help # run the CLI +uv sync # install dependencies +uv run ruff check --fix src/ # lint +uv run ruff format src/ # format +``` + +## Architecture + +Typer-based CLI for bootstrapping a dev environment. + +- `cli.py` — entry point, composes sub-apps +- `install.py` — install tools (fzf, zoxide, lazygit, eza, docker, uv, claude, helpers) +- `docker.py` — build the `python-dev` Docker image locally +- `credentials.py` — GPG encrypt/decrypt `.env` files +- `helpers.py` — `run()`, `append_bashrc_section()`, `load_snippet()` + +Scripts: `scripts/bash_helpers.sh`, `scripts/aliases.sh` (symlinked to `~/`). + +## Adding an install command + +1. Add `@app.command()` in `install.py` +2. Use `run()` for shell commands, `append_bashrc_section()` for bashrc config +3. Optionally add to `bootstrap()` for first-time setup diff --git a/README.md b/README.md new file mode 100644 index 0000000..e45ab79 --- /dev/null +++ b/README.md @@ -0,0 +1,132 @@ +# CLI Tools + +Scripts and tools to set up and manage the terminal environment across systems. + +**Entry point:** `cli-tools` + +--- + +## Install + +Run this tool from the checked-out repo. Do **not** install it with `uv tool install`, +because `cli-tools install helpers` symlinks shell files from `scripts/`. + +```bash +cd /path/to/cli-tools +uv run cli-tools --help +``` + +```bash +cli-tools install bootstrap # install everything (first-time setup) +cli-tools install apt-packages # git, curl, ripgrep, fd, btop, tldr, ... +cli-tools install uv # uv package manager +cli-tools install fzf # fzf + bat +cli-tools install zoxide # zoxide (z navigation) +cli-tools install eza # modern ls replacement +cli-tools install lazygit # terminal UI for git +cli-tools install docker # Docker + add user to group +cli-tools install claude # Claude Code +cli-tools install helpers # symlink bash_helpers.sh + aliases.sh into ~ +``` + +## Docker + +```bash +cli-tools docker build # build python-dev image +cli-tools docker build --tag v1.2 --push # build and push with tag +``` + +## Credentials + +```bash +cli-tools credentials encrypt-env # .env → .env.gpg +cli-tools credentials decrypt-env # .env.gpg → .env +``` + +--- + +## Installed Tools + +### [eza](https://github.com/eza-community/eza) — modern `ls` replacement + +Fast, colorized file listing with Git integration and icons. + +| Command | Description | +|--------------------|------------------------------------------| +| `ls` | list files (aliased to `eza`) | +| `ll` | long list with git status (`eza -la --git`) | +| `eza --tree` | tree view of directory | +| `eza -la --sort=modified` | sort by modification time | + +--- + +### [fzf](https://github.com/junegunn/fzf) — fuzzy finder + +Interactive fuzzy search for files, history, and more. Integrates with shell key bindings. + +| Shortcut / Command | Description | +|-------------------------|------------------------------------------| +| `Ctrl+R` | fuzzy search shell history | +| `Ctrl+T` | fuzzy find file and insert path | +| `Alt+C` | fuzzy cd into a directory | +| `fzf` | pipe any list into interactive selector | +| `vim $(fzf)` | open fuzzy-selected file in editor | + +--- + +### [lazygit](https://github.com/jesseduffield/lazygit) — terminal UI for Git + +Full-featured Git UI in the terminal. Run `lazygit` in any repo. + +| Key (inside lazygit) | Description | +|----------------------|--------------------------------------| +| `space` | stage / unstage file | +| `c` | commit | +| `P` (cap) | push | +| `P` | pull | +| `?` | help / key bindings | +| `q` | quit | + +--- + +### [zoxide](https://github.com/ajeetdsouza/zoxide) — smarter `cd` + +Tracks your most-visited directories and lets you jump to them with partial names. + +| Command | Description | +|----------------|--------------------------------------------------| +| `z foo` | jump to the most frecent dir matching `foo` | +| `z foo bar` | match multiple terms | +| `zi` | interactive selection with fzf | +| `z -` | jump to previous directory | + +--- + +## Shell aliases (`aliases.sh`) + +| Alias | Expands to | +|------------------|-----------------------------------| +| `reload` | `source ~/.bashrc` | +| `venv` | `source .venv/bin/activate` | +| `uvs` | `uv sync --all-extras` | +| `cat` | `batcat` (syntax-highlighted cat) | +| `bat` | `batcat` | +| `ls` | `eza` | +| `ll` | `eza -la --git` | +| `fd` | `fdfind` | +| `clip` | `xclip -selection clipboard` | +| `open_ports` | `sudo ss -tulwn \| grep LISTEN` | +| `docker_stop_all`| stop all running containers | +| `claude-allow` | claude --dangerously-skip-permissions | + +## Shell functions (`bash_helpers.sh`) + +| Function | Usage | +|------------------|--------------------------------------------| +| `attach ` | exec into a running Docker container | +| `python-dev` | start/attach python-dev container (`-p` pull, `-n` fresh) | +| `mount_ssh `| mount remote dir via SSHFS | +| `canview` | launch CAN bus viewer (uses `$CAN_CHANNEL`) | +| `repl` | connect to serial device via picocom (uses `$AMPY_PORT`) | +| `find_and_cat ` | find file by name and print contents | +| `record_window` | record a window to mp4 via ffmpeg | diff --git a/ai-tools/agents/implementer.md b/ai-tools/agents/implementer.md new file mode 100644 index 0000000..2e99f53 --- /dev/null +++ b/ai-tools/agents/implementer.md @@ -0,0 +1,10 @@ +--- +name: implementer +description: Implement a feature by writing tests, production code, and committing changes. Delegates from /develop. +model: sonnet +tools: Read, Edit, Write, Bash, Grep, Glob +skills: + - commit +--- + +Read only the minimum files needed. Write tests first, then production code. Commit atomically as you go using conventional commits. Run tests after each logical step. If stuck after 3 attempts on the same issue, report `[BLOCKED: reason]`. diff --git a/ai-tools/claude/CLAUDE.md b/ai-tools/claude/CLAUDE.md new file mode 100644 index 0000000..ba50adc --- /dev/null +++ b/ai-tools/claude/CLAUDE.md @@ -0,0 +1,31 @@ +## Role +Senior Python developer assistant. Optimize for simple, maintainable code and low-token responses. + +## Behavior +- Act on requests by default. +- Ask questions only if ambiguity affects correctness. +- Keep scope tight; do not add unrequested features. +- Propose a short plan (≤5 bullets) only when useful. +- If a request overcomplicates things, call it out and suggest a simpler option. +- Commit only when explicitly asked. + +## Principles +- KISS, YAGNI. +- Explicit > implicit; readability counts. +- Flat > nested; avoid deep abstractions. +- Sparse > dense; avoid clever one-liners. +- No speculative patterns or overengineering. + +## Coding +- Python 3.10+ with type hints (PEP 604). +- Use `uv` +- Clear names; short docstrings for non-obvious parts. +- No placeholders unless immediately needed. +- Keep files ~300–500 lines when practical. +- Keep imports at top. + +## Sub-agents +- Read minimum required files. +- Return concise summaries, not raw dumps. +- Treat as stateless; don’t pass full context unless needed. +- Don’t use for simple search or git tasks. diff --git a/ai-tools/claude/settings.json b/ai-tools/claude/settings.json new file mode 100644 index 0000000..5e65606 --- /dev/null +++ b/ai-tools/claude/settings.json @@ -0,0 +1,16 @@ +{ + "permissions": { + "allow": [ + "Bash(uv sync:*)", + "Bash(uv run pytest:*)", + "Bash(inv lint:*)", + "Bash(inv test:*)" + ], + "deny": [ + "Read(./.venv/**)", + "Read(./__pycache__/**)", + "Read(./.env*)" + ] + }, + "skipDangerousModePermissionPrompt": true +} diff --git a/ai-tools/skills/architect/SKILL.md b/ai-tools/skills/architect/SKILL.md new file mode 100644 index 0000000..d2d8d49 --- /dev/null +++ b/ai-tools/skills/architect/SKILL.md @@ -0,0 +1,22 @@ +--- +name: architect +description: Create or update docs/architecture.md from user stories and project context. +--- + +# Architect + +Produce `docs/architecture.md`. + +## Process + +1. Read `docs/user_stories.md` and any existing `docs/architecture.md`. +2. Produce or update `docs/architecture.md` using the template in `assets/template.md`. + +## Rules + +- Make reasonable assumptions and flag each with `[ASSUMPTION]`. +- Ask only if a missing answer would materially change the architecture and cannot be inferred. +- Keep the document as short as reasonably possible. +- Mermaid diagrams only. +- Be specific: version numbers, concrete patterns, no "best practices" filler. +- If updating an existing document, preserve decisions that are still valid. diff --git a/ai-tools/skills/architect/assets/template.md b/ai-tools/skills/architect/assets/template.md new file mode 100644 index 0000000..f2df9ce --- /dev/null +++ b/ai-tools/skills/architect/assets/template.md @@ -0,0 +1,33 @@ +# Architecture: [Project Name] + +## Problem and context +What problem this solves and for whom. + +## Goals and non-goals +Numbered goals with measurable criteria. Explicit non-goals. + +## Repository structure +Directory layout with one-line descriptions. + +## System overview +One paragraph, then a Mermaid component diagram showing major components, +responsibilities, and communication paths. + +## Technology stack +| Component | Technology | Version | Rationale | + +## Module boundaries +For each module: what it owns, its public interface, and what it must NOT do. +Communication patterns between modules (sync/async, events, RPC). + +## Key architectural decisions +For each important decision: +- **Decision:** what was chosen +- **Alternatives considered:** what else was evaluated +- **Rationale:** why this option + +## Constraints and conventions +Tech stack rules, naming conventions, forbidden libraries, project-wide patterns. + +## Open questions +Only unresolved items that must be decided later. diff --git a/ai-tools/skills/commit/SKILL.md b/ai-tools/skills/commit/SKILL.md new file mode 100644 index 0000000..4f5f5ea --- /dev/null +++ b/ai-tools/skills/commit/SKILL.md @@ -0,0 +1,23 @@ +--- +name: commit +description: Stage and commit changes on the active feature branch using atomic conventional commits. +--- + +# Commit + +Create atomic conventional commits on the active feature branch. + +## Commit format + +- Format: `type(scope): description` +- Types: `feat`, `fix`, `chore`, `docs`, `style`, `refactor`, `perf`, `test` +- First line: imperative mood, lowercase, concise (e.g., "add login" not "added login"). +- Body: only for complex changes; explain why, not what. +- Footer: `BREAKING CHANGE:` if applicable. + +## Rules + +- Scope is optional but encouraged when a module or file is the clear focus. +- If changes span multiple logical tasks, create separate commits. +- Each commit must be independently reviewable. +- Never combine unrelated changes in a single commit. diff --git a/ai-tools/skills/commit/scripts/commit.sh b/ai-tools/skills/commit/scripts/commit.sh new file mode 100755 index 0000000..bc3f423 --- /dev/null +++ b/ai-tools/skills/commit/scripts/commit.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# Stage specified files and create a commit. +# Usage: bash scripts/commit.sh --message "type(scope): description" [--] file1 file2 ... + +set -euo pipefail + +usage() { + echo "Usage: bash scripts/commit.sh --message MESSAGE [--] FILE [FILE...]" + echo "" + echo "Options:" + echo " --message MESSAGE Commit message (required)" + echo " --help Show this help" + echo "" + echo "Examples:" + echo " bash scripts/commit.sh --message \"feat(auth): add login endpoint\" src/auth.py tests/test_auth.py" + echo " bash scripts/commit.sh --message \"chore: update deps\" -- requirements.txt" +} + +MESSAGE="" +FILES=() + +while [[ $# -gt 0 ]]; do + case "$1" in + --help) usage; exit 0 ;; + --message) + [[ -z "${2:-}" ]] && { echo "Error: --message requires a value."; exit 1; } + MESSAGE="$2"; shift 2 ;; + --) shift; FILES+=("$@"); break ;; + -*) echo "Error: unknown option '$1'. Run with --help for usage."; exit 1 ;; + *) FILES+=("$1"); shift ;; + esac +done + +CURRENT_BRANCH="$(git branch --show-current)" + +if [[ -z "$CURRENT_BRANCH" ]]; then + echo "Error: not on a branch." + exit 1 +fi + +if [[ "$CURRENT_BRANCH" == "main" || "$CURRENT_BRANCH" == "master" ]]; then + echo "Error: refusing to commit directly on '$CURRENT_BRANCH'. Create or switch to a feature branch first." + exit 1 +fi + +if [[ -z "$MESSAGE" ]]; then + echo "Error: --message is required." + echo "" + usage + exit 1 +fi + +if [[ ${#FILES[@]} -eq 0 ]]; then + echo "Error: at least one file must be specified." + echo "" + usage + exit 1 +fi + +git add -- "${FILES[@]}" +git commit --message "$MESSAGE" diff --git a/ai-tools/skills/commit/scripts/status.sh b/ai-tools/skills/commit/scripts/status.sh new file mode 100755 index 0000000..79e854c --- /dev/null +++ b/ai-tools/skills/commit/scripts/status.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# Show branch status and full diff for commit planning. +# Usage: bash scripts/status.sh [BASE_BRANCH] + +set -euo pipefail + +if [[ "${1:-}" == "--help" ]]; then + echo "Usage: bash scripts/status.sh [BASE_BRANCH]" + echo "" + echo "Prints the current branch, git status, and the full diff (staged and unstaged) for commit planning." + exit 0 +fi + +BASE_BRANCH="${1:-main}" +CURRENT_BRANCH="$(git branch --show-current)" + +echo "=== current branch ===" +echo "${CURRENT_BRANCH:-detached HEAD}" + +if git rev-parse --verify "$BASE_BRANCH" >/dev/null 2>&1 && [[ -n "$CURRENT_BRANCH" && "$CURRENT_BRANCH" != "$BASE_BRANCH" ]]; then + echo "" + echo "=== commits ahead of $BASE_BRANCH ===" + git --no-pager log --oneline "$BASE_BRANCH..$CURRENT_BRANCH" +fi + +echo "" +echo "=== git status ===" +git status --short --branch + +echo "" +echo "=== staged diff ===" +git diff --staged + +echo "" +echo "=== unstaged diff ===" +git diff diff --git a/ai-tools/skills/develop/SKILL.md b/ai-tools/skills/develop/SKILL.md new file mode 100644 index 0000000..3e631bc --- /dev/null +++ b/ai-tools/skills/develop/SKILL.md @@ -0,0 +1,88 @@ +--- +name: develop +description: "Autopilot feature delivery: spec, implement, commit, review — stops for manual testing before merge." +--- + +# Develop + +Deliver a feature end-to-end with minimal human intervention. + +## Input + +`/develop ` — a plain-English feature description or slug name. + +If no description is provided: +1. Read `docs/feature_backlog.md` if it exists. +2. Pick the first `## NNN — title` item (lowest `NNN`). +3. Announce: "Next up: **NNN — title** — description. Starting now." then proceed. +4. If no backlog exists, ask: "What should I build?" and wait. + +## Resuming an interrupted run + +1. Run `git log --oneline` to see commits on the branch. +2. Check `Status` in `docs/features/.md`. +3. Skip completed phases, continue from the next one. + +## Phase 0 — Setup + +1. Derive a slug from the description (e.g. `user-auth`, `csv-export`). +2. Determine the numeric prefix: list files in `docs/features/` matching `[0-9][0-9][0-9]-*.md`, find the highest number, and use one higher. If no files exist, start at `001`. Format: `NNN-` (e.g. `001-user-auth`, `042-csv-export`). +3. Use the full prefixed slug everywhere: branch name (`feat/NNN-`), spec file (`docs/features/NNN-.md`), and commit messages. +4. Run `git checkout -b feat/` to create the feature branch. If it already exists, switch to it. +5. If the working tree is dirty, stash changes first with `git stash`, then pop after switching. + +## Phase 1 — Spec + +1. Read `docs/architecture.md` if it exists (not required). +2. Write a lightweight feature spec to `docs/features/.md` using the template in `assets/spec-template.md`. +3. Make reasonable assumptions — flag each with `[ASSUMPTION]` in the spec. +4. Ask only if a missing answer would materially change the result and cannot be inferred. Otherwise choose the simpler option and flag it. +5. Commit the spec: `docs(): add feature spec` + +## Phase 2 — Implement + Commit + +Delegate to the **implementer** subagent (Sonnet). Provide it with: +- The feature spec path +- The architecture doc path (if it exists) +- Instruction to write tests first, then production code +- Instruction to commit atomically as it goes (conventional commits) +- Instruction to run tests after each logical step +- Instruction to read only the minimum files needed + +If the implementer reports `[BLOCKED]`, stop and report the blocker to the user. + +**Copilot / single-thread mode:** run implementation directly — read the spec, write tests, implement, commit, run tests. + +## Phase 3 — Review + +1. Run the test suite (`inv test` or project-appropriate test command). +2. Run `git diff main...HEAD` to see all changes on the feature branch. +3. Compare against the feature spec: + - Are all acceptance criteria addressed? + - Do tests pass? + - Any obvious issues (broken imports, missing files, dead code)? +4. Decision: + - **PASS** → update spec status to `approved`. Stop and tell the user: "Feature branch `feat/` is ready. Test it, then run `/merge ` to merge." + - **FIXABLE** → loop back to Phase 2 with specific fix instructions. Maximum **1 retry**. + - **BLOCKED** → update spec status to `blocked`, stop and report to user. + +## When to stop + +Stop and report to the user ONLY for: +- Contradictions between the feature description and existing architecture +- Test failures that can't be resolved after 3 attempts +- Merge conflicts that can't be auto-resolved +- Missing critical information that genuinely cannot be assumed + +Do NOT stop for: +- Choosing between implementation approaches (pick the simpler one, flag with `[ASSUMPTION]`) +- Creating new files or modules (just do it) +- Commit message wording (use conventional commits) + +## Rules + +- No approval gates. +- Make assumptions and flag them. +- Atomic conventional commits throughout. +- Feature branches are temporary — created and merged automatically. +- If the project has no test infrastructure, skip test-related steps and note it in the review. diff --git a/ai-tools/skills/develop/assets/spec-template.md b/ai-tools/skills/develop/assets/spec-template.md new file mode 100644 index 0000000..f7dfd89 --- /dev/null +++ b/ai-tools/skills/develop/assets/spec-template.md @@ -0,0 +1,23 @@ +# Feature: + +Status: draft + +## Summary + +One sentence: what this feature does and why it's needed. + +## Acceptance Criteria + +- AC-01: ... + +## Test Plan + +- T-01 (AC-01): given ... / when ... / then ... + +## Assumptions + +- [ASSUMPTION]: ... + +## Notes + +Optional: implementation hints, constraints, edge cases. diff --git a/ai-tools/skills/merge/SKILL.md b/ai-tools/skills/merge/SKILL.md new file mode 100644 index 0000000..fb3f98f --- /dev/null +++ b/ai-tools/skills/merge/SKILL.md @@ -0,0 +1,23 @@ +--- +name: merge +description: "Merge a reviewed feature branch to main and clean up." +--- + +# Merge + +Merge a completed feature branch into `main` and delete it. + +## Input + +`/merge ` — the prefixed feature slug (e.g. `042-csv-export`). + +If no slug is provided, check the current branch (`git branch --show-current`). If it matches `feat/`, derive the slug from it. Otherwise, list feature branches and ask: "Which branch should I merge?" + +## Steps + +1. Confirm the feature spec at `docs/features/.md` has status `approved`. If not, stop and report. +2. `git checkout main` +3. `git merge --no-ff feat/` +4. If merge conflicts occur, attempt auto-resolution. If that fails, stop and report the conflicting files. +5. `git branch -d feat/` +6. Report summary: feature name, number of commits merged, what was built. diff --git a/ai-tools/skills/review/SKILL.md b/ai-tools/skills/review/SKILL.md new file mode 100644 index 0000000..730aee7 --- /dev/null +++ b/ai-tools/skills/review/SKILL.md @@ -0,0 +1,39 @@ +--- +name: review +description: Audit the full codebase as a software architect. Score on 5 KPIs (Maintainability, Extensibility, Testability, Robustness, Clarity) and produce docs/review.md. +--- + +# Review + +Audit the codebase, score each KPI 0–10, and produce `docs/review.md`. + +## Process + +1. Run the test suite to verify baseline health and gather coverage stats. +2. Analyze the codebase against the 5 KPIs below. +3. Produce `docs/review.md` using the template in `assets/template.md`. + +## KPIs + +1. **Maintainability** — How easily can the system be debugged, modified, or understood? + Metrics: modularity, cohesion, coupling, readability, simplicity. + +2. **Extensibility** — How easily can new features be added without major refactoring? + Metrics: separation of concerns, dependency injection, use of interfaces/protocols. + +3. **Testability** — How easily can components be tested in isolation and as a whole? + Metrics: pure functions, mockability, dependency inversion. + +4. **Robustness** — How well does the system handle edge cases, errors, and real-world conditions? + Metrics: state management, predictability, fault tolerance. + +5. **Clarity** — How quickly can a new developer understand the system's design and purpose? + Metrics: documentation, consistent naming, clear abstractions. + +## Rules + +- Be specific — cite `file:line` or `file:function` when pointing out issues. +- No filler — every bullet must be actionable or informative. +- Always include your model/version in the reviewer field. +- Do not modify any code. This is a read-only review. +- Keep the write-up concise. diff --git a/ai-tools/skills/review/assets/template.md b/ai-tools/skills/review/assets/template.md new file mode 100644 index 0000000..aa5bce9 --- /dev/null +++ b/ai-tools/skills/review/assets/template.md @@ -0,0 +1,30 @@ +# Code Review + +| Field | Value | +|---|---| +| Date | YYYY-MM-DD | +| Reviewer | model name and version | +| LOC | total lines of code | +| Tests | count | +| Coverage | percentage (if available) | + +## Maintainability — X/10 +- ... + +## Extensibility — X/10 +... + +## Testability — X/10 +... + +## Robustness — X/10 +... + +## Clarity — X/10 +... + +## Summary +One short paragraph on the overall state of the codebase. + +## Priority recommendations +Top 5 actionable improvements, ordered by impact. diff --git a/ai-tools/skills/wbs/SKILL.md b/ai-tools/skills/wbs/SKILL.md new file mode 100644 index 0000000..940e793 --- /dev/null +++ b/ai-tools/skills/wbs/SKILL.md @@ -0,0 +1,62 @@ +--- +name: wbs +description: Break down architecture.md into a prioritized feature backlog, skipping already-built features. +--- + +# Plan + +Produce or update `docs/feature_backlog.md`. + +## Process + +1. Read `docs/architecture.md`. This is the source of truth for what needs to be built. +2. Scan `docs/features/` for existing feature spec files (pattern: `[0-9][0-9][0-9]-*.md`). These are already built — do not include them in the backlog. +3. Find the highest `NNN` number across both `docs/features/` files and any existing `docs/feature_backlog.md` entries. New items start from one higher. +4. Produce `docs/feature_backlog.md` with this structure: + +```markdown +# Feature Backlog + +Completed features are tracked in `docs/features/` and removed from this list. + +--- + +## NNN — slug-style-title + +One sentence describing what gets built. + +## NNN — next-item + +One sentence. Optionally: +depends: NNN +``` + +## What goes on the backlog + +Cover everything in the architecture that is not yet implemented. Group logically — infrastructure first, then features, then integrations. + +Do not add items for: +- Work already represented by a file in `docs/features/` +- Documentation, comments, or cleanup unless explicitly in the architecture +- Vague future ideas not grounded in the architecture + +## Chunk sizing + +Each backlog item will be handed to a coding agent that writes a spec, implements the code, and commits — all in one automated pass with no human in the loop. Size items accordingly: + +- **Self-contained**: scope should contain a single functionality that can be implemented and tested. +- **Scoped**: touches at most 2 modules and adds roughly 50–200 lines of production code +- **Testable**: success is checkable by running the test suite — no manual inspection required + +If an architectural feature is large, split it into ordered items (e.g. `data model`, then `API layer`, then `UI`). +If a feature is trivial (a single function or config value), merge it with a related item. + +If an item depends on another, note it on a second line as `depends: NNN`. + +## Rules + +- Ask only if a trade-off materially changes scope or ordering and cannot be resolved from the architecture. +- Preserve items already in `docs/feature_backlog.md` that are not yet in `docs/features/` — only add, reorder, or remove items, do not rewrite existing descriptions without good reason. +- Each item is a `## NNN — slug-style-title` subheading followed by one sentence (and optionally a `depends:` line). +- Keep descriptions to one sentence — just enough for the coding agent to understand the scope. +- Do not mark any item as done. Completed work is tracked in `docs/features/`, not here. diff --git a/docker/python-dev/.gitignore b/docker/python-dev/.gitignore new file mode 100644 index 0000000..09e05c6 --- /dev/null +++ b/docker/python-dev/.gitignore @@ -0,0 +1,2 @@ +aliases.sh +bash_helpers.sh diff --git a/docker/python-dev/Dockerfile b/docker/python-dev/Dockerfile new file mode 100644 index 0000000..cce2b46 --- /dev/null +++ b/docker/python-dev/Dockerfile @@ -0,0 +1,107 @@ +FROM python:3.13 + +ARG USERNAME=dev +ARG UID=1000 +ARG GID=1000 + +# Create user with sudo support +RUN groupadd --gid $GID $USERNAME \ + && useradd --uid $UID --gid $GID -m $USERNAME \ + && apt-get update \ + && apt-get install -y sudo curl gnupg \ + && echo "$USERNAME ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/$USERNAME \ + && chmod 0440 /etc/sudoers.d/$USERNAME \ + && usermod -a -G dialout $USERNAME + +# Install system packages +RUN apt-get install -y \ + bat \ + fd-find \ + git \ + git-lfs \ + graphviz \ + iputils-ping \ + libgl1 \ + locales \ + make \ + micro \ + mosquitto-clients \ + net-tools \ + picocom \ + ripgrep \ + rsync \ + socat \ + tree \ + zoxide \ + && rm -rf /var/lib/apt/lists/* + +# Install eza +RUN mkdir -p /etc/apt/keyrings \ + && wget -qO- https://raw.githubusercontent.com/eza-community/eza/main/deb.asc \ + | gpg --dearmor -o /etc/apt/keyrings/gierens.gpg \ + && echo 'deb [signed-by=/etc/apt/keyrings/gierens.gpg] http://deb.gierens.de stable main' \ + > /etc/apt/sources.list.d/gierens.list \ + && chmod 644 /etc/apt/keyrings/gierens.gpg /etc/apt/sources.list.d/gierens.list \ + && apt-get update && apt-get install -y eza \ + && rm -rf /var/lib/apt/lists/* + +# Install lazygit +RUN LAZYGIT_VERSION=$(curl -s https://api.github.com/repos/jesseduffield/lazygit/releases/latest \ + | grep '"tag_name"' | cut -d'"' -f4 | sed 's/v//') \ + && curl -Lo /tmp/lazygit.tar.gz \ + https://github.com/jesseduffield/lazygit/releases/latest/download/lazygit_${LAZYGIT_VERSION}_Linux_x86_64.tar.gz \ + && tar -xf /tmp/lazygit.tar.gz -C /tmp lazygit \ + && install /tmp/lazygit /usr/local/bin \ + && rm /tmp/lazygit.tar.gz /tmp/lazygit + +# Set locale +RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && \ + locale-gen +ENV LANG=en_US.UTF-8 +ENV LC_ALL=en_US.UTF-8 + +# Create user directories with proper ownership +RUN mkdir -p /home/${USERNAME}/.vscode-server/extensions \ + && mkdir -p /home/${USERNAME}/.local/bin \ + && mkdir -p /home/${USERNAME}/.claude \ + && mkdir -p /workspace \ + && mkdir -p /workspace/.venv \ + && chown -R ${USERNAME}:${USERNAME} /home/${USERNAME}/.vscode-server \ + && chown -R ${USERNAME}:${USERNAME} /home/${USERNAME}/.local \ + && chown -R ${USERNAME}:${USERNAME} /home/${USERNAME}/.claude \ + && chown -R ${USERNAME}:${USERNAME} /workspace + +VOLUME /workspace/.venv + + + +# Switch to non-root user (only once, for the rest of the build) +USER ${USERNAME} +WORKDIR /home/${USERNAME} + +# Add local bin to PATH +ENV PATH="/home/${USERNAME}/.local/bin:${PATH}" + +# Install uv +RUN curl -LsSf https://astral.sh/uv/install.sh | sh + +# Install fzf +RUN git clone --depth 1 https://github.com/junegunn/fzf.git ~/.fzf && ~/.fzf/install --all + +# Install AI tools as user (frequently updated) +# copilot cli +RUN curl -fsSL https://gh.io/copilot-install | bash + +# claude cli +RUN curl -fsSL https://claude.ai/install.sh | bash + +# User config (most frequently changed) +COPY --chown=${USERNAME}:${USERNAME} CLAUDE.md /home/${USERNAME}/.claude/CLAUDE.md +COPY --chown=${USERNAME}:${USERNAME} aliases.sh /home/${USERNAME}/.aliases.sh +COPY --chown=${USERNAME}:${USERNAME} bash_helpers.sh /home/${USERNAME}/.bash_helpers.sh + +# Customize bash prompt +RUN echo 'export PS1="${PROJECT_NAME:+\[\e[35m\][$PROJECT_NAME]\[\e[m\] }🐍 \[\e[33m\]\W\[\e[m\] \[\033[1;36m\]# \[\033[0m\]"' >> ~/.bashrc \ + && echo 'source ~/.bash_helpers.sh' >> ~/.bashrc \ + && echo 'eval "$(zoxide init bash)"' >> ~/.bashrc \ + && echo '[[ -f /workspace/init_container.sh ]] && source /workspace/init_container.sh' >> ~/.bashrc diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..fe4c324 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,19 @@ +[project] +name = "cli-tools" +version = "0.1.0" +description = "Dev environment CLI tools" +readme = "README.md" +requires-python = ">=3.10" +dependencies = ["typer"] + +[project.scripts] +cli-tools = "cli_tools.cli:app" + +[build-system] +requires = ["uv_build"] +build-backend = "uv_build" + +[dependency-groups] +dev = [ + "ruff>=0.15.9", +] diff --git a/scripts/aliases.sh b/scripts/aliases.sh new file mode 100644 index 0000000..c6c0d80 --- /dev/null +++ b/scripts/aliases.sh @@ -0,0 +1,15 @@ +#!/bin/bash + +alias venv='source .venv/bin/activate' +alias docker_stop_all='docker stop $(docker ps -a -q)' +alias uvs="uv sync --all-extras" +alias clip='xclip -selection clipboard' +alias open_ports='sudo ss -tulwn | grep LISTEN' +alias bat='batcat' +alias cat='batcat' +alias claude-allow="claude --dangerously-skip-permissions" +alias reload='source ~/.bashrc' +alias ls='eza' +alias ll='eza -la --git' +alias fd='fdfind' +alias lg='lazygit' diff --git a/scripts/bash_helpers.sh b/scripts/bash_helpers.sh new file mode 100644 index 0000000..bf26844 --- /dev/null +++ b/scripts/bash_helpers.sh @@ -0,0 +1,160 @@ +#!/bin/bash + +# This file contains helper functions for bash scripts. +# Source it in your bash script to use the functions. + +#------------------------------------- +# Docker-related functions + +# Attach to a running Docker container. +attach(){ + docker exec -it "$1" bash +} + +# Enter development shell in a Docker container using a Python image. +# Flags: --new/-n remove existing container first. +python-dev() { + local IMG="python-dev" + local NAME="python-dev" + + while [[ $# -gt 0 ]]; do + case "$1" in + --help|-h) echo "Usage: python-dev [--new|-n]"; echo " --new, -n Remove existing container before starting"; return 0 ;; + --new|-n) docker rm -f "$NAME" &>/dev/null; shift ;; + *) echo "Unknown option: $1"; return 1 ;; + esac + done + + local built + built="$(docker image inspect --format '{{.Created}}' "$IMG" 2>/dev/null)" + [[ -n "$built" ]] && echo "Image built: $(date -d "$built" '+%Y-%m-%d %H:%M:%S')" + + local PROJECT_NAME + PROJECT_NAME="$(basename "$(pwd)")" + + if docker container inspect "$NAME" &>/dev/null; then + local STATE + STATE="$(docker inspect -f '{{.State.Status}}' "$NAME")" + if [[ "$STATE" == "running" ]]; then + echo "Attaching to existing container '$NAME'" + docker exec -it -e PROJECT_NAME="$PROJECT_NAME" "$NAME" bash + else + echo "Restarting stopped container '$NAME'" + docker start -ai "$NAME" + fi + return $? + fi + + echo "Creating new container '$NAME'" + docker run -it \ + --name="$NAME" \ + --network=host \ + -e PROJECT_NAME="$PROJECT_NAME" \ + -v "$(pwd):/workspace" \ + -v /workspace/.venv \ + -v "$HOME/.ssh:/home/dev/.ssh:ro" \ + -v "$HOME/.gitconfig:/home/dev/.gitconfig:ro" \ + -w /workspace "$IMG" \ + bash +} + +#------------------------------------- +# CAN and serial functions + +# Launch CAN viewer for the given interface (default: slcan0). +canview() { + + if [ -z "$CAN_CHANNEL" ]; then + echo "Warning: CAN_CHANNEL not set. Using default can0" + CAN_CHANNEL=can0 + fi + + + if [ -z "$CAN_INTERFACE" ]; then + echo "Warning: CAN_INTERFACE not set. Using default slcan0" + CAN_INTERFACE=socketcan + fi + + python3 -m can.viewer -c "$CAN_CHANNEL" -i "$CAN_INTERFACE" +} + +# Connect to a serial device using Picocom (default: /dev/ttyACM0). +repl(){ + if [ -z "$AMPY_PORT" ]; then + echo "Warning: AMPY_PORT not set. Using default /dev/ttyACM0" + AMPY_PORT=/dev/ttyACM0 + fi + echo "Connecting to $AMPY_PORT" + picocom -b 115200 $AMPY_PORT +} + +#------------------------------------- +# SSHFS and network functions + +# Mount a remote directory over SSHFS. Takes a remote path as an argument. +function mount_ssh() { + + if [ -z "$1" ]; then + echo "No argument provided." + return 1 + fi + + echo "Mounting $1" + + mkdir -p "$1" + sshfs -o allow_other "$1:" "$1" +} + +#------------------------------------- +# Miscellaneous functions + +# Find a file and display its contents. +find_and_cat() { + if [[ -z $1 ]]; then + echo "Usage: find_and_cat " + return 1 + fi + + find . -type f -name "$1" -print0 | while IFS= read -r -d '' file; do + echo -e "File Location: $file\n" + echo "File Contents:" + cat "$file" + echo -e "\n---\n" + done +} + +# record_window +record_window() { + local outfile="recording_$(date +%F_%H-%M-%S).mp4" + + echo "Click on the window you want to record..." + local wininfo=$(xwininfo) + + local width=$(echo "$wininfo" | grep "Width:" | awk '{print $2}') + local height=$(echo "$wininfo" | grep "Height:" | awk '{print $2}') + local x=$(echo "$wininfo" | grep "Absolute upper-left X:" | awk '{print $4}') + local y=$(echo "$wininfo" | grep "Absolute upper-left Y:" | awk '{print $4}') + + echo "Recording window at ${width}x${height}+${x},${y} to $outfile..." + ffmpeg -video_size ${width}x${height} -framerate 30 -f x11grab -i :0.0+${x},${y} \ + -vcodec libx264 -preset fast -pix_fmt yuv420p -movflags +faststart "$outfile" +} + +fdcat() { + # 1. Run fdfind with all passed arguments and pipe to fzf + # Usage: fdcat -e py logging + # Usage: fdcat -e js auth + local file=$(fdfind "$@" | fzf --preview 'batcat --color=always --style=numbers {}') + + # 2. Copy and print if a selection was made + if [ -n "$file" ]; then + # Adjust 'xclip -sel clip' if you are on WSL (use clip.exe) or Mac (use pbcopy) + echo -n "$file" | xclip -sel clip + echo "✔ Copied: $file" + else + echo "Exit: No file selected." + fi +} +#---- Aliases + +source ~/.aliases.sh diff --git a/src/cli_tools/__init__.py b/src/cli_tools/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/cli_tools/cli.py b/src/cli_tools/cli.py new file mode 100644 index 0000000..6394dcc --- /dev/null +++ b/src/cli_tools/cli.py @@ -0,0 +1,13 @@ +"""CLI entry point.""" + +import typer + +from cli_tools import credentials, docker, install + +app = typer.Typer(help="Dev environment CLI tools.", no_args_is_help=True) +app.add_typer(install.app, name="install") +app.add_typer(docker.app, name="docker") +app.add_typer(credentials.app, name="credentials") + +if __name__ == "__main__": + app() diff --git a/src/cli_tools/credentials.py b/src/cli_tools/credentials.py new file mode 100644 index 0000000..9905109 --- /dev/null +++ b/src/cli_tools/credentials.py @@ -0,0 +1,19 @@ +"""Credential management commands.""" + +import typer + +from cli_tools.helpers import run + +app = typer.Typer(help="Encrypt and decrypt credentials.", no_args_is_help=True) + + +@app.command("encrypt-env") +def encrypt_env(): + """Encrypt .env -> .env.gpg.""" + run("gpg -c .env") + + +@app.command("decrypt-env") +def decrypt_env(): + """Decrypt .env.gpg -> .env.""" + run("gpg -d .env.gpg > .env") diff --git a/src/cli_tools/docker.py b/src/cli_tools/docker.py new file mode 100644 index 0000000..dcffd81 --- /dev/null +++ b/src/cli_tools/docker.py @@ -0,0 +1,32 @@ +"""Docker image commands.""" + +import os + +import typer + +from cli_tools.helpers import run + +app = typer.Typer(help="Build and manage Docker images.", no_args_is_help=True) + +DOCKER_IMAGE = "python-dev" +DOCKER_DIR = "docker/python-dev" + + +@app.command() +def build( + tag: str = typer.Option("latest", help="Image tag."), + no_cache: bool = typer.Option(False, "--no-cache", help="Build without cache."), +): + """Build the python-dev Docker image locally.""" + uid = os.getuid() + gid = os.getgid() + full_tag = f"{DOCKER_IMAGE}:{tag}" + run(f"cp ai-tools/claude/CLAUDE.md {DOCKER_DIR}/CLAUDE.md") + run(f"cp scripts/aliases.sh {DOCKER_DIR}/aliases.sh") + run(f"cp scripts/bash_helpers.sh {DOCKER_DIR}/bash_helpers.sh") + run( + f"docker build -t {full_tag} " + f"--build-arg UID={uid} --build-arg GID={gid} " + f"{'--no-cache ' if no_cache else ''}" + f"--network=host {DOCKER_DIR}", + ) diff --git a/src/cli_tools/helpers.py b/src/cli_tools/helpers.py new file mode 100644 index 0000000..02ac4ee --- /dev/null +++ b/src/cli_tools/helpers.py @@ -0,0 +1,35 @@ +"""Shared helpers for CLI commands.""" + +import re +import subprocess +from pathlib import Path + +YELLOW = "\033[33m" +RESET = "\033[0m" + +BASHRC = Path("~/.bashrc").expanduser() +SNIPPETS_DIR = Path(__file__).parent.parent.parent / "snippets" + + +def load_snippet(name: str) -> str: + """Read a shell snippet from the snippets directory.""" + return (SNIPPETS_DIR / f"{name}.sh").read_text() + + +def run(cmd: str, pty: bool = False) -> None: + """Print command in yellow then execute it.""" + print(f"{YELLOW}$ {cmd}{RESET}") + subprocess.run(cmd, shell=True, check=True) + + +def append_bashrc_section(marker: str, content: str) -> None: + """Idempotently add a named section to ~/.bashrc.""" + begin = f"# BEGIN {marker}" + end = f"# END {marker}" + section = f"\n{begin}\n{content.strip()}\n{end}\n" + + text = BASHRC.read_text() + text = re.sub( + rf"\n?{re.escape(begin)}.*?{re.escape(end)}\n?", "", text, flags=re.DOTALL + ) + BASHRC.write_text(text + section) diff --git a/src/cli_tools/install.py b/src/cli_tools/install.py new file mode 100644 index 0000000..2fb568c --- /dev/null +++ b/src/cli_tools/install.py @@ -0,0 +1,190 @@ +"""Install commands.""" + +import shlex +from pathlib import Path + +import typer + +from cli_tools.helpers import append_bashrc_section, run + +app = typer.Typer( + help="Install tools and configure the environment.", no_args_is_help=True +) + +APT_PACKAGES = [ + "git", + "curl", + "micro", + "mc", + "detox", + "tree", + "ripgrep", + "fd-find", + "btop", + "tldr", +] + + +@app.command() +def claude(): + """Install Claude Code via the official install script.""" + run("curl -fsSL https://claude.ai/install.sh | bash") + + +@app.command() +def copilot(): + """Install GitHub Copilot via the official install script.""" + run("curl -fsSL https://gh.io/copilot-install | bash") + + +@app.command("apt-packages") +def apt_packages(): + """Update apt cache and install base packages.""" + run("sudo apt-get update") + run(f"sudo apt-get install -y {' '.join(APT_PACKAGES)}") + + +@app.command() +def docker(): + """Install Docker and add current user to docker group.""" + import getpass + + run( + "command -v docker >/dev/null 2>&1 || curl -fsSL https://get.docker.com | sudo sh" + ) + run(f"sudo usermod -aG docker {getpass.getuser()}") + + +@app.command() +def uv(): + """Install uv for the current user.""" + run("test -f ~/.local/bin/uv || curl -LsSf https://astral.sh/uv/install.sh | sh") + + +@app.command() +def ccusage(): + """Install ccusage for monitoring code complexity.""" + run("npm install -g ccusage") + +@app.command() +def fzf(): + """Install fzf from git and bat for preview.""" + run("sudo apt-get remove -y fzf 2>/dev/null || true") + run("sudo apt-get install -y bat") + run( + "test -d ~/.fzf || git clone --depth 1 https://github.com/junegunn/fzf.git ~/.fzf" + ) + run("~/.fzf/install --all") + + +@app.command() +def zoxide(): + """Install zoxide and configure .bashrc.""" + run("sudo apt install -y zoxide") + append_bashrc_section("zoxide", 'eval "$(zoxide init bash)"') + + +@app.command() +def helpers(): + """Symlink bash_helpers.sh and aliases.sh into home directory.""" + repo = Path(__file__).resolve().parents[2] + bash_helpers = repo / "scripts" / "bash_helpers.sh" + aliases = repo / "scripts" / "aliases.sh" + + if not bash_helpers.is_file() or not aliases.is_file(): + typer.secho( + "This command must be run from the checked-out cli-tools repo " + "(for example with `uv run cli-tools install helpers`).", + fg=typer.colors.RED, + err=True, + ) + raise typer.Exit(code=1) + + run(f"ln -sf {shlex.quote(str(bash_helpers))} ~/.bash_helpers.sh") + run(f"ln -sf {shlex.quote(str(aliases))} ~/.aliases.sh") + append_bashrc_section("bash_helpers", "source ~/.bash_helpers.sh") + + +@app.command() +def lazygit(): + """Install lazygit (terminal UI for git).""" + run( + "command -v lazygit >/dev/null 2>&1 || (" + "LAZYGIT_VERSION=$(curl -s https://api.github.com/repos/jesseduffield/lazygit/releases/latest" + " | grep '\"tag_name\"' | cut -d'\"' -f4 | sed 's/v//') && " + "curl -Lo /tmp/lazygit.tar.gz" + " https://github.com/jesseduffield/lazygit/releases/latest/download/lazygit_${LAZYGIT_VERSION}_Linux_x86_64.tar.gz && " + "tar -xf /tmp/lazygit.tar.gz -C /tmp lazygit && " + "sudo install /tmp/lazygit /usr/local/bin)" + ) + + +@app.command() +def eza(): + """Install eza (modern ls replacement) via official deb repo.""" + run("sudo mkdir -p /etc/apt/keyrings") + run( + "wget -qO- https://raw.githubusercontent.com/eza-community/eza/main/deb.asc" + " | sudo gpg --dearmor -o /etc/apt/keyrings/gierens.gpg" + ) + run( + "echo 'deb [signed-by=/etc/apt/keyrings/gierens.gpg] http://deb.gierens.de stable main'" + " | sudo tee /etc/apt/sources.list.d/gierens.list" + ) + run( + "sudo chmod 644 /etc/apt/keyrings/gierens.gpg /etc/apt/sources.list.d/gierens.list" + ) + run("sudo apt update && sudo apt install -y eza") + + +@app.command("ai-skills") +def ai_skills(): + """Symlink Claude Code config, skills, agents, and Copilot skills globally.""" + repo = Path(__file__).resolve().parents[2] + claude_src = repo / "ai-tools" / "claude" + skills_src = repo / "ai-tools" / "skills" + agents_src = repo / "ai-tools" / "agents" + + claude_home = Path.home() / ".claude" + claude_skills = Path.home() / ".claude" / "skills" + claude_agents = Path.home() / ".claude" / "agents" + github_skills = Path.home() / ".github" / "skills" + + claude_home.mkdir(parents=True, exist_ok=True) + for src in sorted(claude_src.iterdir()): + dest = claude_home / src.name + run(f"ln -sfn {shlex.quote(str(src))} {shlex.quote(str(dest))}") + + claude_skills.mkdir(parents=True, exist_ok=True) + for skill_dir in sorted(skills_src.iterdir()): + if skill_dir.is_dir() and (skill_dir / "SKILL.md").exists(): + dest = claude_skills / skill_dir.name + run(f"ln -sfn {shlex.quote(str(skill_dir))} {shlex.quote(str(dest))}") + + claude_agents.mkdir(parents=True, exist_ok=True) + for agent_file in sorted(agents_src.glob("*.md")): + dest = claude_agents / agent_file.name + run(f"ln -sf {shlex.quote(str(agent_file))} {shlex.quote(str(dest))}") + + github_skills.mkdir(parents=True, exist_ok=True) + for skill_dir in sorted(claude_skills.iterdir()): + if skill_dir.is_dir(): + dest = github_skills / skill_dir.name + run(f"ln -sfn {shlex.quote(str(skill_dir))} {shlex.quote(str(dest))}") + + + + + +@app.command() +def bootstrap(): + """Install all base tools.""" + apt_packages() + docker() + uv() + claude() + fzf() + zoxide() + lazygit() + eza() + ai_skills() diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..7be8c4e --- /dev/null +++ b/uv.lock @@ -0,0 +1,144 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "cli-tools" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "typer" }, +] + +[package.dev-dependencies] +dev = [ + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [{ name = "typer" }] + +[package.metadata.requires-dev] +dev = [{ name = "ruff", specifier = ">=0.15.9" }] + +[[package]] +name = "click" +version = "8.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/57/75/31212c6bf2503fdf920d87fee5d7a86a2e3bcf444984126f13d8e4016804/click-8.3.2.tar.gz", hash = "sha256:14162b8b3b3550a7d479eafa77dfd3c38d9dc8951f6f69c78913a8f9a7540fd5", size = 302856, upload-time = "2026-04-03T19:14:45.118Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/20/71885d8b97d4f3dde17b1fdb92dbd4908b00541c5a3379787137285f602e/click-8.3.2-py3-none-any.whl", hash = "sha256:1924d2c27c5653561cd2cae4548d1406039cb79b858b747cfea24924bbc1616d", size = 108379, upload-time = "2026-04-03T19:14:43.505Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "rich" +version = "14.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e6/97/e9f1ca355108ef7194e38c812ef40ba98c7208f47b13ad78d023caa583da/ruff-0.15.9.tar.gz", hash = "sha256:29cbb1255a9797903f6dde5ba0188c707907ff44a9006eb273b5a17bfa0739a2", size = 4617361, upload-time = "2026-04-02T18:17:20.829Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/1f/9cdfd0ac4b9d1e5a6cf09bedabdf0b56306ab5e333c85c87281273e7b041/ruff-0.15.9-py3-none-linux_armv6l.whl", hash = "sha256:6efbe303983441c51975c243e26dff328aca11f94b70992f35b093c2e71801e1", size = 10511206, upload-time = "2026-04-02T18:16:41.574Z" }, + { url = "https://files.pythonhosted.org/packages/3d/f6/32bfe3e9c136b35f02e489778d94384118bb80fd92c6d92e7ccd97db12ce/ruff-0.15.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:4965bac6ac9ea86772f4e23587746f0b7a395eccabb823eb8bfacc3fa06069f7", size = 10923307, upload-time = "2026-04-02T18:17:08.645Z" }, + { url = "https://files.pythonhosted.org/packages/ca/25/de55f52ab5535d12e7aaba1de37a84be6179fb20bddcbe71ec091b4a3243/ruff-0.15.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:eaf05aad70ca5b5a0a4b0e080df3a6b699803916d88f006efd1f5b46302daab8", size = 10316722, upload-time = "2026-04-02T18:16:44.206Z" }, + { url = "https://files.pythonhosted.org/packages/48/11/690d75f3fd6278fe55fff7c9eb429c92d207e14b25d1cae4064a32677029/ruff-0.15.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9439a342adb8725f32f92732e2bafb6d5246bd7a5021101166b223d312e8fc59", size = 10623674, upload-time = "2026-04-02T18:16:50.951Z" }, + { url = "https://files.pythonhosted.org/packages/bd/ec/176f6987be248fc5404199255522f57af1b4a5a1b57727e942479fec98ad/ruff-0.15.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9c5e6faf9d97c8edc43877c3f406f47446fc48c40e1442d58cfcdaba2acea745", size = 10351516, upload-time = "2026-04-02T18:16:57.206Z" }, + { url = "https://files.pythonhosted.org/packages/b2/fc/51cffbd2b3f240accc380171d51446a32aa2ea43a40d4a45ada67368fbd2/ruff-0.15.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7b34a9766aeec27a222373d0b055722900fbc0582b24f39661aa96f3fe6ad901", size = 11150202, upload-time = "2026-04-02T18:17:06.452Z" }, + { url = "https://files.pythonhosted.org/packages/d6/d4/25292a6dfc125f6b6528fe6af31f5e996e19bf73ca8e3ce6eb7fa5b95885/ruff-0.15.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:89dd695bc72ae76ff484ae54b7e8b0f6b50f49046e198355e44ea656e521fef9", size = 11988891, upload-time = "2026-04-02T18:17:18.575Z" }, + { url = "https://files.pythonhosted.org/packages/13/e1/1eebcb885c10e19f969dcb93d8413dfee8172578709d7ee933640f5e7147/ruff-0.15.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ce187224ef1de1bd225bc9a152ac7102a6171107f026e81f317e4257052916d5", size = 11480576, upload-time = "2026-04-02T18:16:52.986Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6b/a1548ac378a78332a4c3dcf4a134c2475a36d2a22ddfa272acd574140b50/ruff-0.15.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2b0c7c341f68adb01c488c3b7d4b49aa8ea97409eae6462d860a79cf55f431b6", size = 11254525, upload-time = "2026-04-02T18:17:02.041Z" }, + { url = "https://files.pythonhosted.org/packages/42/aa/4bb3af8e61acd9b1281db2ab77e8b2c3c5e5599bf2a29d4a942f1c62b8d6/ruff-0.15.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:55cc15eee27dc0eebdfcb0d185a6153420efbedc15eb1d38fe5e685657b0f840", size = 11204072, upload-time = "2026-04-02T18:17:13.581Z" }, + { url = "https://files.pythonhosted.org/packages/69/48/d550dc2aa6e423ea0bcc1d0ff0699325ffe8a811e2dba156bd80750b86dc/ruff-0.15.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a6537f6eed5cda688c81073d46ffdfb962a5f29ecb6f7e770b2dc920598997ed", size = 10594998, upload-time = "2026-04-02T18:16:46.369Z" }, + { url = "https://files.pythonhosted.org/packages/63/47/321167e17f5344ed5ec6b0aa2cff64efef5f9e985af8f5622cfa6536043f/ruff-0.15.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:6d3fcbca7388b066139c523bda744c822258ebdcfbba7d24410c3f454cc9af71", size = 10359769, upload-time = "2026-04-02T18:17:10.994Z" }, + { url = "https://files.pythonhosted.org/packages/67/5e/074f00b9785d1d2c6f8c22a21e023d0c2c1817838cfca4c8243200a1fa87/ruff-0.15.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:058d8e99e1bfe79d8a0def0b481c56059ee6716214f7e425d8e737e412d69677", size = 10850236, upload-time = "2026-04-02T18:16:48.749Z" }, + { url = "https://files.pythonhosted.org/packages/76/37/804c4135a2a2caf042925d30d5f68181bdbd4461fd0d7739da28305df593/ruff-0.15.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8e1ddb11dbd61d5983fa2d7d6370ef3eb210951e443cace19594c01c72abab4c", size = 11358343, upload-time = "2026-04-02T18:16:55.068Z" }, + { url = "https://files.pythonhosted.org/packages/88/3d/1364fcde8656962782aa9ea93c92d98682b1ecec2f184e625a965ad3b4a6/ruff-0.15.9-py3-none-win32.whl", hash = "sha256:bde6ff36eaf72b700f32b7196088970bf8fdb2b917b7accd8c371bfc0fd573ec", size = 10583382, upload-time = "2026-04-02T18:17:04.261Z" }, + { url = "https://files.pythonhosted.org/packages/4c/56/5c7084299bd2cacaa07ae63a91c6f4ba66edc08bf28f356b24f6b717c799/ruff-0.15.9-py3-none-win_amd64.whl", hash = "sha256:45a70921b80e1c10cf0b734ef09421f71b5aa11d27404edc89d7e8a69505e43d", size = 11744969, upload-time = "2026-04-02T18:16:59.611Z" }, + { url = "https://files.pythonhosted.org/packages/03/36/76704c4f312257d6dbaae3c959add2a622f63fcca9d864659ce6d8d97d3d/ruff-0.15.9-py3-none-win_arm64.whl", hash = "sha256:0694e601c028fd97dc5c6ee244675bc241aeefced7ef80cd9c6935a871078f53", size = 11005870, upload-time = "2026-04-02T18:17:15.773Z" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "typer" +version = "0.24.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/24/cb09efec5cc954f7f9b930bf8279447d24618bb6758d4f6adf2574c41780/typer-0.24.1.tar.gz", hash = "sha256:e39b4732d65fbdcde189ae76cf7cd48aeae72919dea1fdfc16593be016256b45", size = 118613, upload-time = "2026-02-21T16:54:40.609Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/91/48db081e7a63bb37284f9fbcefda7c44c277b18b0e13fbc36ea2335b71e6/typer-0.24.1-py3-none-any.whl", hash = "sha256:112c1f0ce578bfb4cab9ffdabc68f031416ebcc216536611ba21f04e9aa84c9e", size = 56085, upload-time = "2026-02-21T16:54:41.616Z" }, +]