diff --git a/cmd/nightshift/commands/commit.go b/cmd/nightshift/commands/commit.go new file mode 100644 index 0000000..7e018fb --- /dev/null +++ b/cmd/nightshift/commands/commit.go @@ -0,0 +1,88 @@ +package commands + +import ( + "fmt" + "io" + "os" + + "github.com/marcus/nightshift/internal/commits" + "github.com/spf13/cobra" +) + +var commitCmd = &cobra.Command{ + Use: "commit", + Short: "Conventional Commits helpers", + Long: `Tools for working with Conventional Commits messages. + +Use "commit normalize" to validate and reformat a commit message so it +follows the project's rules (type prefix, lowercase type, subject length, +and wrapped body).`, +} + +var commitNormalizeCmd = &cobra.Command{ + Use: "normalize [MESSAGE]", + Short: "Normalize a commit message to Conventional Commits format", + Long: `Validate and rewrite a commit message into canonical Conventional +Commits form. + +The message is read from a positional argument, from a file passed via +--file (typically .git/COMMIT_EDITMSG by a commit-msg hook), or from stdin +when no argument and no --file are given. + + nightshift commit normalize "feat: add login" + nightshift commit normalize --file .git/COMMIT_EDITMSG + git log -1 --pretty=%B | nightshift commit normalize + +Use --check to only validate without rewriting; the exit code is non-zero +when the message does not conform.`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + check, _ := cmd.Flags().GetBool("check") + file, _ := cmd.Flags().GetString("file") + + raw, err := readCommitMessage(args, file) + if err != nil { + return err + } + + normalized, err := commits.Normalize(raw) + if err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + return err + } + + if check { + fmt.Fprintln(os.Stdout, normalized) + return nil + } + fmt.Fprintln(os.Stdout, normalized) + return nil + }, +} + +func init() { + commitNormalizeCmd.Flags().BoolP("check", "c", false, "Only validate; do not rewrite") + commitNormalizeCmd.Flags().StringP("file", "f", "", "Read the message from this file (use by the commit-msg hook)") + commitCmd.AddCommand(commitNormalizeCmd) + rootCmd.AddCommand(commitCmd) +} + +// readCommitMessage resolves the message source in order: positional arg, +// --file, then stdin. +func readCommitMessage(args []string, file string) (string, error) { + if len(args) == 1 { + return args[0], nil + } + if file != "" { + b, err := os.ReadFile(file) + if err != nil { + return "", fmt.Errorf("read %s: %w", file, err) + } + return string(b), nil + } + b, err := io.ReadAll(os.Stdin) + if err != nil { + return "", fmt.Errorf("read stdin: %w", err) + } + return string(b), nil +} diff --git a/docs/commit-messages.md b/docs/commit-messages.md new file mode 100644 index 0000000..ab6e009 --- /dev/null +++ b/docs/commit-messages.md @@ -0,0 +1,47 @@ +# Commit Messages + +Nightshift uses [Conventional Commits](https://www.conventionalcommits.org/) +for all commit messages. This keeps the history readable and lets tooling +derive changelogs automatically. + +## Format + +``` +(): + + +``` + +- **type** — one of `feat`, `fix`, `docs`, `style`, `refactor`, `test`, + `chore`, `perf`, `build`, `ci`. +- **scope** — optional, e.g. `fix(api): ...`. +- **subject** — lowercase, imperative mood, no trailing period, max 72 chars. +- **body** — optional, wrapped at 72 columns, separated from the subject by a + blank line. + +## The `commit normalize` command + +Validate and reformat a message: + +```sh +nightshift commit normalize "feat: add login screen" +nightshift commit normalize --file .git/COMMIT_EDITMSG +git log -1 --pretty=%B | nightshift commit normalize +``` + +Add `--check` to validate only. The command exits non-zero when a message +cannot be normalized (missing/unknown type, capitalized or overlong subject). + +## commit-msg hook + +To enforce the rules locally, install the hook: + +```sh +make install-hooks +# or manually: +ln -sf ../../scripts/commit-msg.sh .git/hooks/commit-msg +``` + +The hook normalizes your message file in place before the commit is created and +rejects messages that cannot be fixed automatically. Bypass it with +`git commit --no-verify`. diff --git a/internal/commits/normalizer.go b/internal/commits/normalizer.go new file mode 100644 index 0000000..62526b3 --- /dev/null +++ b/internal/commits/normalizer.go @@ -0,0 +1,255 @@ +// Package commits implements Conventional Commits message normalization and +// validation. It exposes pure, well-tested functions used by the CLI and by the +// commit-msg git hook to keep the project's history consistent. +// +// The supported format follows the Conventional Commits 1.0.0 specification: +// +// (): +// +// +// +// The normalizer is intentionally strict but constructive: rather than silently +// accepting malformed input it fixes the trivially fixable (whitespace, type +// casing, trailing punctuation, body wrapping) and rejects anything that needs +// a human decision (missing type, unknown type, missing subject). +package commits + +import ( + "errors" + "fmt" + "strings" + "unicode/utf8" +) + +// MaxSubjectLength is the maximum number of runes allowed in a commit subject. +const MaxSubjectLength = 72 + +// BodyWrapWidth is the column at which the commit body is wrapped. +const BodyWrapWidth = 72 + +// allowedTypes is the set of Conventional Commit types this project accepts. +var allowedTypes = map[string]struct{}{ + "feat": {}, + "fix": {}, + "docs": {}, + "style": {}, + "refactor": {}, + "test": {}, + "chore": {}, + "perf": {}, + "build": {}, + "ci": {}, +} + +// Errors returned by the normalizer. They are wrapped so callers can match on +// the underlying cause with errors.Is. +var ( + // ErrEmptyMessage is returned when the message contains no non-comment, + // non-whitespace content. + ErrEmptyMessage = errors.New("commit message is empty") + // ErrMissingType is returned when the subject line is not a Conventional + // Commit (no type prefix before the colon). + ErrMissingType = errors.New("commit message must start with a conventional commit type") + // ErrUnknownType is returned when the type prefix is not in the allowed set. + ErrUnknownType = errors.New("commit type is not in the allowed set") + // ErrMissingSubject is returned when the type prefix is present but no + // subject text follows the colon. + ErrMissingSubject = errors.New("commit subject is missing") + // ErrSubjectTooLong is returned when the subject exceeds MaxSubjectLength. + ErrSubjectTooLong = fmt.Errorf("commit subject exceeds %d characters", MaxSubjectLength) + // ErrSubjectLowercase is returned when the subject starts with an uppercase + // letter (the rule is "do not capitalize the subject"). + ErrSubjectLowercase = errors.New("commit subject must not be capitalized") +) + +// Normalize parses, validates, and rewrites a raw commit message so that it +// conforms to the project's Conventional Commits rules. It returns the +// canonical form and a non-nil error describing the first unrecoverable +// problem when the message cannot be normalized. +// +// Normalization is idempotent: Normalize(Normalize(m)) == Normalize(m). +func Normalize(msg string) (string, error) { + lines := stripComments(msg) + if len(lines) == 0 { + return "", ErrEmptyMessage + } + + header := lines[0] + body := lines[1:] + + typ, scope, subject, err := parseHeader(header) + if err != nil { + return "", err + } + + subject = cleanSubject(subject) + + var b strings.Builder + b.WriteString(formatHeader(typ, scope, subject)) + + wrapped := wrapBody(body, BodyWrapWidth) + if wrapped != "" { + b.WriteString("\n\n") + b.WriteString(wrapped) + } + + return b.String(), nil +} + +// stripComments removes git's commented-out lines (those beginning with "#"), +// trims trailing whitespace from every line, and drops leading/trailing blank +// lines. It returns the meaningful lines of the message. +func stripComments(msg string) []string { + rawLines := strings.Split(msg, "\n") + out := make([]string, 0, len(rawLines)) + for _, l := range rawLines { + l = strings.TrimRight(l, " \t\r") + if strings.HasPrefix(strings.TrimSpace(l), "#") { + continue + } + out = append(out, l) + } + // Drop leading and trailing blank lines. + for len(out) > 0 && strings.TrimSpace(out[0]) == "" { + out = out[1:] + } + for len(out) > 0 && strings.TrimSpace(out[len(out)-1]) == "" { + out = out[:len(out)-1] + } + return out +} + +// parseHeader splits the first line into its Conventional Commit components and +// validates them. The returned type is lower-cased to match the allowed set. +func parseHeader(header string) (typ, scope, subject string, err error) { + header = strings.TrimSpace(header) + colon := strings.Index(header, ":") + if colon <= 0 { + return "", "", "", ErrMissingType + } + prefix := header[:colon] + subject = strings.TrimSpace(header[colon+1:]) + + // Split an optional "(scope)" from the type. + prefix = strings.TrimSpace(prefix) + if strings.HasPrefix(prefix, "(") { + // A leading "(" with no type is not a valid conventional header. + return "", "", "", ErrMissingType + } + if open := strings.Index(prefix, "("); open > 0 && strings.HasSuffix(prefix, ")") { + typ = prefix[:open] + scope = prefix[open+1 : len(prefix)-1] + } else { + typ = prefix + } + typ = strings.ToLower(strings.TrimSpace(typ)) + scope = strings.TrimSpace(scope) + + if typ == "" { + return "", "", "", ErrMissingType + } + if !isAllowedType(typ) { + return "", "", "", fmt.Errorf("%w: %q", ErrUnknownType, typ) + } + if strings.TrimSpace(subject) == "" { + return "", "", "", ErrMissingSubject + } + if utf8.RuneCountInString(subject) > MaxSubjectLength { + return "", "", "", ErrSubjectTooLong + } + if startsUpper(subject) { + return "", "", "", ErrSubjectLowercase + } + return typ, scope, subject, nil +} + +// cleanSubject normalizes the subject text: lowercases a leading uppercase +// letter is *not* done here (capitalization is a hard error, not a fix), but +// surrounding whitespace and a trailing period are removed. +func cleanSubject(subject string) string { + s := strings.TrimSpace(subject) + s = strings.TrimRight(s, ".") + return s +} + +// formatHeader reassembles a canonical header line from its components. +func formatHeader(typ, scope, subject string) string { + if scope != "" { + return typ + "(" + scope + "): " + subject + } + return typ + ": " + subject +} + +// wrapBody collapses runs of blank lines, preserves non-blank paragraphs, and +// hard-wraps each paragraph line to width. Paragraph breaks (a single blank +// line) are preserved. +func wrapBody(body []string, width int) string { + var paragraphs [][]string + var cur []string + for _, l := range body { + if strings.TrimSpace(l) == "" { + if len(cur) > 0 { + paragraphs = append(paragraphs, cur) + cur = nil + } + continue + } + cur = append(cur, strings.TrimSpace(l)) + } + if len(cur) > 0 { + paragraphs = append(paragraphs, cur) + } + + var b strings.Builder + for i, p := range paragraphs { + if i > 0 { + b.WriteString("\n\n") + } + b.WriteString(wrapParagraph(strings.Join(p, " "), width)) + } + return b.String() +} + +// wrapParagraph hard-wraps a single-line paragraph at width, breaking on word +// boundaries. A word longer than width is left intact rather than split. +func wrapParagraph(text string, width int) string { + words := strings.Fields(text) + if len(words) == 0 { + return "" + } + var b strings.Builder + lineLen := 0 + for i, w := range words { + if i == 0 { + b.WriteString(w) + lineLen = len(w) + continue + } + if lineLen+1+len(w) <= width { + b.WriteByte(' ') + b.WriteString(w) + lineLen += 1 + len(w) + } else { + b.WriteByte('\n') + b.WriteString(w) + lineLen = len(w) + } + } + return b.String() +} + +// isAllowedType reports whether typ is one of the accepted Conventional Commit +// types. +func isAllowedType(typ string) bool { + _, ok := allowedTypes[typ] + return ok +} + +// startsUpper reports whether the first rune of s is an ASCII uppercase letter. +func startsUpper(s string) bool { + if s == "" { + return false + } + r, _ := utf8.DecodeRuneInString(s) + return r >= 'A' && r <= 'Z' +} diff --git a/internal/commits/normalizer_test.go b/internal/commits/normalizer_test.go new file mode 100644 index 0000000..2121633 --- /dev/null +++ b/internal/commits/normalizer_test.go @@ -0,0 +1,133 @@ +package commits + +import ( + "errors" + "strings" + "testing" +) + +func TestNormalize(t *testing.T) { + tests := []struct { + name string + in string + want string + wantErr error + }{ + { + name: "valid simple feat", + in: "feat: add login screen", + want: "feat: add login screen", + }, + { + name: "valid with scope", + in: "fix(api): handle nil response", + want: "fix(api): handle nil response", + }, + { + name: "trims surrounding whitespace and trailing period", + in: " docs: update README. ", + want: "docs: update README", + }, + { + name: "lowercases an uppercased type", + in: "FEAT(ui): render button", + want: "feat(ui): render button", + }, + { + name: "preserves body and wraps long lines", + in: "feat: add thing\n\nthis is a body paragraph that is intentionally far longer than the configured wrap width so it must be hard wrapped onto multiple lines by the normalizer function", + want: "feat: add thing\n\n" + + "this is a body paragraph that is intentionally far longer than the\n" + + "configured wrap width so it must be hard wrapped onto multiple lines by\n" + + "the normalizer function", + }, + { + name: "strips git comment lines", + in: "chore: tidy\n# please enter the commit message\n\nbody here", + want: "chore: tidy\n\nbody here", + }, + { + name: "missing type rejected", + in: "just a plain message", + wantErr: ErrMissingType, + }, + { + name: "unknown type rejected", + in: "wip: halfway done", + wantErr: ErrUnknownType, + }, + { + name: "missing subject rejected", + in: "feat:", + wantErr: ErrMissingSubject, + }, + { + name: "capitalized subject rejected", + in: "feat: Add login screen", + wantErr: ErrSubjectLowercase, + }, + { + name: "overlong subject rejected", + in: "feat: " + strings.Repeat("a", MaxSubjectLength+1), + wantErr: ErrSubjectTooLong, + }, + { + name: "empty message rejected", + in: "\n\n# only comments\n \n", + wantErr: ErrEmptyMessage, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := Normalize(tc.in) + if tc.wantErr != nil { + if err == nil { + t.Fatalf("Normalize(%q): expected error %v, got nil (result %q)", tc.in, tc.wantErr, got) + } + if !errors.Is(err, tc.wantErr) { + t.Fatalf("Normalize(%q): expected error to wrap %v, got %v", tc.in, tc.wantErr, err) + } + return + } + if err != nil { + t.Fatalf("Normalize(%q): unexpected error: %v", tc.in, err) + } + if got != tc.want { + t.Errorf("Normalize(%q):\n got: %q\nwant: %q", tc.in, got, tc.want) + } + }) + } +} + +func TestNormalizeIdempotent(t *testing.T) { + cases := []string{ + "feat: add login screen", + "fix(api): handle nil response\n\nLong body that explains the fix in more detail than the subject alone can manage so that we exercise the wrapping path too and then some more words here.", + "docs: update README\n\nfirst paragraph\n\nsecond paragraph stays separate", + } + for _, in := range cases { + once, err := Normalize(in) + if err != nil { + t.Fatalf("first Normalize(%q) errored: %v", in, err) + } + twice, err := Normalize(once) + if err != nil { + t.Fatalf("second Normalize(%q) errored: %v", once, err) + } + if once != twice { + t.Errorf("not idempotent for %q\n once: %q\n twice: %q", in, once, twice) + } + } +} + +func TestAllowedTypes(t *testing.T) { + for _, typ := range []string{"feat", "fix", "docs", "style", "refactor", "test", "chore", "perf", "build", "ci"} { + if !isAllowedType(typ) { + t.Errorf("expected %q to be an allowed type", typ) + } + } + if isAllowedType("wip") { + t.Error("did not expect wip to be allowed") + } +} diff --git a/scripts/commit-msg.sh b/scripts/commit-msg.sh new file mode 100755 index 0000000..3320a40 --- /dev/null +++ b/scripts/commit-msg.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# commit-msg hook for nightshift +# +# Enforces Conventional Commits on every commit message and rewrites the +# message file into canonical form before the commit is created. Messages that +# cannot be normalized (missing/unknown type, capitalized or overlong subject) +# are rejected with a non-zero exit so the commit is aborted. +# +# Install: +# make install-hooks +# # or manually: +# ln -sf ../../scripts/commit-msg.sh .git/hooks/commit-msg +# chmod +x scripts/commit-msg.sh +set -euo pipefail + +if [[ $# -lt 1 ]]; then + echo "usage: commit-msg " >&2 + exit 1 +fi + +MSG_FILE="$1" + +# Resolve the nightshift binary: prefer the one on $PATH, fall back to +# building the current source tree. +NIGHTSHIFT="$(command -v nightshift || true)" +if [[ -z "$NIGHTSHIFT" ]]; then + NIGHTSHIFT="go run github.com/marcus/nightshift/cmd/nightshift" +fi + +NORMALIZED="$($NIGHTSHIFT commit normalize --file "$MSG_FILE" 2>/tmp/nightshift-commit-msg.err)" +STATUS=$? + +if [[ $STATUS -ne 0 ]]; then + echo "🪡 commit-msg: message does not follow Conventional Commits" >&2 + sed 's/^/ /' /tmp/nightshift-commit-msg.err >&2 || true + echo "" >&2 + echo " Expected format: (): " >&2 + echo " Types: feat fix docs style refactor test chore perf build ci" >&2 + echo " (rewrite your message, or bypass with: git commit --no-verify)" >&2 + exit 1 +fi + +# Rewrite the message file into canonical form. +printf '%s\n' "$NORMALIZED" > "$MSG_FILE" +echo "🪡 commit-msg: normalized" +exit 0 diff --git a/website/docs/cli-reference.md b/website/docs/cli-reference.md index d5a2cd4..f56955d 100644 --- a/website/docs/cli-reference.md +++ b/website/docs/cli-reference.md @@ -19,6 +19,78 @@ title: CLI Reference | `nightshift logs` | Stream or export logs | | `nightshift stats` | Token usage statistics | | `nightshift daemon` | Background scheduler | +| `nightshift report` | Show what nightshift did | + +## Configuration Commands + +| Command | Description | +|---------|-------------| +| `nightshift config` | Show merged configuration | +| `nightshift config get` | Read a value by key path | +| `nightshift config set` | Write a value by key path | +| `nightshift config validate` | Validate configuration files | +| `nightshift init` | Create a config file from a template | +| `nightshift install` | Install a system service (launchd/systemd/cron) | +| `nightshift uninstall` | Remove the installed system service | + +See [Configuration](/docs/configuration) for the config file format and available keys. + +### `nightshift config` + +With no subcommand, prints the configuration sources (global and project paths) and the merged effective configuration. + +```bash +nightshift config # Show merged config +nightshift config get budget.max_percent +nightshift config get providers.claude.enabled +nightshift config set budget.max_percent 15 +nightshift config set logging.level debug +nightshift config set providers.claude.enabled false +nightshift config set --global budget.weekly_tokens 700000 +nightshift config validate +``` + +`config set` writes to the project config (`nightshift.yaml`) if one exists, otherwise to the global config (`~/.config/nightshift/config.yaml`). Values are parsed as bool, int, or float when they look like one; everything else is written as a string. Environment variables prefixed with `NIGHTSHIFT_` override config values for `config get` (for example `NIGHTSHIFT_BUDGET.MAX_PERCENT`). + +| Subcommand | Flags | +|------------|-------| +| `config get KEY` | none | +| `config set KEY VALUE` | `--global`, `-g` — always write to global config instead of project config | +| `config validate` | none | + +`config validate` checks the global config, the project config, and the merged result, and exits non-zero when any of them has errors. + +### `nightshift init` + +Creates a commented config file from a template. By default creates `nightshift.yaml` in the current directory; `--global` creates `~/.config/nightshift/config.yaml` instead. Prompts before overwriting an existing file unless `--force` is given. + +```bash +nightshift init # Project config in ./nightshift.yaml +nightshift init --global # Global config +nightshift init --global --force +``` + +| Flag | Default | Description | +|------|---------|-------------| +| `--global` | `false` | Create global config instead of project config | +| `--force`, `-f` | `false` | Overwrite existing config without prompting | + +### `nightshift install` / `nightshift uninstall` + +`install` generates and loads a system service that runs `nightshift run` on the schedule from your config (default: 2 AM daily): + +- `launchd` — macOS (`~/Library/LaunchAgents/com.nightshift.agent.plist`) +- `systemd` — Linux, user units (`~/.config/systemd/user/nightshift.service` + `nightshift.timer`) +- `cron` — universal (managed crontab entry) + +```bash +nightshift install # Auto-detect launchd/systemd/cron +nightshift install launchd # Explicit service type +nightshift install cron +nightshift uninstall # Remove whichever service is installed +``` + +Neither command takes flags. See [Scheduling](/docs/scheduling) for schedule configuration. ## Run Options @@ -61,6 +133,17 @@ nightshift preview --json # JSON output nightshift preview --write ./dir # Write prompts to files ``` +| Flag | Default | Description | +|------|---------|-------------| +| `--runs`, `-n` | `3` | Number of upcoming runs to preview | +| `--project`, `-p` | | Preview only a specific project path | +| `--task`, `-t` | | Preview only a specific task type | +| `--long` | `false` | Show full prompts (default shows a truncated preview) | +| `--write` | | Write full prompts to a directory | +| `--explain` | `false` | Show budget and task-filter explanations | +| `--plain` | `false` | Disable gum pager output | +| `--json` | `false` | Output JSON (includes full prompts) | + ## Task Commands ```bash @@ -73,6 +156,14 @@ nightshift task run lint-fix --provider claude nightshift task run lint-fix --provider codex --dry-run ``` +| Subcommand | Flags | +|------------|-------| +| `task list` | `--category` (pr, analysis, options, safe, map, emergency), `--cost` (low, medium, high, veryhigh), `--json` | +| `task show ` | `--prompt-only`, `--json`, `--project`, `-p` | +| `task run ` | `--provider` (claude, codex, copilot), `--project`, `-p`, `--dry-run`, `--timeout` (default 30m), `--branch`, `-b` (base branch for new feature branches) | + +See the [Task Reference](/docs/task-reference) for all built-in task types. + ## Budget Commands ```bash @@ -83,10 +174,159 @@ nightshift budget history -n 10 nightshift budget calibrate ``` +| Subcommand | Flags | +|------------|-------| +| `budget` | `--provider`, `-p` (claude, codex, copilot) | +| `budget snapshot` | `--provider`, `-p`; `--local-only` — skip tmux scraping and store a local-only snapshot | +| `budget history` | `--provider`, `-p`; `-n` — number of snapshots to show (default 20) | +| `budget calibrate` | `--provider`, `-p` | + +`budget snapshot` collects local token counts and — when tmux is installed and `calibrate_enabled: true` in config — scrapes the provider CLI's own usage display to infer the weekly budget. `budget calibrate` shows the inferred budget, its source, confidence, and sample count. See [Budget](/docs/budget) for details and the repo guide `docs/guides/provider-calibration.md` for the calibration workflow. + +## Status, Logs, and Stats + +```bash +nightshift status # Last 5 runs +nightshift status -n 20 # Last 20 runs +nightshift status --today # Today's activity summary +``` + +```bash +nightshift logs # Last 50 lines +nightshift logs -f # Follow +nightshift logs -n 200 # More lines +nightshift logs --level warn # Minimum level +nightshift logs --component run # Filter by component +nightshift logs --match "error" # Filter by message substring +nightshift logs --summary # Summary only +nightshift logs --export out.log # Export to file +``` + +| `logs` flag | Default | Description | +|-------------|---------|-------------| +| `--tail`, `-n` | `50` | Number of log lines to show | +| `--follow`, `-f` | `false` | Follow log output | +| `--export`, `-e` | | Export logs to file | +| `--since` / `--until` | | Time range (YYYY-MM-DD, YYYY-MM-DD HH:MM, or RFC3339) | +| `--level` | | Minimum log level (debug, info, warn, error) | +| `--component` | | Filter by component substring | +| `--match` | | Filter by message substring | +| `--summary` | `false` | Show summary only | +| `--raw` | `false` | Show raw log lines without formatting | +| `--no-color` | `false` | Disable ANSI colors | +| `--path` | | Override log directory | + +```bash +nightshift stats # All time +nightshift stats -p last-7d # Last 7 days +nightshift stats --json +``` + +| `stats` flag | Default | Description | +|------|---------|-------------| +| `--period`, `-p` | `all` | Time period: all, last-7d, last-30d, last-night | +| `--json` | `false` | Output as JSON | + +## Daemon Commands + +```bash +nightshift daemon start # Start in background +nightshift daemon start -f # Run in foreground +nightshift daemon stop # Stop via SIGTERM +nightshift daemon status # Check if running +``` + +| Subcommand | Flags | +|------------|-------| +| `daemon start` | `--foreground`, `-f` — run in foreground (don't daemonize); `--timeout` — per-agent execution timeout (default 30m) | +| `daemon stop` | none | +| `daemon status` | none | + +The daemon runs the scheduler loop, executing tasks according to the configured schedule (cron or interval) and respecting time windows. + +## Maintenance Commands + +### `nightshift doctor` + +Runs diagnostics on config, scheduling, service installation, daemon, provider CLIs, database health, budget readiness, snapshots, and tmux availability. Exits non-zero when any check fails. + +```bash +nightshift doctor +``` + +Takes no flags. See [Troubleshooting](/docs/troubleshooting) for common issues it detects. + +### `nightshift report` + +Shows structured reports from recent runs — by default a polished overview of the last night, falling back to the most recent run when the default period is empty. + +```bash +nightshift report # Overview of last night +nightshift report --report tasks # Per-task breakdown +nightshift report --period last-7d # Wider window +nightshift report --runs 10 # Include up to 10 runs +nightshift report --since 2026-09-01 --until 2026-09-08 +nightshift report --format markdown # Markdown output +nightshift report --format json # JSON output +``` + +| Flag | Default | Description | +|------|---------|-------------| +| `--report`, `-r` | `overview` | Report type: overview, tasks, projects, budget, raw | +| `--period`, `-p` | `last-night` | Time period: last-night, last-run, last-24h, last-7d, today, yesterday, all | +| `--runs`, `-n` | `3` | Max runs to include (0 = all) | +| `--since` / `--until` | | Time range (YYYY-MM-DD, YYYY-MM-DD HH:MM, or RFC3339) | +| `--format` | `fancy` | Output format: fancy, plain, markdown, json | +| `--no-color` | `false` | Disable ANSI colors | +| `--paths` | `false` | Include report/log file paths | +| `--max-items` | `5` | Max highlights per run | + +### `nightshift busfactor` + +Analyzes code ownership concentration in a git repository: bus factor (minimum contributors needed for 50% of commits), Herfindahl index, Gini coefficient, and a risk level. + +```bash +nightshift busfactor # Current directory +nightshift busfactor ~/code/myapp # Specific repo +nightshift busfactor --since 2026-01-01 # Limit date range +nightshift busfactor --file "*.go" --json # Single file/pattern, JSON output +nightshift busfactor --save # Persist result to the database +``` + +| Flag | Default | Description | +|------|---------|-------------| +| `--path`, `-p` | current directory | Repository or directory path (or pass as positional argument) | +| `--json` | `false` | Output as JSON | +| `--since` / `--until` | | Date range (RFC3339 or YYYY-MM-DD) | +| `--file`, `-f` | | Analyze a specific file or pattern | +| `--save` | `false` | Save results to the database | +| `--db` | from config | Database path | + +See the repo guide `docs/bus-factor.md` for how the metrics are computed, and the `bus-factor` task in the [Task Reference](/docs/task-reference) for the scheduled variant. + +### `nightshift commit normalize` + +Validates and rewrites a commit message into canonical Conventional Commits form (type prefix, lowercase type, subject length, wrapped body). The message is read from the positional argument, from `--file` (typically `.git/COMMIT_EDITMSG` via a commit-msg hook), or from stdin. + +```bash +nightshift commit normalize "feat: add login" +nightshift commit normalize --file .git/COMMIT_EDITMSG +git log -1 --pretty=%B | nightshift commit normalize +nightshift commit normalize --check "Feat: Add login" # Validate only +``` + +| Flag | Default | Description | +|------|---------|-------------| +| `--check`, `-c` | `false` | Only validate; do not rewrite (non-zero exit when non-conforming) | +| `--file`, `-f` | | Read the message from this file | + +See the repo guide `docs/commit-messages.md` for the full formatting rules. + ## Global Flags | Flag | Description | |------|-------------| | `--verbose` | Verbose output | -| `--provider` | Select provider (claude, codex) | -| `--timeout` | Execution timeout (default 30m) | +| `--version` | Print the nightshift version | + +Provider selection (`--provider`) and timeouts (`--timeout`) are per-command flags — see the individual commands above.