freeagent-cli 0.1.0__tar.gz → 0.2.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: freeagent-cli
3
- Version: 0.1.0
3
+ Version: 0.2.0
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.0"
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.0"
@@ -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,19 +37,6 @@ 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()
51
-
52
-
53
40
  def _resolve(items: list[dict], query: str, label: str) -> dict:
54
41
  if query.startswith("http"):
55
42
  match = [i for i in items if i["url"] == query]
@@ -85,13 +72,20 @@ def _pick_task(tasks: list[dict], task_q: str | None, project_name: str) -> dict
85
72
 
86
73
  @click.group(epilog="""
87
74
  \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]
75
+ Typical flow:
76
+ freeagent-cli projects # projects + tasks in one call
77
+ freeagent-cli log <project> <duration> [comment] [--task <name>] [--dry-run]
78
+ freeagent-cli recent # last few timeslips
79
+
80
+ \b
81
+ Examples:
82
+ freeagent-cli log Acme 1h30m "fixed the thing"
83
+ freeagent-cli log "Big Co" 90m --task Coding
84
+ freeagent-cli log Acme 1.5 --dry-run
91
85
 
92
86
  \b
93
- Time formats accepted: 1.5 90m 1h30m 1:30
94
- Project/task selection: numeric id, full URL, or case-insensitive name substring.
87
+ Time formats: 1.5 90m 1h30m 1:30
88
+ Project/task: numeric id, full URL, or case-insensitive name substring.
95
89
  --task is optional only when the project has a single task; otherwise the error lists the choices.
96
90
  """)
97
91
  def main():
@@ -145,7 +139,7 @@ def auth_status():
145
139
  click.echo(f"Refresh: {'present' if c.refresh_token else 'missing'}")
146
140
 
147
141
 
148
- # -- me / projects / tasks -----------------------------------------------
142
+ # -- me ------------------------------------------------------------------
149
143
 
150
144
  @main.command()
151
145
  def me():
@@ -155,36 +149,41 @@ def me():
155
149
  click.echo(u["url"])
156
150
 
157
151
 
158
- @main.group()
159
- def projects():
160
- """Project commands."""
152
+ # -- projects / tasks ----------------------------------------------------
161
153
 
162
-
163
- @projects.command("list")
154
+ @main.command()
164
155
  @click.option("--all", "show_all", is_flag=True, help="Include inactive projects.")
165
156
  @click.option("--tasks/--no-tasks", "with_tasks", default=True,
166
157
  help="Include tasks for each project (default: yes).")
167
- def projects_list(show_all, with_tasks):
158
+ @click.option("--flat", is_flag=True,
159
+ help="One project/task pair per line for grep (tab-separated).")
160
+ def projects(show_all, with_tasks, flat):
168
161
  """List projects (and their tasks, by default)."""
169
162
  api = _api()
170
163
  view = "all" if show_all else "active"
171
164
  for p in api.projects(view=view):
172
165
  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"]):
166
+ if not with_tasks:
167
+ click.echo(f"{pid}\t{p['name']}")
168
+ continue
169
+ ts = api.tasks(p["url"])
170
+ if flat:
171
+ if not ts:
172
+ click.echo(f"{pid}\t{p['name']}\t\t")
173
+ for t in ts:
174
+ tid = t["url"].rsplit("/", 1)[-1]
175
+ click.echo(f"{pid}\t{p['name']}\t{tid}\t{t['name']}")
176
+ else:
177
+ click.echo(f"{pid}\t{p['name']}")
178
+ for t in ts:
176
179
  tid = t["url"].rsplit("/", 1)[-1]
177
180
  click.echo(f"\t{tid}\t{t['name']}")
178
181
 
179
182
 
180
- @main.group()
181
- def tasks():
182
- """Task commands."""
183
-
184
-
185
- @tasks.command("list")
183
+ @main.command()
186
184
  @click.option("--project", "project_q", required=True, help="Project name substring, id, or URL.")
187
- def tasks_list(project_q):
185
+ def tasks(project_q):
186
+ """List tasks for a project."""
188
187
  api = _api()
189
188
  project = _resolve(api.projects(view="active"), project_q, "project")
190
189
  for t in api.tasks(project["url"]):
@@ -192,24 +191,31 @@ def tasks_list(project_q):
192
191
  click.echo(f"{tid}\t{t['name']}")
193
192
 
194
193
 
195
- # -- time submit ---------------------------------------------------------
194
+ # -- log -----------------------------------------------------------------
196
195
 
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.")
196
+ @main.command()
197
+ @click.argument("project_q", metavar="PROJECT")
198
+ @click.argument("duration", metavar="HOURS")
199
+ @click.argument("comment_parts", nargs=-1, metavar="[COMMENT...]")
204
200
  @click.option("--task", "task_q", default=None,
205
201
  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
202
  @click.option("--date", "date_", default=None, help="YYYY-MM-DD (default: today).")
209
- @click.option("--comment", default=None)
210
203
  @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."""
204
+ def log(project_q, duration, comment_parts, task_q, date_, dry_run):
205
+ """Submit a timeslip: PROJECT HOURS [COMMENT...].
206
+
207
+ \b
208
+ Examples:
209
+ freeagent-cli log Acme 1h30m "fixed the thing"
210
+ freeagent-cli log Acme 90m fixed the thing # comment without quotes
211
+ freeagent-cli log "Big Co" 1.5 --task Coding --date 2026-05-01
212
+ """
213
+ try:
214
+ hours = parse_hours(duration)
215
+ except ValueError as e:
216
+ raise click.UsageError(str(e))
217
+ comment = " ".join(comment_parts) if comment_parts else None
218
+
213
219
  api = _api()
214
220
  project = _resolve(api.projects(view="active"), project_q, "project")
215
221
  task = _pick_task(api.tasks(project["url"]), task_q, project["name"])
@@ -234,5 +240,33 @@ def time_submit(project_q, task_q, hours, date_, comment, dry_run):
234
240
  click.echo(ts["url"])
235
241
 
236
242
 
243
+ # -- recent --------------------------------------------------------------
244
+
245
+ @main.command()
246
+ @click.option("-n", "limit", default=5, show_default=True, help="How many entries to show.")
247
+ @click.option("--days", default=14, show_default=True, help="Look back this many days.")
248
+ @click.option("--all-users", is_flag=True, help="Include other users (default: just you).")
249
+ def recent(limit, days, all_users):
250
+ """Show recent timeslips (most recent first)."""
251
+ api = _api()
252
+ from_date = (_dt.date.today() - _dt.timedelta(days=days)).isoformat()
253
+ user_url = None if all_users else api.me()["url"]
254
+ slips = api.list_timeslips(from_date=from_date, user=user_url, nested=True)
255
+ slips.sort(
256
+ key=lambda t: (t.get("dated_on", ""), t.get("created_at", "")),
257
+ reverse=True,
258
+ )
259
+ if not slips:
260
+ click.echo(f"(no timeslips in the last {days} days)")
261
+ return
262
+ for s in slips[:limit]:
263
+ proj = s.get("project")
264
+ task_ = s.get("task")
265
+ pname = proj["name"] if isinstance(proj, dict) else "?"
266
+ tname = task_["name"] if isinstance(task_, dict) else "?"
267
+ comment = (s.get("comment") or "").replace("\n", " ")
268
+ click.echo(f"{s.get('dated_on','?')}\t{s.get('hours','?')}h\t{pname}\t{tname}\t{comment}")
269
+
270
+
237
271
  if __name__ == "__main__":
238
272
  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.0"
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