- Go 95.6%
- PowerShell 4.4%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
|
||
| .forgejo/workflows | ||
| cmd/nailit | ||
| docs/assets | ||
| internal | ||
| migrations | ||
| scripts | ||
| skills/nailit-cli | ||
| .gitignore | ||
| CLI_GAPS.md | ||
| go.mod | ||
| go.sum | ||
| README.md | ||
| VALIDATION.md | ||
nailit is a small, local-first CLI for tracking applications to hands-on
infrastructure roles: Data Center Technician, IT Hardware Technician, Server
Deployment Technician, Data Center Operations, and similar positions.
It stores jobs, full descriptions, notes, next actions, and status history in one SQLite database. The application is local-only and does not submit applications automatically. Normal operation makes no network requests. There are no accounts, external services, telemetry, scraping, or AI features.
Build and install
Requires Go 1.25 or newer. The first build downloads pinned Go modules; subsequent
operation of the executable is offline. SQLite uses modernc.org/sqlite, a pure-Go
driver. Neither CGO nor a separate SQLite installation is required.
go build -o nailit ./cmd/nailit
./nailit --help
On Windows (PowerShell):
go build -o nailit.exe ./cmd/nailit
.\nailit.exe --help
Alternatively, go install ./cmd/nailit installs into GOBIN, or GOPATH/bin
when GOBIN is unset. Add that directory to PATH to run nailit anywhere.
The resulting executable is self-contained; migrations are embedded.
CI and releases (Forgejo)
The workflow in .forgejo/workflows/ci.yml checks formatting, runs go test,
go vet, and go build, then builds all six release archives on every branch
push and pull request. Go is selected from go.mod; CGO is disabled.
Only a pushed tag matching v* publishes a Forgejo release, after every check
and cross-compilation succeeds. Tags containing - are marked as prereleases.
Server setup:
- Enable Actions in the repository settings.
- Register a Linux Forgejo runner with the
dockerlabel and Docker support (including container actions). The job usesnode:22-bookworm; Go is installed bysetup-go. If your runner uses a different label, changeruns-on. - Allow the runner to reach this Forgejo instance,
code.forgejo.org, Docker Hub, and the Go toolchain/module download services. The release action uses the automatic repository token (secrets.GITHUB_TOKEN); no personal token is needed.
After merging and pushing the workflow, publish a version by tagging its commit:
git tag v0.1.0
git push origin v0.1.0
The release contains nailit_<tag>_<os>_<arch>.tar.gz for Linux/macOS and .zip
for Windows, each for amd64 and arm64, plus SHA256SUMS. Each archive contains
the executable and README. Download the appropriate archive from the repository's
Releases page and put the extracted executable on PATH. macOS binaries are unsigned.
CI cross-compiles these targets; runtime tests execute on the Linux runner only.
Every successful build uploads the six archives and SHA256SUMS as a workflow
artifact named nailit-<commit SHA>, with a requested retention of 14 days
(subject to the server's retention policy). To download it, open Actions in
the repository, select the workflow run, and download the artifact from its
Artifacts section. Extract that download, then the archive for your platform.
Branch/PR builds use the version snapshot and do not publish a release.
Artifacts become available only for runs using this updated workflow.
To build exactly the same archives locally, from the repository root:
go run ./scripts/release.go v0.1.0
The command requires a fresh dist directory (it must not already exist), and
never publishes anything. Move previous output aside before running it again.
Existing releases are not overwritten automatically; use a new version tag for
changed artifacts. For runner setup, see the
Forgejo Actions quick start.
Database location
| Platform | Default database |
|---|---|
| Linux / other Unix | $XDG_DATA_HOME/nailit/nailit.db, or ~/.local/share/nailit/nailit.db |
| macOS | ~/Library/Application Support/nailit/nailit.db |
| Windows | %LOCALAPPDATA%\nailit\nailit.db |
A relative XDG_DATA_HOME is ignored. On Windows, if LOCALAPPDATA is unset,
the fallback is ~/AppData/Local/nailit/nailit.db.
Override with NAILIT_DB or the global --db <path> flag; the flag wins. For example:
nailit --db ./sample.db init
nailit --db ./sample.db list
init creates parent directories and applies migrations. It is safe to repeat.
Other commands do not initialize or upgrade implicitly; they tell you to run
nailit init when needed. Unix creation modes are 0700 for new directories and
0600 for the database; Windows uses inherited filesystem permissions.
Search Tracks
Every job belongs to exactly one search track. nailit init automatically creates
default / Default with priority 100 and makes it the default. When upgrading
an existing database with init, all existing jobs are assigned to this track;
notes, history, and job IDs are preserved without any manual track setup.
Jobs added without -t, --track <slug> use the current default. Default and
priority are independent: default selects the track for an implicit add, while
lower integer priorities indicate greater strategic importance. Track priority
is unrelated to a job's priority or fit score; Nailit does not score or rank
opportunities from it. New tracks have priority 100 unless specified and are
not default.
nailit init
nailit track edit default --slug frontend --name "Frontend Engineering" --priority 1
nailit track add qa "QA / Validation" --priority 2
nailit track add dc "Data Center / Hardware" --priority 3
nailit track set-default qa
nailit add # prompts for a job in qa
nailit add -t frontend # prompts for a job in frontend
nailit edit 42 -t dc # moves an existing job to dc
nailit list -t qa
nailit track list
nailit track list --json
track edit <slug> with no flags, or with values already stored, reports
No changes to track. and preserves the track timestamp.
Tracks can be renamed with track edit <slug> --slug ... --name ...; existing
jobs stay linked. track set-default <slug> switches the default atomically
without changing any priorities or existing job assignments. Unknown slugs and
duplicate slugs fail. Track assignments cannot be cleared; track deletion is
not supported. list without -t continues to show jobs across all tracks,
with the existing archived-job rules. Tracks sort by priority, then slug.
Delete an incorrectly added job
nailit delete <id> permanently deletes the job and its notes and status history
in one transaction. It shows the company, title, track, status, and child-record
counts, then requires typing the same job ID to confirm. A different response or
incomplete input cancels with a non-zero exit code and no changes.
nailit show 42 --json
nailit delete 42 # inspect the summary, then type 42
nailit delete 43 --yes # explicit noninteractive confirmation; -y also works
There is no trash or restore command. Deleted jobs disappear from lists, follow-ups,
and exports; their URL and company/requisition keys can be used again. Tracks
remain intact. Use status <id> archived to hide a job while retaining its records
and uniqueness constraints. Either active or archived jobs can be deleted.
If the job changes while deletion is being confirmed, deletion fails with a conflict. Inspect it again before retrying. Unknown IDs are errors. Only one ID is accepted; there is no deletion by filter or track. No schema upgrade is needed for this command on schema 4.
Commands
Every command, including the root, supports --help without opening a database.
Use list for structured filtering, search <query> for free-text search with
optional structured filters, and show <id> for exact job lookup. Search takes
one required positional query; quote phrases containing spaces.
| Command | Behavior |
|---|---|
nailit version |
Print the application version without opening a database |
nailit init |
Create the database and apply pending migrations |
nailit track list/add/edit/set-default |
List and manage search tracks |
nailit add |
Prompt for a job, or accept fields as flags |
nailit list |
Structured filtering; compact table, newest ID first |
nailit search <query> |
Free-text search with optional structured filters; same output as list |
nailit show <id> |
All fields, complete description, notes, and status history |
nailit edit <id> |
Edit fields interactively or with flags |
nailit delete <id> [--yes] |
Permanently delete one job, its notes and history |
nailit status <id> <status> [--note <text>] |
Atomically change status and append history |
nailit note <id> <text> |
Append a timestamped note; quote multiword text |
nailit note edit <job-id> <note-id> <text> |
Replace a note, preserving its creation timestamp |
nailit note delete <job-id> <note-id> [--yes] |
Permanently delete one note |
nailit followup |
Today's and overdue next actions, oldest due date first |
nailit export [--format json|csv] |
Export every job, including archived, to stdout |
nailit version prints nailit v0.2.0 for a release built with that tag, or
nailit dev for a normal local build. The release script embeds its version
argument into each binary using Go linker flags.
Machine-readable output
Use --json for scripts and agents. Human-readable output remains the default.
nailit list --json
nailit list --status applied --due --json
nailit show 42 --json
nailit followup --json
nailit version --json
nailit export --json
Successful JSON output contains exactly one JSON value on stdout. Errors go to
stderr with a non-zero exit code. list, search, and followup return arrays (including
[] for no matches), with exactly the same filtering and ordering as their human
output. Each job includes all job fields, including its full description.
show returns one job object plus notes and status_history arrays.
export --json returns an array of these detailed objects, including archived
jobs, newest ID first. version --json returns {"name":"nailit","version":"dev"}
for a local build; release builds report their embedded version instead.
The --json schema is a public interface independent of database structs:
- Every job has a non-null
trackobject withslug,name, integerpriority, and booleandefault.track list --jsonreturns an array of these objects, sorted by priority then slug. Internal track IDs are not exposed. - Keys use snake_case. Job IDs and the note IDs displayed by
showare integers. - Optional text, dates, and fit scores are always present, using
nullwhen unset. A fit score of zero stays0. Empty notes/history are[], nevernull. - Dates use
YYYY-MM-DD;created_atandupdated_atuse UTC RFC 3339 strings, with fractional seconds when present. Enum values match the CLI values below. - Notes contain
id,created_at, andtext. Status transitions containfrom_status,to_status,created_at, and nullablenote, in their existing chronological order. Internal history IDs and redundant parent IDs are excluded.
For compatibility, export and export --format json retain the legacy export
schema described below. Use export --json for the same schema as show --json;
--json and --format are mutually exclusive.
Add and edit
If another process changes the job after edit reads it, saving fails with
job changed concurrently; reload and try again. No fields from that edit are
saved. Read the job again and reapply the intended changes; the CLI does not
automatically overwrite the newer record. This also protects interactive forms.
Company and title are required. The other fields are optional. Defaults are
discovered status, normal priority, unknown work mode, and an unset fit score.
Dates are optional and are never inferred: even a transition to applied leaves
the application date unchanged unless you set it with edit.
With no field flags, add asks for each field. Press Enter for an optional blank
or a default. Paste a multiline description and finish with a line containing only
.. Use --description-file if a description itself contains such a line or you
want to preserve the source file's exact line endings and trailing newline.
An interrupted/incomplete form saves nothing.
Supplying any field flag selects noninteractive mode:
nailit add --company Amazon --title "Data Center Technician" \
--location "Chicago, IL" --work-mode onsite --priority high \
--url "https://example.com/jobs/123" --requisition-id DC-123 \
--fit-score 85 --next-action "Submit application" \
--next-action-date 2026-09-04 --date-discovered 2026-09-03 \
--description-file description.txt
The backslash line continuations above are for POSIX shells. In PowerShell, put the command on one line or use PowerShell's backtick continuation.
Available field flags on both add and edit:
--company --title --location
--work-mode --url --source
--requisition-id --compensation --priority
--fit-score --contact --next-action
--next-action-date --date-discovered --date-applied
--description --description-file
--status is available only on add. Description text and description file flags
are mutually exclusive. Compensation and referral/contact are free text.
edit with field flags changes only those fields; --contact "", for example,
clears an optional contact. --fit-score "" clears the score. Interactive editing
uses Enter to keep a value and - to clear an optional value. For the description,
a lone . keeps it, and - followed by . clears it. Validation still applies.
The job ID, creation timestamp, and status history cannot be edited.
Use the note commands below to correct or delete existing notes.
nailit edit 1 --date-applied 2026-09-03 --next-action "Check application status" --next-action-date 2026-09-10
nailit status 1 interview --note "Technical interview scheduled"
nailit note 1 "Recruiter emailed about next round"
JSON write receipts and error codes
add, edit, status, delete, note (including edit/delete), and
track add/edit/set-default accept --json. Successful writes exit 0 and emit
one public receipt on stdout; they never mix it with prose or prompts:
nailit add --company Example --title Engineer --json
# {"operation":"job.add","result":"created","job_id":42,"track_slug":"default","status":"discovered"}
nailit note 42 "Recruiter called" --json
# {"operation":"note.add","result":"created","job_id":42,"note_id":7}
nailit track edit default --slug frontend --json
# {"operation":"track.edit","result":"updated","track_slug":"frontend"}
Receipts confirm the committed operation, not a subsequent read of the database.
Use show <id> --json or track list --json when full current records are needed.
Note creation returns the ID obtained from its insert transaction. Track receipts
use the resulting slug, including after a rename, without internal numeric IDs.
| Operation | Result | Other fields |
|---|---|---|
job.add |
created |
job_id, resolved track_slug, initial status |
job.edit |
updated |
job_id |
job.status |
updated |
job_id, destination status |
job.delete |
deleted |
job_id |
note.add |
created |
job_id, note_id |
note.edit |
updated or unchanged |
job_id, note_id |
note.delete |
deleted |
job_id, note_id |
track.add |
created |
track_slug |
track.edit |
updated or unchanged |
resulting track_slug |
track.set-default |
default_set |
track_slug |
unchanged preserves timestamps. Job edits still save a revision even when the
supplied values equal current values. default_set confirms the requested default,
including when it was already selected. Repeating a job's current status remains
an error without a new history entry.
In JSON mode, add requires company/title flags and edit requires at least one
field flag. Job/note deletion requires --yes. JSON mode never opens a form.
Without --json, interactive and human-readable behavior remains available;
human note creation now includes the note ID too. Explicit --help prints help.
Errors in JSON mode exit 1, leave stdout empty, and emit one stderr object:
{"error":{"code":"not_found","message":"job 999: job not found"}}
Use error.code for branching; message is explanatory and not stable.
The stable codes apply to JSON reads as well as writes:
| Code | Meaning / handling |
|---|---|
invalid_input |
Invalid arguments, flags or field values; correct the request |
not_found |
Job, note or track does not exist (or note belongs to another job) |
duplicate |
Existing uniqueness key; job duplicates also include error.existing_job_id |
conflict |
Snapshot changed; reload and reconsider the intended update |
same_status |
Requested status already recorded; no transition added |
confirmation_required |
JSON deletion needs explicit --yes |
not_initialized |
Database needs initialization |
operation_failed |
Other storage, schema, I/O or runtime error; inspect the message |
Receipts are not idempotency tokens. A lost response or output/storage failure can leave the outcome uncertain: inspect current state before retrying a write, especially creation, note append or deletion.
Correct or delete a note
The existing note <job-id> <text> syntax still adds a new note. Use note IDs
from show <job-id> --json (or the human-readable notes section) for corrections:
nailit note 42 "Recruiter called"
nailit show 42 --json
nailit note edit 42 7 "Recruiter emailed, not called"
nailit note delete 42 7 # inspect the note and type 7 to confirm
nailit note delete 42 8 --yes # explicit noninteractive deletion; -y also works
Both job ID and note ID are required. A note belonging to another job or an unknown note is an error. Editing preserves the note ID, parent job, and original creation timestamp. Previous text versions are not retained. Empty text is rejected; passing the exact current text reports no changes without updating any timestamps. Deletion is permanent and affects only the selected note.
Each change updates the parent's revision and updated_at in the same transaction.
A concurrent change causes a conflict with no partial write; inspect the job again
before retrying. Notes remain in creation/ID order in show and JSON exports.
Note writes accept --json and return the job ID, note ID, and operation result.
No new migration is required on schema 4. Status history remains append-only.
Statuses, priorities, and work modes
Statuses are exactly:
discovered
shortlisted
applied
recruiter_screen
interview
offer
rejected
withdrawn
archived
Any valid status can transition to any other valid status. Setting the current
status again is an error and creates no history entry. Initial creation establishes
the starting status; history contains subsequent transitions only. Each transition
stores its source status, destination status, optional note, and UTC timestamp.
The job update, updated_at, and history insert commit together or roll back.
Priorities: low, normal, high. Work modes: onsite, hybrid, remote,
unknown. Values are case-sensitive. Fit score is an optional integer from 0 to
100; zero is distinct from unset. Invalid values return errors.
Filtering and follow-up
nailit list --status applied
nailit list --company Amazon
nailit list --priority high
nailit list --due
nailit list --company Amazon --status applied --priority high --due
nailit list --all
nailit list --status archived
nailit followup
search <query> searches for a literal substring in
company, title, description, location, URL, or requisition ID. A match in any one
of these fields is sufficient; other filters are combined with AND.
nailit search "QA engineer" -t qa --status applied --json
nailit search "REQ-123" --all
nailit search "hardware" --due
Matching lowercases Unicode text (including Cyrillic) and trims leading/trailing
whitespace from the query. A missing, empty, or whitespace-only query is an error.
The entire query must occur within one field: there is no token splitting,
regex, wildcard expansion, accent removal, or relevance ranking. % and _
are literal characters. Notes, status history, source, contact, next action,
and track metadata are not searched.
Archive rules and ordering remain unchanged: archived jobs require --all or
--status archived; the usual order is newest ID first, or oldest date first
with --due. Query filtering currently scans the rows selected by the other
filters in Go for Unicode matching; there is no full-text index or migration.
Company filtering is a case-insensitive substring match with repeated whitespace
normalized. Normal lists and follow-up exclude archived jobs. --all includes
them, and an explicit --status archived selects them. Other terminal statuses
(such as rejected or withdrawn) remain visible if you have left an action due.
--due and followup include dates on or before today in the local time zone;
they exclude future and unset dates. They sort by date ascending, then ID ascending.
The table includes ID, company, title, current status, priority, action date, and
the next action. Calendar dates use YYYY-MM-DD; event timestamps are persisted
in UTC RFC3339 with fractional seconds when present.
Duplicate protection
Both creation and editing check for an existing normalized URL or a matching normalized company + requisition ID. Errors identify the conflicting job ID. Archived jobs also retain their uniqueness constraints.
URL comparison lowercases the host, drops fragments and a single terminal slash,
sorts query keys, and removes utm_*, gclid, dclid, fbclid, msclkid,
mc_cid, mc_eid, _ga, and _gl. A single terminal slash is treated as
insignificant on listing URLs. Repeated slashes, escaped separators, path casing,
ports, job ID parameters, and ambiguous ref/source parameters are preserved.
HTTP and HTTPS are distinct. The original URL remains available in the record.
Only absolute HTTP(S) URLs without embedded credentials are accepted.
Company normalization trims, lowercases, and collapses whitespace; it does not fuzzy-match or equate parent companies with subsidiaries. Requisition IDs are trimmed and otherwise case-sensitive. Without either a URL or requisition ID, two jobs with the same company and title are allowed.
Export
nailit export --format json > applications.json
nailit export --format csv > applications.csv
JSON (the default) is a top-level array of job objects, with nested notes and
status_history arrays. Keys use snake_case, timestamps are UTC RFC3339, and an
unset fit_score is null. Empty optional text/date fields are omitted in JSON
and stored as NULL in SQLite. Empty notes/history are arrays, not null. Full
descriptions, note text, and history notes are preserved. Exporting an empty
database yields [] for JSON and only the header for CSV.
CSV contains the following primary flat job fields, in order:
id,company,title,location,work_mode,url,source,requisition_id,compensation,status,priority,fit_score,contact,next_action,next_action_date,date_discovered,date_applied,description,created_at,updated_at,track_slug,track_name,track_priority
The final three columns contain current track metadata, including any renames.
track_priority is the integer strategic priority (lower is higher); the existing
priority column remains the job priority (low, normal, high). The original
20 columns retain their names and positions. Empty exports include all 23 headers.
CSV uses standard quoting for commas, quotes, and multiline descriptions. Optional unset values are empty cells. Notes and history appear only in JSON. There is no import command in this MVP. Exports include archived jobs and use newest ID first. Each job's details are read consistently, but the entire export is not a global snapshot of concurrent activity; close other writers when making a backup export.
Example workflow
nailit init
nailit add
nailit list --company Amazon
nailit status 1 applied
nailit note 1 "Applied through Amazon Jobs"
nailit followup
nailit export --format json
Set a next-action date during add or edit for the job to appear in follow-up.
Architecture and migrations
cmd/nailit executable entry point
internal/cli commands, flags, prompts, terminal output, platform paths
internal/job model, validation, application service, repository interface
internal/sqlite SQL, transactions, repository, migration runner
internal/export JSON and CSV serialization
migrations embedded numbered forward-only SQL
scripts reproducible validation and sample-data smoke check
The CLI calls the application service; the service uses a repository interface implemented by SQLite. Core application behavior has no dependency on CLI code. A future dashboard can use these same layers within this module. No HTTP server, web dashboard, plugin system, background worker, or configuration framework exists.
Current schema version: 4. Migration 001 creates jobs, notes, history, and
deduplication indexes. Migration 002 adds lookup indexes. Migration 003 creates
tracks, assigns existing jobs to the bootstrap default, and rebuilds jobs with
a NOT NULL track foreign key while preserving notes, history, and indexes.
Database triggers keep exactly one default and switch it in one statement.
Migration 004 adds an internal job revision for optimistic concurrency checks.
Edits, track assignments, status changes, and notes advance the revision atomically;
the public JSON schema does not expose it.
Applied versions and UTC
application timestamps are stored in schema_migrations. Version numbers are
sorted numerically and applied exactly once. All pending migrations, including
their version records, run in one transaction. Failure rolls the batch back.
Running an older binary against an unknown newer schema is rejected.
Schema version and application version are separate concepts. Released migration files must never be changed; append a new numbered SQL file for any schema change. There are no downgrade migrations. Each future change must add an upgrade test showing existing data survives.
Migrations run only through init (or the explicit repository Migrate method),
so making a pre-upgrade backup is straightforward: close all nailit processes,
then copy the database file before running init with a new binary. The MVP
uses SQLite's default rollback journal, not WAL, and has no automatic backup
manager. Keep full database copies when you need restoration, since export has
no corresponding import command.
Agent skill
The nailit-cli skill explains how agents should use
the CLI with noninteractive flags, preserve descriptions and existing fields,
handle duplicate records, record status changes, and read structured exports.
Its source and optional UI metadata are in skills/nailit-cli.
CLI backlog
See CLI_GAPS.md for the CLI audit, confirmed behavior issues, and prioritized capability gaps after Search Tracks. Proposed commands are not implemented features.
Development and validation
gofmt -w cmd internal migrations
go test ./...
go vet ./...
go build ./...
go build -o nailit ./cmd/nailit
Tests use Go's testing package and isolated t.TempDir() SQLite databases.
They never open the per-user database. Coverage includes create/read/edit, notes,
history, invalid input, both deduplication rules, filters and due dates, export
round trips, migration ordering/idempotence/upgrades, injected transaction failures,
CLI workflows, interactive input, and help before initialization.
For a reproducible full validation with PowerShell 7 and Go on PATH:
pwsh -NoProfile -File ./scripts/validate.ps1
The script sets CGO_ENABLED=0, runs the three required Go checks, builds
bin/nailit.exe (or bin/nailit), and exercises all commands against a uniquely
named .tmp sample database. It preserves logs and sample exports in .tmp.
It never uses the default database. A separate drive can hold build caches:
pwsh -NoProfile -File ./scripts/validate.ps1 -CacheDirectory D:\nailit-build-cache
To rerun just the CLI smoke check after building:
pwsh -NoProfile -File ./scripts/smoke.ps1 -Binary ./bin/nailit.exe
Deliberately deferred: application submission, integrations, scraping, cloud sync,
authentication, AI/resume features, imports, a web/API interface, and automated
backups. This project is standalone and does not use or depend on career-ops.