Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
a6bfe75
Record why command registration is an explicit import list
muellerei Sep 16, 2026
af5c849
Guard the command registry against a module nobody imports
muellerei Sep 16, 2026
82b8765
Build the connection-error wrapper with functools.wraps
muellerei Sep 16, 2026
8a9dd67
Parse the command source instead of splitting one file on "def"
muellerei Sep 16, 2026
14ee8dd
Drop an unused source scan from the cacheable-methods test
muellerei Sep 16, 2026
bf1c7f5
Move the output and error helpers into logseq_cli/output.py
muellerei Sep 16, 2026
0fa30c4
Drop the underscore on the nine names other modules will import
muellerei Sep 16, 2026
6d1bdb9
Move the block rendering helpers into logseq_cli/render.py
muellerei Sep 16, 2026
6643d6a
Move the click group into logseq_cli/group.py
muellerei Sep 16, 2026
d2471a7
Move get-block and find-block into logseq_cli/commands/blocks.py
muellerei Sep 16, 2026
7337e57
Move get-todos and set-todo-status into logseq_cli/commands/todos.py
muellerei Sep 16, 2026
348d46a
Move the property commands into logseq_cli/commands/properties.py
muellerei Sep 16, 2026
030af09
Move smart-query into logseq_cli/commands/query.py
muellerei Sep 16, 2026
fc9929f
Move init and doctor into logseq_cli/commands/meta.py
muellerei Sep 16, 2026
b499b2e
Move the graph analysis commands into logseq_cli/commands/analysis.py
muellerei Sep 16, 2026
fd729f1
Move the page commands into logseq_cli/commands/pages.py
muellerei Sep 16, 2026
7165e78
Move the block editing commands into logseq_cli/commands/edit.py
muellerei Sep 16, 2026
5744cee
Move the journal commands into logseq_cli/commands/journal.py
muellerei Sep 16, 2026
dd06ce0
Reduce cli.py to the entry point
muellerei Sep 16, 2026
169ecf9
Check in CI that the built wheel contains every source module
muellerei Sep 16, 2026
8a6ee1f
Describe the package the way it is now in README and CONTRIBUTING
muellerei Sep 16, 2026
57d4111
Record the module split and the packaging line in the CHANGELOG
muellerei Sep 16, 2026
24a8ccc
Assert directly what the connection-error wrapper has to carry
muellerei Sep 16, 2026
0b600d0
Check the import list itself, not only the registry it produces
muellerei Sep 16, 2026
45156fe
Assert the package's import rules where they can be run
muellerei Sep 16, 2026
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
28 changes: 28 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,31 @@ jobs:
python-version: ${{ matrix.python-version }}
- run: pip install -e ".[dev]"
- run: python -m pytest -q

wheel:
# pip install -e links the source tree, so an editable install cannot see a
# subpackage missing from `packages` in pyproject.toml. The built artefact
# can: this compares the wheel against the source tree rather than looking
# for logseq_cli/commands by name, so it keeps working when another
# subpackage is added.
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
# Both flags are load-bearing, and both were measured rather than
# assumed. Without --no-cache-dir pip serves a cached build of an earlier
# commit, and the check then passes on a wheel it never inspected. A
# stale logseq_cli.egg-info does the same thing: setuptools reuses its
# SOURCES.txt, so a wheel built after `packages` was narrowed still
# contains the subpackage. CI starts from a clean checkout, so the rm is
# a no-op there and a safeguard for anyone running this locally.
- run: rm -rf dist build *.egg-info
- run: pip wheel . -w dist --no-deps --no-cache-dir
- run: >
python -c "import pathlib,zipfile,glob;
src={str(p) for p in pathlib.Path('logseq_cli').rglob('*.py')};
whl={n for n in zipfile.ZipFile(glob.glob('dist/*.whl')[0]).namelist() if n.endswith('.py')};
missing=src-whl;
assert not missing, sorted(missing)"
30 changes: 30 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- The build now ships `logseq_cli.commands`. `pyproject.toml` lists packages
explicitly, which was right while the package was flat and became wrong the
moment it had a subpackage: setuptools does not infer one from an explicit
list. No release was ever affected — the subpackage and the list entry landed
in the same commit — but the failure mode is worth naming, because it is
invisible to the tests. `pip install -e .` links the source tree, so an
editable install imports the subpackage regardless; what a user would have
installed is a CLI that starts and has no commands.

- `get-backlinks --with-context --limit` accepted a negative value and answered
with less data and a count larger than the page held. Three linking blocks
came back as two, with `... 4 more not shown`, exit code 0, in both output
Expand Down Expand Up @@ -78,6 +87,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

- The commands moved out of `cli.py` into `logseq_cli/commands/`, one module per
group of commands, with the click group in `group.py`, the result and error
helpers in `output.py` and the block rendering in `render.py`. `cli.py` is now
the entry point that imports them: 5390 lines to 33.

Nothing about using the tool changes. The console entry point is unchanged,
every command keeps its name, its options, its defaults and its help text —
the per-command `--help` output of all 38 command names was captured before
the first commit and diffed against after every one of them, and it never
differed. The commands themselves were moved as text, in one commit per
module, with the suite green at each.

Two changes are not pure moves and are called out because they are the ones
that could behave differently. Nine helpers that are read from more than one
module lost their leading underscore, in a commit where nothing else happens.
And `handle_connection_error` now builds its wrapper with `functools.wraps`
instead of copying two attributes by hand, so a callback still names the
module it came from — without that, the scan that holds "under `--dry-run`
nothing mutating goes out" across 18 commands would have found nothing at
all and said so by passing.

- Every numeric option now states its lower bound in `--help`, including what
`0` means there, because it differs and the difference was written down
nowhere. `0` lifts the cap for `get-backlinks --limit` and `get-todos
Expand Down
34 changes: 25 additions & 9 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,19 +23,35 @@ Requires Python 3.10+ and a running Logseq Desktop app with the HTTP API enabled
```
logseq-cli/
├── logseq_cli/
│ ├── api.py # HTTP API client (thin wrapper around Logseq's API)
│ ├── datalog.py # EDN/datalog query building (value quoting, keywords)
│ ├── helpers.py # Date parsing, block processing, content formatting
│ └── cli.py # Click CLI with all commands
├── tests/ # pytest suite (no fixtures beyond tests/conftest.py)
├── examples/ # Shell scripts for common workflows
├── AGENTS.md # AI agent reference
└── pyproject.toml # Package config
│ ├── api.py # HTTP API client (thin wrapper around Logseq's API)
│ ├── config.py # Config file discovery, loading and lookup
│ ├── datalog.py # EDN/datalog query building (value quoting, keywords)
│ ├── helpers.py # Date parsing, block processing, content formatting
│ ├── group.py # The click group: global options, API client
│ ├── output.py # Results on stdout, failures on stderr, --json
│ ├── render.py # Blocks to text, and resolving block references
│ ├── commands/ # One module per group of commands
│ │ ├── pages.py # create/get/search/rename/delete a page
│ │ ├── blocks.py # read a block, find blocks
│ │ ├── edit.py # write, move, copy and remove blocks
│ │ ├── journal.py # journal entries and ranges
│ │ ├── todos.py # TODO markers and their references
│ │ ├── properties.py # page and block properties
│ │ ├── analysis.py # graph-wide analysis and suggestions
│ │ ├── query.py # smart-query
│ │ └── meta.py # init and doctor
│ └── cli.py # Entry point: imports every command module
├── tests/ # pytest suite (no fixtures beyond tests/conftest.py)
├── examples/ # Shell scripts for common workflows
├── AGENTS.md # AI agent reference
└── pyproject.toml # Package config
```

## Making Changes

1. **Read the code first.** `cli.py` is the main file — over five thousand lines, which is more than one file should carry and is being split. Each command is a self-contained function decorated with `@cli.command()`.
1. **Read the code first.** Commands live in `logseq_cli/commands/`, one module per group — the tree above says which. Each command is a self-contained function decorated with `@cli.command()`, and it reaches the group through `from logseq_cli.group import cli`.

A new module has to be added to the import list in `cli.py`, or its commands simply do not exist. `tests/test_command_registry.py` holds every Command Name and fails by name when one goes missing; `docs/adr/0001-explicit-command-registration.md` records why that list is written out rather than discovered by scanning.

2. **Follow existing patterns.** New commands should:
- Use `@click.option("--page", "--name", ...)` for page parameters (dual alias)
Expand Down
28 changes: 23 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -625,13 +625,31 @@ See `examples/` directory:
```
logseq-cli/
├── logseq_cli/
│ ├── api.py # HTTP API client (requests.post against Logseq)
│ ├── datalog.py # EDN/datalog query building (value quoting, keywords)
│ ├── helpers.py # Date parsing, block processing, backlink search
│ └── cli.py # Click CLI with all commands
├── examples/ # Shell scripts for scripting/cronjobs
│ ├── api.py # HTTP API client (requests.post against Logseq)
│ ├── config.py # Config file discovery, loading and lookup
│ ├── datalog.py # EDN/datalog query building (value quoting, keywords)
│ ├── helpers.py # Date parsing, block processing, backlink search
│ ├── group.py # The click group: global options, API client
│ ├── output.py # Results on stdout, failures on stderr, --json
│ ├── render.py # Blocks to text, and resolving block references
│ ├── commands/ # One module per group of commands
│ │ ├── pages.py # create/get/search/rename/delete a page
│ │ ├── blocks.py # read a block, find blocks
│ │ ├── edit.py # write, move, copy and remove blocks
│ │ ├── journal.py # journal entries and ranges
│ │ ├── todos.py # TODO markers and their references
│ │ ├── properties.py # page and block properties
│ │ ├── analysis.py # graph-wide analysis and suggestions
│ │ ├── query.py # smart-query
│ │ └── meta.py # init and doctor
│ └── cli.py # Entry point: imports every command module
├── examples/ # Shell scripts for scripting/cronjobs
└── pyproject.toml
```

A command exists once its module has been imported, and `cli.py` is the file
that imports them. `docs/adr/0001-explicit-command-registration.md` says why
that list is written out rather than discovered by scanning the directory.

The CLI communicates with Logseq's built-in HTTP API (Fastify server on port 12315).
The core commands are inspired by [joelhooks/logseq-mcp-tools](https://github.com/joelhooks/logseq-mcp-tools), extended with property management, page operations, and property-based queries.
28 changes: 28 additions & 0 deletions docs/adr/0001-explicit-command-registration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Commands are registered by an explicit import list

`logseq_cli/cli.py` imports each command module by name, and
`logseq_cli/commands/__init__.py` stays empty. Importing a command module
registers its commands as a side effect, because each one decorates against the
group in `logseq_cli/group.py`; `group.py` imports nothing from `commands/`, so
the dependency runs one way and there is no cycle.

## Considered Options

**Discovering modules with `pkgutil.iter_modules`.** A new command module would
register itself, and the import list would never need editing. Rejected: a
module that appears by directory scan has no place where its existence is
written down, and a typo in a filename then presents as a missing command
rather than as an import error. The registry test
(`tests/test_command_registry.py`) would report the symptom and not the cause.

**Keeping the group in `cli.py` and importing the command modules at the bottom
of the file.** This avoids editing the test suite, which patches the API client
by module path. Rejected: it introduces a circular import on purpose. A cycle
that has to be explained in a comment is worse than one mechanical edit across
the tests.

## Consequences

Adding a command module means adding one line to `cli.py`. Forgetting it means
the commands are absent, which is why the registry test asserts the full set of
command names rather than iterating whatever happens to be registered.
Loading
Loading