Initial release: cd-browser v0.1.0
This commit is contained in:
12
.editorconfig
Normal file
12
.editorconfig
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
end_of_line = lf
|
||||||
|
insert_final_newline = true
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 4
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
|
[*.md]
|
||||||
|
trim_trailing_whitespace = false
|
||||||
14
.env.example
Normal file
14
.env.example
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# Application settings
|
||||||
|
APP_NAME="New Project"
|
||||||
|
APP_ENV="development"
|
||||||
|
APP_DEBUG=true
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
LOG_LEVEL="INFO"
|
||||||
|
|
||||||
|
# Example external service configuration
|
||||||
|
API_URL="https://api.example.com"
|
||||||
|
API_KEY="your-api-key-here"
|
||||||
|
|
||||||
|
# Database example (optional)
|
||||||
|
DATABASE_URL="sqlite:///app.db"
|
||||||
28
.gitignore
vendored
Normal file
28
.gitignore
vendored
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# Virtual environments
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
|
||||||
|
# Python cache
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
|
||||||
|
# Tool caches
|
||||||
|
.pytest_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
|
||||||
|
# Build artifacts
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
*.egg-info/
|
||||||
|
|
||||||
|
# IDEs
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# OS files
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
.env/
|
||||||
16
.pre-commit-config.yaml
Normal file
16
.pre-commit-config.yaml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
repos:
|
||||||
|
- repo: https://github.com/psf/black
|
||||||
|
rev: 24.10.0
|
||||||
|
hooks:
|
||||||
|
- id: black
|
||||||
|
|
||||||
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
|
rev: v0.9.10
|
||||||
|
hooks:
|
||||||
|
- id: ruff
|
||||||
|
- id: ruff-format
|
||||||
|
|
||||||
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||||
|
rev: v4.6.0
|
||||||
|
hooks:
|
||||||
|
- id: end-of-file-fixer
|
||||||
1
.python-version
Normal file
1
.python-version
Normal file
@@ -0,0 +1 @@
|
|||||||
|
3.11.9
|
||||||
19
CHANGELOG.md
Normal file
19
CHANGELOG.md
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
The format is based on Keep a Changelog, and this project follows Semantic Versioning.
|
||||||
|
|
||||||
|
## [1.0.0] - 2026-03-14
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Initial release of the Python AI Dev Template
|
||||||
|
- Universal Python project structure using `src/`
|
||||||
|
- `pyproject.toml`-based project configuration
|
||||||
|
- Development tooling with `black`, `ruff`, `mypy`, and `pytest`
|
||||||
|
- `pre-commit` integration
|
||||||
|
- `Makefile` with development, quality, and diagnostic commands
|
||||||
|
- `instructions-agent.md` for AI coding agent guidance
|
||||||
|
- `.env.example` for environment-based configuration
|
||||||
|
- README with setup, workflow, and template reuse instructions
|
||||||
|
- MIT license
|
||||||
181
CONTRIBUTING.md
Normal file
181
CONTRIBUTING.md
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
# Contributing
|
||||||
|
|
||||||
|
Thank you for your interest in contributing to this project.
|
||||||
|
|
||||||
|
This repository is intended to be a clean, reusable, and professional Python project template. Contributions should preserve that goal.
|
||||||
|
|
||||||
|
## General Principles
|
||||||
|
|
||||||
|
- Keep the template generic and adaptable.
|
||||||
|
- Prefer clarity over cleverness.
|
||||||
|
- Avoid unnecessary complexity.
|
||||||
|
- Follow the existing project structure and conventions.
|
||||||
|
- Write all code, comments, docstrings, and documentation in English.
|
||||||
|
|
||||||
|
## Development Setup
|
||||||
|
|
||||||
|
It is recommended to use the provided Makefile.
|
||||||
|
|
||||||
|
### First-time setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make dev
|
||||||
|
# Contributing
|
||||||
|
|
||||||
|
Thank you for your interest in contributing to this project.
|
||||||
|
|
||||||
|
This repository is intended to be a **clean, reusable, and professional Python project template**.
|
||||||
|
All contributions should aim to **improve the template without making it specific to a single project**.
|
||||||
|
|
||||||
|
The goal is to keep this repository:
|
||||||
|
|
||||||
|
- simple
|
||||||
|
- professional
|
||||||
|
- maintainable
|
||||||
|
- widely reusable
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# General Principles
|
||||||
|
|
||||||
|
When contributing, please follow these principles:
|
||||||
|
|
||||||
|
- Keep the template **generic and adaptable**.
|
||||||
|
- Prefer **clarity over cleverness**.
|
||||||
|
- Avoid unnecessary complexity.
|
||||||
|
- Follow the existing **project structure and conventions**.
|
||||||
|
- Keep the repository **easy to understand for new developers**.
|
||||||
|
- Write all **code, comments, docstrings, and documentation in English**.
|
||||||
|
|
||||||
|
This repository should remain a **solid starting point for many different types of Python projects**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Development Setup
|
||||||
|
|
||||||
|
It is recommended to use the provided **Makefile** to set up the development environment.
|
||||||
|
|
||||||
|
## First-time setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make dev
|
||||||
|
```
|
||||||
|
|
||||||
|
This command will:
|
||||||
|
|
||||||
|
- create the `.venv` virtual environment
|
||||||
|
- install project dependencies
|
||||||
|
- install development tools
|
||||||
|
- configure `pre-commit` hooks
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Development Workflow
|
||||||
|
|
||||||
|
Typical workflow when working on the project:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make fix
|
||||||
|
make quality
|
||||||
|
make run
|
||||||
|
```
|
||||||
|
|
||||||
|
Command summary:
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| `make dev` | bootstrap development environment |
|
||||||
|
| `make install` | reinstall dependencies |
|
||||||
|
| `make fix` | auto-format and fix code issues |
|
||||||
|
| `make quality` | run formatting, linting, type checks, and tests |
|
||||||
|
| `make ci` | simulate CI validation |
|
||||||
|
| `make doctor` | show environment and tool versions |
|
||||||
|
| `make run` | run the application |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Code Standards
|
||||||
|
|
||||||
|
All contributions should follow these standards:
|
||||||
|
|
||||||
|
- Python **3.11+**
|
||||||
|
- Use **type hints** where appropriate
|
||||||
|
- Write **clean and modular code**
|
||||||
|
- Prefer **readable code over clever shortcuts**
|
||||||
|
- Avoid dead code
|
||||||
|
- Use **consistent docstrings** for public APIs
|
||||||
|
|
||||||
|
Tools used in this project:
|
||||||
|
|
||||||
|
- **black** → formatting
|
||||||
|
- **ruff** → linting
|
||||||
|
- **mypy** → static typing
|
||||||
|
- **pytest** → testing
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Quality Checks
|
||||||
|
|
||||||
|
Before submitting changes, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make fix
|
||||||
|
make quality
|
||||||
|
pre-commit run --all-files
|
||||||
|
```
|
||||||
|
|
||||||
|
All checks should pass before committing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Documentation
|
||||||
|
|
||||||
|
If you modify project structure, development workflow, or setup instructions, please update the relevant documentation:
|
||||||
|
|
||||||
|
- `README.md`
|
||||||
|
- `CHANGELOG.md`
|
||||||
|
- `instructions-agent.md`
|
||||||
|
|
||||||
|
Clear documentation is as important as clean code.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Commit Messages
|
||||||
|
|
||||||
|
Use clear and concise commit messages.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
```
|
||||||
|
Add make doctor command
|
||||||
|
Improve README template usage instructions
|
||||||
|
Add environment configuration example
|
||||||
|
Fix formatting issues
|
||||||
|
```
|
||||||
|
|
||||||
|
Good commit messages make the project history easier to understand.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scope of Contributions
|
||||||
|
|
||||||
|
Good contributions include:
|
||||||
|
|
||||||
|
- improving the project structure
|
||||||
|
- improving development workflow
|
||||||
|
- improving documentation
|
||||||
|
- improving tooling and automation
|
||||||
|
- fixing template inconsistencies
|
||||||
|
- simplifying developer experience
|
||||||
|
|
||||||
|
Please avoid turning this repository into a **project-specific application**.
|
||||||
|
|
||||||
|
This repository should remain a **general-purpose Python template**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Acknowledgements
|
||||||
|
|
||||||
|
This template was created by **Saky**, drawing on many years of professional software engineering experience and with the assistance of modern AI development tools.
|
||||||
|
|
||||||
|
Contributions that improve the template while preserving its simplicity are always welcome.
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2025 Saky
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
THE SOFTWARE.
|
||||||
70
Makefile
Normal file
70
Makefile
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
PYTHON := python
|
||||||
|
PIP := pip
|
||||||
|
|
||||||
|
.PHONY: help install dev format fix check lint typecheck test run quality ci doctor clean
|
||||||
|
|
||||||
|
help:
|
||||||
|
@echo "Available commands:"
|
||||||
|
@echo " make install Install project with development dependencies"
|
||||||
|
@echo " make dev Create local dev environment and install tools"
|
||||||
|
@echo " make format Format source code with Black"
|
||||||
|
@echo " make fix Auto-fix code issues (Black + Ruff)"
|
||||||
|
@echo " make check Check formatting with Black"
|
||||||
|
@echo " make lint Run Ruff"
|
||||||
|
@echo " make typecheck Run MyPy"
|
||||||
|
@echo " make test Run pytest"
|
||||||
|
@echo " make run Run the application"
|
||||||
|
@echo " make quality Run all quality checks"
|
||||||
|
@echo " make ci Run CI-style validation checks"
|
||||||
|
@echo " make doctor Show environment and tool versions"
|
||||||
|
@echo " make clean Remove cache files"
|
||||||
|
|
||||||
|
install:
|
||||||
|
$(PIP) install --upgrade pip
|
||||||
|
$(PIP) install -e '.[dev]'
|
||||||
|
|
||||||
|
dev:
|
||||||
|
$(PYTHON) -m venv .venv
|
||||||
|
. .venv/bin/activate && pip install --upgrade pip
|
||||||
|
. .venv/bin/activate && pip install -e '.[dev]'
|
||||||
|
. .venv/bin/activate && pre-commit install
|
||||||
|
|
||||||
|
format:
|
||||||
|
black src tests
|
||||||
|
|
||||||
|
fix:
|
||||||
|
black src tests
|
||||||
|
ruff check --fix src tests
|
||||||
|
|
||||||
|
check:
|
||||||
|
black --check src tests
|
||||||
|
|
||||||
|
lint:
|
||||||
|
ruff check src tests
|
||||||
|
|
||||||
|
typecheck:
|
||||||
|
mypy src
|
||||||
|
|
||||||
|
test:
|
||||||
|
pytest
|
||||||
|
|
||||||
|
run:
|
||||||
|
$(PYTHON) -m app.main
|
||||||
|
|
||||||
|
quality: check lint typecheck test
|
||||||
|
|
||||||
|
ci: check lint typecheck test
|
||||||
|
|
||||||
|
doctor:
|
||||||
|
@echo "Python:"
|
||||||
|
@$(PYTHON) --version
|
||||||
|
@echo ""
|
||||||
|
@echo "Tool versions:"
|
||||||
|
@black --version
|
||||||
|
@ruff --version
|
||||||
|
@mypy --version
|
||||||
|
@pytest --version
|
||||||
|
|
||||||
|
clean:
|
||||||
|
find . -type d \( -name "__pycache__" -o -name ".pytest_cache" -o -name ".mypy_cache" -o -name ".ruff_cache" \) -exec rm -rf {} +
|
||||||
|
find . -type f \( -name "*.pyc" -o -name "*.pyo" \) -delete
|
||||||
111
README.md
Normal file
111
README.md
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
# cd-browser
|
||||||
|
|
||||||
|
`cd-browser` is a terminal-native directory navigator for fast filesystem browsing from the command line.
|
||||||
|
|
||||||
|
It provides an interactive interface for exploring directories with the keyboard and returns the selected path at the end of the session so shell wrappers can change the current shell directory.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Interactive terminal directory browser
|
||||||
|
- Keyboard-driven navigation
|
||||||
|
- Parent directory entry via `..`
|
||||||
|
- Expand and collapse directory trees
|
||||||
|
- Session navigation history support in the application state
|
||||||
|
- Installable CLI command: `cd_browser`
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
Install the project in user mode:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install .
|
||||||
|
```
|
||||||
|
|
||||||
|
For editable local development:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -e '.[dev]'
|
||||||
|
```
|
||||||
|
|
||||||
|
After installation, the application command is:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd_browser
|
||||||
|
```
|
||||||
|
|
||||||
|
When the interactive session exits, the program prints the final selected directory path.
|
||||||
|
|
||||||
|
## Shell Integration For `cd_`
|
||||||
|
|
||||||
|
Because a Python subprocess cannot directly change the parent shell directory, use a shell wrapper that captures the final path printed by `cd_browser`.
|
||||||
|
|
||||||
|
Bash or Zsh example:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd_() {
|
||||||
|
local target
|
||||||
|
target="$(cd_browser)" || return
|
||||||
|
|
||||||
|
if [ -n "$target" ] && [ -d "$target" ]; then
|
||||||
|
cd "$target"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
After adding the function to your shell profile, reload it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source ~/.bashrc
|
||||||
|
```
|
||||||
|
|
||||||
|
Or:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source ~/.zshrc
|
||||||
|
```
|
||||||
|
|
||||||
|
Then use:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd_
|
||||||
|
```
|
||||||
|
|
||||||
|
## Uninstall
|
||||||
|
|
||||||
|
If the project was installed with `pip`, remove it with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip uninstall cd-browser
|
||||||
|
```
|
||||||
|
|
||||||
|
If you also added the `cd_` shell wrapper, remove that function from your shell profile and reload the shell configuration.
|
||||||
|
|
||||||
|
## Developer Setup
|
||||||
|
|
||||||
|
Recommended setup:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Run the application in development mode:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m app.main
|
||||||
|
```
|
||||||
|
|
||||||
|
Useful development commands:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make fix
|
||||||
|
make quality
|
||||||
|
make run
|
||||||
|
```
|
||||||
|
|
||||||
|
## Template Origin
|
||||||
|
|
||||||
|
This project was created from the Python AI Dev Template.
|
||||||
|
|
||||||
|
The original template documentation has been preserved in `README_TEMPLATE.md` so the project-specific README can focus on `cd-browser` usage and development.
|
||||||
|
|
||||||
|
For the original template setup, conventions, and generic workflow notes, see `README_TEMPLATE.md`.
|
||||||
397
README_TEMPLATE.md
Normal file
397
README_TEMPLATE.md
Normal file
@@ -0,0 +1,397 @@
|
|||||||
|
# Python AI Dev Template
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
A universal and adaptable **Python project template** designed for professional development and AI‑assisted coding workflows (OpenCode, AI agents, etc.).
|
||||||
|
|
||||||
|
License: MIT
|
||||||
|
|
||||||
|
This repository provides a clean and production‑ready starting point for building:
|
||||||
|
|
||||||
|
- CLI tools
|
||||||
|
- backend services
|
||||||
|
- automation scripts
|
||||||
|
- desktop applications
|
||||||
|
- internal developer tools
|
||||||
|
|
||||||
|
The template enforces **modern Python standards**, **clean architecture**, and **automated quality checks**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Requirements
|
||||||
|
|
||||||
|
- Python **3.11+**
|
||||||
|
- `pyenv` recommended for Python version management
|
||||||
|
|
||||||
|
Check Python version:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python --version
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Quick Start
|
||||||
|
|
||||||
|
Clone the template repository:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <template-repository-url> new-project
|
||||||
|
cd new-project
|
||||||
|
```
|
||||||
|
|
||||||
|
Create a virtual environment:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
```
|
||||||
|
|
||||||
|
Install development dependencies:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install --upgrade pip
|
||||||
|
pip install -e '.[dev]'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Using the Makefile (Recommended)
|
||||||
|
|
||||||
|
This template includes a **Makefile** to simplify common development tasks.
|
||||||
|
|
||||||
|
### First time setup
|
||||||
|
|
||||||
|
To prepare the full development environment automatically:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make dev
|
||||||
|
```
|
||||||
|
|
||||||
|
This command will:
|
||||||
|
|
||||||
|
- create the `.venv` virtual environment
|
||||||
|
- install project dependencies
|
||||||
|
- install development tools
|
||||||
|
- configure `pre-commit` hooks
|
||||||
|
|
||||||
|
This is the **recommended command after cloning the repository**.
|
||||||
|
|
||||||
|
### Reinstall or update dependencies
|
||||||
|
|
||||||
|
If the virtual environment already exists and you only want to reinstall or update dependencies:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make install
|
||||||
|
```
|
||||||
|
|
||||||
|
### Development utilities
|
||||||
|
|
||||||
|
Some useful commands:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make fix # auto‑format and fix code issues
|
||||||
|
make quality # run full quality checks
|
||||||
|
make ci # simulate CI validation
|
||||||
|
make doctor # show environment and tool versions
|
||||||
|
make run # run the application
|
||||||
|
```
|
||||||
|
|
||||||
|
Using the Makefile ensures a consistent development workflow across machines.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Environment Configuration
|
||||||
|
|
||||||
|
This template supports **environment-based configuration** using a `.env` file.
|
||||||
|
|
||||||
|
An example configuration file is provided:
|
||||||
|
|
||||||
|
```
|
||||||
|
.env.example
|
||||||
|
```
|
||||||
|
|
||||||
|
To create your local configuration, copy the example file:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Then edit `.env` and adjust the values for your local environment.
|
||||||
|
|
||||||
|
Typical uses include:
|
||||||
|
|
||||||
|
- application settings
|
||||||
|
- API endpoints
|
||||||
|
- API keys
|
||||||
|
- database connection strings
|
||||||
|
- feature flags
|
||||||
|
|
||||||
|
⚠️ The `.env` file is **ignored by git** and should **never contain secrets in the repository**.
|
||||||
|
|
||||||
|
Only `.env.example` should be committed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Run the Application
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m app.main
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected output:
|
||||||
|
|
||||||
|
```
|
||||||
|
cd-browser is ready.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Development Workflow
|
||||||
|
|
||||||
|
Before committing changes, always validate the project using the following steps.
|
||||||
|
|
||||||
|
### 1. Run the application
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m app.main
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Run tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pytest
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Format code
|
||||||
|
|
||||||
|
```bash
|
||||||
|
black src tests
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Check formatting
|
||||||
|
|
||||||
|
```bash
|
||||||
|
black --check src tests
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Lint code
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ruff check src tests
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Type checking
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mypy src
|
||||||
|
```
|
||||||
|
|
||||||
|
If all commands succeed, the project is considered valid.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Quick Quality Check
|
||||||
|
|
||||||
|
Run all checks quickly:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
black src tests
|
||||||
|
ruff check src tests
|
||||||
|
mypy src
|
||||||
|
pytest
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
# Create a New Repository From This Template
|
||||||
|
|
||||||
|
If you downloaded or cloned this template and want to start a **new project repository**, follow these steps.
|
||||||
|
|
||||||
|
### 1. Clone the template
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://gitea.sakydogalo.es/saky/python-ai-dev-template.git new-project
|
||||||
|
cd new-project
|
||||||
|
```
|
||||||
|
|
||||||
|
### Recommended: Initialize the project automatically
|
||||||
|
|
||||||
|
You can initialize a new project using the helper script:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/init_project.sh my-project-name
|
||||||
|
```
|
||||||
|
|
||||||
|
This script will:
|
||||||
|
|
||||||
|
- reset the git history
|
||||||
|
- prepare the repository for a fresh start
|
||||||
|
- keep the template structure intact
|
||||||
|
|
||||||
|
After running the script, create your new remote repository and push the code as described below.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Manual initialization (alternative)
|
||||||
|
|
||||||
|
If you prefer to perform the steps manually, follow the instructions below.
|
||||||
|
|
||||||
|
### 2. Remove the original remote
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git remote remove origin
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Reset Git history (start clean)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rm -rf .git
|
||||||
|
git init
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Update project metadata
|
||||||
|
|
||||||
|
Update these files to match your new project:
|
||||||
|
|
||||||
|
- `pyproject.toml` → project name and description
|
||||||
|
- `README.md`
|
||||||
|
- `src/app/config.py` → application name
|
||||||
|
|
||||||
|
### 5. Validate the project
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make fix
|
||||||
|
make quality
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Create your new repository
|
||||||
|
|
||||||
|
Create a new repository on your Git server (Gitea, GitHub, etc.).
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```
|
||||||
|
https://gitea.sakydogalo.es/saky/new-project
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. Connect the new remote
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git remote add origin <your-new-repository-url>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8. Create the first commit
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add .
|
||||||
|
git commit -m "Initial new-project from template"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9. Push to the new repository
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git branch -M main
|
||||||
|
git push -u origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
Your new project repository is now ready.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
new-project/
|
||||||
|
│
|
||||||
|
├── src/
|
||||||
|
│ └── app/
|
||||||
|
│ ├── main.py
|
||||||
|
│ ├── config.py
|
||||||
|
│ ├── logging_config.py
|
||||||
|
│ ├── services/
|
||||||
|
│ ├── domain/
|
||||||
|
│ ├── infrastructure/
|
||||||
|
│ └── utils/
|
||||||
|
│
|
||||||
|
├── tests/
|
||||||
|
│ └── test_smoke.py
|
||||||
|
│
|
||||||
|
├── scripts/
|
||||||
|
│
|
||||||
|
├── pyproject.toml
|
||||||
|
├── instructions-agent.md
|
||||||
|
├── README.md
|
||||||
|
└── .editorconfig
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Tooling
|
||||||
|
|
||||||
|
This template includes modern Python development tools:
|
||||||
|
|
||||||
|
| Tool | Purpose |
|
||||||
|
|-----|--------|
|
||||||
|
| pytest | testing framework |
|
||||||
|
| black | automatic code formatting |
|
||||||
|
| ruff | linting and static analysis |
|
||||||
|
| mypy | static type checking |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Purpose of this Template
|
||||||
|
|
||||||
|
This repository is intentionally **generic and adaptable**.
|
||||||
|
|
||||||
|
It can evolve into:
|
||||||
|
|
||||||
|
- a CLI tool
|
||||||
|
- a web API
|
||||||
|
- a microservice
|
||||||
|
- a desktop application
|
||||||
|
- an automation system
|
||||||
|
|
||||||
|
The goal is to provide a **clean, maintainable, and AI‑friendly Python project foundation**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# AI Agent Support
|
||||||
|
|
||||||
|
This repository includes:
|
||||||
|
|
||||||
|
instructions-agent.md
|
||||||
|
|
||||||
|
This file defines rules and expectations for AI coding agents such as:
|
||||||
|
|
||||||
|
- OpenCode
|
||||||
|
- AI IDE assistants
|
||||||
|
- autonomous coding agents
|
||||||
|
|
||||||
|
Agents should follow those instructions when modifying the project.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# License
|
||||||
|
|
||||||
|
This project is released under the **MIT License**, one of the most permissive open‑source licenses available.
|
||||||
|
|
||||||
|
You are free to:
|
||||||
|
|
||||||
|
- use this code for personal or commercial projects
|
||||||
|
- modify it
|
||||||
|
- distribute it
|
||||||
|
- include it in proprietary software
|
||||||
|
|
||||||
|
No special permission is required.
|
||||||
|
|
||||||
|
Copyright (c) 2025 Saky
|
||||||
|
|
||||||
|
This template was created by **Saky**, drawing on many years of professional software engineering experience and with the assistance of modern AI development tools.
|
||||||
|
|
||||||
|
The goal of this repository is to provide a practical, production‑ready starting point for Python projects while remaining as open and reusable as possible.
|
||||||
61
instructions-agent.md
Normal file
61
instructions-agent.md
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# Agent Instructions: Universal Python Project Template
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
This repository is a universal and adaptable Python template intended to serve as a professional starting point for new projects.
|
||||||
|
|
||||||
|
## Core Directives
|
||||||
|
1. All code, comments, variables, string literals, commit messages, and documentation must be written in English.
|
||||||
|
2. Use Python 3.11+ with type hints whenever possible.
|
||||||
|
3. Follow clean code principles and PEP 8.
|
||||||
|
4. Keep modules small, cohesive, and maintainable.
|
||||||
|
5. Use consistent docstrings for public classes and functions.
|
||||||
|
6. Prefer explicit error handling over silent failures.
|
||||||
|
|
||||||
|
## Architecture Guidelines
|
||||||
|
1. Keep business logic separate from infrastructure concerns.
|
||||||
|
2. Avoid mixing configuration, I/O, and domain logic in the same module.
|
||||||
|
3. Prefer reusable services over duplicated logic.
|
||||||
|
4. Do not introduce unnecessary complexity or premature abstractions.
|
||||||
|
|
||||||
|
## Quality Standards
|
||||||
|
1. Every meaningful feature should include tests.
|
||||||
|
2. Run formatting, linting, and type checks before considering a task complete.
|
||||||
|
3. Keep the README updated whenever setup, commands, or structure changes.
|
||||||
|
4. Do not leave placeholder code unless clearly marked.
|
||||||
|
|
||||||
|
## Tooling
|
||||||
|
- Testing: pytest
|
||||||
|
- Formatting: black
|
||||||
|
- Linting: ruff
|
||||||
|
- Static typing: mypy
|
||||||
|
|
||||||
|
## Expected Project Structure
|
||||||
|
src/app/
|
||||||
|
tests/
|
||||||
|
scripts/
|
||||||
|
README.md
|
||||||
|
instructions-agent.md
|
||||||
|
pyproject.toml
|
||||||
|
|
||||||
|
## Agent Workflow
|
||||||
|
1. First understand the repository structure.
|
||||||
|
2. Propose a short implementation plan before major changes.
|
||||||
|
3. Reuse existing modules whenever possible.
|
||||||
|
4. Keep changes minimal, coherent, and production-oriented.
|
||||||
|
5. Validate changes with tests and quality tools when possible.
|
||||||
|
|
||||||
|
## Application Specification
|
||||||
|
|
||||||
|
This repository includes an application specification that defines the expected behavior of the project.
|
||||||
|
|
||||||
|
Primary specification file:
|
||||||
|
|
||||||
|
- `specs/cd_browser_spec.md`
|
||||||
|
|
||||||
|
Agent rules:
|
||||||
|
|
||||||
|
1. Before implementing any feature, read the application specification.
|
||||||
|
2. If implementation details are unclear, follow the specification first.
|
||||||
|
3. If the specification conflicts with a previous assumption, the specification takes precedence.
|
||||||
|
4. Keep implementation aligned with the MVP scope unless explicitly asked to extend it.
|
||||||
|
5. After code changes, run the appropriate validation workflow defined by this repository.
|
||||||
56
pyproject.toml
Normal file
56
pyproject.toml
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=68", "wheel"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "cd-browser"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "A universal and adaptable Python project template for AI-assisted development."
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
authors = [
|
||||||
|
{ name = "Saky" }
|
||||||
|
]
|
||||||
|
dependencies = []
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
cd_browser = "app.main:main"
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"pytest>=8.0",
|
||||||
|
"black>=24.0",
|
||||||
|
"ruff>=0.3.0",
|
||||||
|
"mypy>=1.8.0",
|
||||||
|
"pre-commit>=3.7.0"
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.setuptools]
|
||||||
|
package-dir = {"" = "src"}
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
where = ["src"]
|
||||||
|
|
||||||
|
[tool.black]
|
||||||
|
line-length = 88
|
||||||
|
target-version = ["py311"]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 88
|
||||||
|
target-version = "py311"
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = ["E", "F", "I", "B", "UP"]
|
||||||
|
ignore = []
|
||||||
|
|
||||||
|
[tool.mypy]
|
||||||
|
python_version = "3.11"
|
||||||
|
strict = true
|
||||||
|
warn_unused_configs = true
|
||||||
|
disallow_untyped_defs = true
|
||||||
|
ignore_missing_imports = true
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
pythonpath = ["src"]
|
||||||
|
addopts = "-q"
|
||||||
2
scripts/format.sh
Executable file
2
scripts/format.sh
Executable file
@@ -0,0 +1,2 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
black src tests
|
||||||
68
scripts/init_project.sh
Executable file
68
scripts/init_project.sh
Executable file
@@ -0,0 +1,68 @@
|
|||||||
|
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# This script is designed to work on:
|
||||||
|
# - macOS
|
||||||
|
# - Linux
|
||||||
|
# - Windows when using Git Bash (recommended)
|
||||||
|
#
|
||||||
|
# On Windows, run it from Git Bash:
|
||||||
|
# ./scripts/init_project.sh my-project-name
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
|
||||||
|
REPO_ROOT=$(cd -- "$SCRIPT_DIR/.." && pwd)
|
||||||
|
cd "$REPO_ROOT"
|
||||||
|
|
||||||
|
if [ -d ".git" ]; then
|
||||||
|
echo "Error: this repository still contains a .git directory."
|
||||||
|
echo "Please remove .git first or run this script only on a fresh copy of the template."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
PROJECT_NAME=${1:-}
|
||||||
|
|
||||||
|
if [ -z "$PROJECT_NAME" ]; then
|
||||||
|
echo "Usage: ./scripts/init_project.sh <project-name>"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! printf '%s' "$PROJECT_NAME" | grep -Eq '^[a-zA-Z0-9._-]+$'; then
|
||||||
|
echo "Error: project name contains unsupported characters."
|
||||||
|
echo "Use only letters, numbers, dots, underscores, and hyphens."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Creating project: $PROJECT_NAME"
|
||||||
|
echo "Working directory: $REPO_ROOT"
|
||||||
|
|
||||||
|
|
||||||
|
# rename project metadata (portable sed usage for macOS, Linux, and Git Bash on Windows)
|
||||||
|
# create temporary backup files (.bak) and remove them after replacement
|
||||||
|
if [ ! -f "README.md" ] || [ ! -f "pyproject.toml" ]; then
|
||||||
|
echo "Error: README.md or pyproject.toml not found in repository root."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sed -i.bak "s/New Project/$PROJECT_NAME/g" README.md
|
||||||
|
sed -i.bak "s/python-ai-dev-template/$PROJECT_NAME/g" pyproject.toml
|
||||||
|
|
||||||
|
rm -f README.md.bak pyproject.toml.bak
|
||||||
|
|
||||||
|
# reset git history
|
||||||
|
rm -rf .git
|
||||||
|
git init
|
||||||
|
git branch -M main
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Project initialized."
|
||||||
|
echo ""
|
||||||
|
echo "Next steps:"
|
||||||
|
echo ""
|
||||||
|
echo "git add ."
|
||||||
|
echo "git commit -m \"Initial $PROJECT_NAME\""
|
||||||
|
echo ""
|
||||||
|
echo "Create a remote repository and run:"
|
||||||
|
echo ""
|
||||||
|
echo "git remote add origin <repo-url>"
|
||||||
|
echo "git push -u origin main"
|
||||||
2
scripts/lint.sh
Executable file
2
scripts/lint.sh
Executable file
@@ -0,0 +1,2 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
ruff check src tests
|
||||||
2
scripts/test.sh
Executable file
2
scripts/test.sh
Executable file
@@ -0,0 +1,2 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
pytest
|
||||||
2
scripts/typecheck.sh
Executable file
2
scripts/typecheck.sh
Executable file
@@ -0,0 +1,2 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
mypy src
|
||||||
305
specs/cd_browser_spec.md
Normal file
305
specs/cd_browser_spec.md
Normal file
@@ -0,0 +1,305 @@
|
|||||||
|
# cd_ — Terminal Directory Navigator
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
`cd_` is an interactive terminal utility designed to make directory navigation faster and more intuitive directly from the command line.
|
||||||
|
|
||||||
|
Instead of manually typing `cd` commands, users can browse directories using a keyboard-driven interface.
|
||||||
|
|
||||||
|
The tool behaves like a **lightweight directory browser inside the terminal**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Goals
|
||||||
|
|
||||||
|
The utility should provide:
|
||||||
|
|
||||||
|
- fast directory navigation
|
||||||
|
- keyboard-driven interface
|
||||||
|
- minimal latency
|
||||||
|
- intuitive exploration of directory trees
|
||||||
|
- ability to preview folder structure without entering directories
|
||||||
|
|
||||||
|
The tool must remain **lightweight and terminal-native**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Execution
|
||||||
|
|
||||||
|
The tool is executed from the terminal:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd_
|
||||||
|
```
|
||||||
|
|
||||||
|
When executed, it opens an **interactive terminal navigation interface**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Interface Behavior
|
||||||
|
|
||||||
|
The interface displays:
|
||||||
|
|
||||||
|
- the current working directory
|
||||||
|
- a list of subdirectories
|
||||||
|
- a special entry:
|
||||||
|
|
||||||
|
```
|
||||||
|
..
|
||||||
|
```
|
||||||
|
|
||||||
|
which navigates to the parent directory.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```
|
||||||
|
/home/user/projects
|
||||||
|
|
||||||
|
▶ backend
|
||||||
|
▶ scripts
|
||||||
|
docs
|
||||||
|
..
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Directory Indicators
|
||||||
|
|
||||||
|
Directories must visually indicate whether they contain subdirectories.
|
||||||
|
|
||||||
|
Recommended symbols:
|
||||||
|
|
||||||
|
| Symbol | Meaning |
|
||||||
|
|------|------|
|
||||||
|
| ▶ | collapsed directory containing subdirectories |
|
||||||
|
| ▼ | expanded directory |
|
||||||
|
| space | directory without subdirectories |
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```
|
||||||
|
▶ backend
|
||||||
|
▼ scripts
|
||||||
|
build
|
||||||
|
deploy
|
||||||
|
docs
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Navigation Controls
|
||||||
|
|
||||||
|
## Arrow Up / Arrow Down
|
||||||
|
|
||||||
|
Move selection between entries.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Enter
|
||||||
|
|
||||||
|
Enter the selected directory.
|
||||||
|
|
||||||
|
If `..` is selected, navigate to the parent directory.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ESC
|
||||||
|
|
||||||
|
Exit interactive mode.
|
||||||
|
|
||||||
|
The shell should remain in the directory currently selected.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Tree Expansion
|
||||||
|
|
||||||
|
Users should be able to explore directory structure without entering directories.
|
||||||
|
|
||||||
|
## Arrow Right
|
||||||
|
|
||||||
|
Expand the selected directory.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
Before:
|
||||||
|
|
||||||
|
```
|
||||||
|
▶ scripts
|
||||||
|
```
|
||||||
|
|
||||||
|
After pressing →:
|
||||||
|
|
||||||
|
```
|
||||||
|
▼ scripts
|
||||||
|
build
|
||||||
|
deploy
|
||||||
|
tools
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Arrow Left
|
||||||
|
|
||||||
|
Collapse expanded directory.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Navigation History
|
||||||
|
|
||||||
|
The application maintains a session navigation history.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## h
|
||||||
|
|
||||||
|
Display navigation history.
|
||||||
|
|
||||||
|
The user can select a previous directory from the list.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## b
|
||||||
|
|
||||||
|
Navigate backward in history.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## f
|
||||||
|
|
||||||
|
Navigate forward in history.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# UI Principles
|
||||||
|
|
||||||
|
The interface must be:
|
||||||
|
|
||||||
|
- responsive
|
||||||
|
- minimal
|
||||||
|
- keyboard-focused
|
||||||
|
- easy to understand visually
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Future Extensions
|
||||||
|
|
||||||
|
These features are optional and may be added later.
|
||||||
|
|
||||||
|
## Search Mode
|
||||||
|
|
||||||
|
Activated by pressing:
|
||||||
|
|
||||||
|
```
|
||||||
|
/
|
||||||
|
```
|
||||||
|
|
||||||
|
Allows filtering directories by name.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Favorites
|
||||||
|
|
||||||
|
Mark current directory:
|
||||||
|
|
||||||
|
```
|
||||||
|
m
|
||||||
|
```
|
||||||
|
|
||||||
|
Open favorites:
|
||||||
|
|
||||||
|
```
|
||||||
|
F
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Technical Constraints
|
||||||
|
|
||||||
|
The implementation must:
|
||||||
|
|
||||||
|
- be written in Python
|
||||||
|
- follow the repository coding standards
|
||||||
|
- pass formatting, linting, typing, and tests
|
||||||
|
|
||||||
|
Validation commands:
|
||||||
|
|
||||||
|
```
|
||||||
|
make fix
|
||||||
|
make quality
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Installation Requirements
|
||||||
|
|
||||||
|
The tool must support two usage modes.
|
||||||
|
|
||||||
|
## Developer Mode
|
||||||
|
|
||||||
|
Clone repository and run:
|
||||||
|
|
||||||
|
```
|
||||||
|
make dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Then execute:
|
||||||
|
|
||||||
|
```
|
||||||
|
python -m app.main
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## User Mode (Installable CLI)
|
||||||
|
|
||||||
|
The tool should be installable as a CLI command.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```
|
||||||
|
pip install .
|
||||||
|
```
|
||||||
|
|
||||||
|
After installation, users should be able to run:
|
||||||
|
|
||||||
|
```
|
||||||
|
cd_
|
||||||
|
```
|
||||||
|
|
||||||
|
directly from the terminal.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Shell Integration Constraint
|
||||||
|
|
||||||
|
Because a subprocess cannot change the parent shell directory, the tool must output the selected directory path.
|
||||||
|
|
||||||
|
Shell integration should be implemented using a wrapper function.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd_() {
|
||||||
|
cd "$(cd_browser)"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# MVP Scope
|
||||||
|
|
||||||
|
The first version must include:
|
||||||
|
|
||||||
|
- directory navigation
|
||||||
|
- tree expansion
|
||||||
|
- keyboard controls
|
||||||
|
- session history
|
||||||
|
- terminal interface
|
||||||
|
|
||||||
|
Advanced features are optional.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
|
||||||
|
`cd_` is a terminal-native interactive directory navigator designed to accelerate filesystem navigation while remaining lightweight and keyboard-focused.
|
||||||
0
src/app/__init__.py
Normal file
0
src/app/__init__.py
Normal file
15
src/app/cli.py
Normal file
15
src/app/cli.py
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.navigator import Navigator
|
||||||
|
from app.ui import TerminalUI
|
||||||
|
|
||||||
|
|
||||||
|
def run_cli(start_path: Path | None = None, ui: TerminalUI | None = None) -> Path:
|
||||||
|
"""Run the interactive directory browser and return the selected path."""
|
||||||
|
|
||||||
|
initial_path = (start_path or Path.cwd()).expanduser().resolve()
|
||||||
|
navigator = Navigator(initial_path)
|
||||||
|
terminal_ui = ui or TerminalUI()
|
||||||
|
return terminal_ui.run(navigator)
|
||||||
9
src/app/config.py
Normal file
9
src/app/config.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class AppConfig:
|
||||||
|
"""Application configuration."""
|
||||||
|
|
||||||
|
app_name: str = "Python AI Dev Template"
|
||||||
|
debug: bool = False
|
||||||
0
src/app/domain/__init__.py
Normal file
0
src/app/domain/__init__.py
Normal file
40
src/app/filesystem.py
Normal file
40
src/app/filesystem.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DirectoryEntry:
|
||||||
|
"""Directory metadata used by the navigator layer."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
path: Path
|
||||||
|
has_children: bool
|
||||||
|
|
||||||
|
|
||||||
|
def list_directories(path: Path) -> list[DirectoryEntry]:
|
||||||
|
"""Return direct child directories sorted by name."""
|
||||||
|
|
||||||
|
directory_path = path.expanduser().resolve()
|
||||||
|
entries: list[DirectoryEntry] = []
|
||||||
|
|
||||||
|
for child in directory_path.iterdir():
|
||||||
|
if child.is_dir():
|
||||||
|
entries.append(
|
||||||
|
DirectoryEntry(
|
||||||
|
name=child.name,
|
||||||
|
path=child,
|
||||||
|
has_children=has_subdirectories(child),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return sorted(entries, key=lambda entry: entry.name.casefold())
|
||||||
|
|
||||||
|
|
||||||
|
def has_subdirectories(path: Path) -> bool:
|
||||||
|
"""Return whether the directory contains at least one subdirectory."""
|
||||||
|
|
||||||
|
directory_path = path.expanduser().resolve()
|
||||||
|
|
||||||
|
return any(child.is_dir() for child in directory_path.iterdir())
|
||||||
68
src/app/history.py
Normal file
68
src/app/history.py
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class SessionHistory:
|
||||||
|
"""In-memory session history for visited directories."""
|
||||||
|
|
||||||
|
_entries: list[Path] = field(default_factory=list)
|
||||||
|
_index: int = -1
|
||||||
|
|
||||||
|
def visit(self, path: Path) -> Path:
|
||||||
|
"""Record a directory visit and discard forward history when needed."""
|
||||||
|
|
||||||
|
resolved_path = path.expanduser().resolve()
|
||||||
|
|
||||||
|
if self.current == resolved_path:
|
||||||
|
return resolved_path
|
||||||
|
|
||||||
|
if self._index < len(self._entries) - 1:
|
||||||
|
self._entries = self._entries[: self._index + 1]
|
||||||
|
|
||||||
|
self._entries.append(resolved_path)
|
||||||
|
self._index = len(self._entries) - 1
|
||||||
|
return resolved_path
|
||||||
|
|
||||||
|
@property
|
||||||
|
def current(self) -> Path | None:
|
||||||
|
"""Return the current history entry."""
|
||||||
|
|
||||||
|
if self._index == -1:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return self._entries[self._index]
|
||||||
|
|
||||||
|
def back(self) -> Path | None:
|
||||||
|
"""Move backward in history when possible."""
|
||||||
|
|
||||||
|
if self._index <= 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
self._index -= 1
|
||||||
|
return self._entries[self._index]
|
||||||
|
|
||||||
|
def forward(self) -> Path | None:
|
||||||
|
"""Move forward in history when possible."""
|
||||||
|
|
||||||
|
if self._index == -1 or self._index >= len(self._entries) - 1:
|
||||||
|
return None
|
||||||
|
|
||||||
|
self._index += 1
|
||||||
|
return self._entries[self._index]
|
||||||
|
|
||||||
|
def select(self, index: int) -> Path:
|
||||||
|
"""Jump to a previously visited directory by history index."""
|
||||||
|
|
||||||
|
if index < 0 or index >= len(self._entries):
|
||||||
|
raise IndexError("History index out of range")
|
||||||
|
|
||||||
|
self._index = index
|
||||||
|
return self._entries[self._index]
|
||||||
|
|
||||||
|
def entries(self) -> tuple[Path, ...]:
|
||||||
|
"""Return the full history as an immutable sequence."""
|
||||||
|
|
||||||
|
return tuple(self._entries)
|
||||||
0
src/app/infrastructure/__init__.py
Normal file
0
src/app/infrastructure/__init__.py
Normal file
11
src/app/logging_config.py
Normal file
11
src/app/logging_config.py
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def setup_logging() -> None:
|
||||||
|
"""Configure application logging."""
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
|
||||||
|
stream=sys.stderr,
|
||||||
|
)
|
||||||
16
src/app/main.py
Normal file
16
src/app/main.py
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import sys
|
||||||
|
|
||||||
|
from app.cli import run_cli
|
||||||
|
from app.logging_config import setup_logging
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
"""Run the application entry point."""
|
||||||
|
setup_logging()
|
||||||
|
|
||||||
|
final_path = run_cli()
|
||||||
|
sys.stdout.write(f"{final_path}\n")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
204
src/app/navigator.py
Normal file
204
src/app/navigator.py
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.filesystem import list_directories
|
||||||
|
from app.history import SessionHistory
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class VisibleEntry:
|
||||||
|
"""A directory row visible in the navigator."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
path: Path
|
||||||
|
depth: int
|
||||||
|
has_children: bool
|
||||||
|
is_expanded: bool
|
||||||
|
is_parent: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class Navigator:
|
||||||
|
"""State container for directory navigation behavior."""
|
||||||
|
|
||||||
|
start_path: Path
|
||||||
|
history: SessionHistory = field(default_factory=SessionHistory)
|
||||||
|
current_path: Path = field(init=False)
|
||||||
|
selected_index: int = field(init=False, default=0)
|
||||||
|
_expanded_paths: set[Path] = field(init=False, default_factory=set)
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
self.current_path = self.start_path.expanduser().resolve()
|
||||||
|
self.history.visit(self.current_path)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def visible_entries(self) -> tuple[VisibleEntry, ...]:
|
||||||
|
"""Return the current flattened directory view."""
|
||||||
|
|
||||||
|
entries = [
|
||||||
|
VisibleEntry(
|
||||||
|
name="..",
|
||||||
|
path=self.current_path.parent,
|
||||||
|
depth=0,
|
||||||
|
has_children=True,
|
||||||
|
is_expanded=False,
|
||||||
|
is_parent=True,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
entries.extend(self._build_entries(self.current_path, depth=0))
|
||||||
|
return tuple(entries)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def selected_entry(self) -> VisibleEntry:
|
||||||
|
"""Return the currently selected visible entry."""
|
||||||
|
|
||||||
|
return self.visible_entries[self.selected_index]
|
||||||
|
|
||||||
|
def set_selected_index(self, index: int) -> None:
|
||||||
|
"""Set the selected index within the visible entry range."""
|
||||||
|
|
||||||
|
entry_count = len(self.visible_entries)
|
||||||
|
if index < 0 or index >= entry_count:
|
||||||
|
raise IndexError("Selected index out of range")
|
||||||
|
|
||||||
|
self.selected_index = index
|
||||||
|
|
||||||
|
def enter_selected_directory(self) -> Path:
|
||||||
|
"""Enter the currently selected directory and record it in history."""
|
||||||
|
|
||||||
|
return self._change_directory(self.selected_entry.path)
|
||||||
|
|
||||||
|
def go_to_parent_directory(self) -> Path:
|
||||||
|
"""Navigate to the parent directory of the current path."""
|
||||||
|
|
||||||
|
return self._change_directory(self.current_path.parent)
|
||||||
|
|
||||||
|
def expand_selected_directory(self) -> bool:
|
||||||
|
"""Expand the selected directory if it has children."""
|
||||||
|
|
||||||
|
return self.expand_directory(self.selected_entry.path)
|
||||||
|
|
||||||
|
def collapse_selected_directory(self) -> bool:
|
||||||
|
"""Collapse the selected directory when possible."""
|
||||||
|
|
||||||
|
selected = self.selected_entry
|
||||||
|
if selected.is_parent:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if selected.path in self._expanded_paths:
|
||||||
|
self._collapse_path(selected.path)
|
||||||
|
return True
|
||||||
|
|
||||||
|
if selected.depth > 0:
|
||||||
|
parent = self._find_visible_parent(selected)
|
||||||
|
self.set_selected_index(self.visible_entries.index(parent))
|
||||||
|
return self.collapse_selected_directory()
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def expand_directory(self, path: Path) -> bool:
|
||||||
|
"""Expand a visible directory by path."""
|
||||||
|
|
||||||
|
resolved_path = path.expanduser().resolve()
|
||||||
|
|
||||||
|
for entry in self.visible_entries:
|
||||||
|
if (
|
||||||
|
entry.path == resolved_path
|
||||||
|
and not entry.is_parent
|
||||||
|
and entry.has_children
|
||||||
|
):
|
||||||
|
self._expanded_paths.add(resolved_path)
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def collapse_directory(self, path: Path) -> bool:
|
||||||
|
"""Collapse a previously expanded directory by path."""
|
||||||
|
|
||||||
|
resolved_path = path.expanduser().resolve()
|
||||||
|
if resolved_path not in self._expanded_paths:
|
||||||
|
return False
|
||||||
|
|
||||||
|
self._collapse_path(resolved_path)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def go_back(self) -> Path | None:
|
||||||
|
"""Move backward through navigation history."""
|
||||||
|
|
||||||
|
path = self.history.back()
|
||||||
|
if path is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
self._set_current_path(path)
|
||||||
|
return path
|
||||||
|
|
||||||
|
def go_forward(self) -> Path | None:
|
||||||
|
"""Move forward through navigation history."""
|
||||||
|
|
||||||
|
path = self.history.forward()
|
||||||
|
if path is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
self._set_current_path(path)
|
||||||
|
return path
|
||||||
|
|
||||||
|
def select_history_entry(self, index: int) -> Path:
|
||||||
|
"""Jump to a directory stored in session history."""
|
||||||
|
|
||||||
|
path = self.history.select(index)
|
||||||
|
self._set_current_path(path)
|
||||||
|
return path
|
||||||
|
|
||||||
|
def _build_entries(self, path: Path, depth: int) -> list[VisibleEntry]:
|
||||||
|
entries: list[VisibleEntry] = []
|
||||||
|
|
||||||
|
for directory in list_directories(path):
|
||||||
|
is_expanded = directory.path in self._expanded_paths
|
||||||
|
entries.append(
|
||||||
|
VisibleEntry(
|
||||||
|
name=directory.name,
|
||||||
|
path=directory.path,
|
||||||
|
depth=depth,
|
||||||
|
has_children=directory.has_children,
|
||||||
|
is_expanded=is_expanded,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if is_expanded:
|
||||||
|
entries.extend(self._build_entries(directory.path, depth=depth + 1))
|
||||||
|
|
||||||
|
return entries
|
||||||
|
|
||||||
|
def _change_directory(self, path: Path) -> Path:
|
||||||
|
resolved_path = path.expanduser().resolve()
|
||||||
|
self.history.visit(resolved_path)
|
||||||
|
self._set_current_path(resolved_path)
|
||||||
|
return resolved_path
|
||||||
|
|
||||||
|
def _set_current_path(self, path: Path) -> None:
|
||||||
|
self.current_path = path.expanduser().resolve()
|
||||||
|
self.selected_index = 0
|
||||||
|
self._expanded_paths.clear()
|
||||||
|
|
||||||
|
def _collapse_path(self, path: Path) -> None:
|
||||||
|
self._expanded_paths = {
|
||||||
|
expanded_path
|
||||||
|
for expanded_path in self._expanded_paths
|
||||||
|
if expanded_path != path and path not in expanded_path.parents
|
||||||
|
}
|
||||||
|
|
||||||
|
def _find_visible_parent(self, entry: VisibleEntry) -> VisibleEntry:
|
||||||
|
entries = self.visible_entries
|
||||||
|
current_index = entries.index(entry)
|
||||||
|
|
||||||
|
for candidate_index in range(current_index - 1, -1, -1):
|
||||||
|
candidate = entries[candidate_index]
|
||||||
|
if (
|
||||||
|
candidate.depth == entry.depth - 1
|
||||||
|
and entry.path.parent == candidate.path
|
||||||
|
):
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
raise ValueError("Visible parent entry not found")
|
||||||
0
src/app/services/__init__.py
Normal file
0
src/app/services/__init__.py
Normal file
167
src/app/ui.py
Normal file
167
src/app/ui.py
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import curses
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from collections.abc import Iterator
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import TextIO
|
||||||
|
|
||||||
|
from app.navigator import Navigator, VisibleEntry
|
||||||
|
|
||||||
|
ESCAPE_KEY = 27
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class RenderLine:
|
||||||
|
"""A rendered navigator row ready for terminal output."""
|
||||||
|
|
||||||
|
text: str
|
||||||
|
is_selected: bool
|
||||||
|
|
||||||
|
|
||||||
|
def move_selection(current_index: int, step: int, entry_count: int) -> int:
|
||||||
|
"""Move the selected index within the available entry range."""
|
||||||
|
|
||||||
|
if entry_count <= 0:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
return max(0, min(current_index + step, entry_count - 1))
|
||||||
|
|
||||||
|
|
||||||
|
def format_entry(entry: VisibleEntry) -> str:
|
||||||
|
"""Format a visible entry for terminal display."""
|
||||||
|
|
||||||
|
if entry.is_parent:
|
||||||
|
return entry.name
|
||||||
|
|
||||||
|
indent = " " * entry.depth
|
||||||
|
indicator = " "
|
||||||
|
if entry.has_children:
|
||||||
|
indicator = "▼" if entry.is_expanded else "▶"
|
||||||
|
|
||||||
|
return f"{indent}{indicator} {entry.name}"
|
||||||
|
|
||||||
|
|
||||||
|
def build_render_lines(navigator: Navigator) -> list[RenderLine]:
|
||||||
|
"""Build all visible lines for the current navigator state."""
|
||||||
|
|
||||||
|
return [
|
||||||
|
RenderLine(
|
||||||
|
text=format_entry(entry),
|
||||||
|
is_selected=index == navigator.selected_index,
|
||||||
|
)
|
||||||
|
for index, entry in enumerate(navigator.visible_entries)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class TerminalUI:
|
||||||
|
"""Minimal curses UI for the directory navigator MVP."""
|
||||||
|
|
||||||
|
def run(self, navigator: Navigator) -> Path:
|
||||||
|
"""Start the interactive session and return the final directory path."""
|
||||||
|
|
||||||
|
with open_terminal_streams() as (input_stream, output_stream):
|
||||||
|
with redirect_standard_streams(input_stream, output_stream):
|
||||||
|
return curses.wrapper(self._run_session, navigator)
|
||||||
|
|
||||||
|
def _run_session(self, stdscr: curses.window, navigator: Navigator) -> Path:
|
||||||
|
curses.curs_set(0)
|
||||||
|
stdscr.keypad(True)
|
||||||
|
|
||||||
|
while True:
|
||||||
|
self._render(stdscr, navigator)
|
||||||
|
key = stdscr.getch()
|
||||||
|
|
||||||
|
if key == ESCAPE_KEY:
|
||||||
|
return navigator.current_path
|
||||||
|
|
||||||
|
if key == curses.KEY_UP:
|
||||||
|
navigator.set_selected_index(
|
||||||
|
move_selection(
|
||||||
|
navigator.selected_index,
|
||||||
|
step=-1,
|
||||||
|
entry_count=len(navigator.visible_entries),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if key == curses.KEY_DOWN:
|
||||||
|
navigator.set_selected_index(
|
||||||
|
move_selection(
|
||||||
|
navigator.selected_index,
|
||||||
|
step=1,
|
||||||
|
entry_count=len(navigator.visible_entries),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if key == curses.KEY_RIGHT:
|
||||||
|
navigator.expand_selected_directory()
|
||||||
|
continue
|
||||||
|
|
||||||
|
if key == curses.KEY_LEFT:
|
||||||
|
navigator.collapse_selected_directory()
|
||||||
|
continue
|
||||||
|
|
||||||
|
if key in (curses.KEY_ENTER, 10, 13):
|
||||||
|
navigator.enter_selected_directory()
|
||||||
|
|
||||||
|
def _render(self, stdscr: curses.window, navigator: Navigator) -> None:
|
||||||
|
stdscr.erase()
|
||||||
|
height, width = stdscr.getmaxyx()
|
||||||
|
|
||||||
|
path_text = str(navigator.current_path)
|
||||||
|
stdscr.addnstr(0, 0, path_text, width - 1)
|
||||||
|
|
||||||
|
for row, line in enumerate(build_render_lines(navigator), start=2):
|
||||||
|
if row >= height:
|
||||||
|
break
|
||||||
|
|
||||||
|
attributes = curses.A_REVERSE if line.is_selected else curses.A_NORMAL
|
||||||
|
stdscr.addnstr(row, 0, line.text, width - 1, attributes)
|
||||||
|
|
||||||
|
stdscr.refresh()
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def open_terminal_streams() -> Iterator[tuple[TextIO, TextIO]]:
|
||||||
|
"""Open terminal streams suitable for interactive rendering."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
with (
|
||||||
|
open("/dev/tty", encoding="utf-8") as input_stream,
|
||||||
|
open("/dev/tty", "w", encoding="utf-8") as output_stream,
|
||||||
|
):
|
||||||
|
yield input_stream, output_stream
|
||||||
|
return
|
||||||
|
except OSError:
|
||||||
|
if not sys.stdin.isatty() or not sys.stderr.isatty():
|
||||||
|
raise RuntimeError("Interactive terminal is required") from None
|
||||||
|
|
||||||
|
yield sys.stdin, sys.stderr
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def redirect_standard_streams(
|
||||||
|
input_stream: TextIO, output_stream: TextIO
|
||||||
|
) -> Iterator[None]:
|
||||||
|
"""Temporarily redirect stdin and stdout to terminal-backed streams."""
|
||||||
|
|
||||||
|
sys.stdout.flush()
|
||||||
|
sys.stderr.flush()
|
||||||
|
|
||||||
|
original_stdin = os.dup(0)
|
||||||
|
original_stdout = os.dup(1)
|
||||||
|
|
||||||
|
try:
|
||||||
|
os.dup2(input_stream.fileno(), 0)
|
||||||
|
os.dup2(output_stream.fileno(), 1)
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
os.dup2(original_stdin, 0)
|
||||||
|
os.dup2(original_stdout, 1)
|
||||||
|
os.close(original_stdin)
|
||||||
|
os.close(original_stdout)
|
||||||
0
src/app/utils/__init__.py
Normal file
0
src/app/utils/__init__.py
Normal file
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
43
tests/test_cli.py
Normal file
43
tests/test_cli.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.cli import run_cli
|
||||||
|
from app.navigator import Navigator
|
||||||
|
|
||||||
|
|
||||||
|
class StubUI:
|
||||||
|
def __init__(self, result: Path) -> None:
|
||||||
|
self.result = result
|
||||||
|
self.navigator: Navigator | None = None
|
||||||
|
|
||||||
|
def run(self, navigator: Navigator) -> Path:
|
||||||
|
self.navigator = navigator
|
||||||
|
return self.result
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_cli_uses_current_working_directory(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
stub_ui = StubUI(tmp_path)
|
||||||
|
monkeypatch.setattr(Path, "cwd", lambda: tmp_path)
|
||||||
|
|
||||||
|
result = run_cli(ui=stub_ui)
|
||||||
|
|
||||||
|
assert result == tmp_path.resolve()
|
||||||
|
assert stub_ui.navigator is not None
|
||||||
|
assert stub_ui.navigator.current_path == tmp_path.resolve()
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_cli_uses_provided_start_path(tmp_path: Path) -> None:
|
||||||
|
start_path = tmp_path / "workspace"
|
||||||
|
start_path.mkdir()
|
||||||
|
final_path = tmp_path / "final"
|
||||||
|
final_path.mkdir()
|
||||||
|
stub_ui = StubUI(final_path)
|
||||||
|
|
||||||
|
result = run_cli(start_path=start_path, ui=stub_ui)
|
||||||
|
|
||||||
|
assert result == final_path.resolve()
|
||||||
|
assert stub_ui.navigator is not None
|
||||||
|
assert stub_ui.navigator.current_path == start_path.resolve()
|
||||||
46
tests/test_filesystem.py
Normal file
46
tests/test_filesystem.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.filesystem import DirectoryEntry, has_subdirectories, list_directories
|
||||||
|
|
||||||
|
|
||||||
|
def test_has_subdirectories_detects_nested_directory(tmp_path: Path) -> None:
|
||||||
|
parent = tmp_path / "parent"
|
||||||
|
child = parent / "child"
|
||||||
|
child.mkdir(parents=True)
|
||||||
|
|
||||||
|
assert has_subdirectories(parent) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_has_subdirectories_returns_false_without_nested_directories(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
directory = tmp_path / "empty"
|
||||||
|
directory.mkdir()
|
||||||
|
(directory / "file.txt").write_text("content", encoding="utf-8")
|
||||||
|
|
||||||
|
assert has_subdirectories(directory) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_directories_returns_sorted_directory_metadata(tmp_path: Path) -> None:
|
||||||
|
alpha = tmp_path / "alpha"
|
||||||
|
beta = tmp_path / "Beta"
|
||||||
|
file_path = tmp_path / "notes.txt"
|
||||||
|
|
||||||
|
alpha.mkdir()
|
||||||
|
beta.mkdir()
|
||||||
|
(beta / "nested").mkdir()
|
||||||
|
file_path.write_text("ignore me", encoding="utf-8")
|
||||||
|
|
||||||
|
assert list_directories(tmp_path) == [
|
||||||
|
DirectoryEntry(name="alpha", path=alpha, has_children=False),
|
||||||
|
DirectoryEntry(name="Beta", path=beta, has_children=True),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_directories_raises_for_missing_directory(tmp_path: Path) -> None:
|
||||||
|
missing_path = tmp_path / "missing"
|
||||||
|
|
||||||
|
with pytest.raises(FileNotFoundError):
|
||||||
|
list_directories(missing_path)
|
||||||
84
tests/test_history.py
Normal file
84
tests/test_history.py
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.history import SessionHistory
|
||||||
|
|
||||||
|
|
||||||
|
def test_visit_records_current_directory(tmp_path: Path) -> None:
|
||||||
|
history = SessionHistory()
|
||||||
|
first = tmp_path / "first"
|
||||||
|
first.mkdir()
|
||||||
|
|
||||||
|
visited = history.visit(first)
|
||||||
|
|
||||||
|
assert visited == first.resolve()
|
||||||
|
assert history.current == first.resolve()
|
||||||
|
assert history.entries() == (first.resolve(),)
|
||||||
|
|
||||||
|
|
||||||
|
def test_visit_drops_forward_history_after_new_branch(tmp_path: Path) -> None:
|
||||||
|
history = SessionHistory()
|
||||||
|
first = tmp_path / "first"
|
||||||
|
second = tmp_path / "second"
|
||||||
|
third = tmp_path / "third"
|
||||||
|
for path in (first, second, third):
|
||||||
|
path.mkdir()
|
||||||
|
|
||||||
|
history.visit(first)
|
||||||
|
history.visit(second)
|
||||||
|
assert history.back() == first.resolve()
|
||||||
|
|
||||||
|
history.visit(third)
|
||||||
|
|
||||||
|
assert history.entries() == (first.resolve(), third.resolve())
|
||||||
|
assert history.current == third.resolve()
|
||||||
|
assert history.forward() is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_visit_does_not_duplicate_current_entry(tmp_path: Path) -> None:
|
||||||
|
history = SessionHistory()
|
||||||
|
directory = tmp_path / "directory"
|
||||||
|
directory.mkdir()
|
||||||
|
|
||||||
|
history.visit(directory)
|
||||||
|
history.visit(directory)
|
||||||
|
|
||||||
|
assert history.entries() == (directory.resolve(),)
|
||||||
|
|
||||||
|
|
||||||
|
def test_back_and_forward_move_through_history(tmp_path: Path) -> None:
|
||||||
|
history = SessionHistory()
|
||||||
|
first = tmp_path / "first"
|
||||||
|
second = tmp_path / "second"
|
||||||
|
for path in (first, second):
|
||||||
|
path.mkdir()
|
||||||
|
|
||||||
|
history.visit(first)
|
||||||
|
history.visit(second)
|
||||||
|
|
||||||
|
assert history.back() == first.resolve()
|
||||||
|
assert history.back() is None
|
||||||
|
assert history.forward() == second.resolve()
|
||||||
|
assert history.forward() is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_select_moves_to_specific_history_entry(tmp_path: Path) -> None:
|
||||||
|
history = SessionHistory()
|
||||||
|
first = tmp_path / "first"
|
||||||
|
second = tmp_path / "second"
|
||||||
|
for path in (first, second):
|
||||||
|
path.mkdir()
|
||||||
|
|
||||||
|
history.visit(first)
|
||||||
|
history.visit(second)
|
||||||
|
|
||||||
|
assert history.select(0) == first.resolve()
|
||||||
|
assert history.current == first.resolve()
|
||||||
|
|
||||||
|
|
||||||
|
def test_select_raises_for_invalid_index() -> None:
|
||||||
|
history = SessionHistory()
|
||||||
|
|
||||||
|
with pytest.raises(IndexError):
|
||||||
|
history.select(0)
|
||||||
182
tests/test_navigator.py
Normal file
182
tests/test_navigator.py
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.history import SessionHistory
|
||||||
|
from app.navigator import Navigator
|
||||||
|
|
||||||
|
|
||||||
|
def test_navigator_initial_state_includes_parent_entry(tmp_path: Path) -> None:
|
||||||
|
current = tmp_path / "workspace"
|
||||||
|
current.mkdir()
|
||||||
|
alpha = current / "alpha"
|
||||||
|
beta = current / "beta"
|
||||||
|
alpha.mkdir()
|
||||||
|
beta.mkdir()
|
||||||
|
|
||||||
|
navigator = Navigator(current)
|
||||||
|
|
||||||
|
assert navigator.current_path == current.resolve()
|
||||||
|
assert navigator.selected_index == 0
|
||||||
|
assert [entry.name for entry in navigator.visible_entries] == [
|
||||||
|
"..",
|
||||||
|
"alpha",
|
||||||
|
"beta",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_expand_selected_directory_reveals_nested_entries(tmp_path: Path) -> None:
|
||||||
|
current = tmp_path / "workspace"
|
||||||
|
current.mkdir()
|
||||||
|
scripts = current / "scripts"
|
||||||
|
scripts.mkdir()
|
||||||
|
build = scripts / "build"
|
||||||
|
build.mkdir()
|
||||||
|
|
||||||
|
navigator = Navigator(current)
|
||||||
|
navigator.set_selected_index(1)
|
||||||
|
|
||||||
|
assert navigator.expand_selected_directory() is True
|
||||||
|
assert [(entry.name, entry.depth) for entry in navigator.visible_entries] == [
|
||||||
|
("..", 0),
|
||||||
|
("scripts", 0),
|
||||||
|
("build", 1),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_expand_selected_directory_returns_false_without_children(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
current = tmp_path / "workspace"
|
||||||
|
current.mkdir()
|
||||||
|
docs = current / "docs"
|
||||||
|
docs.mkdir()
|
||||||
|
|
||||||
|
navigator = Navigator(current)
|
||||||
|
navigator.set_selected_index(1)
|
||||||
|
|
||||||
|
assert navigator.expand_selected_directory() is False
|
||||||
|
assert [entry.name for entry in navigator.visible_entries] == ["..", "docs"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_collapse_selected_directory_hides_nested_entries(tmp_path: Path) -> None:
|
||||||
|
current = tmp_path / "workspace"
|
||||||
|
current.mkdir()
|
||||||
|
scripts = current / "scripts"
|
||||||
|
scripts.mkdir()
|
||||||
|
(scripts / "build").mkdir()
|
||||||
|
|
||||||
|
navigator = Navigator(current)
|
||||||
|
navigator.set_selected_index(1)
|
||||||
|
navigator.expand_selected_directory()
|
||||||
|
|
||||||
|
assert navigator.collapse_selected_directory() is True
|
||||||
|
assert [entry.name for entry in navigator.visible_entries] == ["..", "scripts"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_collapse_selected_child_moves_to_parent_and_collapses(tmp_path: Path) -> None:
|
||||||
|
current = tmp_path / "workspace"
|
||||||
|
current.mkdir()
|
||||||
|
scripts = current / "scripts"
|
||||||
|
scripts.mkdir()
|
||||||
|
(scripts / "build").mkdir()
|
||||||
|
|
||||||
|
navigator = Navigator(current)
|
||||||
|
navigator.set_selected_index(1)
|
||||||
|
navigator.expand_selected_directory()
|
||||||
|
navigator.set_selected_index(2)
|
||||||
|
|
||||||
|
assert navigator.collapse_selected_directory() is True
|
||||||
|
assert navigator.selected_entry.name == "scripts"
|
||||||
|
assert [entry.name for entry in navigator.visible_entries] == ["..", "scripts"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_enter_selected_directory_changes_current_path_and_updates_history(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
current = tmp_path / "workspace"
|
||||||
|
current.mkdir()
|
||||||
|
child = current / "child"
|
||||||
|
child.mkdir()
|
||||||
|
|
||||||
|
history = SessionHistory()
|
||||||
|
navigator = Navigator(current, history=history)
|
||||||
|
navigator.set_selected_index(1)
|
||||||
|
|
||||||
|
selected_path = navigator.enter_selected_directory()
|
||||||
|
|
||||||
|
assert selected_path == child.resolve()
|
||||||
|
assert navigator.current_path == child.resolve()
|
||||||
|
assert navigator.selected_index == 0
|
||||||
|
assert navigator.history.entries() == (current.resolve(), child.resolve())
|
||||||
|
|
||||||
|
|
||||||
|
def test_enter_parent_entry_moves_to_parent_directory(tmp_path: Path) -> None:
|
||||||
|
parent = tmp_path / "parent"
|
||||||
|
parent.mkdir()
|
||||||
|
current = parent / "current"
|
||||||
|
current.mkdir()
|
||||||
|
|
||||||
|
navigator = Navigator(current)
|
||||||
|
|
||||||
|
moved_path = navigator.enter_selected_directory()
|
||||||
|
|
||||||
|
assert moved_path == parent.resolve()
|
||||||
|
assert navigator.current_path == parent.resolve()
|
||||||
|
|
||||||
|
|
||||||
|
def test_go_to_parent_directory_moves_up_one_level(tmp_path: Path) -> None:
|
||||||
|
parent = tmp_path / "parent"
|
||||||
|
parent.mkdir()
|
||||||
|
current = parent / "current"
|
||||||
|
current.mkdir()
|
||||||
|
|
||||||
|
navigator = Navigator(current)
|
||||||
|
|
||||||
|
assert navigator.go_to_parent_directory() == parent.resolve()
|
||||||
|
assert navigator.current_path == parent.resolve()
|
||||||
|
|
||||||
|
|
||||||
|
def test_history_navigation_restores_previous_path(tmp_path: Path) -> None:
|
||||||
|
root = tmp_path / "root"
|
||||||
|
root.mkdir()
|
||||||
|
first = root / "first"
|
||||||
|
second = root / "second"
|
||||||
|
first.mkdir()
|
||||||
|
second.mkdir()
|
||||||
|
|
||||||
|
navigator = Navigator(root)
|
||||||
|
navigator.set_selected_index(1)
|
||||||
|
navigator.enter_selected_directory()
|
||||||
|
navigator.go_to_parent_directory()
|
||||||
|
navigator.set_selected_index(2)
|
||||||
|
navigator.enter_selected_directory()
|
||||||
|
|
||||||
|
assert navigator.go_back() == root.resolve()
|
||||||
|
assert navigator.current_path == root.resolve()
|
||||||
|
assert navigator.go_back() == first.resolve()
|
||||||
|
assert navigator.go_forward() == root.resolve()
|
||||||
|
assert navigator.go_forward() == second.resolve()
|
||||||
|
|
||||||
|
|
||||||
|
def test_select_history_entry_changes_current_path(tmp_path: Path) -> None:
|
||||||
|
root = tmp_path / "root"
|
||||||
|
root.mkdir()
|
||||||
|
child = root / "child"
|
||||||
|
child.mkdir()
|
||||||
|
|
||||||
|
navigator = Navigator(root)
|
||||||
|
navigator.set_selected_index(1)
|
||||||
|
navigator.enter_selected_directory()
|
||||||
|
|
||||||
|
assert navigator.select_history_entry(0) == root.resolve()
|
||||||
|
assert navigator.current_path == root.resolve()
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_selected_index_raises_for_invalid_index(tmp_path: Path) -> None:
|
||||||
|
current = tmp_path / "workspace"
|
||||||
|
current.mkdir()
|
||||||
|
navigator = Navigator(current)
|
||||||
|
|
||||||
|
with pytest.raises(IndexError):
|
||||||
|
navigator.set_selected_index(1)
|
||||||
18
tests/test_smoke.py
Normal file
18
tests/test_smoke.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.main import main
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_runs(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr("app.main.run_cli", lambda: Path("/tmp/final"))
|
||||||
|
|
||||||
|
main()
|
||||||
|
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
|
||||||
|
assert captured.out == "/tmp/final\n"
|
||||||
|
assert captured.err == ""
|
||||||
80
tests/test_ui.py
Normal file
80
tests/test_ui.py
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.navigator import Navigator, VisibleEntry
|
||||||
|
from app.ui import (
|
||||||
|
RenderLine,
|
||||||
|
build_render_lines,
|
||||||
|
format_entry,
|
||||||
|
move_selection,
|
||||||
|
open_terminal_streams,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_move_selection_stays_within_bounds() -> None:
|
||||||
|
assert move_selection(current_index=0, step=-1, entry_count=3) == 0
|
||||||
|
assert move_selection(current_index=1, step=1, entry_count=3) == 2
|
||||||
|
assert move_selection(current_index=2, step=1, entry_count=3) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_entry_supports_parent_and_tree_indicators(tmp_path: Path) -> None:
|
||||||
|
parent_entry = VisibleEntry(
|
||||||
|
name="..",
|
||||||
|
path=tmp_path,
|
||||||
|
depth=0,
|
||||||
|
has_children=True,
|
||||||
|
is_expanded=False,
|
||||||
|
is_parent=True,
|
||||||
|
)
|
||||||
|
collapsed_entry = VisibleEntry(
|
||||||
|
name="scripts",
|
||||||
|
path=tmp_path / "scripts",
|
||||||
|
depth=0,
|
||||||
|
has_children=True,
|
||||||
|
is_expanded=False,
|
||||||
|
)
|
||||||
|
expanded_child = VisibleEntry(
|
||||||
|
name="build",
|
||||||
|
path=tmp_path / "scripts" / "build",
|
||||||
|
depth=1,
|
||||||
|
has_children=False,
|
||||||
|
is_expanded=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert format_entry(parent_entry) == ".."
|
||||||
|
assert format_entry(collapsed_entry) == "▶ scripts"
|
||||||
|
assert format_entry(expanded_child) == " build"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_render_lines_marks_selected_entry(tmp_path: Path) -> None:
|
||||||
|
workspace = tmp_path / "workspace"
|
||||||
|
workspace.mkdir()
|
||||||
|
alpha = workspace / "alpha"
|
||||||
|
beta = workspace / "beta"
|
||||||
|
alpha.mkdir()
|
||||||
|
beta.mkdir()
|
||||||
|
|
||||||
|
navigator = Navigator(workspace)
|
||||||
|
navigator.set_selected_index(2)
|
||||||
|
|
||||||
|
assert build_render_lines(navigator) == [
|
||||||
|
RenderLine(text="..", is_selected=False),
|
||||||
|
RenderLine(text=" alpha", is_selected=False),
|
||||||
|
RenderLine(text=" beta", is_selected=True),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_open_terminal_streams_raises_without_terminal(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
def raise_os_error(*args: object, **kwargs: object) -> object:
|
||||||
|
raise OSError
|
||||||
|
|
||||||
|
monkeypatch.setattr("builtins.open", raise_os_error)
|
||||||
|
monkeypatch.setattr("sys.stdin.isatty", lambda: False)
|
||||||
|
monkeypatch.setattr("sys.stderr.isatty", lambda: False)
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="Interactive terminal is required"):
|
||||||
|
with open_terminal_streams():
|
||||||
|
pass
|
||||||
Reference in New Issue
Block a user