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/__init__.py +3 -0
- timing_cli/analysis.py +161 -0
- timing_cli/api.py +311 -0
- timing_cli/cli.py +348 -0
- timing_cli/config.py +92 -0
- timing_cli/db.py +287 -0
- timing_cli/models.py +89 -0
- timing_cli/output.py +78 -0
- timing_cli/rules.py +101 -0
- timing_cli/serve.py +207 -0
- timing_cli/timing_predicates.py +229 -0
- timing_cli-0.1.0.dist-info/METADATA +218 -0
- timing_cli-0.1.0.dist-info/RECORD +16 -0
- timing_cli-0.1.0.dist-info/WHEEL +4 -0
- timing_cli-0.1.0.dist-info/entry_points.txt +2 -0
- timing_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
timing_cli/serve.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""FastMCP server exposing the local Timing database to agents.
|
|
2
|
+
|
|
3
|
+
The server runs on the machine where the Timing database lives, so agents (e.g.
|
|
4
|
+
the Hermes personal agent) can query real local app usage and push time entries
|
|
5
|
+
WITHOUT ever copying or sharing the raw ``SQLite.db``.
|
|
6
|
+
|
|
7
|
+
Run it via ``timing serve`` (stdio by default, or ``--transport http``).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import secrets
|
|
13
|
+
from datetime import date, datetime, time, timedelta
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from fastmcp import FastMCP
|
|
17
|
+
from fastmcp.server.auth import AccessToken, TokenVerifier
|
|
18
|
+
|
|
19
|
+
from timing_cli.analysis import aggregate, summarize_by_project
|
|
20
|
+
from timing_cli.api import TimingApiClient, TimingApiError
|
|
21
|
+
from timing_cli.config import load_config
|
|
22
|
+
from timing_cli.db import (
|
|
23
|
+
date_range,
|
|
24
|
+
list_app_usage,
|
|
25
|
+
list_projects,
|
|
26
|
+
list_timing_predicate_rules,
|
|
27
|
+
open_db,
|
|
28
|
+
)
|
|
29
|
+
from timing_cli.rules import Classifier
|
|
30
|
+
|
|
31
|
+
mcp: FastMCP = FastMCP("timing-cli")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class StaticBearerTokenVerifier(TokenVerifier):
|
|
35
|
+
"""Validate MCP HTTP requests against one configured bearer token."""
|
|
36
|
+
|
|
37
|
+
def __init__(self, token: str) -> None:
|
|
38
|
+
super().__init__()
|
|
39
|
+
self._token = token
|
|
40
|
+
|
|
41
|
+
async def verify_token(self, token: str) -> AccessToken | None:
|
|
42
|
+
if not secrets.compare_digest(token, self._token):
|
|
43
|
+
return None
|
|
44
|
+
return AccessToken(
|
|
45
|
+
token=token,
|
|
46
|
+
client_id="timing-cli-local",
|
|
47
|
+
scopes=[],
|
|
48
|
+
expires_at=None,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _day_window(day: str | None) -> tuple[datetime, datetime]:
|
|
53
|
+
d = date.fromisoformat(day) if day else date.today()
|
|
54
|
+
start = datetime.combine(d, time.min).astimezone()
|
|
55
|
+
return start, start + timedelta(days=1)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _window(day: str | None, start: str | None, end: str | None) -> tuple[datetime, datetime]:
|
|
59
|
+
if start or end:
|
|
60
|
+
lo = datetime.fromisoformat(start).astimezone() if start else _day_window(day)[0]
|
|
61
|
+
hi = datetime.fromisoformat(end).astimezone() if end else datetime.now().astimezone()
|
|
62
|
+
return lo, hi
|
|
63
|
+
return _day_window(day)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@mcp.tool
|
|
67
|
+
def list_timing_projects(include_archived: bool = False) -> list[dict[str, Any]]:
|
|
68
|
+
"""List Timing projects from the local database."""
|
|
69
|
+
cfg = load_config()
|
|
70
|
+
with open_db(cfg.db_path) as conn:
|
|
71
|
+
projects = list_projects(conn, include_archived=include_archived)
|
|
72
|
+
return [p.model_dump() for p in projects]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@mcp.tool
|
|
76
|
+
def list_app_usage_tool(
|
|
77
|
+
day: str | None = None,
|
|
78
|
+
start: str | None = None,
|
|
79
|
+
end: str | None = None,
|
|
80
|
+
project_id: int | None = None,
|
|
81
|
+
) -> list[dict[str, Any]]:
|
|
82
|
+
"""Return raw automatically tracked app usage for a local day or window.
|
|
83
|
+
|
|
84
|
+
Provide either ``day`` (YYYY-MM-DD, defaults to today) or an explicit
|
|
85
|
+
``start``/``end`` ISO-8601 window.
|
|
86
|
+
"""
|
|
87
|
+
cfg = load_config()
|
|
88
|
+
lo, hi = _window(day, start, end)
|
|
89
|
+
with open_db(cfg.db_path) as conn:
|
|
90
|
+
slices = list_app_usage(conn, lo, hi, project_id=project_id)
|
|
91
|
+
return [s.model_dump(mode="json") for s in slices]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@mcp.tool
|
|
95
|
+
def daily_project_summary(
|
|
96
|
+
day: str | None = None,
|
|
97
|
+
include_unassigned: bool = True,
|
|
98
|
+
) -> list[dict[str, Any]]:
|
|
99
|
+
"""Total tracked time per project for a local day (defaults to today)."""
|
|
100
|
+
cfg = load_config()
|
|
101
|
+
lo, hi = _day_window(day)
|
|
102
|
+
with open_db(cfg.db_path) as conn:
|
|
103
|
+
slices = list_app_usage(conn, lo, hi)
|
|
104
|
+
timing_rules = list_timing_predicate_rules(conn)
|
|
105
|
+
classifier = Classifier(cfg.rules, timing_rules=timing_rules)
|
|
106
|
+
summaries = summarize_by_project(slices, classifier, include_unassigned=include_unassigned)
|
|
107
|
+
return [s.model_dump() for s in summaries]
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@mcp.tool
|
|
111
|
+
def suggest_time_entries(
|
|
112
|
+
day: str | None = None,
|
|
113
|
+
start: str | None = None,
|
|
114
|
+
end: str | None = None,
|
|
115
|
+
include_unassigned: bool = False,
|
|
116
|
+
) -> list[dict[str, Any]]:
|
|
117
|
+
"""Aggregate app usage into suggested time entries. Read-only (does not write)."""
|
|
118
|
+
cfg = load_config()
|
|
119
|
+
lo, hi = _window(day, start, end)
|
|
120
|
+
with open_db(cfg.db_path) as conn:
|
|
121
|
+
slices = list_app_usage(conn, lo, hi)
|
|
122
|
+
timing_rules = list_timing_predicate_rules(conn)
|
|
123
|
+
classifier = Classifier(cfg.rules, timing_rules=timing_rules)
|
|
124
|
+
suggestions = aggregate(
|
|
125
|
+
slices,
|
|
126
|
+
classifier,
|
|
127
|
+
min_block_seconds=cfg.min_block_seconds,
|
|
128
|
+
gap_merge_seconds=cfg.gap_merge_seconds,
|
|
129
|
+
include_unassigned=include_unassigned,
|
|
130
|
+
)
|
|
131
|
+
return [s.model_dump(mode="json") for s in suggestions]
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@mcp.tool
|
|
135
|
+
def create_time_entry(
|
|
136
|
+
start: str,
|
|
137
|
+
end: str,
|
|
138
|
+
title: str,
|
|
139
|
+
project_title: str | None = None,
|
|
140
|
+
notes: str = "",
|
|
141
|
+
replace_existing: bool = False,
|
|
142
|
+
) -> dict[str, Any]:
|
|
143
|
+
"""Create a single time entry via the Timing Web API (write operation).
|
|
144
|
+
|
|
145
|
+
``start``/``end`` are ISO-8601 datetimes. When ``project_title`` is given it
|
|
146
|
+
must resolve to one unique Web-API project, optionally through
|
|
147
|
+
``project_mappings`` in the config.
|
|
148
|
+
"""
|
|
149
|
+
try:
|
|
150
|
+
cfg = load_config()
|
|
151
|
+
lo = datetime.fromisoformat(start).astimezone()
|
|
152
|
+
hi = datetime.fromisoformat(end).astimezone()
|
|
153
|
+
if hi <= lo:
|
|
154
|
+
return {"error": "end must be after start"}
|
|
155
|
+
with TimingApiClient(cfg.api_base_url, cfg.resolved_token()) as client:
|
|
156
|
+
project_ref = None
|
|
157
|
+
if project_title:
|
|
158
|
+
project_ref = client.resolve_project_ref(
|
|
159
|
+
project_title,
|
|
160
|
+
title_chain=[project_title],
|
|
161
|
+
overrides=cfg.project_mappings,
|
|
162
|
+
)
|
|
163
|
+
if project_ref is None:
|
|
164
|
+
return {"error": f"Could not map Timing project '{project_title}'"}
|
|
165
|
+
return client.create_time_entry(
|
|
166
|
+
start=lo,
|
|
167
|
+
end=hi,
|
|
168
|
+
project_ref=project_ref,
|
|
169
|
+
title=title,
|
|
170
|
+
notes=notes,
|
|
171
|
+
replace_existing=replace_existing,
|
|
172
|
+
)
|
|
173
|
+
except (TimingApiError, ValueError) as exc:
|
|
174
|
+
return {"error": str(exc)}
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@mcp.tool
|
|
178
|
+
def recorded_date_range() -> dict[str, str] | None:
|
|
179
|
+
"""Return the earliest and latest recorded activity timestamps."""
|
|
180
|
+
cfg = load_config()
|
|
181
|
+
with open_db(cfg.db_path) as conn:
|
|
182
|
+
rng = date_range(conn)
|
|
183
|
+
if rng is None:
|
|
184
|
+
return None
|
|
185
|
+
return {"start": rng[0].isoformat(), "end": rng[1].isoformat()}
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def run_server(transport: str = "stdio", host: str = "127.0.0.1", port: int = 8321) -> None:
|
|
189
|
+
"""Start the MCP server over the requested transport."""
|
|
190
|
+
if transport == "stdio":
|
|
191
|
+
mcp.auth = None
|
|
192
|
+
mcp.run()
|
|
193
|
+
elif transport in ("http", "streamable-http"):
|
|
194
|
+
cfg = load_config()
|
|
195
|
+
token = cfg.resolved_mcp_http_token()
|
|
196
|
+
if not token:
|
|
197
|
+
raise ValueError(
|
|
198
|
+
"HTTP transport requires TIMING_MCP_TOKEN or mcp_http_token in the config"
|
|
199
|
+
)
|
|
200
|
+
mcp.auth = StaticBearerTokenVerifier(token)
|
|
201
|
+
mcp.run(transport="http", host=host, port=port)
|
|
202
|
+
else:
|
|
203
|
+
raise ValueError(f"Unknown transport: {transport}")
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
if __name__ == "__main__":
|
|
207
|
+
run_server()
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""Decode and apply Timing.app project predicate rules.
|
|
2
|
+
|
|
3
|
+
Timing stores project auto-assignment predicates as a protobuf-like binary wire
|
|
4
|
+
format in ``Project.predicate``. The format is undocumented, so the decoder here
|
|
5
|
+
is intentionally conservative: it extracts recognized field/value conditions and
|
|
6
|
+
ignores unknown structure.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from collections.abc import Iterator
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
|
|
15
|
+
from timing_cli.models import AppUsage
|
|
16
|
+
|
|
17
|
+
RECOGNIZED_FIELDS = {
|
|
18
|
+
"applicationID",
|
|
19
|
+
"bundleIdentifier",
|
|
20
|
+
"executable",
|
|
21
|
+
"filePath",
|
|
22
|
+
"keywords",
|
|
23
|
+
"path",
|
|
24
|
+
"title",
|
|
25
|
+
"webDomain",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class TimingPredicateCondition:
|
|
31
|
+
field: str
|
|
32
|
+
values: tuple[str, ...] = ()
|
|
33
|
+
int_values: tuple[int, ...] = ()
|
|
34
|
+
|
|
35
|
+
def matches(self, usage: AppUsage) -> bool:
|
|
36
|
+
if self.field == "applicationID":
|
|
37
|
+
return usage.application_id in self.int_values
|
|
38
|
+
|
|
39
|
+
haystacks = _haystacks_for_field(self.field, usage)
|
|
40
|
+
if not haystacks:
|
|
41
|
+
return False
|
|
42
|
+
if self.field == "keywords":
|
|
43
|
+
return any(
|
|
44
|
+
_keyword_matches(value, haystack)
|
|
45
|
+
for value in self.values
|
|
46
|
+
for haystack in haystacks
|
|
47
|
+
)
|
|
48
|
+
return any(
|
|
49
|
+
value.lower() in haystack
|
|
50
|
+
for value in self.values
|
|
51
|
+
for haystack in haystacks
|
|
52
|
+
if value
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class TimingPredicateRule:
|
|
58
|
+
project_id: int
|
|
59
|
+
project_title: str
|
|
60
|
+
project_title_chain: tuple[str, ...]
|
|
61
|
+
conditions: tuple[TimingPredicateCondition, ...]
|
|
62
|
+
|
|
63
|
+
def matches(self, usage: AppUsage) -> bool:
|
|
64
|
+
return any(condition.matches(usage) for condition in self.conditions)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def decode_timing_predicate(blob: bytes | None) -> tuple[TimingPredicateCondition, ...]:
|
|
68
|
+
"""Extract recognized conditions from a Timing predicate blob."""
|
|
69
|
+
if not blob:
|
|
70
|
+
return ()
|
|
71
|
+
|
|
72
|
+
conditions: list[TimingPredicateCondition] = []
|
|
73
|
+
seen: set[TimingPredicateCondition] = set()
|
|
74
|
+
for condition in _extract_conditions(blob):
|
|
75
|
+
if condition not in seen:
|
|
76
|
+
conditions.append(condition)
|
|
77
|
+
seen.add(condition)
|
|
78
|
+
return tuple(conditions)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _haystacks_for_field(field: str, usage: AppUsage) -> tuple[str, ...]:
|
|
82
|
+
if field == "applicationID":
|
|
83
|
+
return ()
|
|
84
|
+
if field == "bundleIdentifier":
|
|
85
|
+
values = (usage.bundle_id,)
|
|
86
|
+
elif field in {"filePath", "path"}:
|
|
87
|
+
values = (usage.path, usage.title)
|
|
88
|
+
elif field == "executable":
|
|
89
|
+
values = (usage.app, usage.bundle_id)
|
|
90
|
+
else:
|
|
91
|
+
values = (usage.title, usage.path, usage.app, usage.bundle_id)
|
|
92
|
+
return tuple(value.lower() for value in values if value)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _keyword_matches(value: str, haystack: str) -> bool:
|
|
96
|
+
keyword = value.strip().lower()
|
|
97
|
+
if len(keyword) < 3:
|
|
98
|
+
return False
|
|
99
|
+
if not re.search(r"\w", keyword):
|
|
100
|
+
return False
|
|
101
|
+
if re.search(r"\s", keyword):
|
|
102
|
+
return keyword in haystack
|
|
103
|
+
return re.search(rf"(?<!\w){re.escape(keyword)}(?!\w)", haystack) is not None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _extract_conditions(data: bytes) -> Iterator[TimingPredicateCondition]:
|
|
107
|
+
try:
|
|
108
|
+
fields = list(_decode_fields(data))
|
|
109
|
+
except ValueError:
|
|
110
|
+
return
|
|
111
|
+
|
|
112
|
+
field_names: list[str] = []
|
|
113
|
+
text_values: list[str] = []
|
|
114
|
+
int_values: list[int] = []
|
|
115
|
+
|
|
116
|
+
for field_number, wire_type, value in fields:
|
|
117
|
+
if wire_type != 2 or not isinstance(value, bytes):
|
|
118
|
+
continue
|
|
119
|
+
if field_number == 3:
|
|
120
|
+
field_names.extend(_recognized_strings(value))
|
|
121
|
+
elif field_number == 4:
|
|
122
|
+
text_values.extend(_value_strings(value))
|
|
123
|
+
int_values.extend(_value_ints(value))
|
|
124
|
+
|
|
125
|
+
for field_name in field_names:
|
|
126
|
+
if field_name == "applicationID":
|
|
127
|
+
values = tuple(v for v in int_values if v >= 0)
|
|
128
|
+
if values:
|
|
129
|
+
yield TimingPredicateCondition(field_name, int_values=values)
|
|
130
|
+
else:
|
|
131
|
+
values = tuple(_dedupe(text_values))
|
|
132
|
+
if values:
|
|
133
|
+
yield TimingPredicateCondition(field_name, values=values)
|
|
134
|
+
|
|
135
|
+
for _field_number, wire_type, value in fields:
|
|
136
|
+
if wire_type == 2 and isinstance(value, bytes):
|
|
137
|
+
yield from _extract_conditions(value)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _decode_fields(data: bytes) -> Iterator[tuple[int, int, int | bytes]]:
|
|
141
|
+
index = 0
|
|
142
|
+
while index < len(data):
|
|
143
|
+
key, index = _read_varint(data, index)
|
|
144
|
+
field_number = key >> 3
|
|
145
|
+
wire_type = key & 0x07
|
|
146
|
+
if field_number == 0:
|
|
147
|
+
raise ValueError("invalid protobuf field number")
|
|
148
|
+
|
|
149
|
+
if wire_type == 0:
|
|
150
|
+
value, index = _read_varint(data, index)
|
|
151
|
+
yield field_number, wire_type, value
|
|
152
|
+
elif wire_type == 2:
|
|
153
|
+
length, index = _read_varint(data, index)
|
|
154
|
+
end = index + length
|
|
155
|
+
if end > len(data):
|
|
156
|
+
raise ValueError("length-delimited field exceeds message")
|
|
157
|
+
yield field_number, wire_type, data[index:end]
|
|
158
|
+
index = end
|
|
159
|
+
else:
|
|
160
|
+
raise ValueError(f"unsupported protobuf wire type: {wire_type}")
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _read_varint(data: bytes, index: int) -> tuple[int, int]:
|
|
164
|
+
shift = 0
|
|
165
|
+
value = 0
|
|
166
|
+
while index < len(data):
|
|
167
|
+
byte = data[index]
|
|
168
|
+
index += 1
|
|
169
|
+
value |= (byte & 0x7F) << shift
|
|
170
|
+
if not byte & 0x80:
|
|
171
|
+
return value, index
|
|
172
|
+
shift += 7
|
|
173
|
+
if shift > 63:
|
|
174
|
+
raise ValueError("varint too large")
|
|
175
|
+
raise ValueError("truncated varint")
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _recognized_strings(data: bytes) -> list[str]:
|
|
179
|
+
return [value for value in _all_printable_strings(data) if value in RECOGNIZED_FIELDS]
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _value_strings(data: bytes) -> list[str]:
|
|
183
|
+
return [
|
|
184
|
+
value
|
|
185
|
+
for value in _all_printable_strings(data)
|
|
186
|
+
if value not in RECOGNIZED_FIELDS and len(value) >= 2
|
|
187
|
+
]
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _value_ints(data: bytes) -> list[int]:
|
|
191
|
+
ints: list[int] = []
|
|
192
|
+
try:
|
|
193
|
+
fields = list(_decode_fields(data))
|
|
194
|
+
except ValueError:
|
|
195
|
+
return ints
|
|
196
|
+
|
|
197
|
+
for _field_number, wire_type, value in fields:
|
|
198
|
+
if wire_type == 0 and isinstance(value, int):
|
|
199
|
+
ints.append(value)
|
|
200
|
+
elif wire_type == 2 and isinstance(value, bytes):
|
|
201
|
+
ints.extend(_value_ints(value))
|
|
202
|
+
return ints
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _all_printable_strings(data: bytes) -> list[str]:
|
|
206
|
+
values: list[str] = []
|
|
207
|
+
values.extend(
|
|
208
|
+
match.group(0).decode("utf-8", errors="ignore")
|
|
209
|
+
for match in re.finditer(rb"[\x20-\x7e]{2,}", data)
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
try:
|
|
213
|
+
fields = list(_decode_fields(data))
|
|
214
|
+
except ValueError:
|
|
215
|
+
return values
|
|
216
|
+
|
|
217
|
+
for _field_number, wire_type, value in fields:
|
|
218
|
+
if wire_type == 2 and isinstance(value, bytes):
|
|
219
|
+
values.extend(_all_printable_strings(value))
|
|
220
|
+
return list(_dedupe(values))
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _dedupe(values: list[str]) -> Iterator[str]:
|
|
224
|
+
seen: set[str] = set()
|
|
225
|
+
for value in values:
|
|
226
|
+
if value in seen:
|
|
227
|
+
continue
|
|
228
|
+
seen.add(value)
|
|
229
|
+
yield value
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: timing-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: CLI + MCP server for the Timing.app macOS time tracker - reads the local activity database and generates/pushes time entries via the Timing Web API
|
|
5
|
+
Project-URL: Homepage, https://github.com/sussdorff/timing-cli
|
|
6
|
+
Project-URL: Repository, https://github.com/sussdorff/timing-cli
|
|
7
|
+
Project-URL: Issues, https://github.com/sussdorff/timing-cli/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/sussdorff/timing-cli/blob/main/CHANGELOG.md
|
|
9
|
+
Author: Malte Sussdorff
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: cli,macos,mcp,productivity,time-tracking,timing
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Operating System :: MacOS
|
|
17
|
+
Classifier: Operating System :: MacOS :: MacOS X
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Office/Business
|
|
22
|
+
Classifier: Topic :: Utilities
|
|
23
|
+
Requires-Python: >=3.12
|
|
24
|
+
Requires-Dist: fastmcp>=2.0
|
|
25
|
+
Requires-Dist: httpx>=0.27.0
|
|
26
|
+
Requires-Dist: mcp>=1.23.0
|
|
27
|
+
Requires-Dist: pydantic>=2.0.0
|
|
28
|
+
Requires-Dist: rich>=13.0.0
|
|
29
|
+
Requires-Dist: typer>=0.15.0
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
# timing-cli
|
|
33
|
+
|
|
34
|
+
A command-line interface **and MCP server** for [Timing.app](https://timingapp.com/)
|
|
35
|
+
on macOS. Unlike the Timing Web API — which cannot see your locally recorded app
|
|
36
|
+
usage — `timing-cli` reads Timing's **local activity database directly**
|
|
37
|
+
(read-only) and turns real app usage into aggregated time entries, which it can
|
|
38
|
+
then push back to Timing via the Web API.
|
|
39
|
+
|
|
40
|
+
Because it ships an embedded MCP server, agents (e.g. the Hermes personal agent)
|
|
41
|
+
can query your activity and create entries **without ever copying or exposing
|
|
42
|
+
the raw `SQLite.db`** — the server runs locally, next to the database.
|
|
43
|
+
|
|
44
|
+
> **Not affiliated with Timing.** Independent tool. It only ever *reads* the
|
|
45
|
+
> local database; all writes go through the official Web API.
|
|
46
|
+
|
|
47
|
+
## What it does
|
|
48
|
+
|
|
49
|
+
- **Reads** the local Timing store (`~/Library/Application Support/info.eurocomp.Timing2/SQLite.db`)
|
|
50
|
+
read-only: automatic app activity, window titles, document paths, projects.
|
|
51
|
+
- **Classifies** unassigned activity onto projects with your own rules (Timing's
|
|
52
|
+
built-in predicate rules only cover ~15% of activity). It also reuses decoded
|
|
53
|
+
Timing project predicate rules from the local database when Timing did not
|
|
54
|
+
already assign a slice.
|
|
55
|
+
- **Aggregates** consecutive same-project slices into clean time blocks
|
|
56
|
+
(gap-merging + minimum-duration filtering).
|
|
57
|
+
- **Pushes** the resulting suggestions to Timing as real time entries via the
|
|
58
|
+
Web API (with a safe dry-run default).
|
|
59
|
+
- **Serves** all of this over MCP (`timing serve`) for agents.
|
|
60
|
+
|
|
61
|
+
## Quickstart
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
# Install (globally, via uv)
|
|
65
|
+
uv tool install timing-cli
|
|
66
|
+
|
|
67
|
+
# See what's in your local database
|
|
68
|
+
timing info
|
|
69
|
+
|
|
70
|
+
# Daily project summary and suggested entries (read-only)
|
|
71
|
+
timing summary --date 2026-07-05
|
|
72
|
+
timing suggest --date 2026-07-05
|
|
73
|
+
|
|
74
|
+
# Push suggestions to Timing (dry-run first, then --yes)
|
|
75
|
+
export TIMING_API_KEY=... # from https://web.timingapp.com/integrations/tokens
|
|
76
|
+
timing push --date 2026-07-05 # dry-run
|
|
77
|
+
timing push --date 2026-07-05 --yes # actually create entries
|
|
78
|
+
|
|
79
|
+
# Run the MCP server (for Hermes / other agents)
|
|
80
|
+
timing serve # stdio
|
|
81
|
+
export TIMING_MCP_TOKEN=...
|
|
82
|
+
timing serve --transport http # HTTP on 127.0.0.1:8321
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
`timing push --yes` resolves every non-unassigned suggestion to a unique Web-API
|
|
86
|
+
project before creating anything. If a project is ambiguous or unmapped, the push
|
|
87
|
+
fails before the first write; add a `[project_mappings]` override. Re-running the
|
|
88
|
+
same push skips matching existing entries unless `--replace` is passed.
|
|
89
|
+
|
|
90
|
+
## Commands
|
|
91
|
+
|
|
92
|
+
| Command | Description |
|
|
93
|
+
| --- | --- |
|
|
94
|
+
| `timing info` | Database location, recorded date range, token status |
|
|
95
|
+
| `timing projects [--remote] [--archived]` | List projects (local DB or Web API) |
|
|
96
|
+
| `timing usage [--date/--from/--to] [--project ID]` | Raw automatically tracked app usage |
|
|
97
|
+
| `timing summary [--date/--from/--to]` | Total time per project |
|
|
98
|
+
| `timing suggest [--date/--from/--to]` | Aggregated time-entry suggestions (read-only) |
|
|
99
|
+
| `timing push [--date/--from/--to] [--yes] [--replace]` | Create entries via Web API (dry-run by default) |
|
|
100
|
+
| `timing serve [--transport] [--host] [--port]` | Run the MCP server |
|
|
101
|
+
|
|
102
|
+
## Daily workflow
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
# 1. Inspect the day without writing anything.
|
|
106
|
+
timing summary --date 2026-07-05
|
|
107
|
+
timing suggest --date 2026-07-05
|
|
108
|
+
|
|
109
|
+
# 2. If push projects are unmapped, inspect remote project references.
|
|
110
|
+
export TIMING_API_KEY=...
|
|
111
|
+
timing projects --remote
|
|
112
|
+
|
|
113
|
+
# 3. Add missing [project_mappings] entries, then dry-run again.
|
|
114
|
+
timing push --date 2026-07-05
|
|
115
|
+
|
|
116
|
+
# 4. Create entries once the dry-run looks right.
|
|
117
|
+
timing push --date 2026-07-05 --yes
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Re-running step 4 for the same day skips matching existing entries. Use
|
|
121
|
+
`--replace` only when you deliberately want Timing's API to replace overlapping
|
|
122
|
+
entries in the target window.
|
|
123
|
+
|
|
124
|
+
## Configuration
|
|
125
|
+
|
|
126
|
+
Optional config at `~/.config/timing-cli/config.toml`:
|
|
127
|
+
|
|
128
|
+
```toml
|
|
129
|
+
# Override the database path if Timing lives elsewhere.
|
|
130
|
+
# db_path = "~/Library/Application Support/info.eurocomp.Timing2/SQLite.db"
|
|
131
|
+
|
|
132
|
+
api_base_url = "https://web.timingapp.com/api/v1"
|
|
133
|
+
# api_token = "..." # prefer the TIMING_API_KEY env var instead
|
|
134
|
+
# mcp_http_token = "..." # prefer the TIMING_MCP_TOKEN env var instead
|
|
135
|
+
|
|
136
|
+
min_block_seconds = 120 # drop aggregated blocks shorter than this
|
|
137
|
+
gap_merge_seconds = 300 # merge same-project slices split by a gap up to this
|
|
138
|
+
|
|
139
|
+
# Optional overrides from local Timing projects to Web-API project references.
|
|
140
|
+
# Keys can be a local id ("id:42"), a full title chain, or a leaf title.
|
|
141
|
+
[project_mappings]
|
|
142
|
+
"Client / Polaris" = "/projects/123"
|
|
143
|
+
"id:42" = "/projects/456"
|
|
144
|
+
|
|
145
|
+
# Classification rules: map unassigned activity onto projects.
|
|
146
|
+
# First match wins. `app`/`bundle_id` are case-insensitive substrings;
|
|
147
|
+
# `title`/`path` are regexes.
|
|
148
|
+
[[rules]]
|
|
149
|
+
project = "Polaris"
|
|
150
|
+
title = "polaris"
|
|
151
|
+
|
|
152
|
+
[[rules]]
|
|
153
|
+
project = "Cognovis"
|
|
154
|
+
path = "code/mira"
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
### Cognovis example
|
|
158
|
+
|
|
159
|
+
This is a real-world shape for Malte's local setup. Fill the Web-API project
|
|
160
|
+
references from `timing projects --remote`; local project ids can be discovered
|
|
161
|
+
with `timing projects`.
|
|
162
|
+
|
|
163
|
+
```toml
|
|
164
|
+
api_base_url = "https://web.timingapp.com/api/v1"
|
|
165
|
+
min_block_seconds = 120
|
|
166
|
+
gap_merge_seconds = 300
|
|
167
|
+
|
|
168
|
+
[project_mappings]
|
|
169
|
+
"cognovis Verwaltung" = "/projects/REMOTE_COGNOVIS_VERWALTUNG"
|
|
170
|
+
"]project-open[" = "/projects/REMOTE_PROJECT_OPEN"
|
|
171
|
+
"Home Electronic" = "/projects/REMOTE_HOME_ELECTRONIC"
|
|
172
|
+
|
|
173
|
+
[[rules]]
|
|
174
|
+
project = "cognovis Verwaltung"
|
|
175
|
+
title = "(timing|collmex|paperless|invoice|rechnung)"
|
|
176
|
+
|
|
177
|
+
[[rules]]
|
|
178
|
+
project = "cognovis Verwaltung"
|
|
179
|
+
path = "code/(cli-tools|library)"
|
|
180
|
+
|
|
181
|
+
[[rules]]
|
|
182
|
+
project = "]project-open["
|
|
183
|
+
app = "Mail"
|
|
184
|
+
title = "project-open"
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
Timing's own project predicates are loaded from the local `Project.predicate`
|
|
188
|
+
column automatically and applied after explicit config rules. Keep local rules
|
|
189
|
+
for repository paths, editor titles, and project-specific conventions that
|
|
190
|
+
Timing itself does not classify well.
|
|
191
|
+
|
|
192
|
+
## MCP tools
|
|
193
|
+
|
|
194
|
+
`timing serve` exposes: `list_timing_projects`, `list_app_usage_tool`,
|
|
195
|
+
`daily_project_summary`, `suggest_time_entries`, `create_time_entry` (write),
|
|
196
|
+
`recorded_date_range`.
|
|
197
|
+
|
|
198
|
+
HTTP transport requires bearer-token authentication via `TIMING_MCP_TOKEN` or
|
|
199
|
+
`mcp_http_token` in the config. Stdio transport remains local and does not require
|
|
200
|
+
an MCP token.
|
|
201
|
+
|
|
202
|
+
## Release
|
|
203
|
+
|
|
204
|
+
The repository includes a GitHub Actions release workflow at
|
|
205
|
+
`.github/workflows/release.yml`. Tag pushes run tests and lint only; published
|
|
206
|
+
GitHub releases or manual dispatches build the package and publish to PyPI using
|
|
207
|
+
Trusted Publishing. Configure a PyPI trusted publisher for the repository and
|
|
208
|
+
the `pypi` environment before running the publish job.
|
|
209
|
+
|
|
210
|
+
## Requirements
|
|
211
|
+
|
|
212
|
+
- macOS with Timing.app installed
|
|
213
|
+
- Python 3.12+ (installed automatically by `uv tool install`)
|
|
214
|
+
- A Timing Web API token for pushing entries (read-only commands need no token)
|
|
215
|
+
|
|
216
|
+
## License
|
|
217
|
+
|
|
218
|
+
MIT
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
timing_cli/__init__.py,sha256=hY-GD8CkECLw70dtykPj53acjuJAzojiE5JlyGEpgKI,108
|
|
2
|
+
timing_cli/analysis.py,sha256=pfuZMXaK2G2sL0xHIHzTlsGlGBzkM3WB8F5sUunH1R0,5839
|
|
3
|
+
timing_cli/api.py,sha256=A-spEnd2Jml012DqTjZXRdGGB5Ql_DaQEkJ4Y8NBx9E,11207
|
|
4
|
+
timing_cli/cli.py,sha256=LWlE_l3ZWN21rONd1q02SM9T5f9BvPCVeIjZPLyws-g,12256
|
|
5
|
+
timing_cli/config.py,sha256=sAF4yNpYX5Hdxl52BvOajrJFvtyHc-Z3QqdWTHHWwQQ,3103
|
|
6
|
+
timing_cli/db.py,sha256=cohc57l_4fovYfD8Awx2cZ5hNODeKZ7aEWgqyq3oHRY,10076
|
|
7
|
+
timing_cli/models.py,sha256=DXurP-4lDlLDBOlsDXfsN6R4ZCntm8LC7h9CwoQuhWE,2779
|
|
8
|
+
timing_cli/output.py,sha256=-VmJmkgIrXZR9n7bYVC0Qv6cMoFQ8RMumfqS3CBxnAE,2546
|
|
9
|
+
timing_cli/rules.py,sha256=VtivHjbMV5Y8MV1eZfiTUD6ROg5Mwjsd8cAjDmZPohA,3654
|
|
10
|
+
timing_cli/serve.py,sha256=jFh7kW8u-hVAT--TRgmIVr9ykTJw2200LxVXKZ8J6xk,7020
|
|
11
|
+
timing_cli/timing_predicates.py,sha256=pr_wWOF-G_lwazvVjdgzUBb9otExXSEJYVN49L6BQGE,6977
|
|
12
|
+
timing_cli-0.1.0.dist-info/METADATA,sha256=Xf4CK4NUOqzFhd35sw0BOQFVqtvdZMOKqjN_NgjJwgY,8147
|
|
13
|
+
timing_cli-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
14
|
+
timing_cli-0.1.0.dist-info/entry_points.txt,sha256=NFLU0ZwwXYA_JFJkV8YVxN9Wj_4blhE5NQtaxbBs8Aw,46
|
|
15
|
+
timing_cli-0.1.0.dist-info/licenses/LICENSE,sha256=m95-uSx9Hd54_EmMYstMjv6GCB-1u2lpYJRzyLpkcQ8,1072
|
|
16
|
+
timing_cli-0.1.0.dist-info/RECORD,,
|