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/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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ timing = timing_cli.cli:app