Compare commits

..

2 Commits

Author SHA1 Message Date
Jev 18766caffd add comment info 2026-04-17 00:12:16 +02:00
Jev Kuznetsov 60710fab20 initial commit 2026-04-16 11:36:48 +02:00
37 changed files with 1458 additions and 318 deletions
+43 -4
View File
@@ -1,5 +1,44 @@
temp/
*.pyc
*.pyo
*.pyd
# 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
+23
View File
@@ -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
+28
View File
@@ -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
+130 -3
View File
@@ -1,5 +1,132 @@
# CLI tools
# CLI Tools
This folder contains scripts and tools to setup and manage terminal environment across systems
Scripts and tools to set up and manage the terminal environment across systems.
Main orchestration file: `tasks.py`
**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 <name>` | exec into a running Docker container |
| `python-dev` | start/attach python-dev container (`-p` pull, `-n` fresh) |
| `mount_ssh <host>`| 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 <file>` | find file by name and print contents |
| `record_window` | record a window to mp4 via ffmpeg |
+10
View File
@@ -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]`.
+32
View File
@@ -0,0 +1,32 @@
## 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 ~300500 lines when practical.
- Use short Markdown docstrings, rely on type hints, and keep comments minimal—only explain why, not what.
- Keep imports at top.
## Sub-agents
- Read minimum required files.
- Return concise summaries, not raw dumps.
- Treat as stateless; dont pass full context unless needed.
- Dont use for simple search or git tasks.
+16
View File
@@ -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
}
+22
View File
@@ -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.
@@ -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.
+23
View File
@@ -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.
+61
View File
@@ -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"
+36
View File
@@ -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
+88
View File
@@ -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 <description>` — 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/<NNN-slug>.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-<slug>` (e.g. `001-user-auth`, `042-csv-export`).
3. Use the full prefixed slug everywhere: branch name (`feat/NNN-<slug>`), spec file (`docs/features/NNN-<slug>.md`), and commit messages.
4. Run `git checkout -b feat/<NNN-slug>` 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/<NNN-slug>.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(<NNN-slug>): 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/<NNN-slug>` is ready. Test it, then run `/merge <NNN-slug>` 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.
@@ -0,0 +1,23 @@
# Feature: <Name>
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.
+23
View File
@@ -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 <NNN-slug>` — 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/<NNN-slug>`, 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/<NNN-slug>.md` has status `approved`. If not, stop and report.
2. `git checkout main`
3. `git merge --no-ff feat/<NNN-slug>`
4. If merge conflicts occur, attempt auto-resolution. If that fails, stop and report the conflicting files.
5. `git branch -d feat/<NNN-slug>`
6. Report summary: feature name, number of commits merged, what was built.
+39
View File
@@ -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 010, 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.
+30
View File
@@ -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.
+62
View File
@@ -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 50200 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.
+2
View File
@@ -0,0 +1,2 @@
aliases.sh
bash_helpers.sh
+107
View File
@@ -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
-32
View File
@@ -1,32 +0,0 @@
from pathlib import Path
YELLOW = "\033[33m"
RESET = "\033[0m"
BASHRC = "~/.bashrc"
SNIPPETS_DIR = Path(__file__).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(c, cmd: str) -> None:
"""Print command in yellow then execute it with a pty."""
print(f"{YELLOW}$ {cmd}{RESET}")
c.run(cmd, pty=True)
def append_bashrc_section(c, marker: str, content: str) -> None:
"""Idempotently add a named section to ~/.bashrc, replacing it if it already exists."""
import re
begin = f"# BEGIN {marker}"
end = f"# END {marker}"
section = f"\n{begin}\n{content.strip()}\n{end}\n"
bashrc = Path(BASHRC).expanduser()
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)
+19
View File
@@ -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",
]
+15
View File
@@ -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'
+160
View File
@@ -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 <filename>"
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
-68
View File
@@ -1,68 +0,0 @@
#!/bin/bash
set -euo pipefail
echo "=== Installing LXD on Debian Bookworm ==="
# Check if snapd is installed
if ! command -v snap &> /dev/null; then
echo "Installing snapd..."
sudo apt update
sudo apt install -y snapd
echo "Enabling snapd service..."
sudo systemctl enable --now snapd.socket
# Wait for snapd to be ready
sleep 5
else
echo "snapd already installed"
fi
# Check if LXD is already installed
if snap list lxd &> /dev/null; then
echo "LXD already installed via snap"
else
echo "Installing LXD via snap..."
sudo snap install lxd
fi
# Check if LXD is initialized
if sudo lxd init --dump &> /dev/null; then
echo "LXD already initialized"
else
echo "Initializing LXD with default settings..."
sudo lxd init --auto
fi
echo ""
echo "Configuring LXD network for Docker compatibility..."
lxc network set lxdbr0 ipv4.firewall false 2>/dev/null || true
lxc network set lxdbr0 ipv6.firewall false 2>/dev/null || true
lxc network set lxdbr0 ipv4.nat true 2>/dev/null || true
LXD_SUBNET=$(lxc network get lxdbr0 ipv4.address)
if ! sudo iptables -t nat -C POSTROUTING -s "$LXD_SUBNET" ! -d "$LXD_SUBNET" -j MASQUERADE 2>/dev/null; then
sudo iptables -t nat -I POSTROUTING -s "$LXD_SUBNET" ! -d "$LXD_SUBNET" -j MASQUERADE
fi
if command -v docker &>/dev/null; then
if ! sudo iptables -C DOCKER-USER -i lxdbr0 -j ACCEPT 2>/dev/null; then
sudo iptables -I DOCKER-USER -i lxdbr0 -j ACCEPT
fi
if ! sudo iptables -C DOCKER-USER -o lxdbr0 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT 2>/dev/null; then
sudo iptables -I DOCKER-USER -o lxdbr0 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
fi
fi
echo "✓ LXD installation complete!"
echo " Default network bridge (lxdbr0) configured for internet access"
# Add user to lxd group for passwordless access
if groups "$USER" | grep -q "\blxd\b"; then
echo " User '$USER' already in lxd group"
else
echo "Adding user '$USER' to lxd group..."
sudo usermod -a -G lxd "$USER"
echo "✓ User added to lxd group"
echo ""
echo "IMPORTANT: You need to log out and back in for group changes to take effect"
echo "Or run: newgrp lxd"
fi
-1
View File
@@ -1 +0,0 @@
#export FZF_DEFAULT_OPTS='--bind ctrl-f:preview-page-down,ctrl-b:preview-page-up --preview "batcat --color=always {}"'
-1
View File
@@ -1 +0,0 @@
eval "$(zoxide init bash)"
View File
+13
View File
@@ -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()
+19
View File
@@ -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")
+32
View File
@@ -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}",
)
+35
View File
@@ -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)
+190
View File
@@ -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()
-79
View File
@@ -1,79 +0,0 @@
import getpass
from invoke import task
from helpers import append_bashrc_section, load_snippet, run
APT_PACKAGES = [
"git",
"curl",
"micro",
"mc",
"detox",
"tree",
]
@task
def install_claude(c):
"""Install Claude Code via the official install script."""
run(c, "curl -fsSL https://claude.ai/install.sh | bash")
@task
def install_copilot(c):
"""Install GitHub Copilot via the official install script."""
run(c, "curl -fsSL https://gh.io/copilot-install | bash")
@task
def install_apt_packages(c):
"""Update apt cache and install base packages."""
run(c, "sudo apt-get update")
run(c, f"sudo apt-get install -y {' '.join(APT_PACKAGES)}")
@task
def install_docker(c):
"""Install Docker if not already installed and add current user to docker group."""
run(
c,
"command -v docker >/dev/null 2>&1 || curl -fsSL https://get.docker.com | sudo sh",
)
run(c, f"sudo usermod -aG docker {getpass.getuser()}")
@task
def install_uv(c):
"""Install uv for the current user if not already installed."""
run(c, "test -f ~/.local/bin/uv || curl -LsSf https://astral.sh/uv/install.sh | sh")
@task
def install_fzf(c):
"""Install fzf from git, bat for preview, and configure .bashrc."""
run(c, "sudo apt-get install -y bat")
run(
c,
"test -d ~/.fzf || git clone --depth 1 https://github.com/junegunn/fzf.git ~/.fzf",
)
run(c, "~/.fzf/install --all")
append_bashrc_section(c, "fzf", load_snippet("fzf"))
@task
def install_zoxide(c):
"""Install zoxide, configure .bashrc, """
run(c, "sudo apt install -y zoxide fzf")
append_bashrc_section(c, "zoxide", load_snippet("zoxide"))
@task(install_apt_packages, install_docker, install_uv, install_claude, install_fzf, install_zoxide)
def bootstrap(c):
"""Install all base tools."""
@task
def test(c):
"""Run tasks in an ephemeral LXD container."""
run(c, "pytest tests/ -v -s")
-84
View File
@@ -1,84 +0,0 @@
import subprocess
import time
from collections.abc import Generator
import pytest
CONTAINER = "cli-tools-test"
IMAGE = "images:debian/bookworm"
REPO_ROOT = "/home/jev/projects/cli-tools"
def _lxc(*args: str, capture: bool = False) -> subprocess.CompletedProcess:
return subprocess.run(
["lxc", *args],
check=True,
capture_output=capture,
text=capture,
)
def _wait_for_network(container: str, retries: int = 20, delay: float = 2.0) -> None:
print("[container] Waiting for network...", flush=True)
for _ in range(retries):
result = subprocess.run(
["lxc", "exec", container, "--", "bash", "-c", "ping -c1 8.8.8.8 &>/dev/null"],
capture_output=True,
)
if result.returncode == 0:
print("[container] Network ready", flush=True)
return
time.sleep(delay)
raise RuntimeError(f"Container {container!r} did not get network access")
@pytest.fixture(scope="session")
def container() -> Generator[str, None, None]:
# Clean up any stale container from a previous run
subprocess.run(["lxc", "delete", "--force", CONTAINER], capture_output=True)
print(f"\n[container] Launching {CONTAINER}...", flush=True)
_lxc("launch", IMAGE, CONTAINER)
time.sleep(3)
_wait_for_network(CONTAINER)
print("[container] Installing sudo and python3-pip...", flush=True)
_lxc(
"exec", CONTAINER, "--",
"bash", "-c",
"""
apt-get update -qq
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq sudo python3-pip
""",
)
print("[container] Creating jev user...", flush=True)
_lxc(
"exec", CONTAINER, "--",
"bash", "-c",
"""
useradd -m -s /bin/bash -u 1000 jev
usermod -aG sudo jev
echo 'jev ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/jev
chmod 440 /etc/sudoers.d/jev
""",
)
print("[container] Installing invoke...", flush=True)
_lxc(
"exec", CONTAINER, "--",
"bash", "-c",
"pip3 install invoke --quiet --break-system-packages",
)
print("[container] Mounting repo...", flush=True)
_lxc("config", "device", "add", CONTAINER, "repo", "disk",
f"source={REPO_ROOT}", "path=/home/jev/cli-tools")
print("[container] Ready\n", flush=True)
yield CONTAINER
print(f"\n[container] Destroying {CONTAINER}...", flush=True)
_lxc("delete", "--force", CONTAINER)
-46
View File
@@ -1,46 +0,0 @@
import subprocess
def lxc_exec(container: str, cmd: str, check: bool = True) -> subprocess.CompletedProcess:
"""Run a command in the container as the jev user."""
return subprocess.run(
["lxc", "exec", container, "--user", "1000", "--env", "HOME=/home/jev", "--", "bash", "-lc", cmd],
check=check,
)
def run_task(container: str, task: str) -> None:
lxc_exec(container, f"cd /home/jev/cli-tools && invoke {task.replace('_', '-')}")
def test_install_apt_packages(container: str) -> None:
run_task(container, "install_apt_packages")
for binary in ["git", "micro", "tree", "detox"]:
result = lxc_exec(container, f"which {binary}", check=False)
assert result.returncode == 0, f"{binary!r} not found after install_apt_packages"
def test_install_docker(container: str) -> None:
run_task(container, "install_docker")
result = lxc_exec(container, "docker --version", check=False)
assert result.returncode == 0, "docker not found after install_docker"
def test_install_uv(container: str) -> None:
run_task(container, "install_uv")
result = lxc_exec(container, "~/.local/bin/uv --version", check=False)
assert result.returncode == 0, "uv not found after install_uv"
def test_install_fzf(container: str) -> None:
run_task(container, "install_fzf")
result = lxc_exec(container, "test -f ~/.fzf/bin/fzf", check=False)
assert result.returncode == 0, "fzf binary not found at ~/.fzf/bin/fzf after install_fzf"
result = lxc_exec(container, "which batcat", check=False)
assert result.returncode == 0, "batcat not found after install_fzf"
def test_install_zoxide(container: str) -> None:
run_task(container, "install_zoxide")
result = lxc_exec(container, "which zoxide", check=False)
assert result.returncode == 0, "zoxide not found after install_zoxide"
Generated
+144
View File
@@ -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" },
]