first working version of jrnl

This commit is contained in:
Niki Vihtola
2025-11-29 14:22:30 +02:00
parent 255f55b871
commit 7767e0c6c7
34 changed files with 2052 additions and 1 deletions

50
.gitignore vendored Normal file
View 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
View 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
```

View File

@@ -67,3 +67,9 @@ Application code structure should be as modular and as human readable as possibl
- utils
- database
- 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.

View 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
View 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
View File

7
jrnl/__main__.py Normal file
View 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
View 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())

View File

192
jrnl/commands/config_cmd.py Normal file
View 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
View 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
View 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
View 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

View 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
View 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

View File

View 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
View 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
View 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

View 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);
"""

View File

View 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

View 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']

View 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

View 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

View 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

View 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
View File

71
jrnl/utils/date_utils.py Normal file
View 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
View 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
View 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
View File

@@ -0,0 +1,3 @@
"""Version information for JRNL."""
__version__ = "0.1.0"

2
requirements.txt Normal file
View File

@@ -0,0 +1,2 @@
anthropic>=0.39.0
requests>=2.31.0

88
uninstall.sh Executable file
View 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