A keyboard-only timesheet client for the terminal. It lists the tasks assigned to you in Strativ's ERP360 (Odoo 16), expands each one into its timesheet lines, and logs, edits and deletes hours without leaving the keyboard.
Written in Go with Bubble Tea. One static binary, no CGO.
- Features
- Installation
- First run
- Usage
- Configuration
- How syncing works
- Files it touches
- Troubleshooting
- Development
- All your tasks in one list. Pulled from ERP360, no hunting through the web UI.
- See the hours already logged. Open a task, its entries show up as a table.
- Log time in a few keystrokes. Date, what you did, hours — saved straight into ERP360.
- Fix mistakes. Change or delete any entry you logged, from the same screen.
- Typed hours are never lost. A failed save keeps them on screen and says so.
- Only your own hours. A colleague's time on the same task never counts as yours.
- "Where did Tuesday go?" One key lists a whole day across every task, with its total.
- A progress bar for today. How far through your 8 hours, updated as you type.
- Find a task by typing. The list narrows as you go.
- No mouse, ever. The keys you can press are always listed along the bottom.
- Change any key. Rebind anything you like in a small text file.
- Your API key stays private. Encrypted at rest, never written to a file or a screen.
- Works with no internet. Everything is cached; entries reach the ERP when you are back.
Go 1.22 or newer is the only hard requirement. Linux and macOS are the same commands; there is no CGO and nothing platform-specific in the build.
git clone git@github.com:tasnim-strativ/tsk-tui.git
cd tsk-tui
go install ./cmd/tsk # ~/go/bin/tskOr build in place:
go build -o tsk ./cmd/tsk
./tskMake sure ~/go/bin is on your PATH if you used go install.
pass is what keeps your API key encrypted, and it is
the recommended way to run this — see Option A.
On macOS: brew install pass gnupg, plus brew install pinentry-mac so the GPG
passphrase prompt has somewhere to appear. On Linux, your package manager has pass.
If you would rather not run a password store, Option B works without it.
You need an Odoo API key and the database name. Generate the key in ERP360 under Preferences → Account Security → New API Key.
The database name is deliberately not in this repo — it is half of what someone needs to reach the ERP over JSON-RPC. Two ways to get it:
-
Ask odoo baba on Slack.
-
Read it from your own session: log in to erp360.strativ.se, open the browser console and run
await fetch('/web/session/get_session_info', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: '{"jsonrpc":"2.0","method":"call","params":{}}' }).then(r => r.json()).then(d => d.result.db)
The string it prints is the database name.
Now hand both to tsk, either way below. Pick one — each is complete on its own.
Uses pass, the standard Unix password manager, so the
key stays encrypted at rest and tsk can rotate it for you with K. Both values live in
one entry, secret on the first line and the database below it, which is pass's own
convention for metadata:
pass insert -m tsk/api-keyType the two lines and end with ctrl+d. The entry should look like this — the API key
first, on its own line, and the database on a db: line under it:
9f3c1a7e2b5d8406f1ae7c3b95d20e4867fa1cb2
db: example_erp_db
That is exactly what pass show tsk/api-key prints back. tsk reads line one as the key
and looks for db: in the lines below it, so the order matters — a db: line on top
would be read as your key.
(Both values above are made up — use your own key and the database name you looked up.)
Then run tsk. If the entry does not exist yet, it asks for the key on the first screen,
saves it to pass, and fetches. esc at that prompt works offline on whatever is
cached.
Nothing to install, nothing encrypted. tsk reads $TSK_API_KEY before it looks for a
password store, so pass is never invoked at all.
Put the two values in your shell's config file, so they are there in every shell and you never type them again. Open the file for your shell in an editor and add:
fish — ~/.config/fish/config.fish
set -gx TSK_API_KEY 9f3c1a7e2b5d8406f1ae7c3b95d20e4867fa1cb2
set -gx TSK_ODOO_DB example_erp_dbzsh — ~/.zshrc · bash — ~/.bashrc
export TSK_API_KEY=9f3c1a7e2b5d8406f1ae7c3b95d20e4867fa1cb2
export TSK_ODOO_DB=example_erp_dbBoth values above are made up — use your own key and the database name you looked up.
Then pick up the change and run it:
exec fish # or: source ~/.zshrc — or just open a new terminal
tskThree things to know about this route, and why Option A is the recommended one:
- Edit the file, do not type the
exportat your prompt. A command line with the key in it lands in your shell history in plain text. - The file is plaintext. Keep it to yourself —
chmod 600 ~/.config/fish/config.fish(or~/.zshrc) — and remember the key is then in the environment of every process that shell starts. Kcannot rotate the key on this route, because saving goes throughpass insert. Change the key by editing the same config file.
Without a database name — no db: line and no $TSK_ODOO_DB — the task list still
loads, but timesheet lines cannot: the status line will say no Odoo database.
tsk starts on the task list. Press l to open a task, a to add an entry, enter
to edit the row under the cursor, d to delete it, q to quit.
Every mode's keys are in the footer, always. The footer is generated from the same bindings the app matches on, so it cannot drift from what the keys actually do.
These are the defaults — see Custom keybindings to change them.
Task list (where it starts)
| Key | Does |
|---|---|
j / k |
next / previous task |
g / G |
first / last |
ctrl+f / ctrl+b |
half a screen down / up |
l |
expand the task, focus its rows |
h |
collapse |
/ |
date jump — lists that day across every task |
r |
re-fetch tasks from the ERP |
K |
replace the stored API key |
i |
focus the search field |
ctrl+u |
clear the search and focus it |
q |
quit (asks first; ctrl+c quits at once) |
Inside a task
| Key | Does |
|---|---|
j / k, g / G, ctrl+f / ctrl+b |
move between rows |
a |
new entry at the top, dated today |
enter |
edit the focused row in place |
d |
delete the focused row (asks; y only) |
/ |
jump to a date within this task |
h |
collapse and go back to the task line |
esc |
back to the task line, still expanded |
Editing an entry
| Key | Does |
|---|---|
tab / shift+tab |
date → description → hours → ✓ → ✕ |
enter |
next field, or commit on ✓ |
ctrl+u |
clear the current field |
esc |
cancel |
Search field
| Key | Does |
|---|---|
| any key | filter by title and tag, live |
esc / enter |
back to the task list |
ctrl+u |
clear the query and collapse everything |
Destructive prompts — quitting and deleting a line — take y only, so a reflexive
enter cannot fire them.
Durations are read however you write them, and normalized when you leave the field:
7h30m → 7:30 7.5 → 7:30 90m → 1:30 7:30 → 7:30 7 → 7:00
Dates fill in what you leave out, relative to the row you are editing (or today for a new entry):
8 → 08/08/26 (day only: keeps the month and year)
8/9 → 08/09/26 (day and month: keeps the year)
8/9/26 → 08/09/26
When you edit a row the date arrives selected — the first keystroke replaces it, tab
keeps it.
/ means two different things, because there are two different questions:
- From the task list — "where did that day go?"
/12resolves to the 12th of this month and opens a modal listing every entry logged on it, in any task, with the day's total. Nothing expands.esccloses it. - Inside a task — "where is that row?" Here the date is matched part by part, so
/12finds the 12th of any month among that task's rows,/12/7any 12th of July. The cursor moves to it; no modal covers the rows you are reading.
Either way the matching rows stay marked until you clear the jump (enter on an empty
prompt, or ctrl+u).
Every binding is a default, not a fact. Write the current keymap to the config file and edit what you like:
# Linux
mkdir -p ~/.config/tsk
tsk --print-keys > ~/.config/tsk/config.toml# macOS
mkdir -p ~/Library/Application\ Support/tsk
tsk --print-keys > ~/Library/Application\ Support/tsk/config.toml[keys]
half_down = ["ctrl+d"] # back to vim's key
down = ["j", "down", "n"] # several keys per action
quit = [] # an empty list unbinds it; ctrl+c always quitsKeep only the lines you want to change — anything absent keeps its default, and no
config file at all is perfectly fine. Action names are listed by --print-keys.
Keys are spelled the way Bubble Tea spells them: a single character, a ctrl+ /
alt+ / shift+ compound, or one of enter esc tab shift+tab space backspace delete up down left right home end pgup pgdown.
A misspelled key or an unknown action stops the program with a message rather than starting up with a keymap that silently does nothing.
The footer follows a rebind on its own, since help and behavior read the same bindings.
| Variable | Default | Purpose |
|---|---|---|
TSK_API_KEY |
— | use this key and skip pass entirely |
TSK_PASS_NAME |
tsk/api-key |
which pass entry holds the key |
TSK_ODOO_DB |
— | Odoo database, overriding the db: line in pass |
TSK_API_URL |
https://erp360.strativ.se |
point at staging or a local Odoo |
XDG_CONFIG_HOME |
~/.config on Linux, ~/Library/Application Support on macOS |
where the config and cache live |
The REST API and JSON-RPC each cover part of the job:
| Call | Used for |
|---|---|
GET /api/v1/tasks/my |
your task list |
GET /api/v1/timesheets/hour-log-summary |
today's total, for the progress bar |
POST /api/v1/timesheets/log |
creating one entry |
JSON-RPC account.analytic.line search_read |
reading a task's lines |
JSON-RPC write / unlink |
editing and deleting a line |
Worth knowing:
- Lines are read lazily. Expanding a task reads it once;
rclears that so open tasks are read again. - A read merges, it never replaces. Entries you typed but the ERP has not confirmed are kept, and app-created rows carry negative ids so they cannot collide with an Odoo line.
- Reads are filtered to you. Odoo's own task form lists every employee's lines; this does not.
- Nothing retries by itself. One entry per request, and a timed-out retry could double-log, so a failure is reported and left for you to decide.
- The API key lives in memory, the
Authorizationheader, and nowhere else. Not in a URL, not in a log line, not in an error message, not in the cache file.
Its own directory, plus the pass entry if you chose Option A:
| Platform | Directory |
|---|---|
| Linux | ~/.config/tsk/ |
| macOS | ~/Library/Application Support/tsk/ |
$XDG_CONFIG_HOME overrides that on either platform, so set -x XDG_CONFIG_HOME ~/.config
on a Mac puts everything in ~/.config/tsk/ instead.
| File | What |
|---|---|
tasks.json |
cached tasks and hours, so it works offline |
config.toml |
your keybindings (optional) |
pass entry tsk/api-key |
API key on line one, db: below it (Option A only) |
tasks.json holds real hours and task names — worth keeping out of any repo or paste.
no Odoo database — add a db: line to the pass entry
Timesheet lines come from JSON-RPC, which needs the database name. pass edit tsk/api-key and add db: <database-name> as the second line, or export
TSK_ODOO_DB.
pass not found — install password-store, or export TSK_API_KEY
You are on the pass route without pass. Either install it — brew install pass gnupg
on macOS, your package manager on Linux — and initialise a store (pass init <gpg-id>),
or switch to Option B and set TSK_API_KEY.
A GPG passphrase prompt appears
Expected — pass show runs with the terminal handed over to it, so a tty pinentry
works without corrupting the screen. gpg-agent caches it after the first time.
offline — showing the tasks on disk
The ERP could not be reached. Everything still works against the cache, and hours you
type are kept locally until a write succeeds.
A task shows no entries even though Odoo has some The first read failed (usually the missing database name). Collapse and expand it to try again; failures are not remembered as "already read".
401 / the key prompt opens by itself
The key was rejected or revoked. K stores a new one.
ctrl+f or ctrl+u seems to be swallowed
Your terminal multiplexer or shell may claim it first. ctrl+l was dropped as a
binding for exactly this reason. Rebind anything that clashes — see
Custom keybindings.
cmd/tsk/ entry point
internal/model/ root model, one Update handler per mode, View
internal/parse/ hours and dates — pure, table-tested
internal/store/ the cache on disk, and the key from pass
internal/api/ REST client and Odoo JSON-RPC
internal/config/ the config.toml keymap, and where it lives per platform
internal/theme/ every color and style, in one place
go test ./... && go vet ./... # the gate before any change is doneConventions worth keeping: no I/O in Update or View (disk and network go through a
tea.Cmd), minutes are stored as minutes and formatted only when rendered, totals and
the progress bar are derived on every render rather than cached, and every parsing rule
has a table-driven test. The rendering tests assert real terminal widths — a line wider
than the screen wraps and pushes the layout out of place. CLAUDE.md carries the longer
reasoning behind these decisions.
