freeagent-cli 0.1.0__tar.gz

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.
@@ -0,0 +1,7 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
4
+ dist/
5
+ build/
6
+ *.egg-info/
7
+ .python-version
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Tom Dyson
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,82 @@
1
+ Metadata-Version: 2.4
2
+ Name: freeagent-cli
3
+ Version: 0.1.0
4
+ Summary: CLI for submitting FreeAgent timeslips
5
+ Project-URL: Homepage, https://github.com/tomdyson/freeagent-cli
6
+ Project-URL: Repository, https://github.com/tomdyson/freeagent-cli
7
+ Project-URL: Issues, https://github.com/tomdyson/freeagent-cli/issues
8
+ Author-email: Tom Dyson <tom@naive.co.uk>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: cli,freeagent,timeslip,timetracking
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: End Users/Desktop
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Office/Business :: Financial :: Accounting
21
+ Classifier: Topic :: Utilities
22
+ Requires-Python: >=3.11
23
+ Requires-Dist: click>=8.1
24
+ Requires-Dist: httpx>=0.27
25
+ Requires-Dist: platformdirs>=4.0
26
+ Description-Content-Type: text/markdown
27
+
28
+ # freeagent-cli
29
+
30
+ A small CLI for submitting FreeAgent timeslips without clicking through the web UI.
31
+
32
+ ```
33
+ freeagent-cli time submit --project acme --hours 1h30m --comment "fixed the thing"
34
+ ```
35
+
36
+ ## Install
37
+
38
+ ```
39
+ uv tool install freeagent-cli
40
+ ```
41
+
42
+ ## One-time setup
43
+
44
+ You'll need to register your own OAuth app with FreeAgent. It takes about two minutes and keeps your data and rate limits separate from everyone else's.
45
+
46
+ 1. Go to <https://dev.freeagent.com/apps> and create a new app.
47
+ 2. Set the redirect URI to: `http://localhost:7878/callback`
48
+ 3. Note the **OAuth identifier** and **OAuth secret**.
49
+ 4. Save them locally:
50
+
51
+ ```
52
+ freeagent-cli auth init --client-id <id> --client-secret <secret>
53
+ ```
54
+
55
+ Add `--sandbox` if you want to test against the FreeAgent sandbox first.
56
+ 5. Authorise the app in your browser:
57
+
58
+ ```
59
+ freeagent-cli auth login
60
+ ```
61
+
62
+ A browser tab opens, you approve, and the CLI captures the refresh token. The refresh token lasts ~20 years; access tokens auto-refresh on every command.
63
+
64
+ Credentials are stored at `~/Library/Application Support/freeagent-cli/config.json` (macOS) or the equivalent platform config directory, with file mode `0600`.
65
+
66
+ ## Usage
67
+
68
+ ```
69
+ freeagent-cli --help # canonical flow
70
+ freeagent-cli projects list # projects + tasks in one call
71
+ freeagent-cli time submit --project <name> --hours 1h30m [--task <name>] [--comment "..."] [--dry-run]
72
+ ```
73
+
74
+ - **`--hours`** accepts `1.5`, `90m`, `1h30m`, or `1:30`.
75
+ - **`--project`** / **`--task`** match by case-insensitive name substring, numeric id, or full URL.
76
+ - **`--task`** is optional when the project has a single task; otherwise the error message lists the choices.
77
+ - **`--date`** defaults to today (ISO `YYYY-MM-DD` to override).
78
+ - **`--dry-run`** resolves the project/task/date and prints the would-be submission without sending it.
79
+
80
+ ## License
81
+
82
+ MIT
@@ -0,0 +1,55 @@
1
+ # freeagent-cli
2
+
3
+ A small CLI for submitting FreeAgent timeslips without clicking through the web UI.
4
+
5
+ ```
6
+ freeagent-cli time submit --project acme --hours 1h30m --comment "fixed the thing"
7
+ ```
8
+
9
+ ## Install
10
+
11
+ ```
12
+ uv tool install freeagent-cli
13
+ ```
14
+
15
+ ## One-time setup
16
+
17
+ You'll need to register your own OAuth app with FreeAgent. It takes about two minutes and keeps your data and rate limits separate from everyone else's.
18
+
19
+ 1. Go to <https://dev.freeagent.com/apps> and create a new app.
20
+ 2. Set the redirect URI to: `http://localhost:7878/callback`
21
+ 3. Note the **OAuth identifier** and **OAuth secret**.
22
+ 4. Save them locally:
23
+
24
+ ```
25
+ freeagent-cli auth init --client-id <id> --client-secret <secret>
26
+ ```
27
+
28
+ Add `--sandbox` if you want to test against the FreeAgent sandbox first.
29
+ 5. Authorise the app in your browser:
30
+
31
+ ```
32
+ freeagent-cli auth login
33
+ ```
34
+
35
+ A browser tab opens, you approve, and the CLI captures the refresh token. The refresh token lasts ~20 years; access tokens auto-refresh on every command.
36
+
37
+ Credentials are stored at `~/Library/Application Support/freeagent-cli/config.json` (macOS) or the equivalent platform config directory, with file mode `0600`.
38
+
39
+ ## Usage
40
+
41
+ ```
42
+ freeagent-cli --help # canonical flow
43
+ freeagent-cli projects list # projects + tasks in one call
44
+ freeagent-cli time submit --project <name> --hours 1h30m [--task <name>] [--comment "..."] [--dry-run]
45
+ ```
46
+
47
+ - **`--hours`** accepts `1.5`, `90m`, `1h30m`, or `1:30`.
48
+ - **`--project`** / **`--task`** match by case-insensitive name substring, numeric id, or full URL.
49
+ - **`--task`** is optional when the project has a single task; otherwise the error message lists the choices.
50
+ - **`--date`** defaults to today (ISO `YYYY-MM-DD` to override).
51
+ - **`--dry-run`** resolves the project/task/date and prints the would-be submission without sending it.
52
+
53
+ ## License
54
+
55
+ MIT
@@ -0,0 +1,42 @@
1
+ [project]
2
+ name = "freeagent-cli"
3
+ version = "0.1.0"
4
+ description = "CLI for submitting FreeAgent timeslips"
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ license = "MIT"
8
+ license-files = ["LICENSE"]
9
+ authors = [{ name = "Tom Dyson", email = "tom@naive.co.uk" }]
10
+ keywords = ["freeagent", "cli", "timetracking", "timeslip"]
11
+ classifiers = [
12
+ "Development Status :: 4 - Beta",
13
+ "Environment :: Console",
14
+ "Intended Audience :: End Users/Desktop",
15
+ "Operating System :: OS Independent",
16
+ "Programming Language :: Python :: 3",
17
+ "Programming Language :: Python :: 3.11",
18
+ "Programming Language :: Python :: 3.12",
19
+ "Programming Language :: Python :: 3.13",
20
+ "Topic :: Office/Business :: Financial :: Accounting",
21
+ "Topic :: Utilities",
22
+ ]
23
+ dependencies = [
24
+ "click>=8.1",
25
+ "httpx>=0.27",
26
+ "platformdirs>=4.0",
27
+ ]
28
+
29
+ [project.urls]
30
+ Homepage = "https://github.com/tomdyson/freeagent-cli"
31
+ Repository = "https://github.com/tomdyson/freeagent-cli"
32
+ Issues = "https://github.com/tomdyson/freeagent-cli/issues"
33
+
34
+ [project.scripts]
35
+ freeagent-cli = "freeagent_cli.cli:main"
36
+
37
+ [build-system]
38
+ requires = ["hatchling"]
39
+ build-backend = "hatchling.build"
40
+
41
+ [tool.hatch.build.targets.wheel]
42
+ packages = ["src/freeagent_cli"]
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,62 @@
1
+ from __future__ import annotations
2
+
3
+ import httpx
4
+
5
+ from . import auth
6
+ from . import config as cfg
7
+
8
+
9
+ class FreeAgent:
10
+ def __init__(self, c: cfg.Config):
11
+ self.cfg = c
12
+
13
+ def _client(self) -> httpx.Client:
14
+ return httpx.Client(
15
+ base_url=self.cfg.api_base,
16
+ headers={
17
+ "Authorization": f"Bearer {auth.access_token(self.cfg)}",
18
+ "Accept": "application/json",
19
+ "User-Agent": "freeagent-cli/0.1",
20
+ },
21
+ timeout=30,
22
+ )
23
+
24
+ def get(self, path: str, **params) -> dict:
25
+ with self._client() as c:
26
+ r = c.get(path, params=params)
27
+ r.raise_for_status()
28
+ return r.json()
29
+
30
+ def post(self, path: str, json_body: dict) -> dict:
31
+ with self._client() as c:
32
+ r = c.post(path, json=json_body)
33
+ if r.status_code >= 400:
34
+ raise httpx.HTTPStatusError(
35
+ f"{r.status_code} {r.reason_phrase}: {r.text}",
36
+ request=r.request, response=r,
37
+ )
38
+ return r.json()
39
+
40
+ def me(self) -> dict:
41
+ return self.get("/v2/users/me")["user"]
42
+
43
+ def projects(self, view: str = "active") -> list[dict]:
44
+ return self.get("/v2/projects", view=view).get("projects", [])
45
+
46
+ def tasks(self, project_url: str) -> list[dict]:
47
+ return self.get("/v2/tasks", project=project_url).get("tasks", [])
48
+
49
+ def create_timeslip(self, *, user: str, project: str, task: str,
50
+ dated_on: str, hours: float, comment: str | None = None) -> dict:
51
+ body: dict = {
52
+ "timeslip": {
53
+ "user": user,
54
+ "project": project,
55
+ "task": task,
56
+ "dated_on": dated_on,
57
+ "hours": str(hours),
58
+ }
59
+ }
60
+ if comment:
61
+ body["timeslip"]["comment"] = comment
62
+ return self.post("/v2/timeslips", body)
@@ -0,0 +1,118 @@
1
+ from __future__ import annotations
2
+
3
+ import secrets
4
+ import time
5
+ import webbrowser
6
+ from http.server import BaseHTTPRequestHandler, HTTPServer
7
+ from urllib.parse import parse_qs, urlencode, urlparse
8
+
9
+ import httpx
10
+
11
+ from . import config as cfg
12
+
13
+
14
+ def _token_url(c: cfg.Config) -> str:
15
+ return f"{c.api_base}/v2/token_endpoint"
16
+
17
+
18
+ def _authorize_url(c: cfg.Config) -> str:
19
+ return f"{c.api_base}/v2/approve_app"
20
+
21
+
22
+ class _Handler(BaseHTTPRequestHandler):
23
+ received: dict[str, str] = {}
24
+
25
+ def do_GET(self): # noqa: N802
26
+ parsed = urlparse(self.path)
27
+ if parsed.path != "/callback":
28
+ self.send_response(404)
29
+ self.end_headers()
30
+ return
31
+ params = {k: v[0] for k, v in parse_qs(parsed.query).items()}
32
+ _Handler.received = params
33
+ self.send_response(200)
34
+ self.send_header("Content-Type", "text/html; charset=utf-8")
35
+ self.end_headers()
36
+ body = b"<html><body style='font-family:sans-serif;padding:2rem'>" \
37
+ b"<h1>FreeAgent CLI authorised</h1>" \
38
+ b"<p>You can close this tab and return to the terminal.</p>" \
39
+ b"</body></html>"
40
+ self.wfile.write(body)
41
+
42
+ def log_message(self, *args, **kwargs): # silence
43
+ pass
44
+
45
+
46
+ def _wait_for_callback(port: int) -> dict[str, str]:
47
+ _Handler.received = {}
48
+ server = HTTPServer(("localhost", port), _Handler)
49
+ server.handle_request()
50
+ server.server_close()
51
+ return _Handler.received
52
+
53
+
54
+ def login(c: cfg.Config) -> None:
55
+ if not c.client_id or not c.client_secret:
56
+ raise RuntimeError("client_id/client_secret not configured; run `auth init`")
57
+ state = secrets.token_urlsafe(16)
58
+ params = {
59
+ "client_id": c.client_id,
60
+ "response_type": "code",
61
+ "redirect_uri": c.redirect_uri,
62
+ "state": state,
63
+ }
64
+ url = f"{_authorize_url(c)}?{urlencode(params)}"
65
+ port = urlparse(c.redirect_uri).port or cfg.DEFAULT_PORT
66
+ print(f"Opening browser for authorisation. If it doesn't open, visit:\n {url}")
67
+ webbrowser.open(url)
68
+ received = _wait_for_callback(port)
69
+ if "error" in received:
70
+ raise RuntimeError(f"Authorisation failed: {received.get('error_description') or received['error']}")
71
+ if received.get("state") != state:
72
+ raise RuntimeError("OAuth state mismatch — possible CSRF; aborting.")
73
+ code = received.get("code")
74
+ if not code:
75
+ raise RuntimeError(f"No authorisation code in callback: {received}")
76
+ resp = httpx.post(
77
+ _token_url(c),
78
+ auth=(c.client_id, c.client_secret),
79
+ data={
80
+ "grant_type": "authorization_code",
81
+ "code": code,
82
+ "redirect_uri": c.redirect_uri,
83
+ },
84
+ timeout=30,
85
+ )
86
+ resp.raise_for_status()
87
+ _apply_token_response(c, resp.json())
88
+ cfg.save(c)
89
+
90
+
91
+ def refresh(c: cfg.Config) -> None:
92
+ if not c.refresh_token:
93
+ raise RuntimeError("No refresh token; run `auth login`")
94
+ resp = httpx.post(
95
+ _token_url(c),
96
+ auth=(c.client_id, c.client_secret),
97
+ data={
98
+ "grant_type": "refresh_token",
99
+ "refresh_token": c.refresh_token,
100
+ },
101
+ timeout=30,
102
+ )
103
+ resp.raise_for_status()
104
+ _apply_token_response(c, resp.json())
105
+ cfg.save(c)
106
+
107
+
108
+ def _apply_token_response(c: cfg.Config, payload: dict) -> None:
109
+ c.access_token = payload["access_token"]
110
+ c.access_token_expires_at = time.time() + int(payload.get("expires_in", 3600))
111
+ if payload.get("refresh_token"):
112
+ c.refresh_token = payload["refresh_token"]
113
+
114
+
115
+ def access_token(c: cfg.Config) -> str:
116
+ if not c.access_token or time.time() >= c.access_token_expires_at - 60:
117
+ refresh(c)
118
+ return c.access_token
@@ -0,0 +1,238 @@
1
+ from __future__ import annotations
2
+
3
+ import datetime as _dt
4
+ import re
5
+
6
+ import click
7
+
8
+ from . import auth
9
+ from . import config as cfg
10
+ from .api import FreeAgent
11
+
12
+
13
+ def _api() -> FreeAgent:
14
+ c = cfg.load()
15
+ if not c.client_id:
16
+ raise click.UsageError("Not configured. Run `freeagent-cli auth init`.")
17
+ if not c.refresh_token:
18
+ raise click.UsageError("Not authenticated. Run `freeagent-cli auth login`.")
19
+ return FreeAgent(c)
20
+
21
+
22
+ def parse_hours(s: str) -> float:
23
+ """Parse a duration: 1.5, 90m, 1h, 1h30m, 1:30."""
24
+ s = s.strip().lower()
25
+ try:
26
+ return float(s)
27
+ except ValueError:
28
+ pass
29
+ if ":" in s:
30
+ h, m = s.split(":", 1)
31
+ return int(h) + int(m) / 60
32
+ m = re.fullmatch(r"(?:(\d+(?:\.\d+)?)h)?(?:(\d+)m)?", s)
33
+ if m and (m.group(1) or m.group(2)):
34
+ total = float(m.group(1) or 0)
35
+ total += int(m.group(2) or 0) / 60
36
+ return total
37
+ raise ValueError(f"Cannot parse duration {s!r}; try 1.5, 90m, 1h30m, or 1:30")
38
+
39
+
40
+ class _HoursType(click.ParamType):
41
+ name = "duration"
42
+
43
+ def convert(self, value, param, ctx):
44
+ try:
45
+ return parse_hours(value)
46
+ except ValueError as e:
47
+ self.fail(str(e), param, ctx)
48
+
49
+
50
+ HOURS = _HoursType()
51
+
52
+
53
+ def _resolve(items: list[dict], query: str, label: str) -> dict:
54
+ if query.startswith("http"):
55
+ match = [i for i in items if i["url"] == query]
56
+ if match:
57
+ return match[0]
58
+ if query.isdigit():
59
+ match = [i for i in items if i["url"].rsplit("/", 1)[-1] == query]
60
+ if match:
61
+ return match[0]
62
+ q = query.lower()
63
+ matches = [i for i in items if q in i["name"].lower()]
64
+ if not matches:
65
+ names = ", ".join(i["name"] for i in items) or "(none)"
66
+ raise click.UsageError(f"No {label} matches {query!r}. Available: {names}")
67
+ if len(matches) > 1:
68
+ names = ", ".join(m["name"] for m in matches)
69
+ raise click.UsageError(f"Multiple {label}s match {query!r}: {names}")
70
+ return matches[0]
71
+
72
+
73
+ def _pick_task(tasks: list[dict], task_q: str | None, project_name: str) -> dict:
74
+ if task_q:
75
+ return _resolve(tasks, task_q, "task")
76
+ if not tasks:
77
+ raise click.UsageError(f"Project {project_name!r} has no tasks.")
78
+ if len(tasks) == 1:
79
+ return tasks[0]
80
+ names = ", ".join(t["name"] for t in tasks)
81
+ raise click.UsageError(
82
+ f"--task required for {project_name!r}. Available: {names}"
83
+ )
84
+
85
+
86
+ @click.group(epilog="""
87
+ \b
88
+ Typical flow (for agents/scripts):
89
+ freeagent-cli projects list # projects + tasks in one call
90
+ freeagent-cli time submit --project <name> --hours 1h30m [--task <name>] [--comment "..."] [--dry-run]
91
+
92
+ \b
93
+ Time formats accepted: 1.5 90m 1h30m 1:30
94
+ Project/task selection: numeric id, full URL, or case-insensitive name substring.
95
+ --task is optional only when the project has a single task; otherwise the error lists the choices.
96
+ """)
97
+ def main():
98
+ """FreeAgent CLI."""
99
+
100
+
101
+ # -- auth ----------------------------------------------------------------
102
+
103
+ @main.group()
104
+ def auth_grp():
105
+ """Authentication commands."""
106
+
107
+
108
+ main.add_command(auth_grp, name="auth")
109
+
110
+
111
+ @auth_grp.command("init")
112
+ @click.option("--client-id", required=True)
113
+ @click.option("--client-secret", required=True)
114
+ @click.option("--sandbox/--production", default=False, help="Use sandbox API (default: production).")
115
+ @click.option("--redirect-uri", default=cfg.DEFAULT_REDIRECT, show_default=True)
116
+ def auth_init(client_id, client_secret, sandbox, redirect_uri):
117
+ """Store OAuth client credentials."""
118
+ c = cfg.load()
119
+ c.client_id = client_id
120
+ c.client_secret = client_secret
121
+ c.redirect_uri = redirect_uri
122
+ c.api_base = cfg.SANDBOX_BASE if sandbox else cfg.PROD_BASE
123
+ cfg.save(c)
124
+ click.echo(f"Saved → {cfg.config_path()}")
125
+ click.echo("Next: `freeagent-cli auth login`")
126
+
127
+
128
+ @auth_grp.command("login")
129
+ def auth_login():
130
+ """Run the browser OAuth flow and store a refresh token."""
131
+ c = cfg.load()
132
+ if not c.client_id:
133
+ raise click.UsageError("Run `freeagent-cli auth init` first.")
134
+ auth.login(c)
135
+ click.echo("Authenticated.")
136
+
137
+
138
+ @auth_grp.command("status")
139
+ def auth_status():
140
+ """Show authentication status."""
141
+ c = cfg.load()
142
+ click.echo(f"Config: {cfg.config_path()}")
143
+ click.echo(f"API: {c.api_base}")
144
+ click.echo(f"Client: {'set' if c.client_id else 'missing'}")
145
+ click.echo(f"Refresh: {'present' if c.refresh_token else 'missing'}")
146
+
147
+
148
+ # -- me / projects / tasks -----------------------------------------------
149
+
150
+ @main.command()
151
+ def me():
152
+ """Show the authenticated user."""
153
+ u = _api().me()
154
+ click.echo(f"{u.get('first_name','')} {u.get('last_name','')} <{u.get('email','')}>")
155
+ click.echo(u["url"])
156
+
157
+
158
+ @main.group()
159
+ def projects():
160
+ """Project commands."""
161
+
162
+
163
+ @projects.command("list")
164
+ @click.option("--all", "show_all", is_flag=True, help="Include inactive projects.")
165
+ @click.option("--tasks/--no-tasks", "with_tasks", default=True,
166
+ help="Include tasks for each project (default: yes).")
167
+ def projects_list(show_all, with_tasks):
168
+ """List projects (and their tasks, by default)."""
169
+ api = _api()
170
+ view = "all" if show_all else "active"
171
+ for p in api.projects(view=view):
172
+ pid = p["url"].rsplit("/", 1)[-1]
173
+ click.echo(f"{pid}\t{p['name']}")
174
+ if with_tasks:
175
+ for t in api.tasks(p["url"]):
176
+ tid = t["url"].rsplit("/", 1)[-1]
177
+ click.echo(f"\t{tid}\t{t['name']}")
178
+
179
+
180
+ @main.group()
181
+ def tasks():
182
+ """Task commands."""
183
+
184
+
185
+ @tasks.command("list")
186
+ @click.option("--project", "project_q", required=True, help="Project name substring, id, or URL.")
187
+ def tasks_list(project_q):
188
+ api = _api()
189
+ project = _resolve(api.projects(view="active"), project_q, "project")
190
+ for t in api.tasks(project["url"]):
191
+ tid = t["url"].rsplit("/", 1)[-1]
192
+ click.echo(f"{tid}\t{t['name']}")
193
+
194
+
195
+ # -- time submit ---------------------------------------------------------
196
+
197
+ @main.group()
198
+ def time():
199
+ """Timeslip commands."""
200
+
201
+
202
+ @time.command("submit")
203
+ @click.option("--project", "project_q", required=True, help="Project name substring, id, or URL.")
204
+ @click.option("--task", "task_q", default=None,
205
+ help="Task name substring, id, or URL. Optional only if the project has a single task.")
206
+ @click.option("--hours", required=True, type=HOURS,
207
+ help="Duration: 1.5, 90m, 1h30m, 1:30, etc.")
208
+ @click.option("--date", "date_", default=None, help="YYYY-MM-DD (default: today).")
209
+ @click.option("--comment", default=None)
210
+ @click.option("--dry-run", is_flag=True, help="Resolve and preview, but don't submit.")
211
+ def time_submit(project_q, task_q, hours, date_, comment, dry_run):
212
+ """Submit a timeslip."""
213
+ api = _api()
214
+ project = _resolve(api.projects(view="active"), project_q, "project")
215
+ task = _pick_task(api.tasks(project["url"]), task_q, project["name"])
216
+ dated_on = date_ or _dt.date.today().isoformat()
217
+
218
+ if dry_run:
219
+ click.echo(f"DRY RUN — would submit {hours}h on {dated_on} → {project['name']} / {task['name']}")
220
+ click.echo(f" project: {project['url']}")
221
+ click.echo(f" task: {task['url']}")
222
+ if comment:
223
+ click.echo(f" comment: {comment}")
224
+ return
225
+
226
+ user_url = api.me()["url"]
227
+ result = api.create_timeslip(
228
+ user=user_url, project=project["url"], task=task["url"],
229
+ dated_on=dated_on, hours=hours, comment=comment,
230
+ )
231
+ ts = result["timeslips"][0] if "timeslips" in result else result.get("timeslip", result)
232
+ click.echo(f"Submitted {hours}h on {dated_on} → {project['name']} / {task['name']}")
233
+ if isinstance(ts, dict) and "url" in ts:
234
+ click.echo(ts["url"])
235
+
236
+
237
+ if __name__ == "__main__":
238
+ main()
@@ -0,0 +1,44 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ from dataclasses import asdict, dataclass
6
+ from pathlib import Path
7
+
8
+ from platformdirs import user_config_dir
9
+
10
+ PROD_BASE = "https://api.freeagent.com"
11
+ SANDBOX_BASE = "https://api.sandbox.freeagent.com"
12
+ DEFAULT_REDIRECT = "http://localhost:7878/callback"
13
+ DEFAULT_PORT = 7878
14
+
15
+
16
+ @dataclass
17
+ class Config:
18
+ client_id: str = ""
19
+ client_secret: str = ""
20
+ redirect_uri: str = DEFAULT_REDIRECT
21
+ api_base: str = PROD_BASE
22
+ refresh_token: str = ""
23
+ access_token: str = ""
24
+ access_token_expires_at: float = 0.0
25
+
26
+
27
+ def config_path() -> Path:
28
+ d = Path(user_config_dir("freeagent-cli"))
29
+ d.mkdir(parents=True, exist_ok=True)
30
+ return d / "config.json"
31
+
32
+
33
+ def load() -> Config:
34
+ p = config_path()
35
+ if not p.exists():
36
+ return Config()
37
+ data = json.loads(p.read_text())
38
+ return Config(**{k: v for k, v in data.items() if k in Config.__dataclass_fields__})
39
+
40
+
41
+ def save(config: Config) -> None:
42
+ p = config_path()
43
+ p.write_text(json.dumps(asdict(config), indent=2))
44
+ os.chmod(p, 0o600)
@@ -0,0 +1,127 @@
1
+ version = 1
2
+ revision = 3
3
+ requires-python = ">=3.11"
4
+
5
+ [[package]]
6
+ name = "anyio"
7
+ version = "4.13.0"
8
+ source = { registry = "https://pypi.org/simple" }
9
+ dependencies = [
10
+ { name = "idna" },
11
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
12
+ ]
13
+ sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" }
14
+ wheels = [
15
+ { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" },
16
+ ]
17
+
18
+ [[package]]
19
+ name = "certifi"
20
+ version = "2026.4.22"
21
+ source = { registry = "https://pypi.org/simple" }
22
+ sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" }
23
+ wheels = [
24
+ { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" },
25
+ ]
26
+
27
+ [[package]]
28
+ name = "click"
29
+ version = "8.3.3"
30
+ source = { registry = "https://pypi.org/simple" }
31
+ dependencies = [
32
+ { name = "colorama", marker = "sys_platform == 'win32'" },
33
+ ]
34
+ sdist = { url = "https://files.pythonhosted.org/packages/bb/63/f9e1ea081ce35720d8b92acde70daaedace594dc93b693c869e0d5910718/click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2", size = 328061, upload-time = "2026-04-22T15:11:27.506Z" }
35
+ wheels = [
36
+ { url = "https://files.pythonhosted.org/packages/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", size = 110502, upload-time = "2026-04-22T15:11:25.044Z" },
37
+ ]
38
+
39
+ [[package]]
40
+ name = "colorama"
41
+ version = "0.4.6"
42
+ source = { registry = "https://pypi.org/simple" }
43
+ sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
44
+ wheels = [
45
+ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
46
+ ]
47
+
48
+ [[package]]
49
+ name = "freeagent-cli"
50
+ version = "0.1.0"
51
+ source = { editable = "." }
52
+ dependencies = [
53
+ { name = "click" },
54
+ { name = "httpx" },
55
+ { name = "platformdirs" },
56
+ ]
57
+
58
+ [package.metadata]
59
+ requires-dist = [
60
+ { name = "click", specifier = ">=8.1" },
61
+ { name = "httpx", specifier = ">=0.27" },
62
+ { name = "platformdirs", specifier = ">=4.0" },
63
+ ]
64
+
65
+ [[package]]
66
+ name = "h11"
67
+ version = "0.16.0"
68
+ source = { registry = "https://pypi.org/simple" }
69
+ sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
70
+ wheels = [
71
+ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
72
+ ]
73
+
74
+ [[package]]
75
+ name = "httpcore"
76
+ version = "1.0.9"
77
+ source = { registry = "https://pypi.org/simple" }
78
+ dependencies = [
79
+ { name = "certifi" },
80
+ { name = "h11" },
81
+ ]
82
+ sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
83
+ wheels = [
84
+ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
85
+ ]
86
+
87
+ [[package]]
88
+ name = "httpx"
89
+ version = "0.28.1"
90
+ source = { registry = "https://pypi.org/simple" }
91
+ dependencies = [
92
+ { name = "anyio" },
93
+ { name = "certifi" },
94
+ { name = "httpcore" },
95
+ { name = "idna" },
96
+ ]
97
+ sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
98
+ wheels = [
99
+ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
100
+ ]
101
+
102
+ [[package]]
103
+ name = "idna"
104
+ version = "3.14"
105
+ source = { registry = "https://pypi.org/simple" }
106
+ sdist = { url = "https://files.pythonhosted.org/packages/05/b1/efac073e0c297ecf2fb33c346989a529d4e19164f1759102dee5953ee17e/idna-3.14.tar.gz", hash = "sha256:466d810d7a2cc1022bea9b037c39728d51ae7dad40d480fc9b7d7ecf98ba8ee3", size = 198272, upload-time = "2026-05-10T20:32:15.935Z" }
107
+ wheels = [
108
+ { url = "https://files.pythonhosted.org/packages/6c/3c/3f62dee257eb3d6b2c1ef2a09d36d9793c7111156a73b5654d2c2305e5ce/idna-3.14-py3-none-any.whl", hash = "sha256:e677eaf072e290f7b725f9acf0b3a2bd55f9fd6f7c70abe5f0e34823d0accf69", size = 72184, upload-time = "2026-05-10T20:32:14.295Z" },
109
+ ]
110
+
111
+ [[package]]
112
+ name = "platformdirs"
113
+ version = "4.9.6"
114
+ source = { registry = "https://pypi.org/simple" }
115
+ sdist = { url = "https://files.pythonhosted.org/packages/9f/4a/0883b8e3802965322523f0b200ecf33d31f10991d0401162f4b23c698b42/platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a", size = 29400, upload-time = "2026-04-09T00:04:10.812Z" }
116
+ wheels = [
117
+ { url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" },
118
+ ]
119
+
120
+ [[package]]
121
+ name = "typing-extensions"
122
+ version = "4.15.0"
123
+ source = { registry = "https://pypi.org/simple" }
124
+ sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
125
+ wheels = [
126
+ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
127
+ ]