freeagent-cli 0.1.0__tar.gz → 0.2.1__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.
- {freeagent_cli-0.1.0 → freeagent_cli-0.2.1}/PKG-INFO +19 -8
- {freeagent_cli-0.1.0 → freeagent_cli-0.2.1}/README.md +18 -7
- {freeagent_cli-0.1.0 → freeagent_cli-0.2.1}/pyproject.toml +1 -1
- freeagent_cli-0.2.1/src/freeagent_cli/__init__.py +1 -0
- {freeagent_cli-0.1.0 → freeagent_cli-0.2.1}/src/freeagent_cli/api.py +12 -1
- {freeagent_cli-0.1.0 → freeagent_cli-0.2.1}/src/freeagent_cli/cli.py +100 -48
- {freeagent_cli-0.1.0 → freeagent_cli-0.2.1}/uv.lock +1 -1
- freeagent_cli-0.1.0/src/freeagent_cli/__init__.py +0 -1
- {freeagent_cli-0.1.0 → freeagent_cli-0.2.1}/.gitignore +0 -0
- {freeagent_cli-0.1.0 → freeagent_cli-0.2.1}/LICENSE +0 -0
- {freeagent_cli-0.1.0 → freeagent_cli-0.2.1}/src/freeagent_cli/auth.py +0 -0
- {freeagent_cli-0.1.0 → freeagent_cli-0.2.1}/src/freeagent_cli/config.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: freeagent-cli
|
|
3
|
-
Version: 0.1
|
|
3
|
+
Version: 0.2.1
|
|
4
4
|
Summary: CLI for submitting FreeAgent timeslips
|
|
5
5
|
Project-URL: Homepage, https://github.com/tomdyson/freeagent-cli
|
|
6
6
|
Project-URL: Repository, https://github.com/tomdyson/freeagent-cli
|
|
@@ -30,7 +30,7 @@ Description-Content-Type: text/markdown
|
|
|
30
30
|
A small CLI for submitting FreeAgent timeslips without clicking through the web UI.
|
|
31
31
|
|
|
32
32
|
```
|
|
33
|
-
freeagent-cli
|
|
33
|
+
freeagent-cli log acme 1h30m "fixed the thing"
|
|
34
34
|
```
|
|
35
35
|
|
|
36
36
|
## Install
|
|
@@ -66,16 +66,27 @@ Credentials are stored at `~/Library/Application Support/freeagent-cli/config.js
|
|
|
66
66
|
## Usage
|
|
67
67
|
|
|
68
68
|
```
|
|
69
|
-
freeagent-cli --help
|
|
70
|
-
freeagent-cli projects
|
|
71
|
-
freeagent-cli
|
|
69
|
+
freeagent-cli --help # canonical flow
|
|
70
|
+
freeagent-cli projects # projects + tasks in one call
|
|
71
|
+
freeagent-cli log <project> <duration> [comment...] # submit a timeslip
|
|
72
|
+
freeagent-cli recent # last few timeslips
|
|
72
73
|
```
|
|
73
74
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
75
|
+
Examples:
|
|
76
|
+
|
|
77
|
+
```
|
|
78
|
+
freeagent-cli log Acme 1h30m "fixed the thing"
|
|
79
|
+
freeagent-cli log Acme 90m fixed the thing # comment without quotes
|
|
80
|
+
freeagent-cli log "Big Co" 1.5 --task Coding --date 2026-05-01
|
|
81
|
+
freeagent-cli log Acme 1.5 --dry-run # preview, don't submit
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
- **Duration** accepts `1.5`, `90m`, `1h30m`, or `1:30`.
|
|
85
|
+
- **Project / task** match by case-insensitive name substring, numeric id, or full URL.
|
|
86
|
+
- **`--task`** is optional when the project has a single task; otherwise the error lists the choices.
|
|
77
87
|
- **`--date`** defaults to today (ISO `YYYY-MM-DD` to override).
|
|
78
88
|
- **`--dry-run`** resolves the project/task/date and prints the would-be submission without sending it.
|
|
89
|
+
- **`projects --flat`** emits one project/task pair per line (tab-separated) for grep/awk.
|
|
79
90
|
|
|
80
91
|
## License
|
|
81
92
|
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
A small CLI for submitting FreeAgent timeslips without clicking through the web UI.
|
|
4
4
|
|
|
5
5
|
```
|
|
6
|
-
freeagent-cli
|
|
6
|
+
freeagent-cli log acme 1h30m "fixed the thing"
|
|
7
7
|
```
|
|
8
8
|
|
|
9
9
|
## Install
|
|
@@ -39,16 +39,27 @@ Credentials are stored at `~/Library/Application Support/freeagent-cli/config.js
|
|
|
39
39
|
## Usage
|
|
40
40
|
|
|
41
41
|
```
|
|
42
|
-
freeagent-cli --help
|
|
43
|
-
freeagent-cli projects
|
|
44
|
-
freeagent-cli
|
|
42
|
+
freeagent-cli --help # canonical flow
|
|
43
|
+
freeagent-cli projects # projects + tasks in one call
|
|
44
|
+
freeagent-cli log <project> <duration> [comment...] # submit a timeslip
|
|
45
|
+
freeagent-cli recent # last few timeslips
|
|
45
46
|
```
|
|
46
47
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
48
|
+
Examples:
|
|
49
|
+
|
|
50
|
+
```
|
|
51
|
+
freeagent-cli log Acme 1h30m "fixed the thing"
|
|
52
|
+
freeagent-cli log Acme 90m fixed the thing # comment without quotes
|
|
53
|
+
freeagent-cli log "Big Co" 1.5 --task Coding --date 2026-05-01
|
|
54
|
+
freeagent-cli log Acme 1.5 --dry-run # preview, don't submit
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
- **Duration** accepts `1.5`, `90m`, `1h30m`, or `1:30`.
|
|
58
|
+
- **Project / task** match by case-insensitive name substring, numeric id, or full URL.
|
|
59
|
+
- **`--task`** is optional when the project has a single task; otherwise the error lists the choices.
|
|
50
60
|
- **`--date`** defaults to today (ISO `YYYY-MM-DD` to override).
|
|
51
61
|
- **`--dry-run`** resolves the project/task/date and prints the would-be submission without sending it.
|
|
62
|
+
- **`projects --flat`** emits one project/task pair per line (tab-separated) for grep/awk.
|
|
52
63
|
|
|
53
64
|
## License
|
|
54
65
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.2.1"
|
|
@@ -16,7 +16,7 @@ class FreeAgent:
|
|
|
16
16
|
headers={
|
|
17
17
|
"Authorization": f"Bearer {auth.access_token(self.cfg)}",
|
|
18
18
|
"Accept": "application/json",
|
|
19
|
-
"User-Agent": "freeagent-cli/0.
|
|
19
|
+
"User-Agent": "freeagent-cli/0.2",
|
|
20
20
|
},
|
|
21
21
|
timeout=30,
|
|
22
22
|
)
|
|
@@ -46,6 +46,17 @@ class FreeAgent:
|
|
|
46
46
|
def tasks(self, project_url: str) -> list[dict]:
|
|
47
47
|
return self.get("/v2/tasks", project=project_url).get("tasks", [])
|
|
48
48
|
|
|
49
|
+
def list_timeslips(self, *, from_date: str, to_date: str | None = None,
|
|
50
|
+
user: str | None = None, nested: bool = False) -> list[dict]:
|
|
51
|
+
params: dict = {"from_date": from_date}
|
|
52
|
+
if to_date:
|
|
53
|
+
params["to_date"] = to_date
|
|
54
|
+
if user:
|
|
55
|
+
params["user"] = user
|
|
56
|
+
if nested:
|
|
57
|
+
params["nested"] = "true"
|
|
58
|
+
return self.get("/v2/timeslips", **params).get("timeslips", [])
|
|
59
|
+
|
|
49
60
|
def create_timeslip(self, *, user: str, project: str, task: str,
|
|
50
61
|
dated_on: str, hours: float, comment: str | None = None) -> dict:
|
|
51
62
|
body: dict = {
|
|
@@ -37,17 +37,20 @@ def parse_hours(s: str) -> float:
|
|
|
37
37
|
raise ValueError(f"Cannot parse duration {s!r}; try 1.5, 90m, 1h30m, or 1:30")
|
|
38
38
|
|
|
39
39
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
40
|
+
def format_hours(value) -> str:
|
|
41
|
+
"""Format a decimal-hours value as 40m / 1h / 1h30m, rounding to the nearest minute."""
|
|
42
|
+
try:
|
|
43
|
+
total_min = round(float(value) * 60)
|
|
44
|
+
except (TypeError, ValueError):
|
|
45
|
+
return str(value)
|
|
46
|
+
if total_min == 0:
|
|
47
|
+
return "0m"
|
|
48
|
+
h, m = divmod(total_min, 60)
|
|
49
|
+
if h == 0:
|
|
50
|
+
return f"{m}m"
|
|
51
|
+
if m == 0:
|
|
52
|
+
return f"{h}h"
|
|
53
|
+
return f"{h}h{m}m"
|
|
51
54
|
|
|
52
55
|
|
|
53
56
|
def _resolve(items: list[dict], query: str, label: str) -> dict:
|
|
@@ -85,13 +88,20 @@ def _pick_task(tasks: list[dict], task_q: str | None, project_name: str) -> dict
|
|
|
85
88
|
|
|
86
89
|
@click.group(epilog="""
|
|
87
90
|
\b
|
|
88
|
-
Typical flow
|
|
89
|
-
freeagent-cli
|
|
90
|
-
freeagent-cli
|
|
91
|
+
Typical flow:
|
|
92
|
+
freeagent-cli recent # check what you've already logged (avoids duplicates)
|
|
93
|
+
freeagent-cli log <project> <duration> [comment] [--task <name>] [--dry-run]
|
|
94
|
+
freeagent-cli projects # first-time / discovery: projects + tasks
|
|
91
95
|
|
|
92
96
|
\b
|
|
93
|
-
|
|
94
|
-
|
|
97
|
+
Examples:
|
|
98
|
+
freeagent-cli log Acme 1h30m "fixed the thing"
|
|
99
|
+
freeagent-cli log "Big Co" 90m --task Coding
|
|
100
|
+
freeagent-cli log Acme 1.5 --dry-run
|
|
101
|
+
|
|
102
|
+
\b
|
|
103
|
+
Time formats: 1.5 90m 1h30m 1:30
|
|
104
|
+
Project/task: numeric id, full URL, or case-insensitive name substring.
|
|
95
105
|
--task is optional only when the project has a single task; otherwise the error lists the choices.
|
|
96
106
|
""")
|
|
97
107
|
def main():
|
|
@@ -145,7 +155,7 @@ def auth_status():
|
|
|
145
155
|
click.echo(f"Refresh: {'present' if c.refresh_token else 'missing'}")
|
|
146
156
|
|
|
147
157
|
|
|
148
|
-
# -- me
|
|
158
|
+
# -- me ------------------------------------------------------------------
|
|
149
159
|
|
|
150
160
|
@main.command()
|
|
151
161
|
def me():
|
|
@@ -155,36 +165,41 @@ def me():
|
|
|
155
165
|
click.echo(u["url"])
|
|
156
166
|
|
|
157
167
|
|
|
158
|
-
|
|
159
|
-
def projects():
|
|
160
|
-
"""Project commands."""
|
|
161
|
-
|
|
168
|
+
# -- projects / tasks ----------------------------------------------------
|
|
162
169
|
|
|
163
|
-
@
|
|
170
|
+
@main.command()
|
|
164
171
|
@click.option("--all", "show_all", is_flag=True, help="Include inactive projects.")
|
|
165
172
|
@click.option("--tasks/--no-tasks", "with_tasks", default=True,
|
|
166
173
|
help="Include tasks for each project (default: yes).")
|
|
167
|
-
|
|
174
|
+
@click.option("--flat", is_flag=True,
|
|
175
|
+
help="One project/task pair per line for grep (tab-separated).")
|
|
176
|
+
def projects(show_all, with_tasks, flat):
|
|
168
177
|
"""List projects (and their tasks, by default)."""
|
|
169
178
|
api = _api()
|
|
170
179
|
view = "all" if show_all else "active"
|
|
171
180
|
for p in api.projects(view=view):
|
|
172
181
|
pid = p["url"].rsplit("/", 1)[-1]
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
182
|
+
if not with_tasks:
|
|
183
|
+
click.echo(f"{pid}\t{p['name']}")
|
|
184
|
+
continue
|
|
185
|
+
ts = api.tasks(p["url"])
|
|
186
|
+
if flat:
|
|
187
|
+
if not ts:
|
|
188
|
+
click.echo(f"{pid}\t{p['name']}\t\t")
|
|
189
|
+
for t in ts:
|
|
190
|
+
tid = t["url"].rsplit("/", 1)[-1]
|
|
191
|
+
click.echo(f"{pid}\t{p['name']}\t{tid}\t{t['name']}")
|
|
192
|
+
else:
|
|
193
|
+
click.echo(f"{pid}\t{p['name']}")
|
|
194
|
+
for t in ts:
|
|
176
195
|
tid = t["url"].rsplit("/", 1)[-1]
|
|
177
196
|
click.echo(f"\t{tid}\t{t['name']}")
|
|
178
197
|
|
|
179
198
|
|
|
180
|
-
@main.
|
|
181
|
-
def tasks():
|
|
182
|
-
"""Task commands."""
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
@tasks.command("list")
|
|
199
|
+
@main.command()
|
|
186
200
|
@click.option("--project", "project_q", required=True, help="Project name substring, id, or URL.")
|
|
187
|
-
def
|
|
201
|
+
def tasks(project_q):
|
|
202
|
+
"""List tasks for a project."""
|
|
188
203
|
api = _api()
|
|
189
204
|
project = _resolve(api.projects(view="active"), project_q, "project")
|
|
190
205
|
for t in api.tasks(project["url"]):
|
|
@@ -192,31 +207,40 @@ def tasks_list(project_q):
|
|
|
192
207
|
click.echo(f"{tid}\t{t['name']}")
|
|
193
208
|
|
|
194
209
|
|
|
195
|
-
# --
|
|
196
|
-
|
|
197
|
-
@main.group()
|
|
198
|
-
def time():
|
|
199
|
-
"""Timeslip commands."""
|
|
200
|
-
|
|
210
|
+
# -- log -----------------------------------------------------------------
|
|
201
211
|
|
|
202
|
-
@
|
|
203
|
-
@click.
|
|
212
|
+
@main.command()
|
|
213
|
+
@click.argument("project_q", metavar="PROJECT")
|
|
214
|
+
@click.argument("duration", metavar="HOURS")
|
|
215
|
+
@click.argument("comment_parts", nargs=-1, metavar="[COMMENT...]")
|
|
204
216
|
@click.option("--task", "task_q", default=None,
|
|
205
217
|
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
218
|
@click.option("--date", "date_", default=None, help="YYYY-MM-DD (default: today).")
|
|
209
|
-
@click.option("--comment", default=None)
|
|
210
219
|
@click.option("--dry-run", is_flag=True, help="Resolve and preview, but don't submit.")
|
|
211
|
-
def
|
|
212
|
-
"""Submit a timeslip.
|
|
220
|
+
def log(project_q, duration, comment_parts, task_q, date_, dry_run):
|
|
221
|
+
"""Submit a timeslip: PROJECT HOURS [COMMENT...].
|
|
222
|
+
|
|
223
|
+
\b
|
|
224
|
+
Examples:
|
|
225
|
+
freeagent-cli log Acme 1h30m "fixed the thing"
|
|
226
|
+
freeagent-cli log Acme 90m fixed the thing # comment without quotes
|
|
227
|
+
freeagent-cli log "Big Co" 1.5 --task Coding --date 2026-05-01
|
|
228
|
+
|
|
229
|
+
Tip: run `freeagent-cli recent` first to avoid duplicate entries.
|
|
230
|
+
"""
|
|
231
|
+
try:
|
|
232
|
+
hours = parse_hours(duration)
|
|
233
|
+
except ValueError as e:
|
|
234
|
+
raise click.UsageError(str(e))
|
|
235
|
+
comment = " ".join(comment_parts) if comment_parts else None
|
|
236
|
+
|
|
213
237
|
api = _api()
|
|
214
238
|
project = _resolve(api.projects(view="active"), project_q, "project")
|
|
215
239
|
task = _pick_task(api.tasks(project["url"]), task_q, project["name"])
|
|
216
240
|
dated_on = date_ or _dt.date.today().isoformat()
|
|
217
241
|
|
|
218
242
|
if dry_run:
|
|
219
|
-
click.echo(f"DRY RUN — would submit {hours}
|
|
243
|
+
click.echo(f"DRY RUN — would submit {format_hours(hours)} on {dated_on} → {project['name']} / {task['name']}")
|
|
220
244
|
click.echo(f" project: {project['url']}")
|
|
221
245
|
click.echo(f" task: {task['url']}")
|
|
222
246
|
if comment:
|
|
@@ -229,10 +253,38 @@ def time_submit(project_q, task_q, hours, date_, comment, dry_run):
|
|
|
229
253
|
dated_on=dated_on, hours=hours, comment=comment,
|
|
230
254
|
)
|
|
231
255
|
ts = result["timeslips"][0] if "timeslips" in result else result.get("timeslip", result)
|
|
232
|
-
click.echo(f"Submitted {hours}
|
|
256
|
+
click.echo(f"Submitted {format_hours(hours)} on {dated_on} → {project['name']} / {task['name']}")
|
|
233
257
|
if isinstance(ts, dict) and "url" in ts:
|
|
234
258
|
click.echo(ts["url"])
|
|
235
259
|
|
|
236
260
|
|
|
261
|
+
# -- recent --------------------------------------------------------------
|
|
262
|
+
|
|
263
|
+
@main.command()
|
|
264
|
+
@click.option("-n", "limit", default=5, show_default=True, help="How many entries to show.")
|
|
265
|
+
@click.option("--days", default=14, show_default=True, help="Look back this many days.")
|
|
266
|
+
@click.option("--all-users", is_flag=True, help="Include other users (default: just you).")
|
|
267
|
+
def recent(limit, days, all_users):
|
|
268
|
+
"""Show recent timeslips (most recent first)."""
|
|
269
|
+
api = _api()
|
|
270
|
+
from_date = (_dt.date.today() - _dt.timedelta(days=days)).isoformat()
|
|
271
|
+
user_url = None if all_users else api.me()["url"]
|
|
272
|
+
slips = api.list_timeslips(from_date=from_date, user=user_url, nested=True)
|
|
273
|
+
slips.sort(
|
|
274
|
+
key=lambda t: (t.get("dated_on", ""), t.get("created_at", "")),
|
|
275
|
+
reverse=True,
|
|
276
|
+
)
|
|
277
|
+
if not slips:
|
|
278
|
+
click.echo(f"(no timeslips in the last {days} days)")
|
|
279
|
+
return
|
|
280
|
+
for s in slips[:limit]:
|
|
281
|
+
proj = s.get("project")
|
|
282
|
+
task_ = s.get("task")
|
|
283
|
+
pname = proj["name"] if isinstance(proj, dict) else "?"
|
|
284
|
+
tname = task_["name"] if isinstance(task_, dict) else "?"
|
|
285
|
+
comment = (s.get("comment") or "").replace("\n", " ")
|
|
286
|
+
click.echo(f"{s.get('dated_on','?')}\t{format_hours(s.get('hours'))}\t{pname}\t{tname}\t{comment}")
|
|
287
|
+
|
|
288
|
+
|
|
237
289
|
if __name__ == "__main__":
|
|
238
290
|
main()
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.1.0"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|