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.
- {freeagent_cli-0.1.0 → freeagent_cli-0.2.0}/PKG-INFO +19 -8
- {freeagent_cli-0.1.0 → freeagent_cli-0.2.0}/README.md +18 -7
- {freeagent_cli-0.1.0 → freeagent_cli-0.2.0}/pyproject.toml +1 -1
- freeagent_cli-0.2.0/src/freeagent_cli/__init__.py +1 -0
- {freeagent_cli-0.1.0 → freeagent_cli-0.2.0}/src/freeagent_cli/api.py +12 -1
- {freeagent_cli-0.1.0 → freeagent_cli-0.2.0}/src/freeagent_cli/cli.py +82 -48
- {freeagent_cli-0.1.0 → freeagent_cli-0.2.0}/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.0}/.gitignore +0 -0
- {freeagent_cli-0.1.0 → freeagent_cli-0.2.0}/LICENSE +0 -0
- {freeagent_cli-0.1.0 → freeagent_cli-0.2.0}/src/freeagent_cli/auth.py +0 -0
- {freeagent_cli-0.1.0 → freeagent_cli-0.2.0}/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.
|
|
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
|
|
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.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.
|
|
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
|
|
89
|
-
freeagent-cli projects
|
|
90
|
-
freeagent-cli
|
|
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
|
|
94
|
-
Project/task
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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.
|
|
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
|
|
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
|
-
# --
|
|
194
|
+
# -- log -----------------------------------------------------------------
|
|
196
195
|
|
|
197
|
-
@main.
|
|
198
|
-
|
|
199
|
-
|
|
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
|
|
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()
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.1.0"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|