From c26c5fd368a6c536fab9149ee507f411f97e8bb1 Mon Sep 17 00:00:00 2001 From: Lasse Larsen Date: Sun, 28 Jun 2026 02:10:21 +0200 Subject: [PATCH 1/3] feat(commits): add Conventional Commits message normalizer Add internal/commits with pure Normalize/validate functions enforcing the project's Conventional Commits rules (known type set, lowercase type, lowercase subject, 72-char subject limit, whitespace trimming, and 72-column body wrapping). Wire it into the CLI as 'nightshift commit normalize' (positional, --file, and stdin sources; --check to validate only), ship a commit-msg git hook under scripts/, and document the format and installation in docs/commit-messages.md. Nightshift-Task: commit-normalize Nightshift-Ref: https://github.com/marcus/nightshift --- cmd/nightshift/commands/commit.go | 88 ++++++++++ docs/commit-messages.md | 47 +++++ internal/commits/normalizer.go | 255 ++++++++++++++++++++++++++++ internal/commits/normalizer_test.go | 133 +++++++++++++++ scripts/commit-msg.sh | 46 +++++ 5 files changed, 569 insertions(+) create mode 100644 cmd/nightshift/commands/commit.go create mode 100644 docs/commit-messages.md create mode 100644 internal/commits/normalizer.go create mode 100644 internal/commits/normalizer_test.go create mode 100755 scripts/commit-msg.sh 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 From 33d4300964dd7b437fcf8998c173e3f685dafd3c Mon Sep 17 00:00:00 2001 From: Lasse Larsen Date: Fri, 18 Sep 2026 02:02:34 +0200 Subject: [PATCH 2/3] chore: fix lint errors in commit and config commands - errcheck: handle fmt.Fprintln error in commit-normalize RunE - govet: replace deprecated reflect.Ptr alias with reflect.Pointer Nightshift-Task: lint-fix Nightshift-Ref: https://github.com/marcus/nightshift --- cmd/nightshift/commands/commit.go | 8 ++++++-- cmd/nightshift/commands/config.go | 4 ++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/cmd/nightshift/commands/commit.go b/cmd/nightshift/commands/commit.go index 7e018fb..1f266fd 100644 --- a/cmd/nightshift/commands/commit.go +++ b/cmd/nightshift/commands/commit.go @@ -52,10 +52,14 @@ when the message does not conform.`, } if check { - fmt.Fprintln(os.Stdout, normalized) + if _, err := fmt.Fprintln(os.Stdout, normalized); err != nil { + return err + } return nil } - fmt.Fprintln(os.Stdout, normalized) + if _, err := fmt.Fprintln(os.Stdout, normalized); err != nil { + return err + } return nil }, } diff --git a/cmd/nightshift/commands/config.go b/cmd/nightshift/commands/config.go index 4300470..ebb398f 100644 --- a/cmd/nightshift/commands/config.go +++ b/cmd/nightshift/commands/config.go @@ -370,7 +370,7 @@ func printStruct(v reflect.Value, indent int) { case reflect.Struct: fmt.Printf("%s%s:\n", prefix, tag) printStruct(value, indent+1) - case reflect.Ptr: + case reflect.Pointer: if !value.IsNil() { if value.Elem().Kind() == reflect.Struct { fmt.Printf("%s%s:\n", prefix, tag) @@ -408,7 +408,7 @@ func printStruct(v reflect.Value, indent int) { func isZero(v reflect.Value) bool { switch v.Kind() { - case reflect.Ptr, reflect.Interface: + case reflect.Pointer, reflect.Interface: return v.IsNil() case reflect.Slice, reflect.Map: return v.Len() == 0 From 13df1371ca091c56af3e9e8c7c47b659f0d4d91b Mon Sep 17 00:00:00 2001 From: Lasse Larsen Date: Sat, 19 Sep 2026 02:06:15 +0200 Subject: [PATCH 3/3] docs: backfill CLI reference, sidebar, and package docs - cli-reference.md: document commit normalize, busfactor, config get/set/validate, init, daemon start/stop/status, install/uninstall, report, and budget snapshot/history/calibrate flags, verified against the cobra definitions in cmd/nightshift/commands/ - sidebars.js: add troubleshooting to the Reference category (page was unreachable from the docs nav) - cmd/provider-calibration: add missing package doc comment - adding-tasks.md: replace stale '50+' task count with the actual 59 - README.md: add a Guides section linking the docs/guides/ pages Nightshift-Task: docs-backfill Nightshift-Ref: https://github.com/marcus/nightshift --- README.md | 11 ++ cmd/provider-calibration/main.go | 3 + docs/guides/adding-tasks.md | 2 +- website/docs/cli-reference.md | 193 ++++++++++++++++++++++++++++++- website/sidebars.js | 2 +- 5 files changed, 207 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 84f92cd..16bc2e9 100644 --- a/README.md +++ b/README.md @@ -256,6 +256,17 @@ Each task has a default cooldown interval to prevent the same task from running `skill-groom` is enabled by default. Add it to `tasks.disabled` if you want to opt out. It updates project-local skills under `.claude/skills` and `.codex/skills` using `README.md` as project context and starts Agent Skills docs lookup from `https://agentskills.io/llms.txt`. +## Guides + +Deeper guides live in [`docs/guides/`](docs/guides/): + +- [Adding Tasks](docs/guides/adding-tasks.md) — how to add a new built-in or custom task +- [Run Lifecycle](docs/guides/run-lifecycle.md) — what happens from a scheduled trigger to a finished run +- [Codex Budget Tracking](docs/guides/codex-budget-tracking.md) — how nightshift tracks and budgets Codex CLI usage +- [Provider Calibration](docs/guides/provider-calibration.md) — comparing Claude and Codex token behavior on your machine +- [Agent tmux Integration](docs/guides/agent-tmux-integration.md) — driving the CLIs via tmux +- [Website](docs/guides/website.md) — building and deploying this site + ## Development ### Pre-commit hooks diff --git a/cmd/provider-calibration/main.go b/cmd/provider-calibration/main.go index ee573b2..6c5c149 100644 --- a/cmd/provider-calibration/main.go +++ b/cmd/provider-calibration/main.go @@ -1,3 +1,6 @@ +// Package main is the provider-calibration tool: it reads Claude and Codex +// session artifacts from local data directories and compares their token +// usage to help calibrate nightshift's budget assumptions. package main import ( diff --git a/docs/guides/adding-tasks.md b/docs/guides/adding-tasks.md index de87d5c..6787d7f 100644 --- a/docs/guides/adding-tasks.md +++ b/docs/guides/adding-tasks.md @@ -2,7 +2,7 @@ ## Built-in Tasks -Nightshift ships with 50+ built-in task types organized into six categories. To add a new built-in task, modify the task registry in `internal/tasks/tasks.go`. +Nightshift ships with 59 built-in task types organized into six categories. To add a new built-in task, modify the task registry in `internal/tasks/tasks.go`. ### Step 1: Define the Task Type Constant diff --git a/website/docs/cli-reference.md b/website/docs/cli-reference.md index d5a2cd4..bf35651 100644 --- a/website/docs/cli-reference.md +++ b/website/docs/cli-reference.md @@ -10,15 +10,22 @@ title: CLI Reference | Command | Description | |---------|-------------| | `nightshift setup` | Guided global configuration | +| `nightshift init` | Create a configuration file | +| `nightshift config` | Manage configuration (get/set/validate) | | `nightshift run` | Execute scheduled tasks | | `nightshift preview` | Show upcoming runs | | `nightshift budget` | Check token budget status | | `nightshift task` | Browse and run tasks | +| `nightshift report` | Show what nightshift did | | `nightshift doctor` | Check environment health | | `nightshift status` | View run history | | `nightshift logs` | Stream or export logs | | `nightshift stats` | Token usage statistics | +| `nightshift busfactor` | Analyze code ownership concentration | +| `nightshift commit` | Conventional Commits helpers | | `nightshift daemon` | Background scheduler | +| `nightshift install` | Install system service | +| `nightshift uninstall` | Remove system service | ## Run Options @@ -73,6 +80,32 @@ nightshift task run lint-fix --provider claude nightshift task run lint-fix --provider codex --dry-run ``` +### `task list` + +| Flag | Default | Description | +|------|---------|-------------| +| `--category` | | Filter by category (pr, analysis, options, safe, map, emergency) | +| `--cost` | | Filter by cost tier (low, medium, high, veryhigh) | +| `--json` | `false` | Output as JSON | + +### `task show ` + +| Flag | Default | Description | +|------|---------|-------------| +| `--prompt-only` | `false` | Output only the raw prompt text | +| `--json` | `false` | Output as JSON | +| `--project`, `-p` | | Project directory (used in prompt context) | + +### `task run ` + +| Flag | Default | Description | +|------|---------|-------------| +| `--provider` | | Provider to run against (claude, codex, copilot) | +| `--project`, `-p` | | Project directory to run in | +| `--dry-run` | `false` | Show prompt without executing | +| `--timeout` | `30m` | Execution timeout | +| `--branch`, `-b` | current branch | Base branch for new feature branches | + ## Budget Commands ```bash @@ -83,10 +116,166 @@ nightshift budget history -n 10 nightshift budget calibrate ``` +### `budget snapshot` + +Capture a usage snapshot for budget calibration. Collects local token counts and (optionally) scrapes the provider's usage display via tmux. When both local tokens and a scraped percentage are available, nightshift infers the weekly budget. + +| Flag | Default | Description | +|------|---------|-------------| +| `--provider`, `-p` | | Provider to snapshot (claude, codex, copilot) | +| `--local-only` | `false` | Skip tmux scraping and store a local-only snapshot | + +### `budget history` + +Show recent usage snapshots for budget calibration. + +| Flag | Default | Description | +|------|---------|-------------| +| `--provider`, `-p` | | Provider to show history for (claude, codex, copilot) | +| `--n` | `20` | Number of snapshots to show | + +### `budget calibrate` + +Show inferred budget calibration status for providers. + +| Flag | Default | Description | +|------|---------|-------------| +| `--provider`, `-p` | | Provider to calibrate (claude, codex, copilot) | + +## Commit Commands + +```bash +nightshift commit normalize "feat: add login" +nightshift commit normalize --file .git/COMMIT_EDITMSG +git log -1 --pretty=%B | nightshift commit normalize +``` + +### `commit normalize [MESSAGE]` + +Validate and rewrite 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 the file passed via `--file` (typically `.git/COMMIT_EDITMSG`, used by a commit-msg hook), or from stdin when neither is given. + +| Flag | Default | Description | +|------|---------|-------------| +| `--check`, `-c` | `false` | Only validate; do not rewrite (non-zero exit when the message does not conform) | +| `--file`, `-f` | | Read the message from this file | + +## Bus Factor + +```bash +nightshift busfactor # Analyze current directory +nightshift busfactor ~/code/myapp # Analyze a specific repository +nightshift busfactor --file '*.go' --json +nightshift busfactor --since 2026-01-01 --until 2026-06-30 +``` + +### `busfactor [path]` + +Analyze code ownership concentration. Reports the bus factor (minimum contributors needed for 50% of commits), the Herfindahl index, the Gini coefficient, and an overall risk level. + +| Flag | Default | Description | +|------|---------|-------------| +| `--path`, `-p` | current directory | Repository or directory path | +| `--json` | `false` | Output as JSON | +| `--since` | | Start date (RFC3339 or YYYY-MM-DD) | +| `--until` | | End date (RFC3339 or YYYY-MM-DD) | +| `--file`, `-f` | | Analyze a specific file or pattern | +| `--save` | `false` | Save results to database | +| `--db` | config | Database path | + +## Config Commands + +```bash +nightshift config # Show merged configuration +nightshift config get budget.max_percent +nightshift config set budget.max_percent 15 +nightshift config set logging.level debug --global +nightshift config validate +``` + +### `config get KEY` + +Get a specific configuration value by key path (e.g. `budget.max_percent`, `providers.claude.enabled`). + +### `config set KEY VALUE` + +Set a configuration value by key path. Writes to the project config if it exists, otherwise to global config. + +| Flag | Default | Description | +|------|---------|-------------| +| `--global`, `-g` | `false` | Write to global config instead of project config | + +### `config validate` + +Validate the current configuration, checking both global and project configs for errors. + +## Init + +```bash +nightshift init # Create nightshift.yaml in the current directory +nightshift init --global # Create ~/.config/nightshift/config.yaml +nightshift init --force # Overwrite existing config without prompting +``` + +| Flag | Default | Description | +|------|---------|-------------| +| `--global` | `false` | Create global config instead of project config | +| `--force`, `-f` | `false` | Overwrite existing config without prompting | + +## Daemon Commands + +```bash +nightshift daemon start # Start the background daemon +nightshift daemon stop # Stop the running daemon (SIGTERM) +nightshift daemon status # Check if the daemon is running +``` + +The daemon runs the scheduler loop, executing tasks according to the configured schedule (cron or interval) and respecting time windows. + +### `daemon start` + +| Flag | Default | Description | +|------|---------|-------------| +| `--foreground`, `-f` | `false` | Run in foreground (don't daemonize) | +| `--timeout` | | Per-agent execution timeout | + +## Service Management + +```bash +nightshift install # Auto-detect init system +nightshift install launchd # macOS (~/Library/LaunchAgents plist) +nightshift install systemd # Linux (user systemd unit) +nightshift install cron # Universal (crontab entry) +nightshift uninstall # Remove the installed service +``` + +## Report + +```bash +nightshift report # Overview of last night +nightshift report --report tasks # Per-task breakdown +nightshift report --period last-7d # Wider window +nightshift report --format markdown # Markdown output +nightshift report --since 2026-09-01 --until 2026-09-18 +``` + +View structured reports from recent nightshift runs. If the default period has no reports, falls back to the most recent run. + +| 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` | | Start time (YYYY-MM-DD, YYYY-MM-DD HH:MM, or RFC3339) | +| `--until` | | End time (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 | + ## Global Flags | Flag | Description | |------|-------------| | `--verbose` | Verbose output | -| `--provider` | Select provider (claude, codex) | -| `--timeout` | Execution timeout (default 30m) | + +Provider selection (`--provider`) and timeouts (`--timeout`) are per-command flags — see the command sections above. diff --git a/website/sidebars.js b/website/sidebars.js index e516e2c..ddb5931 100644 --- a/website/sidebars.js +++ b/website/sidebars.js @@ -15,7 +15,7 @@ const sidebars = { { type: 'category', label: 'Reference', - items: ['cli-reference', 'integrations'], + items: ['cli-reference', 'integrations', 'troubleshooting'], }, ], };