@profullstack/timer 0.1.0
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.
- package/AGENTS.md +103 -0
- package/LICENSE +21 -0
- package/README.md +144 -0
- package/bin/timer.mjs +4 -0
- package/package.json +19 -0
- package/src/args.mjs +83 -0
- package/src/cli.mjs +757 -0
- package/src/entries.mjs +194 -0
- package/src/output.mjs +64 -0
- package/src/paths.mjs +31 -0
- package/src/store.mjs +148 -0
- package/src/time.mjs +163 -0
package/AGENTS.md
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# timer, for agents
|
|
2
|
+
|
|
3
|
+
`timer` is a time tracker whose second audience is a program. This file is the
|
|
4
|
+
contract.
|
|
5
|
+
|
|
6
|
+
## The rules
|
|
7
|
+
|
|
8
|
+
1. **`--json` works on every command.** It prints one JSON document on stdout
|
|
9
|
+
and nothing else.
|
|
10
|
+
2. **Advisories go to stderr.** `timer start` may warn that another clock is
|
|
11
|
+
running; that never appears on stdout.
|
|
12
|
+
3. **A failed run prints nothing on stdout.** The error goes to stderr as
|
|
13
|
+
`{"error": "...", "kind": "..."}`. Parsing stdout can never give you a
|
|
14
|
+
success shape for a command that failed.
|
|
15
|
+
4. **Exit codes mean something.**
|
|
16
|
+
|
|
17
|
+
| Code | Meaning |
|
|
18
|
+
| --- | --- |
|
|
19
|
+
| 0 | success |
|
|
20
|
+
| 1 | runtime failure (unreadable timesheet, locked file) |
|
|
21
|
+
| 2 | bad command line (unknown flag, missing argument, impossible range) |
|
|
22
|
+
| 3 | you named an entry that does not exist |
|
|
23
|
+
|
|
24
|
+
5. **An unknown flag is an error**, never silently ignored. If `timer log
|
|
25
|
+
--bogus` exits 0 you are on a different tool.
|
|
26
|
+
|
|
27
|
+
## The entry schema
|
|
28
|
+
|
|
29
|
+
Everything `--json` returns is built from this shape:
|
|
30
|
+
|
|
31
|
+
```json
|
|
32
|
+
{
|
|
33
|
+
"id": "4f2ap8qk",
|
|
34
|
+
"project": "acme",
|
|
35
|
+
"task": "fix the login redirect",
|
|
36
|
+
"tags": ["dev", "api"],
|
|
37
|
+
"start": "2026-08-29T09:00:00.000Z",
|
|
38
|
+
"end": "2026-08-29T10:30:00.000Z",
|
|
39
|
+
"running": false,
|
|
40
|
+
"seconds": 5400,
|
|
41
|
+
"hours": 1.5,
|
|
42
|
+
"billable": true,
|
|
43
|
+
"rate": null,
|
|
44
|
+
"agent": "claude-opus-5",
|
|
45
|
+
"notes": "",
|
|
46
|
+
"meta": {}
|
|
47
|
+
}
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
`end` is `null` while the clock runs, and `seconds` counts up to now.
|
|
51
|
+
`hours` is rounded to two decimals — it is the number that goes on an invoice.
|
|
52
|
+
|
|
53
|
+
## Clocking your own work
|
|
54
|
+
|
|
55
|
+
```sh
|
|
56
|
+
export TIMER_AGENT="claude-opus-5"
|
|
57
|
+
ID=$(timer start acme --task "refactor auth" --json | jq -r .started.id)
|
|
58
|
+
# ... do the work ...
|
|
59
|
+
timer stop --id "$ID" --json
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Several clocks may run at once, which is the point: parallel agents each track
|
|
63
|
+
their own work and do not stop each other. Use `--switch` only if you mean to
|
|
64
|
+
close everyone else's clock.
|
|
65
|
+
|
|
66
|
+
If you did the work before you thought to time it, record it after the fact:
|
|
67
|
+
|
|
68
|
+
```sh
|
|
69
|
+
timer add acme --task "refactor auth" --duration 45m --json
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Attaching your own identifiers
|
|
73
|
+
|
|
74
|
+
`--meta` takes a JSON object and stores it unchanged:
|
|
75
|
+
|
|
76
|
+
```sh
|
|
77
|
+
timer add acme --duration 20m --meta '{"repo":"acme/api","pr":42}' --json
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Filter later with `timer log --agent claude-opus-5 --json`.
|
|
81
|
+
|
|
82
|
+
## Reading the timesheet directly
|
|
83
|
+
|
|
84
|
+
If you would rather not shell out, the file is plain JSON at
|
|
85
|
+
`~/.profullstack/timer/timesheet.json` (or `$TIMER_DATA`):
|
|
86
|
+
|
|
87
|
+
```json
|
|
88
|
+
{ "version": 1, "entries": [ /* raw entries */ ] }
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Two cautions. Raw entries have no `seconds`/`hours`/`running` — those are
|
|
92
|
+
computed. And writes are locked: if you write the file yourself while a `timer`
|
|
93
|
+
process is running you can lose an entry. Prefer the CLI for writes and the
|
|
94
|
+
file for reads.
|
|
95
|
+
|
|
96
|
+
## Billing the hours
|
|
97
|
+
|
|
98
|
+
`@profullstack/billing` reads the same file and will not bill the same entry
|
|
99
|
+
twice:
|
|
100
|
+
|
|
101
|
+
```sh
|
|
102
|
+
billing invoice new --client acme --from-timer --month --json
|
|
103
|
+
```
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Profullstack, LLC
|
|
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.
|
package/README.md
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# timer
|
|
2
|
+
|
|
3
|
+
Track time against projects, from the terminal, on Linux, macOS and Windows.
|
|
4
|
+
|
|
5
|
+
It is a stopwatch with a memory: start a clock on a project, stop it, and get
|
|
6
|
+
billable hours back. Every command also speaks `--json`, so a coding agent can
|
|
7
|
+
clock its own work the same way you do — and
|
|
8
|
+
[`@profullstack/billing`](https://github.com/profullstack/billing) turns those
|
|
9
|
+
hours into invoices.
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
npm install -g @profullstack/timer
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Node 20.11 or newer. No runtime dependencies.
|
|
16
|
+
|
|
17
|
+
## Use it
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
timer start acme fix the login redirect # everything after the project is the task
|
|
21
|
+
timer status # what is running, and today's total
|
|
22
|
+
timer stop
|
|
23
|
+
|
|
24
|
+
timer add acme code review --duration 45m # time you forgot to clock
|
|
25
|
+
timer add acme --from 09:00 --to 11:30
|
|
26
|
+
|
|
27
|
+
timer log --week # what happened
|
|
28
|
+
timer report --month --group project # what it adds up to
|
|
29
|
+
timer projects # everything you have ever tracked
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Nothing is configured before first use. The timesheet appears the first time
|
|
33
|
+
you start a clock.
|
|
34
|
+
|
|
35
|
+
## Commands
|
|
36
|
+
|
|
37
|
+
| Command | What it does |
|
|
38
|
+
| --- | --- |
|
|
39
|
+
| `start <project> [task…]` | Start a clock. `--at 09:15`, `--at -20m`, `--tag`, `--note`, `--rate`, `--switch` |
|
|
40
|
+
| `stop [id]` | Stop the newest clock, an id, `--project <p>`, or `--all` |
|
|
41
|
+
| `status` | Running clocks and today's total |
|
|
42
|
+
| `log [project]` | List entries in a window |
|
|
43
|
+
| `add <project> [task…]` | Record untimed work from two of `--from` / `--to` / `--duration` |
|
|
44
|
+
| `edit <id>` | Change any field of an entry |
|
|
45
|
+
| `rm <id…>` | Delete entries (`--force` for a running one) |
|
|
46
|
+
| `resume [id]` | Start a fresh clock like the last one |
|
|
47
|
+
| `note <text…>` | Append a note to the running clock |
|
|
48
|
+
| `report` | Totals, `--group project\|task\|tag\|day\|agent\|none` |
|
|
49
|
+
| `projects` | Projects seen, with totals and last activity |
|
|
50
|
+
| `export` | `--format json\|ndjson\|csv`, `--out <file>` |
|
|
51
|
+
| `config` | Where the timesheet lives |
|
|
52
|
+
|
|
53
|
+
`timer help <command>` prints the flags and examples for one command.
|
|
54
|
+
|
|
55
|
+
### Windows of time
|
|
56
|
+
|
|
57
|
+
`log`, `report`, `projects` and `export` all take the same window flags:
|
|
58
|
+
`--today`, `--yesterday`, `--week` (from Monday), `--month`, `--year`, or an
|
|
59
|
+
explicit `--since` / `--until`.
|
|
60
|
+
|
|
61
|
+
Dates are loose on purpose: `09:15`, `2026-08-01`, `-2h`, `yesterday`, or a
|
|
62
|
+
full ISO instant. A bare date means local midnight, not UTC midnight.
|
|
63
|
+
|
|
64
|
+
A window compares against the entry's **start**, and `--until` is exclusive. An
|
|
65
|
+
entry that runs past midnight therefore belongs to the day it began on — which
|
|
66
|
+
is what keeps a total from being counted twice.
|
|
67
|
+
|
|
68
|
+
### Billable and not
|
|
69
|
+
|
|
70
|
+
Every entry is billable unless you say otherwise with `--no-billable`. Reports
|
|
71
|
+
carry both numbers, because "how long did this take" and "what can I charge for
|
|
72
|
+
it" are different questions:
|
|
73
|
+
|
|
74
|
+
```sh
|
|
75
|
+
timer add internal standup --duration 15m --no-billable
|
|
76
|
+
timer report --month
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## For agents
|
|
80
|
+
|
|
81
|
+
Two things make this usable by an agentic CLI without a wrapper.
|
|
82
|
+
|
|
83
|
+
**Every command answers `--json`** with a single JSON document on stdout and
|
|
84
|
+
nothing else. Advisory lines go to stderr. A command that fails prints its
|
|
85
|
+
error as JSON on **stderr** and leaves stdout empty, so parsing stdout can
|
|
86
|
+
never yield a success shape for a failed run.
|
|
87
|
+
|
|
88
|
+
**Exit codes are distinct**: `0` success, `1` a runtime failure, `2` a bad
|
|
89
|
+
command line, `3` you named something that is not there. `timer stop` with no
|
|
90
|
+
clock running is a `0` — that is an answer, not a failure.
|
|
91
|
+
|
|
92
|
+
```sh
|
|
93
|
+
export TIMER_AGENT="claude-opus-5" # stamps every entry it creates
|
|
94
|
+
timer start acme --task "refactor auth" --json
|
|
95
|
+
timer stop --json
|
|
96
|
+
timer log --today --json
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
`--meta '{"pr":42}'` hangs your own identifiers off an entry, and they survive
|
|
100
|
+
round-trip unchanged.
|
|
101
|
+
|
|
102
|
+
There is more detail, including the entry schema, in [AGENTS.md](AGENTS.md).
|
|
103
|
+
|
|
104
|
+
## Where the data lives
|
|
105
|
+
|
|
106
|
+
One JSON file, in one place on every platform:
|
|
107
|
+
|
|
108
|
+
```
|
|
109
|
+
~/.profullstack/timer/timesheet.json
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
| Variable | Overrides |
|
|
113
|
+
| --- | --- |
|
|
114
|
+
| `TIMER_DATA` | the timesheet file itself |
|
|
115
|
+
| `TIMER_HOME` | the directory it sits in |
|
|
116
|
+
| `PROFULLSTACK_HOME` | the parent shared with other Profullstack CLIs |
|
|
117
|
+
| `TIMER_AGENT` | the agent name stamped on new entries |
|
|
118
|
+
| `NO_COLOR` | turns off colour |
|
|
119
|
+
|
|
120
|
+
`timer config` prints all of it. The file is plain JSON you can read and edit;
|
|
121
|
+
writes are atomic and locked, so several agents can clock in at once without
|
|
122
|
+
losing an entry.
|
|
123
|
+
|
|
124
|
+
## With billing
|
|
125
|
+
|
|
126
|
+
`@profullstack/billing` reads this file directly — it does not need `timer` on
|
|
127
|
+
`PATH` — and turns unbilled hours into invoice line items:
|
|
128
|
+
|
|
129
|
+
```sh
|
|
130
|
+
billing invoice new --client acme --from-timer --month
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
## In moshcode
|
|
134
|
+
|
|
135
|
+
[moshcode](https://github.com/moshcoder/moshcode) installs and fronts it:
|
|
136
|
+
|
|
137
|
+
```
|
|
138
|
+
moshcode install timer
|
|
139
|
+
/timer start acme fix the login redirect
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## Licence
|
|
143
|
+
|
|
144
|
+
MIT © Profullstack, LLC
|
package/bin/timer.mjs
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@profullstack/timer",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "A time tracker for the terminal and for agents — start a clock against a project, stop it, and get billable hours back as text or JSON.",
|
|
6
|
+
"keywords": ["timer", "time-tracking", "timesheet", "billable", "hours", "cli", "agent", "freelance"],
|
|
7
|
+
"repository": { "type": "git", "url": "git+https://github.com/profullstack/timer.git" },
|
|
8
|
+
"homepage": "https://github.com/profullstack/timer#readme",
|
|
9
|
+
"bugs": { "url": "https://github.com/profullstack/timer/issues" },
|
|
10
|
+
"license": "MIT",
|
|
11
|
+
"author": "Profullstack, LLC",
|
|
12
|
+
"bin": { "timer": "bin/timer.mjs" },
|
|
13
|
+
"files": ["bin", "src", "README.md", "AGENTS.md", "LICENSE"],
|
|
14
|
+
"engines": { "node": ">=20.11" },
|
|
15
|
+
"scripts": {
|
|
16
|
+
"test": "node --test",
|
|
17
|
+
"start": "node bin/timer.mjs"
|
|
18
|
+
}
|
|
19
|
+
}
|
package/src/args.mjs
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// A small argv parser, hand-rolled so the package stays dependency-free.
|
|
2
|
+
//
|
|
3
|
+
// The one rule worth stating: a flag only consumes the next token if it is
|
|
4
|
+
// declared as taking a value. Guessing from the shape of the next token is
|
|
5
|
+
// what makes `timer log --json acme` mean two different things on two
|
|
6
|
+
// different days, and this tool is meant to be scripted by agents.
|
|
7
|
+
export function parseArgs(argv, { booleans = [], values = [], multi = [], aliases = {} } = {}) {
|
|
8
|
+
const isBool = new Set(booleans);
|
|
9
|
+
const takesValue = new Set([...values, ...multi]);
|
|
10
|
+
const isMulti = new Set(multi);
|
|
11
|
+
const flags = Object.create(null);
|
|
12
|
+
const positional = [];
|
|
13
|
+
const rest = [];
|
|
14
|
+
const unknown = [];
|
|
15
|
+
|
|
16
|
+
const resolve = (name) => aliases[name] || name;
|
|
17
|
+
const set = (name, value) => {
|
|
18
|
+
if (isMulti.has(name)) (flags[name] ||= []).push(value);
|
|
19
|
+
else flags[name] = value;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
23
|
+
const token = argv[i];
|
|
24
|
+
if (token === "--") {
|
|
25
|
+
rest.push(...argv.slice(i + 1));
|
|
26
|
+
break;
|
|
27
|
+
}
|
|
28
|
+
if (token.startsWith("--")) {
|
|
29
|
+
let body = token.slice(2);
|
|
30
|
+
let inline = null;
|
|
31
|
+
const eq = body.indexOf("=");
|
|
32
|
+
if (eq !== -1) {
|
|
33
|
+
inline = body.slice(eq + 1);
|
|
34
|
+
body = body.slice(0, eq);
|
|
35
|
+
}
|
|
36
|
+
if (body.startsWith("no-")) {
|
|
37
|
+
const name = resolve(body.slice(3));
|
|
38
|
+
flags[name] = false;
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
const name = resolve(body);
|
|
42
|
+
if (isBool.has(name)) {
|
|
43
|
+
// `--json=false` is the one way to turn a boolean off inline; agents
|
|
44
|
+
// building command lines from templates rely on it.
|
|
45
|
+
flags[name] = inline == null ? true : !/^(0|false|no)$/i.test(inline);
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (takesValue.has(name)) {
|
|
49
|
+
const value = inline != null ? inline : argv[++i];
|
|
50
|
+
if (value === undefined) throw new Error(`--${body} needs a value`);
|
|
51
|
+
set(name, value);
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
unknown.push(`--${body}`);
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (token.length > 1 && token.startsWith("-") && !/^-\d/.test(token)) {
|
|
58
|
+
// Short flags, including bundles (-qj). A bundled flag that takes a
|
|
59
|
+
// value must be last, the way tar and grep do it.
|
|
60
|
+
const letters = token.slice(1).split("");
|
|
61
|
+
for (let j = 0; j < letters.length; j += 1) {
|
|
62
|
+
const name = resolve(letters[j]);
|
|
63
|
+
if (isBool.has(name)) { flags[name] = true; continue; }
|
|
64
|
+
if (takesValue.has(name)) {
|
|
65
|
+
const inline = letters.slice(j + 1).join("");
|
|
66
|
+
const value = inline || argv[++i];
|
|
67
|
+
if (value === undefined) throw new Error(`-${letters[j]} needs a value`);
|
|
68
|
+
set(name, value);
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
unknown.push(`-${letters[j]}`);
|
|
72
|
+
}
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
positional.push(token);
|
|
76
|
+
}
|
|
77
|
+
return { flags, positional, rest, unknown };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Flags every command answers to, so they are declared once. */
|
|
81
|
+
export const GLOBAL_BOOLEANS = ["json", "help", "version", "quiet"];
|
|
82
|
+
export const GLOBAL_VALUES = ["data"];
|
|
83
|
+
export const GLOBAL_ALIASES = { h: "help", v: "version", q: "quiet", j: "json" };
|