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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: freeagent-cli
3
- Version: 0.1.0
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 time submit --project acme --hours 1h30m --comment "fixed the thing"
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 # 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]
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
- - **`--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.
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 time submit --project acme --hours 1h30m --comment "fixed the thing"
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 # 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]
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
- - **`--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.
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
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "freeagent-cli"
3
- version = "0.1.0"
3
+ version = "0.2.1"
4
4
  description = "CLI for submitting FreeAgent timeslips"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -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.1",
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
- 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()
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 (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
+ 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
- Time formats accepted: 1.5 90m 1h30m 1:30
94
- Project/task selection: numeric id, full URL, or case-insensitive name substring.
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 / projects / tasks -----------------------------------------------
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
- @main.group()
159
- def projects():
160
- """Project commands."""
161
-
168
+ # -- projects / tasks ----------------------------------------------------
162
169
 
163
- @projects.command("list")
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
- def projects_list(show_all, with_tasks):
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
- click.echo(f"{pid}\t{p['name']}")
174
- if with_tasks:
175
- for t in api.tasks(p["url"]):
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.group()
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 tasks_list(project_q):
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
- # -- time submit ---------------------------------------------------------
196
-
197
- @main.group()
198
- def time():
199
- """Timeslip commands."""
200
-
210
+ # -- log -----------------------------------------------------------------
201
211
 
202
- @time.command("submit")
203
- @click.option("--project", "project_q", required=True, help="Project name substring, id, or URL.")
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 time_submit(project_q, task_q, hours, date_, comment, dry_run):
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}h on {dated_on} → {project['name']} / {task['name']}")
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}h on {dated_on} → {project['name']} / {task['name']}")
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()
@@ -47,7 +47,7 @@ wheels = [
47
47
 
48
48
  [[package]]
49
49
  name = "freeagent-cli"
50
- version = "0.1.0"
50
+ version = "0.2.1"
51
51
  source = { editable = "." }
52
52
  dependencies = [
53
53
  { name = "click" },
@@ -1 +0,0 @@
1
- __version__ = "0.1.0"
File without changes
File without changes