Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 107 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

Pomodoro is a command-line interface to run timers from your terminal for people using the [Pomodoro Technique](https://en.wikipedia.org/wiki/Pomodoro_Technique).

I started this project in 2017, as a pet project to learn how to write a CLI in [Swift](https://swift.org). I never stopped writing CLI tools from then.
[I started this project in 2017][blog-post], as a pet project to learn how to write a CLI in [Swift](https://swift.org). I never stopped writing command-line tools from then.

<div align="center">
<img src="https://github.com/dirtyhenry/pomodoro-cli/blob/main/Resources/usage-carbon.png?raw=true" alt="pomodoro-cli usage example" width="673" height="250">
Expand All @@ -11,34 +11,130 @@ I started this project in 2017, as a pet project to learn how to write a CLI in

## Usage

```
➜ Usage: pomodoro-cli [options]
### Basic Examples

```bash
# Standard 25-minute pomodoro
pomodoro-cli -m "Write documentation"

# Custom duration (5 minutes)
pomodoro-cli -d 5m -m "Quick review"

CLI pomodoro
# Duration in seconds
pomodoro-cli -d 300 -m "Five minute break"

Options:
-d, --duration <value> The duration of the pomodoro in seconds (100) or in minutes (10m) (default to 25m)
-h, --help Show help information
-m, --message <value> The intent of the pomodoro (example: email zero)
# Indefinite pomodoro (for meetings with unknown duration)
pomodoro-cli --indefinite -m "Team meeting"

# Catch-up mode (log a pomodoro that already happened)
pomodoro-cli --catch-up -m "Forgot to log earlier work"
```

### Available Options

| Option | Description | Default |
| ------------------------ | ------------------------------------------------- | ----------------------- |
| `-d, --duration <value>` | Duration in seconds (100) or minutes (10m) | 25m |
| `-m, --message <value>` | Description of the pomodoro's intent | Prompts if not provided |
| `--indefinite` | Run indefinitely until interrupted (for meetings) | Off |
| `--catch-up` | Exit immediately, only run finish hook | Off |
| `-h, --help` | Show help information | - |

## Features

### Interrupt Handling

Press **Ctrl+C** during a running pomodoro to see an interactive menu:

1. **Exit without saving** - Abandon the pomodoro (no hooks, no journal entry)
2. **Save shortened pomodoro** - Record the actual time worked with hooks and journal

**Double Ctrl+C** forces immediate exit without any prompts.

**Use case**: Perfect for when emergencies arise or priorities shift mid-pomodoro.

### Indefinite Mode

Use `--indefinite` for meetings or tasks with unknown duration:

- Timer shows elapsed time: `Running: 5m 23s...` (updates every second)
- No progress bar (since there's no known end time)
- Must be stopped with Ctrl+C to finish
- When saved, records actual duration in journal and calls hooks with real times

**Use case**: Ideal for meetings, pair programming sessions, or any task where you don't know the duration upfront.

## Hooks

Pomodoro can optionally run shell scripts when a pomodoro starts and/or finishes.

### Hook Locations

- **Start hook**: `~/.pomodoro-cli/pomodoro-start.sh`
- **Finish hook**: `~/.pomodoro-cli/pomodoro-finish.sh`

### Hook Parameters

Both hooks receive 4 arguments:

| Parameter | Description | Example |
| --------- | ---------------------------------- | -------------------------- |
| `$1` | Start date (ISO8601 format) | `2025-11-17T10:30:00.123Z` |
| `$2` | End date (ISO8601 or "indefinite") | `2025-11-17T10:55:00.123Z` |
| `$3` | Duration (seconds or "indefinite") | `1500.0` |
| `$4` | Message | `Write documentation` |

**Note**: For indefinite pomodoros, the start hook receives `"indefinite"` for end date and duration. The finish hook always receives actual values.

### Example Hook

```bash
#!/usr/bin/env bash
# ~/.pomodoro-cli/pomodoro-finish.sh

START_DATE=$1
END_DATE=$2
DURATION=$3
MESSAGE=$4

# Send a notification
osascript -e "display notification \"$MESSAGE\" with title \"Pomodoro Complete!\""

# Log to a custom file
echo "$(date): Completed pomodoro - $MESSAGE ($DURATION seconds)" >> ~/pomodoro-log.txt
```

Sample scripts can be found in [the `SampleHooks` directory](https://github.com/dirtyhenry/pomodoro-cli/blob/main/Resources/SampleHooks).

## Journal

A journal of pomodoros is created in `~/.pomodoro-cli/journal.yml`.
A journal of all completed pomodoros is automatically maintained at `~/.pomodoro-cli/journal.yml`.

### Format

The journal uses YAML format with the following structure:

```yaml
- - startDate: 11/17/25, 10:30:15 AM
- endDate: 11/17/25, 10:55:15 AM
- message: Write documentation
- - startDate: 11/17/25, 11:00:42 AM
- endDate: 11/17/25, 11:05:42 AM
- message: Quick review
- - startDate: 11/17/25, 2:15:30 PM
- endDate: 11/17/25, 2:47:18 PM
- message: Team meeting
```

**Note**: Duration can be calculated from the difference between start and end dates. Shortened and indefinite pomodoros record their actual elapsed time.

## Installation

### From Source

To install from sources, [Swift](https://swift.org/getting-started/) is required.

Installing `swiftlint` and `swiftformat` via [Homebrew](https://brew.sh/), and having installed [Ruby](https://www.ruby-lang.org/fr/)/[Bundler](https://bundler.io) are recommended for an easy installation.
Installing `swiftlint` and `swiftformat` via [Homebrew](https://brew.sh/) is recommended for an easy installation.

- `make install` will install development dependencies;
- `make deploy` will build a release binary, move it to `/usr/local/bin` by default, with default hooks installed;
Expand All @@ -49,7 +145,7 @@ Check out [`Makefile`](https://github.com/dirtyhenry/pomodoro-cli/blob/main/Make

The `.dmg` files are created via:

```
```bash
make clean notarize
# and upon successful feedback from the Apple notary service:
make image
Expand Down
8 changes: 6 additions & 2 deletions Sources/Pomodoro/Hook.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,14 @@ extension Hook {

canBeExecuted { executable, path in
if executable, let path {
// For indefinite pomodoros, pass "indefinite" as end date
let endDateString = description.isIndefinite ? "indefinite" : formatter.string(from: description.endDate)
let durationString = description.isIndefinite ? "indefinite" : description.duration.description

let task = Process.launchedProcess(launchPath: path, arguments: [
formatter.string(from: description.startDate),
formatter.string(from: description.endDate),
description.duration.description,
endDateString,
durationString,
description.message ?? "n/a"
])
task.waitUntilExit()
Expand Down
69 changes: 69 additions & 0 deletions Sources/Pomodoro/InterruptHandler.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import Foundation

/// Manages SIGINT (Ctrl+C) signal handling for graceful interruption
class InterruptHandler {
enum State {
case none
case firstInterrupt
case secondInterrupt
}

private var state: State = .none
private let stateLock = NSLock()
private var semaphore: DispatchSemaphore?
private var signalSource: DispatchSourceSignal?

/// Configures the handler to listen for SIGINT signals
/// - Parameter semaphore: The semaphore to signal when interrupted
func setup(semaphore: DispatchSemaphore) {
self.semaphore = semaphore

// Ignore default SIGINT handling so we can handle it ourselves
signal(SIGINT, SIG_IGN)

// Create dispatch source for SIGINT on global queue (CLI apps don't run main RunLoop)
signalSource = DispatchSource.makeSignalSource(signal: SIGINT, queue: .global())

signalSource?.setEventHandler { [weak self] in
self?.handleInterrupt()
}

signalSource?.resume()
}

/// Handles an interrupt signal
private func handleInterrupt() {
stateLock.lock()
defer { stateLock.unlock() }

switch state {
case .none:
// First interrupt - signal the semaphore to wake the timer
state = .firstInterrupt
semaphore?.signal()

case .firstInterrupt:
// Second interrupt - force exit immediately
state = .secondInterrupt
exit(0)

case .secondInterrupt:
// Should never reach here, but just in case
exit(0)
}
}

/// Checks if an interrupt has occurred
var isInterrupted: Bool {
stateLock.lock()
defer { stateLock.unlock() }
return state == .firstInterrupt
}

/// Cancels the signal handler
func cancel() {
signalSource?.cancel()
signalSource = nil
signal(SIGINT, SIG_DFL) // Restore default signal handling
}
}
27 changes: 25 additions & 2 deletions Sources/Pomodoro/PomodoroDescription.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,20 +21,43 @@ public struct PomodoroDescription {
self.message = message
}

/// Creates a pomodoro with a custom start date.
/// - Parameters:
/// - startDate: the start date of the pomodoro.
/// - duration: the duration of the pomodoro.
/// - message: a message describing the intent of the pomodoro.
public init(startDate: Date, duration: TimeInterval, message: String?) {
self.startDate = startDate
self.duration = duration
self.message = message
}

let startDate: Date
let duration: TimeInterval
let message: String?

/// Returns true if this is an indefinite pomodoro (runs until manually stopped)
var isIndefinite: Bool {
duration.isInfinite
}

var endDate: Date {
startDate.addingTimeInterval(duration)
if isIndefinite {
// Return far future date for indefinite pomodoros
return Date.distantFuture
}
return startDate.addingTimeInterval(duration)
}

var formattedStartDate: String {
PomodoroDescription.dateFormatter.string(from: startDate)
}

var formattedEndDate: String {
PomodoroDescription.dateFormatter.string(from: endDate)
if isIndefinite {
return "indefinite"
}
return PomodoroDescription.dateFormatter.string(from: endDate)
}
}

Expand Down
Loading