timing-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
timing_cli/cli.py ADDED
@@ -0,0 +1,348 @@
1
+ """Typer CLI for timing-cli."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import date, datetime, time, timedelta
6
+
7
+ import typer
8
+
9
+ from timing_cli import __version__
10
+ from timing_cli.analysis import aggregate, summarize_by_project
11
+ from timing_cli.api import TimingApiClient, TimingApiError
12
+ from timing_cli.config import Config, load_config
13
+ from timing_cli.db import (
14
+ TimingDatabaseError,
15
+ date_range,
16
+ list_app_usage,
17
+ list_projects,
18
+ list_timing_predicate_rules,
19
+ open_db,
20
+ )
21
+ from timing_cli.models import TimeEntrySuggestion
22
+ from timing_cli.output import console, err_console, render_suggestions, render_summary, render_usage
23
+ from timing_cli.rules import UNASSIGNED, Classifier
24
+
25
+ app = typer.Typer(
26
+ name="timing",
27
+ help="Read the local Timing.app database and generate/push time entries.",
28
+ no_args_is_help=True,
29
+ add_completion=False,
30
+ )
31
+
32
+
33
+ def _version_callback(value: bool) -> None:
34
+ if value:
35
+ console.print(f"timing-cli {__version__}")
36
+ raise typer.Exit()
37
+
38
+
39
+ @app.callback()
40
+ def main(
41
+ version: bool = typer.Option(
42
+ False, "--version", callback=_version_callback, is_eager=True, help="Show version and exit"
43
+ ),
44
+ ) -> None:
45
+ """timing-cli - local Timing.app activity to Timing time entries."""
46
+
47
+
48
+ def _resolve_window(
49
+ date_opt: str | None,
50
+ from_opt: str | None,
51
+ to_opt: str | None,
52
+ ) -> tuple[datetime, datetime]:
53
+ """Resolve a local [start, end) window from the CLI date options.
54
+
55
+ Precedence: explicit --from/--to override --date; --date selects a whole
56
+ local day; with nothing given, defaults to today.
57
+ """
58
+ try:
59
+ if from_opt or to_opt:
60
+ start = (
61
+ datetime.fromisoformat(from_opt).astimezone()
62
+ if from_opt
63
+ else _day_start(date.today())
64
+ )
65
+ end = (
66
+ datetime.fromisoformat(to_opt).astimezone()
67
+ if to_opt
68
+ else datetime.now().astimezone()
69
+ )
70
+ else:
71
+ day = date.fromisoformat(date_opt) if date_opt else date.today()
72
+ start = _day_start(day)
73
+ end = start + timedelta(days=1)
74
+ except ValueError as exc:
75
+ raise typer.BadParameter("Use ISO-8601 dates, e.g. 2026-07-05") from exc
76
+
77
+ if end <= start:
78
+ raise typer.BadParameter("--to must be after --from")
79
+ return start, end
80
+
81
+
82
+ def _day_start(day: date) -> datetime:
83
+ return datetime.combine(day, time.min).astimezone()
84
+
85
+
86
+ def _load() -> Config:
87
+ return load_config()
88
+
89
+
90
+ def _exit_with_error(message: str) -> None:
91
+ err_console.print(f"[red]{message}[/red]")
92
+ raise typer.Exit(1)
93
+
94
+
95
+ DateOpt = typer.Option(None, "--date", "-d", help="Local day YYYY-MM-DD (default: today)")
96
+ FromOpt = typer.Option(None, "--from", "-f", help="Start datetime (ISO 8601), overrides --date")
97
+ ToOpt = typer.Option(None, "--to", "-t", help="End datetime (ISO 8601), overrides --date")
98
+
99
+
100
+ @app.command()
101
+ def info() -> None:
102
+ """Show the database location and the recorded activity date range."""
103
+ cfg = _load()
104
+ console.print(f"Database: [cyan]{cfg.db_path}[/cyan]")
105
+ try:
106
+ with open_db(cfg.db_path) as conn:
107
+ rng = date_range(conn)
108
+ projects = list_projects(conn, include_archived=False)
109
+ except TimingDatabaseError as exc:
110
+ _exit_with_error(str(exc))
111
+ if rng:
112
+ console.print(
113
+ f"Recorded: [green]{rng[0]:%Y-%m-%d}[/green] -> "
114
+ f"[green]{rng[1]:%Y-%m-%d}[/green]"
115
+ )
116
+ console.print(f"Active projects: {len(projects)}")
117
+ console.print(f"API token: {'set' if cfg.resolved_token() else '[yellow]not set[/yellow]'}")
118
+
119
+
120
+ @app.command()
121
+ def projects(
122
+ remote: bool = typer.Option(False, "--remote", help="List projects from the Web API instead"),
123
+ archived: bool = typer.Option(False, "--archived", help="Include archived projects"),
124
+ ) -> None:
125
+ """List projects (local database by default, or the Web API with --remote)."""
126
+ cfg = _load()
127
+ if remote:
128
+ try:
129
+ with TimingApiClient(cfg.api_base_url, cfg.resolved_token()) as client:
130
+ for p in client.list_projects(hide_archived=not archived):
131
+ chain = " / ".join(p.get("title_chain") or [p.get("title", "")])
132
+ console.print(f"[magenta]{p.get('self')}[/magenta] {chain}")
133
+ except TimingApiError as exc:
134
+ err_console.print(f"[red]{exc}[/red]")
135
+ raise typer.Exit(1) from exc
136
+ return
137
+ try:
138
+ with open_db(cfg.db_path) as conn:
139
+ for p in list_projects(conn, include_archived=archived):
140
+ marker = " [dim](archived)[/dim]" if p.is_archived else ""
141
+ console.print(f"[magenta]{p.id}[/magenta] {p.title}{marker}")
142
+ except TimingDatabaseError as exc:
143
+ _exit_with_error(str(exc))
144
+
145
+
146
+ @app.command()
147
+ def usage(
148
+ date_opt: str | None = DateOpt,
149
+ from_opt: str | None = FromOpt,
150
+ to_opt: str | None = ToOpt,
151
+ project_id: int | None = typer.Option(
152
+ None,
153
+ "--project",
154
+ "-p",
155
+ help="Filter by local project id",
156
+ ),
157
+ ) -> None:
158
+ """Show raw automatically tracked app usage for a window."""
159
+ cfg = _load()
160
+ start, end = _resolve_window(date_opt, from_opt, to_opt)
161
+ try:
162
+ with open_db(cfg.db_path) as conn:
163
+ slices = list_app_usage(conn, start, end, project_id=project_id)
164
+ except TimingDatabaseError as exc:
165
+ _exit_with_error(str(exc))
166
+ render_usage(slices)
167
+
168
+
169
+ @app.command()
170
+ def summary(
171
+ date_opt: str | None = DateOpt,
172
+ from_opt: str | None = FromOpt,
173
+ to_opt: str | None = ToOpt,
174
+ include_unassigned: bool = typer.Option(
175
+ True, "--unassigned/--no-unassigned", help="Include time not mapped to any project"
176
+ ),
177
+ ) -> None:
178
+ """Show total tracked time per project for a window."""
179
+ cfg = _load()
180
+ start, end = _resolve_window(date_opt, from_opt, to_opt)
181
+ try:
182
+ with open_db(cfg.db_path) as conn:
183
+ slices = list_app_usage(conn, start, end)
184
+ timing_rules = list_timing_predicate_rules(conn)
185
+ except TimingDatabaseError as exc:
186
+ _exit_with_error(str(exc))
187
+ classifier = Classifier(cfg.rules, timing_rules=timing_rules)
188
+ summaries = summarize_by_project(slices, classifier, include_unassigned=include_unassigned)
189
+ render_summary(summaries, title=f"Project summary {start:%Y-%m-%d} .. {end:%Y-%m-%d}")
190
+
191
+
192
+ @app.command()
193
+ def suggest(
194
+ date_opt: str | None = DateOpt,
195
+ from_opt: str | None = FromOpt,
196
+ to_opt: str | None = ToOpt,
197
+ include_unassigned: bool = typer.Option(
198
+ False, "--unassigned/--no-unassigned", help="Also suggest entries for unassigned time"
199
+ ),
200
+ ) -> None:
201
+ """Show suggested time entries aggregated from app usage (does not write)."""
202
+ cfg = _load()
203
+ start, end = _resolve_window(date_opt, from_opt, to_opt)
204
+ try:
205
+ with open_db(cfg.db_path) as conn:
206
+ slices = list_app_usage(conn, start, end)
207
+ timing_rules = list_timing_predicate_rules(conn)
208
+ except TimingDatabaseError as exc:
209
+ _exit_with_error(str(exc))
210
+ classifier = Classifier(cfg.rules, timing_rules=timing_rules)
211
+ suggestions = aggregate(
212
+ slices,
213
+ classifier,
214
+ min_block_seconds=cfg.min_block_seconds,
215
+ gap_merge_seconds=cfg.gap_merge_seconds,
216
+ include_unassigned=include_unassigned,
217
+ )
218
+ render_suggestions(suggestions)
219
+
220
+
221
+ @app.command()
222
+ def push(
223
+ date_opt: str | None = DateOpt,
224
+ from_opt: str | None = FromOpt,
225
+ to_opt: str | None = ToOpt,
226
+ yes: bool = typer.Option(
227
+ False,
228
+ "--yes",
229
+ "-y",
230
+ help="Actually create entries (default: dry-run)",
231
+ ),
232
+ replace: bool = typer.Option(False, "--replace", help="Replace overlapping existing entries"),
233
+ include_unassigned: bool = typer.Option(
234
+ False, "--unassigned/--no-unassigned", help="Also push unassigned time"
235
+ ),
236
+ ) -> None:
237
+ """Create Timing time entries from suggestions via the Web API.
238
+
239
+ Defaults to a dry-run. Pass --yes to actually create entries. Non-unassigned
240
+ projects must resolve to one unique Web-API project before anything is
241
+ written.
242
+ """
243
+ cfg = _load()
244
+ start, end = _resolve_window(date_opt, from_opt, to_opt)
245
+ try:
246
+ with open_db(cfg.db_path) as conn:
247
+ slices = list_app_usage(conn, start, end)
248
+ timing_rules = list_timing_predicate_rules(conn)
249
+ except TimingDatabaseError as exc:
250
+ _exit_with_error(str(exc))
251
+ classifier = Classifier(cfg.rules, timing_rules=timing_rules)
252
+ suggestions = aggregate(
253
+ slices,
254
+ classifier,
255
+ min_block_seconds=cfg.min_block_seconds,
256
+ gap_merge_seconds=cfg.gap_merge_seconds,
257
+ include_unassigned=include_unassigned,
258
+ )
259
+ render_suggestions(suggestions)
260
+
261
+ if not suggestions:
262
+ console.print("[yellow]Nothing to push.[/yellow]")
263
+ return
264
+ if not yes:
265
+ console.print(
266
+ f"[yellow]Dry-run:[/yellow] would create {len(suggestions)} entries. "
267
+ "Re-run with --yes to push."
268
+ )
269
+ return
270
+
271
+ try:
272
+ with TimingApiClient(cfg.api_base_url, cfg.resolved_token()) as client:
273
+ ref_cache: dict[tuple[int | None, tuple[str, ...], str], str | None] = {}
274
+ planned: list[tuple[TimeEntrySuggestion, str | None]] = []
275
+ unmapped: list[str] = []
276
+
277
+ for s in suggestions:
278
+ project_ref = None
279
+ if s.project_title != UNASSIGNED:
280
+ key = (s.project_id, tuple(s.project_title_chain), s.project_title)
281
+ if key not in ref_cache:
282
+ ref_cache[key] = client.resolve_project_ref(
283
+ s.project_title,
284
+ title_chain=s.project_title_chain,
285
+ project_id=s.project_id,
286
+ overrides=cfg.project_mappings,
287
+ )
288
+ project_ref = ref_cache[key]
289
+ if project_ref is None:
290
+ label = " / ".join(s.project_title_chain) or s.project_title
291
+ unmapped.append(label)
292
+ planned.append((s, project_ref))
293
+
294
+ if unmapped:
295
+ projects = ", ".join(sorted(set(unmapped)))
296
+ _exit_with_error(
297
+ "Could not map local projects to Timing Web API projects: "
298
+ f"{projects}. Add [project_mappings] entries or rename projects."
299
+ )
300
+
301
+ existing_entries = [] if replace else client.list_time_entries(start, end)
302
+ created = 0
303
+ skipped = 0
304
+ for s, project_ref in planned:
305
+ if not replace and client.has_matching_time_entry(
306
+ existing_entries,
307
+ s.start,
308
+ s.end,
309
+ s.title,
310
+ project_ref,
311
+ ):
312
+ skipped += 1
313
+ continue
314
+ client.create_time_entry(
315
+ start=s.start,
316
+ end=s.end,
317
+ project_ref=project_ref,
318
+ title=s.title,
319
+ notes=s.notes,
320
+ replace_existing=replace,
321
+ )
322
+ created += 1
323
+ message = f"Created {created} time entries."
324
+ if skipped:
325
+ message += f" Skipped {skipped} existing entries."
326
+ console.print(f"[green]{message}[/green]")
327
+ except TimingApiError as exc:
328
+ err_console.print(f"[red]{exc}[/red]")
329
+ raise typer.Exit(1) from exc
330
+
331
+
332
+ @app.command()
333
+ def serve(
334
+ transport: str = typer.Option("stdio", "--transport", help="MCP transport: stdio or http"),
335
+ host: str = typer.Option("127.0.0.1", "--host", help="Bind host for http transport"),
336
+ port: int = typer.Option(8321, "--port", help="Bind port for http transport"),
337
+ ) -> None:
338
+ """Run the Timing MCP server so agents (e.g. Hermes) can query it."""
339
+ from timing_cli.serve import run_server
340
+
341
+ try:
342
+ run_server(transport=transport, host=host, port=port)
343
+ except ValueError as exc:
344
+ _exit_with_error(str(exc))
345
+
346
+
347
+ if __name__ == "__main__":
348
+ app()
timing_cli/config.py ADDED
@@ -0,0 +1,92 @@
1
+ """Configuration: database location, Web-API access, and classification rules.
2
+
3
+ Config is loaded from ``~/.config/timing-cli/config.toml`` when present, else
4
+ sensible defaults are used. The Web-API token can also come from the
5
+ ``TIMING_API_KEY`` environment variable (which takes precedence).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import tomllib
12
+ from pathlib import Path
13
+
14
+ from pydantic import BaseModel, Field
15
+
16
+ # Default location of the Timing.app Core-Data store on macOS.
17
+ DEFAULT_DB_PATH = (
18
+ Path.home()
19
+ / "Library"
20
+ / "Application Support"
21
+ / "info.eurocomp.Timing2"
22
+ / "SQLite.db"
23
+ )
24
+
25
+ DEFAULT_API_BASE_URL = "https://web.timingapp.com/api/v1"
26
+
27
+ CONFIG_PATH = Path.home() / ".config" / "timing-cli" / "config.toml"
28
+
29
+
30
+ class Rule(BaseModel):
31
+ """A classification rule mapping app usage onto a project.
32
+
33
+ A rule matches when every provided pattern matches (case-insensitive
34
+ substring for ``app``/``bundle_id``, regex for ``title``/``path``). The
35
+ first matching rule (in list order) wins.
36
+ """
37
+
38
+ project: str = Field(description="Target project title the match maps to")
39
+ app: str | None = None
40
+ bundle_id: str | None = None
41
+ title: str | None = Field(default=None, description="Regex matched against the window title")
42
+ path: str | None = Field(default=None, description="Regex matched against the document path")
43
+
44
+
45
+ class Config(BaseModel):
46
+ """Runtime configuration for timing-cli."""
47
+
48
+ db_path: Path = DEFAULT_DB_PATH
49
+ api_base_url: str = DEFAULT_API_BASE_URL
50
+ api_token: str | None = None
51
+ mcp_http_token: str | None = None
52
+
53
+ # Aggregation tuning (see analysis.aggregate).
54
+ min_block_seconds: int = Field(
55
+ default=120,
56
+ description="Drop aggregated blocks shorter than this many seconds",
57
+ )
58
+ gap_merge_seconds: int = Field(
59
+ default=300,
60
+ description="Merge same-project slices separated by a gap up to this many seconds",
61
+ )
62
+
63
+ rules: list[Rule] = Field(default_factory=list)
64
+ project_mappings: dict[str, str] = Field(default_factory=dict)
65
+
66
+ def resolved_token(self) -> str | None:
67
+ """Return the API token, preferring the environment variable."""
68
+ return os.environ.get("TIMING_API_KEY") or self.api_token
69
+
70
+ def resolved_mcp_http_token(self) -> str | None:
71
+ """Return the MCP HTTP bearer token, preferring the environment variable."""
72
+ return os.environ.get("TIMING_MCP_TOKEN") or self.mcp_http_token
73
+
74
+
75
+ def load_config(path: Path | None = None) -> Config:
76
+ """Load configuration from disk, falling back to defaults.
77
+
78
+ Unknown keys are ignored so the config format can evolve without breaking
79
+ older files.
80
+ """
81
+ cfg_path = path or CONFIG_PATH
82
+ if not cfg_path.exists():
83
+ return Config()
84
+
85
+ with cfg_path.open("rb") as fh:
86
+ data = tomllib.load(fh)
87
+
88
+ rules = [Rule(**r) for r in data.pop("rules", [])]
89
+ known = {k: v for k, v in data.items() if k in Config.model_fields}
90
+ if "db_path" in known:
91
+ known["db_path"] = Path(known["db_path"]).expanduser()
92
+ return Config(rules=rules, **known)