Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 3 additions & 4 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ concurrency:
cancel-in-progress: true

env:
GO_VERSION: '1.26.2'
GO_VERSION: '1.27.1'

jobs:
# ── Tier 1: Fast file-level checks (no toolchain, seconds) ──
Expand Down Expand Up @@ -127,9 +127,8 @@ jobs:
cache: true
- uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0
with:
# v2.12.2 is built with Go 1.26.2 — earlier 2.0.x was built
# with Go 1.24 and refused to lint a Go-1.26-targeted repo.
version: v2.12.2
# Keep the linter's build toolchain at least as new as go.mod.
version: v2.14.0
args: --timeout=5m

# ── Tier 3: Unit + integration tests (parallel with lint) ──
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ jobs:
- if: matrix.language == 'go'
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v6.0.0
with:
go-version: '1.26.2'
go-version: '1.27.1'
cache: true

- uses: github/codeql-action/init@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ By participating, you agree to uphold the [Code of Conduct](.github/CODE_OF_COND
## Getting started

1. Fork the repository and clone it locally.
2. Make sure you have **Go ≥ 1.26** installed (`go version`).
2. Make sure you have **Go ≥ 1.27** installed (`go version`).
3. Install the local hooks once:
```bash
pre-commit install
Expand Down
66 changes: 32 additions & 34 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
<p align="center">
<img src="docs/screenshots/repo_facade.svg" alt="noxctl — declarative Bear notes for the terminal" width="100%" />
</p>
<img src="docs/screenshots/repo_facade.svg" alt="noxctl — declarative Bear notes for the terminal" width="100%" />
Comment thread
barad1tos marked this conversation as resolved.

# noxctl

Expand Down Expand Up @@ -60,8 +58,8 @@ For each managed tag, noxctl writes two things to Bear:

Atoms keep their human-authored body; noxctl only owns the canonical line at the top and the master/hub layout. Here is one `#library/books` tag in Bear, before and after a first `noxctl apply`:

| Before | After |
|-----------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------|
| Before | After |
|-----------------------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------|
| ![Bear filtered to #nox-demo/books before apply: five atom notes, no master](docs/screenshots/before.png) | ![Bear filtered to #nox-demo/books after apply: master ✱ Books plus five atoms, canonical tag-line chips on each](docs/screenshots/after.png) |

(Demo vault is at `examples/demo-vault/` — `setup.sh` populates it under `#nox-demo/books` and the paired `noxctl.toml` manages exactly that tag.)
Expand Down Expand Up @@ -132,7 +130,7 @@ noxctl has two entry paths that share the same install and the same `validate
**Before you start.**

- You need macOS with [Bear](https://bear.app/) installed. noxctl talks to Bear through Bear's bundled `bearcli`.
- You need Go 1.26 or newer:
- You need Go 1.27 or newer:
```bash
go version
```
Expand Down Expand Up @@ -224,13 +222,13 @@ Bear ships a built-in backup in **File → Backup Database…** — recommended

**How those map to a blueprint:**

| Observed shape | Recommendation |
|-------------------------------------------------|-----------------------------------------------------------|
| No usable bucket signal | `flat-list` (high confidence when small, medium when large) |
| Top-level tag, few atoms per bucket | `grouped-vertical` (alternative: `hub-routed-with-subtag`) |
| Top-level tag, many atoms per bucket | `hub-routed-with-subtag` (alternative: `grouped-vertical`) |
| Nested tag, strong author signal or many buckets| `hub-routed` |
| Nested tag, a small declared bucket set | `grouped-vertical` |
| Observed shape | Recommendation |
|--------------------------------------------------|-------------------------------------------------------------|
| No usable bucket signal | `flat-list` (high confidence when small, medium when large) |
| Top-level tag, few atoms per bucket | `grouped-vertical` (alternative: `hub-routed-with-subtag`) |
| Top-level tag, many atoms per bucket | `hub-routed-with-subtag` (alternative: `grouped-vertical`) |
| Nested tag, strong author signal or many buckets | `hub-routed` |
| Nested tag, a small declared bucket set | `grouped-vertical` |

This follows the *Choosing a blueprint* decision tree, with one extra shortcut the eyeball version omits: on a nested tag, a high distinct-bucket count alone is enough for `hub-routed` (even without an author signal), so the master stays a scannable list of hubs.

Expand Down Expand Up @@ -345,10 +343,10 @@ Exit-code shape:

noxctl has two operating modes. Start with the one-shot mode until you trust the plan output on your own vault.

| Mode | What it does | When to use it | Persistence |
|------|--------------|----------------|-------------|
| `validate → plan → apply` | Runs once, shows a diff, then applies it only when you ask | First runs, careful changes, manual control | Exits after the command finishes |
| `daemon` | Runs continuously and reconciles managed structures when Bear changes | Mature configs you want kept in sync automatically | Lives only while the process is running unless you install a LaunchAgent |
| Mode | What it does | When to use it | Persistence |
|---------------------------|-----------------------------------------------------------------------|----------------------------------------------------|--------------------------------------------------------------------------|
| `validate → plan → apply` | Runs once, shows a diff, then applies it only when you ask | First runs, careful changes, manual control | Exits after the command finishes |
| `daemon` | Runs continuously and reconciles managed structures when Bear changes | Mature configs you want kept in sync automatically | Lives only while the process is running unless you install a LaunchAgent |

Run the daemon manually when testing continuous reconciliation:

Expand Down Expand Up @@ -411,15 +409,15 @@ launchctl bootout gui/$(id -u) ~/Library/LaunchAgents/com.barad1tos.noxctl.plist

noxctl is useful when Bear is still the place you want to write, but manual structure maintenance has become the annoying part.

| You gain | You trade away | Why it matters |
|----------|----------------|----------------|
| Generated index/hub notes | Some generated structure is now tool-owned | Edit your content freely, but do not hand-tune generated masters/hubs and expect those edits to survive regeneration |
| Reviewable `plan` output before writes | You need to understand the plan before applying it | Safer than silent automation, but still requires attention |
| Readiness and verification gates | A few extra commands before the first write | `doctor` catches local setup issues before mutation; `verify` confirms the vault and daemon are clean afterward |
| Consistent structure across many tags | A config file becomes part of your note system | Your organization rules live in `noxctl.toml`, not only in your head |
| Optional live reconciliation | A long-running process may be running on your Mac | Great for stable configs, but start manually before installing a LaunchAgent |
| Obsidian-like organization depth inside Bear | macOS + Bear + terminal are required | This is for Bear power users on macOS, not a cross-platform no-code workflow |
| Faster cleanup of drift | Bad config can produce bad structure quickly | Always back up before first use and inspect `plan` output |
| You gain | You trade away | Why it matters |
|----------------------------------------------|----------------------------------------------------|----------------------------------------------------------------------------------------------------------------------|
| Generated index/hub notes | Some generated structure is now tool-owned | Edit your content freely, but do not hand-tune generated masters/hubs and expect those edits to survive regeneration |
| Reviewable `plan` output before writes | You need to understand the plan before applying it | Safer than silent automation, but still requires attention |
| Readiness and verification gates | A few extra commands before the first write | `doctor` catches local setup issues before mutation; `verify` confirms the vault and daemon are clean afterward |
| Consistent structure across many tags | A config file becomes part of your note system | Your organization rules live in `noxctl.toml`, not only in your head |
| Optional live reconciliation | A long-running process may be running on your Mac | Great for stable configs, but start manually before installing a LaunchAgent |
| Obsidian-like organization depth inside Bear | macOS + Bear + terminal are required | This is for Bear power users on macOS, not a cross-platform no-code workflow |
| Faster cleanup of drift | Bad config can produce bad structure quickly | Always back up before first use and inspect `plan` output |

A good first test is one low-risk tag with a small number of notes. Import it, review the generated config, run `plan`, read the diff, back up Bear, and only then run `apply`.

Expand All @@ -439,13 +437,13 @@ Five rendering blueprints, each fitting a distinct tag shape. Walk the decision
<details>
<summary><b>Full comparison table & structure diagram</b></summary>

| Blueprint | When to use | Required fields beyond the basics | Bucket source | Output shape |
|--------------------------|-----------------------------------------------------------------|-----------------------------------|------------------------------------------|---------------------------------------------------|
| `flat-list` | inbox / capture tags, no grouping | none | n/a | one master with bullet list of every atom |
| `grouped-vertical` | bucketed collection in one master, operator-declared buckets | `buckets`, `unknown_bucket` | operator-declared (sub-tag or canonical 3rd segment, auto by tag depth) | one master with `## Bucket (N)` H2 per bucket |
| `hub-routed` | author / source grouping where bucket names live in atom bodies | `unknown_bucket`, `hub_h2_prefix` | atom body H2 (author/source), stamped into the canonical line | Tier-2: master lists hubs, each hub lists atoms |
| `hub-routed-with-subtag` | hub-style layout but bucket names are sub-tags | `buckets`, `unknown_bucket` | atom sub-tag `#tag/bucket` | Tier-2: master lists hubs, each hub lists atoms |
| `umbrella` | aggregate multiple existing domains under one master | `children`, `default_child` | n/a | master lists every child domain |
| Blueprint | When to use | Required fields beyond the basics | Bucket source | Output shape |
|--------------------------|-----------------------------------------------------------------|-----------------------------------|-------------------------------------------------------------------------|-------------------------------------------------|
| `flat-list` | inbox / capture tags, no grouping | none | n/a | one master with bullet list of every atom |
| `grouped-vertical` | bucketed collection in one master, operator-declared buckets | `buckets`, `unknown_bucket` | operator-declared (sub-tag or canonical 3rd segment, auto by tag depth) | one master with `## Bucket (N)` H2 per bucket |
| `hub-routed` | author / source grouping where bucket names live in atom bodies | `unknown_bucket`, `hub_h2_prefix` | atom body H2 (author/source), stamped into the canonical line | Tier-2: master lists hubs, each hub lists atoms |
| `hub-routed-with-subtag` | hub-style layout but bucket names are sub-tags | `buckets`, `unknown_bucket` | atom sub-tag `#tag/bucket` | Tier-2: master lists hubs, each hub lists atoms |
| `umbrella` | aggregate multiple existing domains under one master | `children`, `default_child` | n/a | master lists every child domain |

Required fields beyond the basics — every blueprint also needs `tag`, `index_title`, `blueprint`. See `examples/<blueprint>.toml` for a copy-pasteable starter per blueprint.

Expand Down Expand Up @@ -521,7 +519,7 @@ See `examples/minimal.toml` for a tested starter and `examples/personal.toml` fo
## Status & scope

- **Platform:** macOS only. Bear is macOS-only; the watcher uses FSEvents via `fsnotify`'s Darwin backend; the CLI bridge is `bearcli` at `/Applications/Bear.app/Contents/MacOS/bearcli`.
- **Runtime:** Go ≥ 1.26. Direct dependencies are intentionally small: TOML parsing, Cobra CLI wiring, fsnotify for the daemon watcher, and a small set of Go `x/*` support packages. Adding a runtime dependency is deliberate and requires justification.
- **Runtime:** Go ≥ 1.27. Direct dependencies are intentionally small: TOML parsing, Cobra CLI wiring, fsnotify for the daemon watcher, and a small set of Go `x/*` support packages. Adding a runtime dependency is deliberate and requires justification.
- **Heritage:** descended from `regen-watchd`, a personal FSEvents daemon that managed a 28-domain Bear corpus; the closed catalog of five blueprints covers every shape that production used.
- **Acceptance test:** byte-equivalent vault output against the legacy daemon for the maintainer's 28-domain corpus.
- **License:** MIT.
Expand Down
2 changes: 1 addition & 1 deletion bear/domain/canonical.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ func (s *atomicParseState) consumeLeadingBlank(trimmed string, p *AtomicParts) b
// switch documents that order matters (H1 before header before blank-skip).
func (d *Domain) parseAtomicContent(content, author string) AtomicParts {
authorH2Marker := "## " + author
family := strings.SplitN(d.Tag, "/", 2)[0]
family, _, _ := strings.Cut(d.Tag, "/")
var parts AtomicParts
state := atomicParseState{stripAuthor: d.StripLegacyAuthorH2}
for line := range strings.SplitSeq(content, "\n") {
Expand Down
2 changes: 1 addition & 1 deletion bear/domain/routing.go
Original file line number Diff line number Diff line change
Expand Up @@ -469,7 +469,7 @@ func (d *Domain) computeTagOverrides(notes []Note) (overrides map[string]string,
if d.CanonicalTagFor == nil {
return nil, 0
}
family := strings.SplitN(d.Tag, "/", 2)[0]
family, _, _ := strings.Cut(d.Tag, "/")
prefix := family + "/"
for _, note := range notes {
if !hasFamilyMembership(note.Tags, family) {
Expand Down
22 changes: 10 additions & 12 deletions cmd/noxctl/daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,18 +85,16 @@ func runDaemon(cmd *cobra.Command, _ []string) error {
return fmt.Errorf(errFmtNoxctlDaemon, bearDBErr)
}
opts := engine.DaemonOpts{
ApplyOpts: engine.ApplyOpts{
Domains: domains,
Pins: pins,
StatePath: dc.StatePath,
LockPath: dc.LockPath,
Features: cliutil.ResolveFeatures(cat, dc),
AuditEnabled: dc.AuditEnabled,
BearcliConcurrency: dc.BearcliConcurrency,
Stderr: os.Stderr,
DailyDefaultTag: dailyDefaultTagFromCatalog(cat),
PromotionRules: promotionRulesFromCatalog(cat),
},
Domains: domains,
Pins: pins,
StatePath: dc.StatePath,
LockPath: dc.LockPath,
Features: cliutil.ResolveFeatures(cat, dc),
AuditEnabled: dc.AuditEnabled,
BearcliConcurrency: dc.BearcliConcurrency,
Stderr: os.Stderr,
DailyDefaultTag: dailyDefaultTagFromCatalog(cat),
PromotionRules: promotionRulesFromCatalog(cat),
BearDBDir: bearDBDir,
DebouncePause: dc.DebouncePause,
MaxBurstWindow: dc.MaxBurstWindow,
Expand Down
4 changes: 2 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
module github.com/barad1tos/noxctl

go 1.26.2
go 1.27
Comment thread
barad1tos marked this conversation as resolved.
Comment thread
barad1tos marked this conversation as resolved.

require (
github.com/BurntSushi/toml v1.6.0
github.com/fsnotify/fsnotify v1.10.1
github.com/spf13/cobra v1.10.2
golang.org/x/sys v0.48.0
golang.org/x/sync v0.23.0
golang.org/x/sys v0.48.0
)

require (
Expand Down
5 changes: 2 additions & 3 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,8 @@ github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=

golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
2 changes: 1 addition & 1 deletion tests/bear/config/daemon_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ type loadDaemonCase[V any] struct {
//
// Closure-capture safety: `c` is captured by the `t.Run` closure, which
// would alias to the last iteration on Go < 1.22. The module pins
// `go 1.26.2` in go.mod, so per-iteration scoping is guaranteed by the
// `go 1.27` in go.mod, so per-iteration scoping is guaranteed by the
// language spec and no defensive `c := c` shadow is needed.
func runLoadDaemonCases[V any](
t *testing.T,
Expand Down
Loading
Loading