first working version of jrnl
This commit is contained in:
50
.gitignore
vendored
Normal file
50
.gitignore
vendored
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
pip-wheel-metadata/
|
||||||
|
share/python-wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
MANIFEST
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
.pytest_cache/
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
|
||||||
|
# Local development
|
||||||
|
*.db
|
||||||
|
*.log
|
||||||
|
test_*.py
|
||||||
153
README.md
Normal file
153
README.md
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
# JRNL
|
||||||
|
|
||||||
|
A CLI tool for developers to automatically track work for daily standups using LLM-powered summaries.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Automatic Git Logging**: Captures commits via global git hooks
|
||||||
|
- **LLM-Powered Summarization**: Compresses commit info into concise standup messages
|
||||||
|
- **Multiple LLM Providers**: Support for Anthropic Claude and Ollama
|
||||||
|
- **Manual Logging**: Add work items manually
|
||||||
|
- **Daily Standup Generation**: Generate formatted standup messages from your work logs
|
||||||
|
- **Per-Repository Control**: Opt-out specific repositories from tracking
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./install.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
This will:
|
||||||
|
1. Create a Python virtual environment at `~/.jrnl/venv`
|
||||||
|
2. Install dependencies
|
||||||
|
3. Initialize the SQLite database
|
||||||
|
4. Create default configuration
|
||||||
|
5. Optionally install global git hooks
|
||||||
|
6. Install the `jrnl` CLI command to `~/.local/bin`
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
After installation, configure your LLM provider:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# For Anthropic Claude
|
||||||
|
jrnl config set anthropic api_key YOUR_API_KEY
|
||||||
|
|
||||||
|
# Or for Ollama (local)
|
||||||
|
jrnl config set-provider ollama
|
||||||
|
```
|
||||||
|
|
||||||
|
Configuration file: `~/.jrnl/config.json`
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Manual Logging
|
||||||
|
|
||||||
|
```bash
|
||||||
|
jrnl new -m "Had meeting with customer about the service"
|
||||||
|
```
|
||||||
|
|
||||||
|
### View Logs
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# View recent logs
|
||||||
|
jrnl logs
|
||||||
|
|
||||||
|
# View logs from last N days
|
||||||
|
jrnl logs --days 3
|
||||||
|
```
|
||||||
|
|
||||||
|
### Generate Daily Standup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Generate standup from logs since last daily
|
||||||
|
jrnl daily
|
||||||
|
|
||||||
|
# Generate standup covering multiple days
|
||||||
|
jrnl daily --days 2
|
||||||
|
|
||||||
|
# Regenerate today's standup
|
||||||
|
jrnl daily --regenerate
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configuration Management
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Show current configuration
|
||||||
|
jrnl config
|
||||||
|
|
||||||
|
# Switch LLM provider
|
||||||
|
jrnl config set-provider ollama
|
||||||
|
|
||||||
|
# Set provider-specific settings
|
||||||
|
jrnl config set anthropic model claude-3-5-sonnet-20241022
|
||||||
|
jrnl config set anthropic max_tokens_daily 1000
|
||||||
|
```
|
||||||
|
|
||||||
|
### Repository Exclusion
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Exclude current repository from tracking
|
||||||
|
jrnl config exclude-current
|
||||||
|
|
||||||
|
# Exclude specific repository
|
||||||
|
jrnl config exclude /path/to/repo
|
||||||
|
|
||||||
|
# Re-enable repository
|
||||||
|
jrnl config include /path/to/repo
|
||||||
|
```
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
1. **Git Hooks**: When you make a commit, the post-commit hook captures the commit message and diff
|
||||||
|
2. **LLM Compression**: The commit info is processed through your chosen LLM to create a concise summary
|
||||||
|
3. **Database Storage**: Logs are stored in SQLite at `~/.jrnl/jrnl.db`
|
||||||
|
4. **Daily Generation**: When you run `jrnl daily`, all logs since your last daily are sent to the LLM to generate a formatted standup message
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
~/.jrnl/ # Application directory
|
||||||
|
├── config.json # Configuration
|
||||||
|
├── jrnl.db # SQLite database
|
||||||
|
├── venv/ # Python virtual environment
|
||||||
|
└── logs/ # Application logs
|
||||||
|
```
|
||||||
|
|
||||||
|
## Uninstallation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./uninstall.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Or from anywhere:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
jrnl uninstall
|
||||||
|
```
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Python 3.8+
|
||||||
|
- Git
|
||||||
|
- Anthropic API key (for Claude) or Ollama installation (for local models)
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
The project follows a modular structure:
|
||||||
|
|
||||||
|
- `jrnl/commands/` - CLI command implementations
|
||||||
|
- `jrnl/database/` - Database layer with SQLite
|
||||||
|
- `jrnl/llm_providers/` - LLM provider abstractions
|
||||||
|
- `jrnl/git_integration/` - Git hook and commit processing
|
||||||
|
- `jrnl/utils/` - Utility functions
|
||||||
|
|
||||||
|
To run from source:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m jrnl --help
|
||||||
|
```
|
||||||
@@ -67,3 +67,9 @@ Application code structure should be as modular and as human readable as possibl
|
|||||||
- utils
|
- utils
|
||||||
- database
|
- database
|
||||||
- sql statements
|
- sql statements
|
||||||
|
|
||||||
|
Never use `except Exception` as default, use always proper most accurate error handling as possible and let script to crash on unexpected error.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
Create unittests for each module, for example to test llm provider separately. Create temporary virtual environment for tests only. Report code line coverage.
|
||||||
55
hooks/post-commit.template
Normal file
55
hooks/post-commit.template
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
#
|
||||||
|
# JRNL post-commit hook
|
||||||
|
# Automatically logs commits with LLM-compressed summaries
|
||||||
|
#
|
||||||
|
|
||||||
|
# Get repository information
|
||||||
|
REPO_PATH=$(git rev-parse --show-toplevel 2>/dev/null)
|
||||||
|
COMMIT_HASH=$(git rev-parse HEAD 2>/dev/null)
|
||||||
|
|
||||||
|
# Exit if we couldn't get repo info
|
||||||
|
if [ -z "$REPO_PATH" ] || [ -z "$COMMIT_HASH" ]; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# jrnl paths
|
||||||
|
JRNL_DIR="$HOME/.jrnl"
|
||||||
|
JRNL_CMD="$HOME/.local/bin/jrnl"
|
||||||
|
|
||||||
|
# Skip if this is the jrnl directory itself
|
||||||
|
if [ "$REPO_PATH" = "$JRNL_DIR" ]; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if jrnl is installed
|
||||||
|
if [ ! -f "$JRNL_CMD" ]; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if hooks are enabled in config
|
||||||
|
if [ ! -f "$JRNL_DIR/config.json" ]; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
HOOKS_ENABLED=$(cat "$JRNL_DIR/config.json" | grep -o '"git_hooks_enabled"[[:space:]]*:[[:space:]]*true')
|
||||||
|
if [ -z "$HOOKS_ENABLED" ]; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if current repo is excluded
|
||||||
|
EXCLUDED=$(cat "$JRNL_DIR/config.json" | grep -o "\"$REPO_PATH\"")
|
||||||
|
if [ -n "$EXCLUDED" ]; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Run jrnl new command in background
|
||||||
|
"$JRNL_CMD" new --git \
|
||||||
|
--repo-path "$REPO_PATH" \
|
||||||
|
--commit-hash "$COMMIT_HASH" \
|
||||||
|
>> "$JRNL_DIR/logs/hook.log" 2>&1 &
|
||||||
|
|
||||||
|
# Disown the background process
|
||||||
|
disown
|
||||||
|
|
||||||
|
exit 0
|
||||||
224
install.sh
Executable file
224
install.sh
Executable file
@@ -0,0 +1,224 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
#
|
||||||
|
# JRNL Installation Script
|
||||||
|
#
|
||||||
|
|
||||||
|
set -e # Exit on error
|
||||||
|
|
||||||
|
JRNL_DIR="$HOME/.jrnl"
|
||||||
|
VENV_DIR="$JRNL_DIR/venv"
|
||||||
|
LOCAL_BIN="$HOME/.local/bin"
|
||||||
|
REPO_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
|
||||||
|
echo "Installing JRNL..."
|
||||||
|
echo
|
||||||
|
|
||||||
|
# Check Python 3.8+
|
||||||
|
check_python() {
|
||||||
|
if ! command -v python3 &> /dev/null; then
|
||||||
|
echo "Error: Python 3 is required but not found"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
python_version=$(python3 --version | cut -d' ' -f2)
|
||||||
|
major=$(echo "$python_version" | cut -d'.' -f1)
|
||||||
|
minor=$(echo "$python_version" | cut -d'.' -f2)
|
||||||
|
|
||||||
|
if [ "$major" -lt 3 ] || ([ "$major" -eq 3 ] && [ "$minor" -lt 8 ]); then
|
||||||
|
echo "Error: Python 3.8+ required, found $python_version"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "✓ Python $python_version found"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Create directory structure
|
||||||
|
setup_directories() {
|
||||||
|
mkdir -p "$JRNL_DIR/logs"
|
||||||
|
mkdir -p "$LOCAL_BIN"
|
||||||
|
echo "✓ Created directories"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Create virtual environment
|
||||||
|
setup_venv() {
|
||||||
|
echo "Creating virtual environment..."
|
||||||
|
python3 -m venv "$VENV_DIR"
|
||||||
|
source "$VENV_DIR/bin/activate"
|
||||||
|
pip install --upgrade pip > /dev/null 2>&1
|
||||||
|
pip install -r "$REPO_DIR/requirements.txt" > /dev/null 2>&1
|
||||||
|
deactivate
|
||||||
|
echo "✓ Virtual environment created and dependencies installed"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Initialize database
|
||||||
|
init_database() {
|
||||||
|
echo "Initializing database..."
|
||||||
|
"$VENV_DIR/bin/python" -c "
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, '$REPO_DIR')
|
||||||
|
from jrnl.database.connection import init_database
|
||||||
|
init_database()
|
||||||
|
"
|
||||||
|
echo "✓ Database initialized"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Create default configuration
|
||||||
|
create_config() {
|
||||||
|
if [ ! -f "$JRNL_DIR/config.json" ]; then
|
||||||
|
echo "Creating default configuration..."
|
||||||
|
cat > "$JRNL_DIR/config.json" << 'EOF'
|
||||||
|
{
|
||||||
|
"active_llm_provider": "anthropic",
|
||||||
|
"llm_providers": {
|
||||||
|
"anthropic": {
|
||||||
|
"api_key": "",
|
||||||
|
"model": "claude-3-5-sonnet-20241022",
|
||||||
|
"max_tokens_commit": 200,
|
||||||
|
"max_tokens_daily": 500
|
||||||
|
},
|
||||||
|
"ollama": {
|
||||||
|
"url": "http://localhost:11434",
|
||||||
|
"model": "llama3.1:8b",
|
||||||
|
"max_tokens_commit": 200,
|
||||||
|
"max_tokens_daily": 500
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"git_hooks_enabled": true,
|
||||||
|
"excluded_repos": [],
|
||||||
|
"standup_time": "10:30",
|
||||||
|
"timezone": "local"
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
chmod 600 "$JRNL_DIR/config.json"
|
||||||
|
echo "✓ Configuration created at $JRNL_DIR/config.json"
|
||||||
|
else
|
||||||
|
echo "✓ Configuration already exists"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Install git hooks
|
||||||
|
install_git_hooks() {
|
||||||
|
echo
|
||||||
|
read -p "Install global git hooks to automatically track commits? (Y/n): " response
|
||||||
|
response=${response:-Y}
|
||||||
|
|
||||||
|
if [[ "$response" =~ ^([yY][eE][sS]|[yY])$ ]]; then
|
||||||
|
echo "Installing git hooks..."
|
||||||
|
|
||||||
|
# Get current global hooks path
|
||||||
|
HOOKS_PATH=$(git config --global core.hooksPath 2>/dev/null || echo "")
|
||||||
|
|
||||||
|
# If no hooks path set, use ~/.git-hooks
|
||||||
|
if [ -z "$HOOKS_PATH" ]; then
|
||||||
|
HOOKS_PATH="$HOME/.git-hooks"
|
||||||
|
mkdir -p "$HOOKS_PATH"
|
||||||
|
git config --global core.hooksPath "$HOOKS_PATH"
|
||||||
|
echo "✓ Set global hooks path to $HOOKS_PATH"
|
||||||
|
fi
|
||||||
|
|
||||||
|
POST_COMMIT="$HOOKS_PATH/post-commit"
|
||||||
|
|
||||||
|
# Check if post-commit exists
|
||||||
|
if [ -f "$POST_COMMIT" ]; then
|
||||||
|
# Check if jrnl is already in the hook
|
||||||
|
if grep -q "JRNL" "$POST_COMMIT"; then
|
||||||
|
echo "✓ JRNL already configured in post-commit hook"
|
||||||
|
else
|
||||||
|
echo "Appending JRNL to existing post-commit hook..."
|
||||||
|
cat >> "$POST_COMMIT" << 'HOOKEOF'
|
||||||
|
|
||||||
|
# JRNL - Automatic commit logging
|
||||||
|
JRNL_CMD="$HOME/.local/bin/jrnl"
|
||||||
|
if [ -f "$JRNL_CMD" ]; then
|
||||||
|
REPO_PATH=$(git rev-parse --show-toplevel 2>/dev/null)
|
||||||
|
COMMIT_HASH=$(git rev-parse HEAD 2>/dev/null)
|
||||||
|
if [ -n "$REPO_PATH" ] && [ -n "$COMMIT_HASH" ]; then
|
||||||
|
"$JRNL_CMD" new --git \
|
||||||
|
--repo-path "$REPO_PATH" \
|
||||||
|
--commit-hash "$COMMIT_HASH" \
|
||||||
|
>> "$HOME/.jrnl/logs/hook.log" 2>&1 &
|
||||||
|
disown
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
HOOKEOF
|
||||||
|
echo "✓ Added JRNL to existing post-commit hook"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
# Create new hook from template
|
||||||
|
cp "$REPO_DIR/hooks/post-commit.template" "$POST_COMMIT"
|
||||||
|
chmod +x "$POST_COMMIT"
|
||||||
|
echo "✓ Created post-commit hook"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "Skipping git hooks installation"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Create CLI wrapper
|
||||||
|
setup_cli() {
|
||||||
|
echo "Setting up CLI command..."
|
||||||
|
|
||||||
|
cat > "$LOCAL_BIN/jrnl" << CLIEOF
|
||||||
|
#!/bin/bash
|
||||||
|
exec "$VENV_DIR/bin/python" -m jrnl "\$@"
|
||||||
|
CLIEOF
|
||||||
|
|
||||||
|
chmod +x "$LOCAL_BIN/jrnl"
|
||||||
|
echo "✓ CLI command installed to $LOCAL_BIN/jrnl"
|
||||||
|
|
||||||
|
# Check if ~/.local/bin is in PATH
|
||||||
|
if [[ ":$PATH:" != *":$LOCAL_BIN:"* ]]; then
|
||||||
|
echo
|
||||||
|
echo "⚠ $LOCAL_BIN is not in your PATH"
|
||||||
|
echo
|
||||||
|
echo "Add it by running one of the following:"
|
||||||
|
echo
|
||||||
|
|
||||||
|
if [ -n "$ZSH_VERSION" ] || [ -f "$HOME/.zshrc" ]; then
|
||||||
|
echo " For zsh:"
|
||||||
|
echo " echo 'export PATH=\"\$HOME/.local/bin:\$PATH\"' >> ~/.zshrc"
|
||||||
|
echo " source ~/.zshrc"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$BASH_VERSION" ] || [ -f "$HOME/.bashrc" ]; then
|
||||||
|
echo " For bash:"
|
||||||
|
echo " echo 'export PATH=\"\$HOME/.local/bin:\$PATH\"' >> ~/.bashrc"
|
||||||
|
echo " source ~/.bashrc"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
else
|
||||||
|
echo "✓ $LOCAL_BIN is in your PATH"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Main installation flow
|
||||||
|
main() {
|
||||||
|
check_python
|
||||||
|
setup_directories
|
||||||
|
setup_venv
|
||||||
|
init_database
|
||||||
|
create_config
|
||||||
|
install_git_hooks
|
||||||
|
setup_cli
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "============================================================"
|
||||||
|
echo "JRNL installed successfully!"
|
||||||
|
echo "============================================================"
|
||||||
|
echo
|
||||||
|
echo "Next steps:"
|
||||||
|
echo "1. Add your API key:"
|
||||||
|
echo " jrnl config set anthropic api_key YOUR_KEY"
|
||||||
|
echo
|
||||||
|
echo "2. Test manual logging:"
|
||||||
|
echo " jrnl new -m 'Test message'"
|
||||||
|
echo
|
||||||
|
echo "3. Generate a standup:"
|
||||||
|
echo " jrnl daily"
|
||||||
|
echo
|
||||||
|
echo "Configuration file: $JRNL_DIR/config.json"
|
||||||
|
echo
|
||||||
|
}
|
||||||
|
|
||||||
|
main
|
||||||
0
jrnl/__init__.py
Normal file
0
jrnl/__init__.py
Normal file
7
jrnl/__main__.py
Normal file
7
jrnl/__main__.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
"""Entry point for python -m jrnl."""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from .cli import main
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
sys.exit(main())
|
||||||
93
jrnl/cli.py
Normal file
93
jrnl/cli.py
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
"""JRNL - Developer work journal for standups."""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import argparse
|
||||||
|
from .commands import new, daily, logs, config_cmd, uninstall_cmd
|
||||||
|
from .version import __version__
|
||||||
|
|
||||||
|
|
||||||
|
def create_parser():
|
||||||
|
"""Create the argument parser with all subcommands."""
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
prog='jrnl',
|
||||||
|
description='Developer work journal for standups'
|
||||||
|
)
|
||||||
|
parser.add_argument('--version', action='version', version=f'jrnl {__version__}')
|
||||||
|
|
||||||
|
subparsers = parser.add_subparsers(dest='command', help='Available commands')
|
||||||
|
|
||||||
|
# jrnl new
|
||||||
|
new_parser = subparsers.add_parser('new', help='Create a log entry')
|
||||||
|
new_parser.add_argument('-m', '--message', help='Log message')
|
||||||
|
new_parser.add_argument('-l', '--label', help='Optional label for this entry')
|
||||||
|
new_parser.add_argument('--git', action='store_true', help='Git hook mode')
|
||||||
|
new_parser.add_argument('--repo-path', help='Repository path (git mode)')
|
||||||
|
new_parser.add_argument('--commit-hash', help='Commit hash (git mode)')
|
||||||
|
|
||||||
|
# jrnl daily / standup
|
||||||
|
daily_parser = subparsers.add_parser('daily', aliases=['standup'],
|
||||||
|
help='Generate standup message')
|
||||||
|
daily_parser.add_argument('-d', '--days', type=int, default=1,
|
||||||
|
help='Number of days to include (default: 1)')
|
||||||
|
daily_parser.add_argument('-r', '--regenerate', action='store_true',
|
||||||
|
help='Regenerate last daily')
|
||||||
|
|
||||||
|
# jrnl logs
|
||||||
|
logs_parser = subparsers.add_parser('logs', help='View log entries')
|
||||||
|
logs_parser.add_argument('-d', '--days', type=int,
|
||||||
|
help='Filter logs by number of days')
|
||||||
|
logs_parser.add_argument('-n', '--limit', type=int, default=50,
|
||||||
|
help='Maximum number of logs to show (default: 50)')
|
||||||
|
|
||||||
|
# jrnl config
|
||||||
|
config_parser = subparsers.add_parser('config', help='Manage configuration')
|
||||||
|
config_parser.add_argument('action', nargs='?',
|
||||||
|
choices=['show', 'set-provider', 'set', 'exclude', 'include', 'exclude-current'],
|
||||||
|
help='Configuration action')
|
||||||
|
config_parser.add_argument('args', nargs='*', help='Action arguments')
|
||||||
|
|
||||||
|
# jrnl uninstall
|
||||||
|
uninstall_parser = subparsers.add_parser('uninstall', help='Uninstall jrnl')
|
||||||
|
uninstall_parser.add_argument('--no-backup', action='store_true',
|
||||||
|
help='Skip database backup')
|
||||||
|
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Main CLI entry point."""
|
||||||
|
parser = create_parser()
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if not args.command:
|
||||||
|
parser.print_help()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Route to appropriate command handler
|
||||||
|
if args.command == 'new':
|
||||||
|
return new.handle(args)
|
||||||
|
elif args.command in ['daily', 'standup']:
|
||||||
|
return daily.handle(args)
|
||||||
|
elif args.command == 'logs':
|
||||||
|
return logs.handle(args)
|
||||||
|
elif args.command == 'config':
|
||||||
|
return config_cmd.handle(args)
|
||||||
|
elif args.command == 'uninstall':
|
||||||
|
return uninstall_cmd.handle(args)
|
||||||
|
else:
|
||||||
|
parser.print_help()
|
||||||
|
return 1
|
||||||
|
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\nInterrupted")
|
||||||
|
return 130
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Unexpected error: {type(e).__name__}: {e}", file=sys.stderr)
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
sys.exit(main())
|
||||||
0
jrnl/commands/__init__.py
Normal file
0
jrnl/commands/__init__.py
Normal file
192
jrnl/commands/config_cmd.py
Normal file
192
jrnl/commands/config_cmd.py
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
"""jrnl config command - Manage configuration."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
from ..config import Config
|
||||||
|
from ..utils.formatting import format_success
|
||||||
|
|
||||||
|
|
||||||
|
def handle(args):
|
||||||
|
"""Handle the 'config' command."""
|
||||||
|
config = Config.load()
|
||||||
|
|
||||||
|
if not args.action or args.action == 'show':
|
||||||
|
return show_config(config)
|
||||||
|
elif args.action == 'set-provider':
|
||||||
|
return set_provider(config, args)
|
||||||
|
elif args.action == 'set':
|
||||||
|
return set_value(config, args)
|
||||||
|
elif args.action == 'exclude':
|
||||||
|
return exclude_repo(config, args)
|
||||||
|
elif args.action == 'include':
|
||||||
|
return include_repo(config, args)
|
||||||
|
elif args.action == 'exclude-current':
|
||||||
|
return exclude_current_repo(config)
|
||||||
|
else:
|
||||||
|
print(f"Unknown action: {args.action}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
def show_config(config):
|
||||||
|
"""Show current configuration."""
|
||||||
|
print("\nCurrent Configuration:")
|
||||||
|
print(f" Active LLM Provider: {config.get('active_llm_provider')}")
|
||||||
|
print(f" Git Hooks Enabled: {config.get('git_hooks_enabled')}")
|
||||||
|
print(f" Standup Time: {config.get('standup_time')}")
|
||||||
|
|
||||||
|
# Show LLM provider settings
|
||||||
|
print("\n LLM Providers:")
|
||||||
|
providers = config.get('llm_providers', {})
|
||||||
|
for provider_name, provider_config in providers.items():
|
||||||
|
print(f"\n {provider_name}:")
|
||||||
|
for key, value in provider_config.items():
|
||||||
|
if key == 'api_key' and value:
|
||||||
|
# Mask API key - show last 4 chars if long enough, otherwise full mask
|
||||||
|
if len(value) > 4:
|
||||||
|
print(f" {key}: {'*' * 8}{value[-4:]}")
|
||||||
|
else:
|
||||||
|
print(f" {key}: {'*' * len(value)}")
|
||||||
|
else:
|
||||||
|
print(f" {key}: {value}")
|
||||||
|
|
||||||
|
# Show excluded repos
|
||||||
|
excluded = config.get('excluded_repos', [])
|
||||||
|
print(f"\n Excluded Repositories: {len(excluded)}")
|
||||||
|
for repo in excluded:
|
||||||
|
print(f" - {repo}")
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def set_provider(config, args):
|
||||||
|
"""Switch active LLM provider."""
|
||||||
|
if len(args.args) != 1:
|
||||||
|
print("Usage: jrnl config set-provider <provider>")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
provider = args.args[0]
|
||||||
|
if provider not in config.get('llm_providers', {}):
|
||||||
|
print(f"Unknown provider: {provider}")
|
||||||
|
print(f"Available: {', '.join(config.get('llm_providers', {}).keys())}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
config['active_llm_provider'] = provider
|
||||||
|
Config.save(config)
|
||||||
|
print(format_success(f"Active LLM provider set to {provider}"))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def set_value(config, args):
|
||||||
|
"""Set provider-specific value."""
|
||||||
|
if len(args.args) != 3:
|
||||||
|
print("Usage: jrnl config set <provider> <key> <value>")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
provider, key, value = args.args
|
||||||
|
|
||||||
|
if provider not in config.get('llm_providers', {}):
|
||||||
|
print(f"Unknown provider: {provider}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# Get allowed keys from default config
|
||||||
|
from ..config import Config as ConfigClass
|
||||||
|
allowed_keys = ConfigClass.DEFAULT_CONFIG['llm_providers'].get(provider, {}).keys()
|
||||||
|
|
||||||
|
if key not in allowed_keys:
|
||||||
|
print(f"Unknown key '{key}' for provider '{provider}'")
|
||||||
|
print(f"Allowed keys: {', '.join(allowed_keys)}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# Convert numeric values
|
||||||
|
if value.isdigit():
|
||||||
|
value = int(value)
|
||||||
|
|
||||||
|
config['llm_providers'][provider][key] = value
|
||||||
|
Config.save(config)
|
||||||
|
|
||||||
|
# Mask API key in output
|
||||||
|
display_value = value
|
||||||
|
if key == 'api_key' and value:
|
||||||
|
if len(str(value)) > 4:
|
||||||
|
display_value = f"{'*' * 8}{str(value)[-4:]}"
|
||||||
|
else:
|
||||||
|
display_value = '*' * len(str(value))
|
||||||
|
|
||||||
|
print(format_success(f"Set {provider}.{key} = {display_value}"))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def exclude_repo(config, args):
|
||||||
|
"""Add repository to exclude list."""
|
||||||
|
if len(args.args) != 1:
|
||||||
|
print("Usage: jrnl config exclude <repo-path>")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
repo_path = os.path.abspath(args.args[0])
|
||||||
|
excluded = config.get('excluded_repos', [])
|
||||||
|
|
||||||
|
if repo_path in excluded:
|
||||||
|
print(f"Repository already excluded: {repo_path}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
excluded.append(repo_path)
|
||||||
|
config['excluded_repos'] = excluded
|
||||||
|
Config.save(config)
|
||||||
|
print(format_success(f"Excluded repository: {repo_path}"))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def include_repo(config, args):
|
||||||
|
"""Remove repository from exclude list."""
|
||||||
|
if len(args.args) != 1:
|
||||||
|
print("Usage: jrnl config include <repo-path>")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
repo_path = os.path.abspath(args.args[0])
|
||||||
|
excluded = config.get('excluded_repos', [])
|
||||||
|
|
||||||
|
if repo_path not in excluded:
|
||||||
|
print(f"Repository not in exclude list: {repo_path}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
excluded.remove(repo_path)
|
||||||
|
config['excluded_repos'] = excluded
|
||||||
|
Config.save(config)
|
||||||
|
print(format_success(f"Re-enabled repository: {repo_path}"))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def exclude_current_repo(config):
|
||||||
|
"""Exclude current repository."""
|
||||||
|
try:
|
||||||
|
# Get current git repo path
|
||||||
|
result = subprocess.run(
|
||||||
|
['git', 'rev-parse', '--show-toplevel'],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=True
|
||||||
|
)
|
||||||
|
repo_path = result.stdout.strip()
|
||||||
|
|
||||||
|
excluded = config.get('excluded_repos', [])
|
||||||
|
if repo_path in excluded:
|
||||||
|
print(f"Current repository already excluded: {repo_path}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
excluded.append(repo_path)
|
||||||
|
config['excluded_repos'] = excluded
|
||||||
|
Config.save(config)
|
||||||
|
print(format_success(f"Excluded current repository: {repo_path}"))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
except subprocess.CalledProcessError:
|
||||||
|
print("Error: Not in a git repository")
|
||||||
|
return 1
|
||||||
|
except RuntimeError as e: # From Config class
|
||||||
|
print(f"Error: {e}")
|
||||||
|
return 1
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Unexpected error: {type(e).__name__}: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
return 1
|
||||||
102
jrnl/commands/daily.py
Normal file
102
jrnl/commands/daily.py
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
"""jrnl daily command - Generate standup summaries."""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
from ..database.operations import (
|
||||||
|
get_logs_since,
|
||||||
|
get_latest_daily,
|
||||||
|
get_previous_daily_before,
|
||||||
|
insert_daily
|
||||||
|
)
|
||||||
|
from ..database.models import Daily
|
||||||
|
from ..config import Config
|
||||||
|
from ..llm_providers import get_provider
|
||||||
|
from ..utils.date_utils import get_utc_now, get_current_date, get_datetime_ago
|
||||||
|
from ..utils.formatting import format_daily_header
|
||||||
|
|
||||||
|
|
||||||
|
def handle(args):
|
||||||
|
"""Handle the 'daily' command."""
|
||||||
|
try:
|
||||||
|
config = Config.load()
|
||||||
|
|
||||||
|
# Determine date range for logs
|
||||||
|
if args.regenerate:
|
||||||
|
cutoff = get_regenerate_cutoff()
|
||||||
|
else:
|
||||||
|
cutoff = get_normal_cutoff()
|
||||||
|
|
||||||
|
# Get logs
|
||||||
|
logs = get_logs_since(cutoff)
|
||||||
|
|
||||||
|
if not logs:
|
||||||
|
if args.regenerate:
|
||||||
|
print("No logs found. Cannot regenerate - no previous daily exists.")
|
||||||
|
else:
|
||||||
|
print("No logs found since last daily. Try: jrnl logs")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
print(f"Generating standup from {len(logs)} log entries...")
|
||||||
|
|
||||||
|
# Get LLM provider
|
||||||
|
provider = get_provider(config)
|
||||||
|
|
||||||
|
# Generate daily message
|
||||||
|
print("Generating standup (this may take 10-30 seconds)...")
|
||||||
|
daily_message = provider.generate_daily(
|
||||||
|
logs=[log.to_dict() for log in logs],
|
||||||
|
days=args.days
|
||||||
|
)
|
||||||
|
|
||||||
|
# Save daily
|
||||||
|
today = get_current_date()
|
||||||
|
daily = Daily(
|
||||||
|
timestamp=get_utc_now(),
|
||||||
|
daily_date=today,
|
||||||
|
daily_message=daily_message
|
||||||
|
)
|
||||||
|
|
||||||
|
insert_daily(daily)
|
||||||
|
|
||||||
|
# Display result
|
||||||
|
print(format_daily_header(today))
|
||||||
|
print(daily_message)
|
||||||
|
print("\n" + "="*60 + "\n")
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
except sqlite3.Error as e:
|
||||||
|
print(f"Database error: {e}")
|
||||||
|
return 1
|
||||||
|
except RuntimeError as e: # From LLM providers
|
||||||
|
print(f"Error: {e}")
|
||||||
|
return 1
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Unexpected error generating daily: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
def get_normal_cutoff():
|
||||||
|
"""Get cutoff timestamp for normal daily generation."""
|
||||||
|
latest_daily = get_latest_daily()
|
||||||
|
if latest_daily:
|
||||||
|
return latest_daily.timestamp
|
||||||
|
else:
|
||||||
|
# No previous daily - get logs from 24 hours ago
|
||||||
|
return get_datetime_ago(hours=24)
|
||||||
|
|
||||||
|
|
||||||
|
def get_regenerate_cutoff():
|
||||||
|
"""Get cutoff timestamp for regenerating today's daily."""
|
||||||
|
today = get_current_date()
|
||||||
|
latest_daily = get_latest_daily()
|
||||||
|
|
||||||
|
if latest_daily and latest_daily.daily_date == today:
|
||||||
|
# Today's daily exists - get previous daily's timestamp
|
||||||
|
prev_daily = get_previous_daily_before(today)
|
||||||
|
if prev_daily:
|
||||||
|
return prev_daily.timestamp
|
||||||
|
|
||||||
|
# Fallback: 24 hours ago
|
||||||
|
return get_datetime_ago(hours=24)
|
||||||
37
jrnl/commands/logs.py
Normal file
37
jrnl/commands/logs.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
"""jrnl logs command - View log entries."""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
from ..database.operations import get_logs_since, get_all_logs
|
||||||
|
from ..utils.date_utils import get_datetime_ago
|
||||||
|
from ..utils.formatting import format_log_entry
|
||||||
|
|
||||||
|
|
||||||
|
def handle(args):
|
||||||
|
"""Handle the 'logs' command."""
|
||||||
|
try:
|
||||||
|
# Get logs based on filters
|
||||||
|
if args.days:
|
||||||
|
cutoff = get_datetime_ago(days=args.days)
|
||||||
|
logs = get_logs_since(cutoff)
|
||||||
|
else:
|
||||||
|
logs = get_all_logs(limit=args.limit)
|
||||||
|
|
||||||
|
if not logs:
|
||||||
|
print("No logs found")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Display logs
|
||||||
|
print(f"\nShowing {len(logs)} log entries:\n")
|
||||||
|
for log in logs:
|
||||||
|
print(format_log_entry(log))
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
except sqlite3.Error as e:
|
||||||
|
print(f"Database error: {e}")
|
||||||
|
return 1
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Unexpected error: {type(e).__name__}: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
return 1
|
||||||
157
jrnl/commands/new.py
Normal file
157
jrnl/commands/new.py
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
"""jrnl new command - Create log entries."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
import subprocess
|
||||||
|
import sqlite3
|
||||||
|
from pathlib import Path
|
||||||
|
from ..database.operations import insert_log
|
||||||
|
from ..database.models import Log
|
||||||
|
from ..utils.date_utils import get_utc_now
|
||||||
|
from ..utils.formatting import format_success, format_error
|
||||||
|
from ..config import Config
|
||||||
|
from ..llm_providers import get_provider
|
||||||
|
|
||||||
|
|
||||||
|
def handle(args):
|
||||||
|
"""Handle the 'new' command."""
|
||||||
|
if args.git:
|
||||||
|
# Git hook mode
|
||||||
|
return handle_git_mode(args)
|
||||||
|
else:
|
||||||
|
# Manual mode
|
||||||
|
return handle_manual_mode(args)
|
||||||
|
|
||||||
|
|
||||||
|
def handle_manual_mode(args):
|
||||||
|
"""Handle manual log entry."""
|
||||||
|
# Validate required arguments
|
||||||
|
if not args.message:
|
||||||
|
print(format_error("Message is required. Use: jrnl new -m 'your message'"))
|
||||||
|
return 1
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Create log entry
|
||||||
|
log = Log(
|
||||||
|
timestamp=get_utc_now(),
|
||||||
|
log_message=args.message,
|
||||||
|
type='manual',
|
||||||
|
label=args.label or str(uuid.uuid4())[:8]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Save to database
|
||||||
|
insert_log(log)
|
||||||
|
|
||||||
|
print(format_success(f"Log entry created: {args.message}"))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
except sqlite3.IntegrityError as e:
|
||||||
|
print(format_error(f"Database constraint error: {e}"))
|
||||||
|
return 1
|
||||||
|
except sqlite3.Error as e:
|
||||||
|
print(format_error(f"Database error: {e}"))
|
||||||
|
return 1
|
||||||
|
except Exception as e:
|
||||||
|
print(format_error(f"Unexpected error: {e}"))
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
def handle_git_mode(args):
|
||||||
|
"""Handle git hook mode."""
|
||||||
|
# Validate required arguments
|
||||||
|
if not args.repo_path or not args.commit_hash:
|
||||||
|
log_error("Git mode requires --repo-path and --commit-hash")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Extract commit info
|
||||||
|
commit_info = extract_commit_info(args.repo_path, args.commit_hash)
|
||||||
|
if not commit_info:
|
||||||
|
return 1 # Silently fail
|
||||||
|
|
||||||
|
# Load config and get LLM provider
|
||||||
|
config = Config.load()
|
||||||
|
provider = get_provider(config)
|
||||||
|
|
||||||
|
# Compress commit info
|
||||||
|
log_message = provider.compress_commit(
|
||||||
|
commit_message=commit_info['message'],
|
||||||
|
commit_diff=commit_info['diff']
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create log entry
|
||||||
|
log = Log(
|
||||||
|
timestamp=get_utc_now(),
|
||||||
|
log_message=log_message,
|
||||||
|
type='git-hook',
|
||||||
|
label=commit_info['hash'][:8]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Save to database
|
||||||
|
insert_log(log)
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
# Log error but don't fail (never block commits)
|
||||||
|
log_error(f"Error processing commit: {type(e).__name__}: {e}")
|
||||||
|
return 0 # Return success to avoid blocking commit
|
||||||
|
|
||||||
|
|
||||||
|
def extract_commit_info(repo_path: str, commit_hash: str) -> dict:
|
||||||
|
"""Extract commit message and diff."""
|
||||||
|
try:
|
||||||
|
# Get commit message
|
||||||
|
result = subprocess.run(
|
||||||
|
['git', '-C', repo_path, 'log', '-1', '--pretty=%B', commit_hash],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=5
|
||||||
|
)
|
||||||
|
commit_message = result.stdout.strip()
|
||||||
|
|
||||||
|
# Get commit diff with context
|
||||||
|
result = subprocess.run(
|
||||||
|
['git', '-C', repo_path, 'show', '--unified=3', '--no-color', commit_hash],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=10
|
||||||
|
)
|
||||||
|
commit_diff = result.stdout
|
||||||
|
|
||||||
|
# Truncate large diffs to avoid exceeding LLM context limits
|
||||||
|
MAX_DIFF_SIZE = 50000 # characters
|
||||||
|
if len(commit_diff) > MAX_DIFF_SIZE:
|
||||||
|
commit_diff = commit_diff[:MAX_DIFF_SIZE] + "\n... (diff truncated for size)"
|
||||||
|
|
||||||
|
return {
|
||||||
|
'hash': commit_hash,
|
||||||
|
'message': commit_message,
|
||||||
|
'diff': commit_diff,
|
||||||
|
'repo': repo_path
|
||||||
|
}
|
||||||
|
except FileNotFoundError:
|
||||||
|
log_error("git command not found in PATH")
|
||||||
|
return None
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
log_error("Git command timed out")
|
||||||
|
return None
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
log_error(f"Git command failed: {e}")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
log_error(f"Unexpected error extracting commit: {type(e).__name__}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def log_error(message: str):
|
||||||
|
"""Log error to error log file."""
|
||||||
|
try:
|
||||||
|
error_log = Path.home() / '.jrnl' / 'logs' / 'errors.log'
|
||||||
|
error_log.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with open(error_log, 'a') as f:
|
||||||
|
from datetime import datetime
|
||||||
|
f.write(f"{datetime.now().isoformat()} - {message}\n")
|
||||||
|
except Exception:
|
||||||
|
pass # Silently fail logging
|
||||||
42
jrnl/commands/uninstall_cmd.py
Normal file
42
jrnl/commands/uninstall_cmd.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
"""jrnl uninstall command - Uninstall the application."""
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def handle(args):
|
||||||
|
"""Handle the 'uninstall' command."""
|
||||||
|
print("This will run the uninstall script.")
|
||||||
|
print("Note: You can also run ./uninstall.sh directly")
|
||||||
|
|
||||||
|
# Confirm
|
||||||
|
response = input("\nContinue with uninstall? (y/N): ")
|
||||||
|
if response.lower() not in ['y', 'yes']:
|
||||||
|
print("Uninstall cancelled")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Find and run uninstall.sh
|
||||||
|
# Assume it's in the git repo root
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
['git', 'rev-parse', '--show-toplevel'],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=True
|
||||||
|
)
|
||||||
|
repo_root = Path(result.stdout.strip())
|
||||||
|
uninstall_script = repo_root / 'uninstall.sh'
|
||||||
|
|
||||||
|
if uninstall_script.exists():
|
||||||
|
subprocess.run(['bash', str(uninstall_script)], check=True)
|
||||||
|
return 0
|
||||||
|
else:
|
||||||
|
print(f"Error: uninstall.sh not found at {uninstall_script}")
|
||||||
|
print("Please run the uninstall script manually")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
except subprocess.CalledProcessError:
|
||||||
|
print("Error: Could not locate uninstall script")
|
||||||
|
print("Please run: ./uninstall.sh from the jrnl repository")
|
||||||
|
return 1
|
||||||
103
jrnl/config.py
Normal file
103
jrnl/config.py
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
"""Configuration management for JRNL."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, Any
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
"""Configuration manager."""
|
||||||
|
|
||||||
|
CONFIG_PATH = Path.home() / '.jrnl' / 'config.json'
|
||||||
|
|
||||||
|
DEFAULT_CONFIG = {
|
||||||
|
'active_llm_provider': 'anthropic',
|
||||||
|
'llm_providers': {
|
||||||
|
'anthropic': {
|
||||||
|
'api_key': '',
|
||||||
|
'model': 'claude-sonnet-4-5-20250929',
|
||||||
|
'max_tokens_commit': 200,
|
||||||
|
'max_tokens_daily': 500
|
||||||
|
},
|
||||||
|
'ollama': {
|
||||||
|
'url': 'http://localhost:11434',
|
||||||
|
'model': 'llama3.1:8b',
|
||||||
|
'max_tokens_commit': 200,
|
||||||
|
'max_tokens_daily': 500
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'git_hooks_enabled': True,
|
||||||
|
'excluded_repos': [],
|
||||||
|
'standup_time': '10:30',
|
||||||
|
'timezone': 'local'
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def load(cls) -> Dict[str, Any]:
|
||||||
|
"""Load configuration from file."""
|
||||||
|
if not cls.CONFIG_PATH.exists():
|
||||||
|
return cls.DEFAULT_CONFIG.copy()
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(cls.CONFIG_PATH, 'r') as f:
|
||||||
|
config = json.load(f)
|
||||||
|
|
||||||
|
# Merge with defaults (for new fields)
|
||||||
|
merged = cls._deep_merge(cls.DEFAULT_CONFIG.copy(), config)
|
||||||
|
return merged
|
||||||
|
|
||||||
|
except FileNotFoundError:
|
||||||
|
# Config doesn't exist - return defaults (this is normal for first run)
|
||||||
|
return cls.DEFAULT_CONFIG.copy()
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
print(f"Config file is corrupted at line {e.lineno}: {e.msg}")
|
||||||
|
response = input("Recreate config with defaults? (y/N): ")
|
||||||
|
if response.lower() == 'y':
|
||||||
|
cls.save(cls.DEFAULT_CONFIG.copy())
|
||||||
|
return cls.DEFAULT_CONFIG.copy()
|
||||||
|
raise RuntimeError(f"Invalid config file at {cls.CONFIG_PATH}")
|
||||||
|
except PermissionError as e:
|
||||||
|
raise RuntimeError(f"Cannot read config file (permission denied): {cls.CONFIG_PATH}")
|
||||||
|
except Exception as e:
|
||||||
|
raise RuntimeError(f"Failed to load config: {type(e).__name__}: {e}")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def save(cls, config: Dict[str, Any]):
|
||||||
|
"""Save configuration to file."""
|
||||||
|
try:
|
||||||
|
cls.CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with open(cls.CONFIG_PATH, 'w') as f:
|
||||||
|
json.dump(config, f, indent=2)
|
||||||
|
|
||||||
|
# Set permissions to 600 for security (API keys)
|
||||||
|
cls.CONFIG_PATH.chmod(0o600)
|
||||||
|
|
||||||
|
except PermissionError:
|
||||||
|
raise RuntimeError(f"Cannot write config file (permission denied): {cls.CONFIG_PATH}")
|
||||||
|
except OSError as e:
|
||||||
|
raise RuntimeError(f"Failed to write config file: {e}")
|
||||||
|
except Exception as e:
|
||||||
|
raise RuntimeError(f"Failed to save config: {type(e).__name__}: {e}")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get(cls, key: str, default=None):
|
||||||
|
"""Get a configuration value."""
|
||||||
|
config = cls.load()
|
||||||
|
return config.get(key, default)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def set(cls, key: str, value):
|
||||||
|
"""Set a configuration value."""
|
||||||
|
config = cls.load()
|
||||||
|
config[key] = value
|
||||||
|
cls.save(config)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _deep_merge(cls, base: Dict, updates: Dict) -> Dict:
|
||||||
|
"""Deep merge two dictionaries."""
|
||||||
|
result = base.copy()
|
||||||
|
for key, value in updates.items():
|
||||||
|
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
|
||||||
|
result[key] = cls._deep_merge(result[key], value)
|
||||||
|
else:
|
||||||
|
result[key] = value
|
||||||
|
return result
|
||||||
0
jrnl/database/__init__.py
Normal file
0
jrnl/database/__init__.py
Normal file
50
jrnl/database/connection.py
Normal file
50
jrnl/database/connection.py
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
"""Database connection and initialization."""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
from pathlib import Path
|
||||||
|
from contextlib import contextmanager
|
||||||
|
|
||||||
|
DB_PATH = Path.home() / '.jrnl' / 'jrnl.db'
|
||||||
|
|
||||||
|
|
||||||
|
def init_database():
|
||||||
|
"""Initialize the database with schema."""
|
||||||
|
from .sql_statements import CREATE_LOGS_TABLE, CREATE_DAILIES_TABLE
|
||||||
|
|
||||||
|
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Create tables
|
||||||
|
cursor.executescript(CREATE_LOGS_TABLE)
|
||||||
|
cursor.executescript(CREATE_DAILIES_TABLE)
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def get_connection():
|
||||||
|
"""Get database connection context manager."""
|
||||||
|
# Auto-initialize database on first access
|
||||||
|
if not DB_PATH.exists():
|
||||||
|
init_database()
|
||||||
|
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
try:
|
||||||
|
yield conn
|
||||||
|
conn.commit()
|
||||||
|
except sqlite3.Error as e:
|
||||||
|
conn.rollback()
|
||||||
|
import sys
|
||||||
|
print(f"Database error: {e}", file=sys.stderr)
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
conn.rollback()
|
||||||
|
import sys
|
||||||
|
print(f"Unexpected database operation error: {type(e).__name__}: {e}", file=sys.stderr)
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
42
jrnl/database/models.py
Normal file
42
jrnl/database/models.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
"""Data models for JRNL."""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Log:
|
||||||
|
"""Log entry model."""
|
||||||
|
timestamp: str
|
||||||
|
log_message: str
|
||||||
|
type: str # 'manual' or 'git-hook'
|
||||||
|
label: str
|
||||||
|
id: Optional[int] = None
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
"""Convert to dictionary."""
|
||||||
|
return {
|
||||||
|
'id': self.id,
|
||||||
|
'timestamp': self.timestamp,
|
||||||
|
'log_message': self.log_message,
|
||||||
|
'type': self.type,
|
||||||
|
'label': self.label
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Daily:
|
||||||
|
"""Daily standup model."""
|
||||||
|
timestamp: str
|
||||||
|
daily_date: str
|
||||||
|
daily_message: str
|
||||||
|
id: Optional[int] = None
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
"""Convert to dictionary."""
|
||||||
|
return {
|
||||||
|
'id': self.id,
|
||||||
|
'timestamp': self.timestamp,
|
||||||
|
'daily_date': self.daily_date,
|
||||||
|
'daily_message': self.daily_message
|
||||||
|
}
|
||||||
131
jrnl/database/operations.py
Normal file
131
jrnl/database/operations.py
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
"""Database CRUD operations."""
|
||||||
|
|
||||||
|
from typing import List, Optional
|
||||||
|
from .connection import get_connection
|
||||||
|
from .models import Log, Daily
|
||||||
|
|
||||||
|
|
||||||
|
def insert_log(log: Log) -> int:
|
||||||
|
"""Insert a new log entry."""
|
||||||
|
with get_connection() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(
|
||||||
|
'''INSERT INTO logs (timestamp, log_message, type, label)
|
||||||
|
VALUES (?, ?, ?, ?)''',
|
||||||
|
(log.timestamp, log.log_message, log.type, log.label)
|
||||||
|
)
|
||||||
|
return cursor.lastrowid
|
||||||
|
|
||||||
|
|
||||||
|
def get_logs_since(timestamp: str) -> List[Log]:
|
||||||
|
"""Get all logs since a given timestamp."""
|
||||||
|
with get_connection() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(
|
||||||
|
'''SELECT * FROM logs
|
||||||
|
WHERE timestamp >= ?
|
||||||
|
ORDER BY timestamp ASC''',
|
||||||
|
(timestamp,)
|
||||||
|
)
|
||||||
|
rows = cursor.fetchall()
|
||||||
|
return [Log(
|
||||||
|
id=row['id'],
|
||||||
|
timestamp=row['timestamp'],
|
||||||
|
log_message=row['log_message'],
|
||||||
|
type=row['type'],
|
||||||
|
label=row['label']
|
||||||
|
) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def get_all_logs(limit: int = 50) -> List[Log]:
|
||||||
|
"""Get recent logs."""
|
||||||
|
with get_connection() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(
|
||||||
|
'''SELECT * FROM logs
|
||||||
|
ORDER BY timestamp DESC
|
||||||
|
LIMIT ?''',
|
||||||
|
(limit,)
|
||||||
|
)
|
||||||
|
rows = cursor.fetchall()
|
||||||
|
return [Log(
|
||||||
|
id=row['id'],
|
||||||
|
timestamp=row['timestamp'],
|
||||||
|
log_message=row['log_message'],
|
||||||
|
type=row['type'],
|
||||||
|
label=row['label']
|
||||||
|
) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def insert_daily(daily: Daily) -> int:
|
||||||
|
"""Insert or replace a daily entry."""
|
||||||
|
with get_connection() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(
|
||||||
|
'''INSERT OR REPLACE INTO dailies (timestamp, daily_date, daily_message)
|
||||||
|
VALUES (?, ?, ?)''',
|
||||||
|
(daily.timestamp, daily.daily_date, daily.daily_message)
|
||||||
|
)
|
||||||
|
return cursor.lastrowid
|
||||||
|
|
||||||
|
|
||||||
|
def get_latest_daily() -> Optional[Daily]:
|
||||||
|
"""Get the most recent daily entry."""
|
||||||
|
with get_connection() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(
|
||||||
|
'''SELECT * FROM dailies
|
||||||
|
ORDER BY timestamp DESC
|
||||||
|
LIMIT 1'''
|
||||||
|
)
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if row:
|
||||||
|
return Daily(
|
||||||
|
id=row['id'],
|
||||||
|
timestamp=row['timestamp'],
|
||||||
|
daily_date=row['daily_date'],
|
||||||
|
daily_message=row['daily_message']
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_daily_for_date(date: str) -> Optional[Daily]:
|
||||||
|
"""Get daily for a specific date."""
|
||||||
|
with get_connection() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(
|
||||||
|
'''SELECT * FROM dailies
|
||||||
|
WHERE daily_date = ?''',
|
||||||
|
(date,)
|
||||||
|
)
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if row:
|
||||||
|
return Daily(
|
||||||
|
id=row['id'],
|
||||||
|
timestamp=row['timestamp'],
|
||||||
|
daily_date=row['daily_date'],
|
||||||
|
daily_message=row['daily_message']
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_previous_daily_before(date: str) -> Optional[Daily]:
|
||||||
|
"""Get the daily entry before a specific date."""
|
||||||
|
with get_connection() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(
|
||||||
|
'''SELECT * FROM dailies
|
||||||
|
WHERE daily_date < ?
|
||||||
|
ORDER BY daily_date DESC
|
||||||
|
LIMIT 1''',
|
||||||
|
(date,)
|
||||||
|
)
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if row:
|
||||||
|
return Daily(
|
||||||
|
id=row['id'],
|
||||||
|
timestamp=row['timestamp'],
|
||||||
|
daily_date=row['daily_date'],
|
||||||
|
daily_message=row['daily_message']
|
||||||
|
)
|
||||||
|
return None
|
||||||
30
jrnl/database/sql_statements/__init__.py
Normal file
30
jrnl/database/sql_statements/__init__.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
"""SQL statement definitions for JRNL database."""
|
||||||
|
|
||||||
|
CREATE_LOGS_TABLE = """
|
||||||
|
CREATE TABLE IF NOT EXISTS logs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
timestamp TEXT NOT NULL,
|
||||||
|
log_message TEXT NOT NULL,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
label TEXT NOT NULL,
|
||||||
|
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CHECK (type IN ('manual', 'git-hook'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_logs_timestamp ON logs(timestamp);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_logs_type ON logs(type);
|
||||||
|
"""
|
||||||
|
|
||||||
|
CREATE_DAILIES_TABLE = """
|
||||||
|
CREATE TABLE IF NOT EXISTS dailies (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
timestamp TEXT NOT NULL,
|
||||||
|
daily_date TEXT NOT NULL,
|
||||||
|
daily_message TEXT NOT NULL,
|
||||||
|
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(daily_date)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_dailies_date ON dailies(daily_date);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_dailies_timestamp ON dailies(timestamp);
|
||||||
|
"""
|
||||||
0
jrnl/git_integration/__init__.py
Normal file
0
jrnl/git_integration/__init__.py
Normal file
65
jrnl/git_integration/commit_processor.py
Normal file
65
jrnl/git_integration/commit_processor.py
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
"""Git commit processing utilities."""
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
from typing import Optional, Dict
|
||||||
|
|
||||||
|
|
||||||
|
def extract_commit_info(repo_path: str, commit_hash: str) -> Optional[Dict]:
|
||||||
|
"""
|
||||||
|
Extract commit message and diff from a git repository.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
repo_path: Path to git repository
|
||||||
|
commit_hash: Commit hash to extract
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with commit info or None if extraction fails
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Get commit message
|
||||||
|
result = subprocess.run(
|
||||||
|
['git', '-C', repo_path, 'log', '-1', '--pretty=%B', commit_hash],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=5
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
commit_message = result.stdout.strip()
|
||||||
|
|
||||||
|
# Get commit diff with context
|
||||||
|
result = subprocess.run(
|
||||||
|
['git', '-C', repo_path, 'show', '--unified=3', '--no-color', commit_hash],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=10
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
commit_diff = result.stdout
|
||||||
|
|
||||||
|
return {
|
||||||
|
'hash': commit_hash,
|
||||||
|
'message': commit_message,
|
||||||
|
'diff': commit_diff,
|
||||||
|
'repo': repo_path
|
||||||
|
}
|
||||||
|
|
||||||
|
except FileNotFoundError:
|
||||||
|
# git not in PATH
|
||||||
|
return None
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return None
|
||||||
|
except subprocess.CalledProcessError:
|
||||||
|
return None
|
||||||
|
except Exception:
|
||||||
|
# Catch-all for other subprocess errors
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_repo_name(repo_path: str) -> str:
|
||||||
|
"""Get repository name from path."""
|
||||||
|
from pathlib import Path
|
||||||
|
return Path(repo_path).name
|
||||||
24
jrnl/llm_providers/__init__.py
Normal file
24
jrnl/llm_providers/__init__.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
"""LLM provider factory and exports."""
|
||||||
|
|
||||||
|
from .base import LLMProvider
|
||||||
|
from .anthropic_provider import AnthropicProvider
|
||||||
|
from .ollama_provider import OllamaProvider
|
||||||
|
|
||||||
|
PROVIDERS = {
|
||||||
|
'anthropic': AnthropicProvider,
|
||||||
|
'ollama': OllamaProvider,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_provider(config: dict) -> LLMProvider:
|
||||||
|
"""Get the configured LLM provider."""
|
||||||
|
provider_name = config.get('active_llm_provider', 'anthropic')
|
||||||
|
|
||||||
|
if provider_name not in PROVIDERS:
|
||||||
|
raise ValueError(f"Unknown LLM provider: {provider_name}")
|
||||||
|
|
||||||
|
provider_config = config.get('llm_providers', {}).get(provider_name, {})
|
||||||
|
return PROVIDERS[provider_name](provider_config)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ['LLMProvider', 'AnthropicProvider', 'OllamaProvider', 'get_provider']
|
||||||
91
jrnl/llm_providers/anthropic_provider.py
Normal file
91
jrnl/llm_providers/anthropic_provider.py
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
"""Anthropic/Claude LLM provider."""
|
||||||
|
|
||||||
|
import anthropic
|
||||||
|
from typing import Dict, List
|
||||||
|
from .base import LLMProvider
|
||||||
|
from .prompts import COMPRESS_COMMIT_PROMPT, GENERATE_DAILY_PROMPT
|
||||||
|
|
||||||
|
|
||||||
|
class AnthropicProvider(LLMProvider):
|
||||||
|
"""Anthropic/Claude LLM provider."""
|
||||||
|
|
||||||
|
def __init__(self, config: Dict):
|
||||||
|
super().__init__(config)
|
||||||
|
api_key = config.get('api_key', '')
|
||||||
|
if not api_key:
|
||||||
|
raise ValueError("Anthropic API key not configured. Run: jrnl config set anthropic api_key YOUR_KEY")
|
||||||
|
|
||||||
|
self.client = anthropic.Anthropic(api_key=api_key)
|
||||||
|
self.model = config.get('model', 'claude-sonnet-4-5-20250929')
|
||||||
|
self.max_tokens_commit = config.get('max_tokens_commit', 200)
|
||||||
|
self.max_tokens_daily = config.get('max_tokens_daily', 500)
|
||||||
|
|
||||||
|
def compress_commit(self, commit_message: str, commit_diff: str) -> str:
|
||||||
|
"""Compress commit using Claude."""
|
||||||
|
prompt = COMPRESS_COMMIT_PROMPT.format(
|
||||||
|
commit_message=commit_message,
|
||||||
|
commit_diff=commit_diff
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
message = self.client.messages.create(
|
||||||
|
model=self.model,
|
||||||
|
max_tokens=self.max_tokens_commit,
|
||||||
|
temperature=0.3,
|
||||||
|
messages=[
|
||||||
|
{"role": "user", "content": prompt}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return message.content[0].text.strip()
|
||||||
|
except anthropic.AuthenticationError:
|
||||||
|
return f"[Auth Error] {commit_message}"
|
||||||
|
except anthropic.RateLimitError:
|
||||||
|
return f"[Rate Limit] {commit_message}"
|
||||||
|
except anthropic.APIError as e:
|
||||||
|
return f"[API Error] {commit_message}"
|
||||||
|
except Exception as e:
|
||||||
|
return f"[LLM Error] {commit_message}"
|
||||||
|
|
||||||
|
def generate_daily(self, logs: List[Dict], days: int = 1) -> str:
|
||||||
|
"""Generate daily standup using Claude."""
|
||||||
|
# Format logs for prompt
|
||||||
|
log_text = "\n".join([
|
||||||
|
f"- [{log['type']}] {log['log_message']}"
|
||||||
|
for log in logs
|
||||||
|
])
|
||||||
|
|
||||||
|
prompt = GENERATE_DAILY_PROMPT.format(
|
||||||
|
days=days,
|
||||||
|
logs=log_text
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
message = self.client.messages.create(
|
||||||
|
model=self.model,
|
||||||
|
max_tokens=self.max_tokens_daily,
|
||||||
|
temperature=0.5,
|
||||||
|
messages=[
|
||||||
|
{"role": "user", "content": prompt}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return message.content[0].text.strip()
|
||||||
|
except anthropic.AuthenticationError:
|
||||||
|
raise RuntimeError("Invalid Anthropic API key. Run: jrnl config set anthropic api_key YOUR_KEY")
|
||||||
|
except anthropic.RateLimitError:
|
||||||
|
raise RuntimeError("Anthropic API rate limit exceeded. Try again later or use ollama.")
|
||||||
|
except anthropic.APIError as e:
|
||||||
|
raise RuntimeError(f"Anthropic API error: {e}")
|
||||||
|
except Exception as e:
|
||||||
|
raise RuntimeError(f"Failed to generate daily: {type(e).__name__}: {e}")
|
||||||
|
|
||||||
|
def test_connection(self) -> bool:
|
||||||
|
"""Test Anthropic API connection."""
|
||||||
|
try:
|
||||||
|
self.client.messages.create(
|
||||||
|
model=self.model,
|
||||||
|
max_tokens=10,
|
||||||
|
messages=[{"role": "user", "content": "test"}]
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
45
jrnl/llm_providers/base.py
Normal file
45
jrnl/llm_providers/base.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
"""Abstract base class for LLM providers."""
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import Dict, List
|
||||||
|
|
||||||
|
|
||||||
|
class LLMProvider(ABC):
|
||||||
|
"""Abstract base class for LLM providers."""
|
||||||
|
|
||||||
|
def __init__(self, config: Dict):
|
||||||
|
"""Initialize provider with configuration."""
|
||||||
|
self.config = config
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def compress_commit(self, commit_message: str, commit_diff: str) -> str:
|
||||||
|
"""
|
||||||
|
Compress git commit information into a concise log message.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
commit_message: The git commit message
|
||||||
|
commit_diff: The git diff output
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Compressed log message suitable for standup
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def generate_daily(self, logs: List[Dict], days: int = 1) -> str:
|
||||||
|
"""
|
||||||
|
Generate a daily standup message from logs.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
logs: List of log entries (dicts with timestamp, message, type, label)
|
||||||
|
days: Number of days being covered
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Formatted standup message
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def test_connection(self) -> bool:
|
||||||
|
"""Test if the provider is accessible and configured correctly."""
|
||||||
|
pass
|
||||||
94
jrnl/llm_providers/ollama_provider.py
Normal file
94
jrnl/llm_providers/ollama_provider.py
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
"""Ollama local LLM provider."""
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from typing import Dict, List
|
||||||
|
from .base import LLMProvider
|
||||||
|
from .prompts import COMPRESS_COMMIT_PROMPT, GENERATE_DAILY_PROMPT
|
||||||
|
|
||||||
|
|
||||||
|
class OllamaProvider(LLMProvider):
|
||||||
|
"""Ollama local LLM provider."""
|
||||||
|
|
||||||
|
def __init__(self, config: Dict):
|
||||||
|
super().__init__(config)
|
||||||
|
self.base_url = config.get('url', 'http://localhost:11434')
|
||||||
|
self.model = config.get('model', 'llama3.1:8b')
|
||||||
|
self.max_tokens_commit = config.get('max_tokens_commit', 200)
|
||||||
|
self.max_tokens_daily = config.get('max_tokens_daily', 500)
|
||||||
|
|
||||||
|
def compress_commit(self, commit_message: str, commit_diff: str) -> str:
|
||||||
|
"""Compress commit using Ollama."""
|
||||||
|
prompt = COMPRESS_COMMIT_PROMPT.format(
|
||||||
|
commit_message=commit_message,
|
||||||
|
commit_diff=commit_diff
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.post(
|
||||||
|
f"{self.base_url}/api/generate",
|
||||||
|
json={
|
||||||
|
"model": self.model,
|
||||||
|
"prompt": prompt,
|
||||||
|
"stream": False,
|
||||||
|
"options": {
|
||||||
|
"temperature": 0.3,
|
||||||
|
"num_predict": self.max_tokens_commit
|
||||||
|
}
|
||||||
|
},
|
||||||
|
timeout=30
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()['response'].strip()
|
||||||
|
except requests.exceptions.ConnectionError:
|
||||||
|
return f"[Ollama Not Running] {commit_message}"
|
||||||
|
except requests.exceptions.Timeout:
|
||||||
|
return f"[Ollama Timeout] {commit_message}"
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
return f"[Ollama Error] {commit_message}"
|
||||||
|
except Exception as e:
|
||||||
|
return f"[LLM Error] {commit_message}"
|
||||||
|
|
||||||
|
def generate_daily(self, logs: List[Dict], days: int = 1) -> str:
|
||||||
|
"""Generate daily standup using Ollama."""
|
||||||
|
log_text = "\n".join([
|
||||||
|
f"- [{log['type']}] {log['log_message']}"
|
||||||
|
for log in logs
|
||||||
|
])
|
||||||
|
|
||||||
|
prompt = GENERATE_DAILY_PROMPT.format(
|
||||||
|
days=days,
|
||||||
|
logs=log_text
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.post(
|
||||||
|
f"{self.base_url}/api/generate",
|
||||||
|
json={
|
||||||
|
"model": self.model,
|
||||||
|
"prompt": prompt,
|
||||||
|
"stream": False,
|
||||||
|
"options": {
|
||||||
|
"temperature": 0.5,
|
||||||
|
"num_predict": self.max_tokens_daily
|
||||||
|
}
|
||||||
|
},
|
||||||
|
timeout=60
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()['response'].strip()
|
||||||
|
except requests.exceptions.ConnectionError:
|
||||||
|
raise RuntimeError("Cannot connect to Ollama. Is it running at http://localhost:11434?")
|
||||||
|
except requests.exceptions.Timeout:
|
||||||
|
raise RuntimeError("Ollama request timed out. Try a faster model or increase timeout.")
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
raise RuntimeError(f"Ollama HTTP error: {e}")
|
||||||
|
except Exception as e:
|
||||||
|
raise RuntimeError(f"Failed to generate daily with Ollama: {type(e).__name__}: {e}")
|
||||||
|
|
||||||
|
def test_connection(self) -> bool:
|
||||||
|
"""Test Ollama connection."""
|
||||||
|
try:
|
||||||
|
response = requests.get(f"{self.base_url}/api/tags", timeout=5)
|
||||||
|
return response.status_code == 200
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
36
jrnl/llm_providers/prompts.py
Normal file
36
jrnl/llm_providers/prompts.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
"""LLM prompt templates."""
|
||||||
|
|
||||||
|
COMPRESS_COMMIT_PROMPT = """You are helping a developer track their work for daily standups.
|
||||||
|
|
||||||
|
Given this git commit information:
|
||||||
|
|
||||||
|
COMMIT MESSAGE:
|
||||||
|
{commit_message}
|
||||||
|
|
||||||
|
COMMIT DIFF:
|
||||||
|
{commit_diff}
|
||||||
|
|
||||||
|
Compress this into a single concise paragraph (max 300 chars) that describes what work was completed. Focus on WHAT was done, not HOW. Use past tense. This will be used in a standup summary.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- "Fixed authentication bug in user login flow"
|
||||||
|
- "Added dark mode toggle to settings page"
|
||||||
|
- "Refactored database connection handling"
|
||||||
|
- "Updated API documentation for new endpoints"
|
||||||
|
|
||||||
|
Your response:"""
|
||||||
|
|
||||||
|
GENERATE_DAILY_PROMPT = """You are helping a developer prepare for their daily standup meeting.
|
||||||
|
|
||||||
|
Generate a standup summary from the following work logs covering the past {days} day(s):
|
||||||
|
|
||||||
|
{logs}
|
||||||
|
|
||||||
|
Create a compact paragraph (3-5 sentences) covering:
|
||||||
|
1. What was completed (synthesize related items)
|
||||||
|
2. What's planned next (infer from recent work)
|
||||||
|
3. Any obstacles or blockers (mention if none)
|
||||||
|
|
||||||
|
Keep it professional but conversational. Use past tense for completed work.
|
||||||
|
|
||||||
|
Your standup summary:"""
|
||||||
0
jrnl/utils/__init__.py
Normal file
0
jrnl/utils/__init__.py
Normal file
71
jrnl/utils/date_utils.py
Normal file
71
jrnl/utils/date_utils.py
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
"""Date and time utilities."""
|
||||||
|
|
||||||
|
from datetime import datetime, date, timedelta, timezone
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
def get_utc_now() -> str:
|
||||||
|
"""Get current UTC time in ISO 8601 format."""
|
||||||
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_date() -> str:
|
||||||
|
"""Get current date in YYYY-MM-DD format."""
|
||||||
|
return date.today().isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def parse_iso_datetime(timestamp: str) -> datetime:
|
||||||
|
"""Parse ISO 8601 timestamp to datetime object."""
|
||||||
|
# Handle both with and without timezone
|
||||||
|
if timestamp.endswith('Z'):
|
||||||
|
timestamp = timestamp.replace('Z', '+00:00')
|
||||||
|
return datetime.fromisoformat(timestamp)
|
||||||
|
|
||||||
|
|
||||||
|
def get_datetime_ago(days: int = 0, hours: int = 0) -> str:
|
||||||
|
"""Get datetime N days/hours ago in ISO 8601 format."""
|
||||||
|
dt = datetime.now(timezone.utc) - timedelta(days=days, hours=hours)
|
||||||
|
return dt.isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def format_relative_time(timestamp: str) -> str:
|
||||||
|
"""Format timestamp as relative time (e.g., '2 hours ago')."""
|
||||||
|
try:
|
||||||
|
dt = parse_iso_datetime(timestamp)
|
||||||
|
now = datetime.now(dt.tzinfo) if dt.tzinfo else datetime.now()
|
||||||
|
|
||||||
|
# Make both timezone-aware or both naive
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
dt = dt.replace(tzinfo=timezone.utc)
|
||||||
|
if now.tzinfo is None:
|
||||||
|
now = now.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
delta = now - dt
|
||||||
|
|
||||||
|
if delta.days > 0:
|
||||||
|
return f"{delta.days} day{'s' if delta.days != 1 else ''} ago"
|
||||||
|
elif delta.seconds >= 3600:
|
||||||
|
hours = delta.seconds // 3600
|
||||||
|
return f"{hours} hour{'s' if hours != 1 else ''} ago"
|
||||||
|
elif delta.seconds >= 60:
|
||||||
|
minutes = delta.seconds // 60
|
||||||
|
return f"{minutes} minute{'s' if minutes != 1 else ''} ago"
|
||||||
|
else:
|
||||||
|
return "just now"
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return timestamp
|
||||||
|
except Exception:
|
||||||
|
# Keep catch-all for formatting - don't want to crash display
|
||||||
|
return timestamp
|
||||||
|
|
||||||
|
|
||||||
|
def format_date_for_display(date_str: str) -> str:
|
||||||
|
"""Format YYYY-MM-DD date for display."""
|
||||||
|
try:
|
||||||
|
dt = datetime.fromisoformat(date_str)
|
||||||
|
return dt.strftime("%A, %B %d, %Y")
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return date_str
|
||||||
|
except Exception:
|
||||||
|
# Keep catch-all for formatting - don't want to crash display
|
||||||
|
return date_str
|
||||||
26
jrnl/utils/errors.py
Normal file
26
jrnl/utils/errors.py
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
"""Custom exception classes for JRNL."""
|
||||||
|
|
||||||
|
|
||||||
|
class JRNLError(Exception):
|
||||||
|
"""Base exception for JRNL."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigError(JRNLError):
|
||||||
|
"""Configuration-related error."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class DatabaseError(JRNLError):
|
||||||
|
"""Database-related error."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class LLMError(JRNLError):
|
||||||
|
"""LLM provider-related error."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class GitError(JRNLError):
|
||||||
|
"""Git-related error."""
|
||||||
|
pass
|
||||||
32
jrnl/utils/formatting.py
Normal file
32
jrnl/utils/formatting.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
"""Output formatting utilities."""
|
||||||
|
|
||||||
|
from .date_utils import format_relative_time
|
||||||
|
from ..database.models import Log
|
||||||
|
|
||||||
|
|
||||||
|
def format_log_entry(log: Log) -> str:
|
||||||
|
"""Format a log entry for display."""
|
||||||
|
time_str = format_relative_time(log.timestamp)
|
||||||
|
type_badge = "[GIT]" if log.type == "git-hook" else "[MAN]"
|
||||||
|
|
||||||
|
return f"{type_badge} {time_str:20} {log.label:10} {log.log_message}"
|
||||||
|
|
||||||
|
|
||||||
|
def format_daily_header(date_str: str) -> str:
|
||||||
|
"""Format a header for daily standup output."""
|
||||||
|
return f"\n{'='*60}\nSTANDUP - {date_str}\n{'='*60}\n"
|
||||||
|
|
||||||
|
|
||||||
|
def format_success(message: str) -> str:
|
||||||
|
"""Format a success message."""
|
||||||
|
return f"✓ {message}"
|
||||||
|
|
||||||
|
|
||||||
|
def format_error(message: str) -> str:
|
||||||
|
"""Format an error message."""
|
||||||
|
return f"✗ {message}"
|
||||||
|
|
||||||
|
|
||||||
|
def format_info(message: str) -> str:
|
||||||
|
"""Format an info message."""
|
||||||
|
return f"ℹ {message}"
|
||||||
3
jrnl/version.py
Normal file
3
jrnl/version.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
"""Version information for JRNL."""
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
2
requirements.txt
Normal file
2
requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
anthropic>=0.39.0
|
||||||
|
requests>=2.31.0
|
||||||
88
uninstall.sh
Executable file
88
uninstall.sh
Executable file
@@ -0,0 +1,88 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
#
|
||||||
|
# JRNL Uninstallation Script
|
||||||
|
#
|
||||||
|
|
||||||
|
JRNL_DIR="$HOME/.jrnl"
|
||||||
|
LOCAL_BIN="$HOME/.local/bin"
|
||||||
|
|
||||||
|
echo "JRNL Uninstaller"
|
||||||
|
echo
|
||||||
|
|
||||||
|
# Confirm uninstall
|
||||||
|
read -p "Are you sure you want to uninstall JRNL? (y/N): " confirm
|
||||||
|
if [[ ! "$confirm" =~ ^([yY][eE][sS]|[yY])$ ]]; then
|
||||||
|
echo "Uninstall cancelled"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Offer database backup
|
||||||
|
if [ -f "$JRNL_DIR/jrnl.db" ]; then
|
||||||
|
echo
|
||||||
|
read -p "Backup database before uninstalling? (Y/n): " backup
|
||||||
|
backup=${backup:-Y}
|
||||||
|
|
||||||
|
if [[ "$backup" =~ ^([yY][eE][sS]|[yY])$ ]]; then
|
||||||
|
BACKUP_FILE="$HOME/jrnl_backup_$(date +%Y%m%d_%H%M%S).db"
|
||||||
|
cp "$JRNL_DIR/jrnl.db" "$BACKUP_FILE"
|
||||||
|
echo "✓ Database backed up to $BACKUP_FILE"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Uninstalling JRNL..."
|
||||||
|
|
||||||
|
# Remove git hooks
|
||||||
|
remove_git_hooks() {
|
||||||
|
HOOKS_PATH=$(git config --global core.hooksPath 2>/dev/null || echo "")
|
||||||
|
|
||||||
|
if [ -n "$HOOKS_PATH" ] && [ -f "$HOOKS_PATH/post-commit" ]; then
|
||||||
|
echo "Removing JRNL from git hooks..."
|
||||||
|
|
||||||
|
# Remove JRNL-specific lines
|
||||||
|
if grep -q "JRNL" "$HOOKS_PATH/post-commit"; then
|
||||||
|
# Create temp file without JRNL lines
|
||||||
|
grep -v "JRNL" "$HOOKS_PATH/post-commit" | \
|
||||||
|
grep -v "jrnl" | \
|
||||||
|
grep -v "disown" > "$HOOKS_PATH/post-commit.tmp"
|
||||||
|
|
||||||
|
# Check if hook is now empty (only shebang or empty)
|
||||||
|
if [ $(wc -l < "$HOOKS_PATH/post-commit.tmp") -le 2 ]; then
|
||||||
|
rm "$HOOKS_PATH/post-commit"
|
||||||
|
echo "✓ Removed empty post-commit hook"
|
||||||
|
else
|
||||||
|
mv "$HOOKS_PATH/post-commit.tmp" "$HOOKS_PATH/post-commit"
|
||||||
|
chmod +x "$HOOKS_PATH/post-commit"
|
||||||
|
echo "✓ Removed JRNL from post-commit hook"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Clean up temp file if it exists
|
||||||
|
rm -f "$HOOKS_PATH/post-commit.tmp"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Remove JRNL directory
|
||||||
|
if [ -d "$JRNL_DIR" ]; then
|
||||||
|
rm -rf "$JRNL_DIR"
|
||||||
|
echo "✓ Removed $JRNL_DIR"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Remove CLI wrapper
|
||||||
|
if [ -f "$LOCAL_BIN/jrnl" ]; then
|
||||||
|
rm "$LOCAL_BIN/jrnl"
|
||||||
|
echo "✓ Removed $LOCAL_BIN/jrnl"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Remove git hooks
|
||||||
|
remove_git_hooks
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "============================================================"
|
||||||
|
echo "JRNL uninstalled successfully"
|
||||||
|
echo "============================================================"
|
||||||
|
echo
|
||||||
|
|
||||||
|
if [ -f "$HOME/jrnl_backup_"*.db ]; then
|
||||||
|
echo "Your database backup(s) are in your home directory"
|
||||||
|
fi
|
||||||
Reference in New Issue
Block a user