No description
  • Go 95.6%
  • PowerShell 4.4%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Yaroslav Glukhov 0466b7076e
All checks were successful
CI and release / build (push) Successful in 6m54s
Include track metadata in CSV exports
2026-09-06 20:14:19 -05:00
.forgejo/workflows Upload CI build artifacts 2026-09-06 12:56:27 -05:00
cmd/nailit Add JSON write receipts and stable CLI error codes 2026-09-06 20:10:31 -05:00
docs/assets add logo 2026-09-02 22:10:01 -05:00
internal Include track metadata in CSV exports 2026-09-06 20:14:19 -05:00
migrations Add optimistic concurrency control for job edits 2026-09-06 19:22:09 -05:00
scripts Add Forgejo CI and cross-platform release packaging 2026-09-06 12:42:47 -05:00
skills/nailit-cli Include track metadata in CSV exports 2026-09-06 20:14:19 -05:00
.gitignore Add Forgejo CI and cross-platform release packaging 2026-09-06 12:42:47 -05:00
CLI_GAPS.md Include track metadata in CSV exports 2026-09-06 20:14:19 -05:00
go.mod init repo 2026-09-02 21:54:50 -05:00
go.sum init repo 2026-09-02 21:54:50 -05:00
README.md Include track metadata in CSV exports 2026-09-06 20:14:19 -05:00
VALIDATION.md init repo 2026-09-02 21:54:50 -05:00

nailit

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:

  1. Enable Actions in the repository settings.
  2. Register a Linux Forgejo runner with the docker label and Docker support (including container actions). The job uses node:22-bookworm; Go is installed by setup-go. If your runner uses a different label, change runs-on.
  3. 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 track object with slug, name, integer priority, and boolean default. track list --json returns 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 show are integers.
  • Optional text, dates, and fit scores are always present, using null when unset. A fit score of zero stays 0. Empty notes/history are [], never null.
  • Dates use YYYY-MM-DD; created_at and updated_at use UTC RFC 3339 strings, with fractional seconds when present. Enum values match the CLI values below.
  • Notes contain id, created_at, and text. Status transitions contain from_status, to_status, created_at, and nullable note, 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.